Compare commits
86 Commits
preview-mo
...
preview-ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6315ba5246 | ||
|
|
f6b0147049 | ||
|
|
f50f4af4ad | ||
|
|
97784e568a | ||
|
|
470c96a4a0 | ||
|
|
a46860ed15 | ||
|
|
5c0d5262d5 | ||
|
|
e3e5c2028e | ||
|
|
f03c85ad34 | ||
|
|
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 | ||
|
|
f773e0fb2a | ||
|
|
767a24164d | ||
|
|
8394eb5ad4 | ||
|
|
b8425d6388 | ||
|
|
ebb7f00305 | ||
|
|
418d51590d | ||
|
|
a6dd4a8fed | ||
|
|
4d1163c343 | ||
|
|
b085e12ff9 | ||
|
|
33e7a153aa | ||
|
|
9891a7577c | ||
|
|
077e355c77 | ||
|
|
21ab20bba9 | ||
|
|
cdfb30ea16 | ||
|
|
771ecdf781 | ||
|
|
863b675c77 | ||
|
|
5b998bef82 | ||
|
|
0113612ced | ||
|
|
f8c9689745 | ||
|
|
af8d6b475c | ||
|
|
dcc13080bc | ||
|
|
e97a13e1e4 | ||
|
|
1de518d915 | ||
|
|
4e44282387 | ||
|
|
67bd639a43 | ||
|
|
ada467ecf4 | ||
|
|
9cc6930fed | ||
|
|
3b4d6bf5b8 | ||
|
|
07e4662205 | ||
|
|
4eddbaa71b | ||
|
|
27112be933 | ||
|
|
a790b1abcc | ||
|
|
f0a6055774 | ||
|
|
a3f4773a35 | ||
|
|
73d8efaa54 | ||
|
|
9712f56054 | ||
|
|
b1f07f0eb2 | ||
|
|
64f05bcad6 | ||
|
|
80927b9705 | ||
|
|
d563b36186 | ||
|
|
117617188e | ||
|
|
525a538f34 | ||
|
|
0d2273ff6e | ||
|
|
e035cd84ae | ||
|
|
438ccfe9c3 | ||
|
|
c181cee328 | ||
|
|
98a5b05816 | ||
|
|
b29959b063 | ||
|
|
9a2c12e51c |
@@ -7,7 +7,7 @@
|
|||||||
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
"projectName": "jellyseerr",
|
"projectName": "jellyseerr",
|
||||||
"projectOwner": "Fallenbagel",
|
"projectOwner": "fallenbagel",
|
||||||
"repoType": "github",
|
"repoType": "github",
|
||||||
"repoHost": "https://github.com",
|
"repoHost": "https://github.com",
|
||||||
"skipCi": true,
|
"skipCi": true,
|
||||||
@@ -94,7 +94,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4",
|
||||||
"profile": "https://github.com/jab416171",
|
"profile": "https://github.com/jab416171",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc",
|
||||||
|
"code"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -248,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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -338,7 +340,8 @@
|
|||||||
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||||
"profile": "https://gauthierth.fr/",
|
"profile": "https://gauthierth.fr/",
|
||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code",
|
||||||
|
"maintenance"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -601,6 +604,240 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"design"
|
"design"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ishanjain28",
|
||||||
|
"name": "Ishan Jain",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/7921368?v=4",
|
||||||
|
"profile": "https://ishanjain.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "michaelhthomas",
|
||||||
|
"name": "Michael Thomas",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18223295?v=4",
|
||||||
|
"profile": "http://michaelt.xyz",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "j0srisk",
|
||||||
|
"name": "Joseph Risk",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||||
|
"profile": "http://josephrisk.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Loetwiek",
|
||||||
|
"name": "Loetwiek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||||
|
"profile": "https://github.com/Loetwiek",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fuochi",
|
||||||
|
"name": "Fuochi",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||||
|
"profile": "https://github.com/Fuochi",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "demrich",
|
||||||
|
"name": "David Emrich",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||||
|
"profile": "https://github.com/demrich",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "maxnatamo",
|
||||||
|
"name": "Max T. Kristiansen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||||
|
"profile": "https://maxtrier.dk",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DamsDev1",
|
||||||
|
"name": "Damien Fajole",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||||
|
"profile": "https://damsdev.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "AhmedNSidd",
|
||||||
|
"name": "Ahmed Siddiqui",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||||
|
"profile": "https://github.com/AhmedNSidd",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "JackW6809",
|
||||||
|
"name": "JackOXI",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
||||||
|
"profile": "https://github.com/JackW6809",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "StancuFlorin",
|
||||||
|
"name": "Stancu Florin",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
||||||
|
"profile": "http://indicus.ro",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "RankWeis",
|
||||||
|
"name": "RankWeis",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/733691?v=4",
|
||||||
|
"profile": "https://github.com/RankWeis",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "j0srisk",
|
||||||
|
"name": "Joseph Risk",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
|
||||||
|
"profile": "http://josephrisk.com",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Loetwiek",
|
||||||
|
"name": "Loetwiek",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
|
||||||
|
"profile": "https://github.com/Loetwiek",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "Fuochi",
|
||||||
|
"name": "Fuochi",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
|
||||||
|
"profile": "https://github.com/Fuochi",
|
||||||
|
"contributions": [
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "demrich",
|
||||||
|
"name": "David Emrich",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
|
||||||
|
"profile": "https://github.com/demrich",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "maxnatamo",
|
||||||
|
"name": "Max T. Kristiansen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
|
||||||
|
"profile": "https://maxtrier.dk",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "DamsDev1",
|
||||||
|
"name": "Damien Fajole",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
|
||||||
|
"profile": "https://damsdev.me",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "AhmedNSidd",
|
||||||
|
"name": "Ahmed Siddiqui",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
|
||||||
|
"profile": "https://github.com/AhmedNSidd",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "JackW6809",
|
||||||
|
"name": "JackOXI",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4",
|
||||||
|
"profile": "https://github.com/JackW6809",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "StancuFlorin",
|
||||||
|
"name": "Stancu Florin",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4",
|
||||||
|
"profile": "http://indicus.ro",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "lmiklosko",
|
||||||
|
"name": "Lukas Miklosko",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/44380311?v=4",
|
||||||
|
"profile": "https://github.com/lmiklosko",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "gauthier-th",
|
||||||
|
"name": "Gauthier",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/37781713?v=4",
|
||||||
|
"profile": "https://gauthierth.fr/",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
|||||||
8
.github/workflows/cypress.yml
vendored
@@ -36,3 +36,11 @@ jobs:
|
|||||||
# Fix test titles in cypress dashboard
|
# Fix test titles in cypress dashboard
|
||||||
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
COMMIT_INFO_MESSAGE: ${{github.event.pull_request.title}}
|
||||||
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
COMMIT_INFO_SHA: ${{github.event.pull_request.head.sha}}
|
||||||
|
- name: Upload video files
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cypress-videos
|
||||||
|
path: |
|
||||||
|
cypress/videos
|
||||||
|
cypress/screenshots
|
||||||
|
|||||||
2
.github/workflows/preview.yml
vendored
@@ -33,5 +33,7 @@ jobs:
|
|||||||
push: true
|
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 }}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -97,7 +112,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
|||||||
|
|
||||||
## Translation
|
## Translation
|
||||||
|
|
||||||
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Overseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-frontend/) for our translations, and your help with localizing Jellyseerr would be greatly appreciated! If your language is not listed below, please [open a feature request](https://github.com/fallenbagel/jellyseerr/issues/new/choose).
|
||||||
|
|
||||||
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
<a href="https://jellyseerr.borgcube.de/engage/jellysseerr/"><img src="https://jellyseerr.borgcube.de/widget/jellyseerr/multi-auto.svg" alt="Translation status" /></a>
|
||||||
|
|
||||||
|
|||||||
17
Dockerfile
@@ -14,7 +14,7 @@ RUN \
|
|||||||
;; \
|
;; \
|
||||||
esac
|
esac
|
||||||
|
|
||||||
RUN npm install --global pnpm
|
RUN npm install --global pnpm@9
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
COPY package.json pnpm-lock.yaml postinstall-win.js ./
|
||||||
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
RUN CYPRESS_INSTALL_BINARY=0 pnpm install --frozen-lockfile
|
||||||
@@ -38,14 +38,23 @@ 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
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
RUN apk add --no-cache tzdata tini && rm -rf /tmp/*
|
||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm@9
|
||||||
|
|
||||||
# copy from build image
|
# copy from build image
|
||||||
COPY --from=BUILD_IMAGE /app ./
|
COPY --from=BUILD_IMAGE /app ./
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ FROM node:22-alpine
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
Run npm install --global pnpm
|
RUN npm install --global pnpm@9
|
||||||
|
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
|
|||||||
138
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-65-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-91-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/)**.
|
||||||
@@ -86,89 +86,121 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fallenbagel"><img src="https://avatars.githubusercontent.com/u/98979876?v=4?s=100" width="100px;" alt="Fallenbagel"/><br /><sub><b>Fallenbagel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fallenbagel" title="Code">💻</a> <a href="#maintenance-Fallenbagel" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/Fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/seanzhang98"><img src="https://avatars.githubusercontent.com/u/34902361?v=4?s=100" width="100px;" alt="Sean"/><br /><sub><b>Sean</b></sub></a><br /><a href="#translation-seanzhang98" title="Translation">🌍</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=seanzhang98" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/notfakie"><img src="https://avatars.githubusercontent.com/u/103784113?v=4?s=100" width="100px;" alt="notfakie"/><br /><sub><b>notfakie</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=notfakie" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Jumail"><img src="https://avatars.githubusercontent.com/u/7672055?v=4?s=100" width="100px;" alt="Mohamed Jumail"/><br /><sub><b>Mohamed Jumail</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/pulls?q=is%3Apr+reviewed-by%3AJumail" title="Reviewed Pull Requests">👀</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.heywhale.com"><img src="https://avatars.githubusercontent.com/u/4048787?v=4?s=100" width="100px;" alt="Shilong Jiang"/><br /><sub><b>Shilong Jiang</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jsl9208" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://jinas.me"><img src="https://avatars.githubusercontent.com/u/28459081?v=4?s=100" width="100px;" alt="Boring Dragon"/><br /><sub><b>Boring Dragon</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=boring-dragon" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sambartik"><img src="https://avatars.githubusercontent.com/u/63553146?v=4?s=100" width="100px;" alt="Samuel Bartík"/><br /><sub><b>Samuel Bartík</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=sambartik" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CyferShepard"><img src="https://avatars.githubusercontent.com/u/24864904?v=4?s=100" width="100px;" alt="Thegan Govender"/><br /><sub><b>Thegan Govender</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=CyferShepard" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jab416171"><img src="https://avatars.githubusercontent.com/u/345752?v=4?s=100" width="100px;" alt="jab416171"/><br /><sub><b>jab416171</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Documentation">📖</a> <a href="https://github.com/fallenbagel/jellyseerr/commits?author=jab416171" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://nvds.be"><img src="https://avatars.githubusercontent.com/u/5257222?v=4?s=100" width="100px;" alt="Nicolai Van der Storm"/><br /><sub><b>Nicolai Van der Storm</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=NicolaiVdS" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Smexhy"><img src="https://avatars.githubusercontent.com/u/4880625?v=4?s=100" width="100px;" alt="Smexhy"/><br /><sub><b>Smexhy</b></sub></a><br /><a href="#translation-Smexhy" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://dd06-dev.fr"><img src="https://avatars.githubusercontent.com/u/58089504?v=4?s=100" width="100px;" alt="dd060606"/><br /><sub><b>dd060606</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=dd060606" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://qwer.tz"><img src="https://avatars.githubusercontent.com/u/71837281?v=4?s=100" width="100px;" alt="Daniel"/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=darmiel" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/undone37"><img src="https://avatars.githubusercontent.com/u/10513808?v=4?s=100" width="100px;" alt="undone37"/><br /><sub><b>undone37</b></sub></a><br /><a href="#translation-undone37" title="Translation">🌍</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/CheChu10"><img src="https://avatars.githubusercontent.com/u/32913133?v=4?s=100" width="100px;" alt="Chechu García"/><br /><sub><b>Chechu García</b></sub></a><br /><a href="#translation-CheChu10" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/DimitriDR"><img src="https://avatars.githubusercontent.com/u/56969769?v=4?s=100" width="100px;" alt="Dimitri"/><br /><sub><b>Dimitri</b></sub></a><br /><a href="#translation-DimitriDR" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/andrey4korop"><img src="https://avatars.githubusercontent.com/u/24610708?v=4?s=100" width="100px;" alt="andrey4korop"/><br /><sub><b>andrey4korop</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=andrey4korop" title="Code">💻</a> <a href="#translation-andrey4korop" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://geoffrey-coulaud.fr"><img src="https://avatars.githubusercontent.com/u/20744730?v=4?s=100" width="100px;" alt="Geoffrey Coulaud"/><br /><sub><b>Geoffrey Coulaud</b></sub></a><br /><a href="#translation-GeoffreyCoulaud" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Pikachu920"><img src="https://avatars.githubusercontent.com/u/28607612?v=4?s=100" width="100px;" alt="Pikachu920"/><br /><sub><b>Pikachu920</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Pikachu920" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yalagin"><img src="https://avatars.githubusercontent.com/u/12879142?v=4?s=100" width="100px;" alt="Maxim Yalagin"/><br /><sub><b>Maxim Yalagin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=yalagin" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jeaboswell"><img src="https://avatars.githubusercontent.com/u/11653068?v=4?s=100" width="100px;" alt="Jesse Boswell"/><br /><sub><b>Jesse Boswell</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=jeaboswell" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/d-fendrich"><img src="https://avatars.githubusercontent.com/u/27904138?v=4?s=100" width="100px;" alt="d-fendrich"/><br /><sub><b>d-fendrich</b></sub></a><br /><a href="#translation-d-fendrich" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/davidfdezalcoba"><img src="https://avatars.githubusercontent.com/u/15996018?v=4?s=100" width="100px;" alt="David Fernández Alcoba"/><br /><sub><b>David Fernández Alcoba</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=davidfdezalcoba" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Gauvino"><img src="https://avatars.githubusercontent.com/u/68083474?v=4?s=100" width="100px;" alt="Gauvino"/><br /><sub><b>Gauvino</b></sub></a><br /><a href="#translation-Gauvino" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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></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></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>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://www.boniface.me"><img src="https://avatars.githubusercontent.com/u/4031396?v=4?s=100" width="100px;" alt="Joshua M. Boniface"/><br /><sub><b>Joshua M. Boniface</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=joshuaboniface" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=gauthier-th" title="Code">💻</a> <a href="#maintenance-gauthier-th" title="Maintenance">🚧</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Kara-Zor-El"><img src="https://avatars.githubusercontent.com/u/69772087?v=4?s=100" width="100px;" alt="Kara"/><br /><sub><b>Kara</b></sub></a><br /><a href="#infra-Kara-Zor-El" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/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="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/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>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://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>
|
||||||
<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>
|
<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>
|
||||||
<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/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/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://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://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://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/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>
|
||||||
<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>
|
||||||
<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>
|
||||||
<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="http://michaelt.xyz"><img src="https://avatars.githubusercontent.com/u/18223295?v=4?s=100" width="100px;" alt="Michael Thomas"/><br /><sub><b>Michael Thomas</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=michaelhthomas" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=demrich" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=maxnatamo" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=DamsDev1" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/RankWeis"><img src="https://avatars.githubusercontent.com/u/733691?v=4?s=100" width="100px;" alt="RankWeis"/><br /><sub><b>RankWeis</b></sub></a><br /><a href="https://github.com/fallenbagel/jellyseerr/commits?author=RankWeis" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<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/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/fallenbagel/jellyseerr/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/fallenbagel/jellyseerr/commits?author=gauthier-th" 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>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -286,7 +318,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/byakurau"><img src="https://avatars.githubusercontent.com/u/1811683?v=4?s=100" width="100px;" alt="byakurau"/><br /><sub><b>byakurau</b></sub></a><br /><a href="#translation-byakurau" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miknii"><img src="https://avatars.githubusercontent.com/u/109232569?v=4?s=100" width="100px;" alt="miknii"/><br /><sub><b>miknii</b></sub></a><br /><a href="#translation-miknii" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Eclipseop"><img src="https://avatars.githubusercontent.com/u/5846213?v=4?s=100" width="100px;" alt="Mackenzie"/><br /><sub><b>Mackenzie</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Eclipseop" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s0up4200"><img src="https://avatars.githubusercontent.com/u/18177310?v=4?s=100" width="100px;" alt="soup"/><br /><sub><b>soup</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=s0up4200" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ceptonit"><img src="https://avatars.githubusercontent.com/u/12678743?v=4?s=100" width="100px;" alt="ceptonit"/><br /><sub><b>ceptonit</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ceptonit" title="Documentation">📖</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/aedelbro"><img src="https://avatars.githubusercontent.com/u/36162221?v=4?s=100" width="100px;" alt="aedelbro"/><br /><sub><b>aedelbro</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=aedelbro" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -319,6 +351,10 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/JackW6809"><img src="https://avatars.githubusercontent.com/u/53652452?v=4?s=100" width="100px;" alt="JackOXI"/><br /><sub><b>JackOXI</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JackW6809" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://indicus.ro"><img src="https://avatars.githubusercontent.com/u/1199404?v=4?s=100" width="100px;" alt="Stancu Florin"/><br /><sub><b>Stancu Florin</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=StancuFlorin" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/lmiklosko"><img src="https://avatars.githubusercontent.com/u/44380311?v=4?s=100" width="100px;" alt="Lukas Miklosko"/><br /><sub><b>Lukas Miklosko</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=lmiklosko" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://gauthierth.fr/"><img src="https://avatars.githubusercontent.com/u/37781713?v=4?s=100" width="100px;" alt="Gauthier"/><br /><sub><b>Gauthier</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=gauthier-th" title="Code">💻</a></td>
|
||||||
</tr>
|
</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.1.1
|
version: 2.3.3
|
||||||
appVersion: "2.3.0"
|
appVersion: "2.5.2"
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
projectId: 'xkm1b4',
|
projectId: 'xkm1b4',
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: 'http://localhost:5055',
|
baseUrl: 'http://localhost:5055',
|
||||||
|
video: true,
|
||||||
experimentalSessionAndOrigin: true,
|
experimentalSessionAndOrigin: true,
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
|
||||||
"main": {
|
"main": {
|
||||||
"apiKey": "testkey",
|
"apiKey": "testkey",
|
||||||
"applicationTitle": "Overseerr",
|
"applicationTitle": "Jellyseerr",
|
||||||
"applicationUrl": "",
|
"applicationUrl": "",
|
||||||
"csrfProtection": false,
|
"csrfProtection": false,
|
||||||
"cacheImages": false,
|
"cacheImages": false,
|
||||||
@@ -23,8 +23,6 @@
|
|||||||
"mediaServerType": 1,
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
"enableSpecialEpisodes": false,
|
"enableSpecialEpisodes": false,
|
||||||
"forceIpv4First": false,
|
|
||||||
"dnsServers": "",
|
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"plex": {
|
"plex": {
|
||||||
@@ -71,7 +69,7 @@
|
|||||||
"ignoreTls": false,
|
"ignoreTls": false,
|
||||||
"requireTls": false,
|
"requireTls": false,
|
||||||
"allowSelfSigned": false,
|
"allowSelfSigned": false,
|
||||||
"senderName": "Overseerr"
|
"senderName": "Jellyseerr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
@@ -139,7 +137,8 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"url": "",
|
"url": "",
|
||||||
"token": ""
|
"token": "",
|
||||||
|
"priority": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ describe('General Settings', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('modifies setting that requires restart', () => {
|
it('modifies setting that requires restart', () => {
|
||||||
cy.visit('/settings');
|
cy.visit('/settings/network');
|
||||||
|
|
||||||
cy.get('#trustProxy').click();
|
cy.get('#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should(
|
cy.get('[data-testid=modal-title]').should(
|
||||||
'contain',
|
'contain',
|
||||||
'Server Restart Required'
|
'Server Restart Required'
|
||||||
@@ -26,7 +26,7 @@ describe('General Settings', () => {
|
|||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
|
|
||||||
cy.get('[type=checkbox]#trustProxy').click();
|
cy.get('[type=checkbox]#trustProxy').click();
|
||||||
cy.get('[data-testid=settings-main-form]').submit();
|
cy.get('[data-testid=settings-network-form]').submit();
|
||||||
cy.get('[data-testid=modal-title]').should('not.exist');
|
cy.get('[data-testid=modal-title]').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Cypress.Commands.add('login', (email, password) => {
|
|||||||
[email, password],
|
[email, password],
|
||||||
() => {
|
() => {
|
||||||
cy.visit('/login');
|
cy.visit('/login');
|
||||||
cy.contains('Use your Overseerr account').click();
|
|
||||||
|
|
||||||
cy.get('[data-testid=email]').type(email);
|
cy.get('[data-testid=email]').type(email);
|
||||||
cy.get('[data-testid=password]').type(password);
|
cy.get('[data-testid=password]').type(password);
|
||||||
|
|||||||
@@ -255,7 +255,8 @@ To run jellyseerr as a service:
|
|||||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||||
2. Install NSSM:
|
2. Install NSSM:
|
||||||
```powershell
|
```powershell
|
||||||
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" ["C:\jellyseerr\dist\index.js"]
|
nssm install Jellyseerr "C:\Program Files\nodejs\node.exe" "C:\jellyseerr\dist\index.js"
|
||||||
|
nssm set Jellyseerr AppDirectory "C:\jellyseerr"
|
||||||
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
nssm set Jellyseerr AppEnvironmentExtra NODE_ENV=production
|
||||||
```
|
```
|
||||||
3. Start the service:
|
3. Start the service:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ docker run -d \
|
|||||||
-p 5055:5055 \
|
-p 5055:5055 \
|
||||||
-v /path/to/appdata/config:/app/config \
|
-v /path/to/appdata/config:/app/config \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
fallenbagel/jellyseerr
|
ghcr.io/fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
:::tip
|
:::tip
|
||||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
||||||
@@ -55,7 +55,7 @@ docker stop jellyseerr && docker rm Jellyseerr
|
|||||||
```
|
```
|
||||||
Pull the latest image:
|
Pull the latest image:
|
||||||
```bash
|
```bash
|
||||||
docker pull fallenbagel/jellyseerr
|
docker pull ghcr.io/fallenbagel/jellyseerr
|
||||||
```
|
```
|
||||||
Finally, run the container with the same parameters originally used to create the container:
|
Finally, run the container with the same parameters originally used to create the container:
|
||||||
```bash
|
```bash
|
||||||
@@ -78,7 +78,7 @@ Define the `jellyseerr` service in your `compose.yaml` as follows:
|
|||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
jellyseerr:
|
jellyseerr:
|
||||||
image: fallenbagel/jellyseerr:latest
|
image: ghcr.io/fallenbagel/jellyseerr:latest
|
||||||
container_name: jellyseerr
|
container_name: jellyseerr
|
||||||
environment:
|
environment:
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
@@ -146,7 +146,7 @@ Then, create and start the Jellyseerr container:
|
|||||||
<Tabs groupId="docker-methods" queryString>
|
<Tabs groupId="docker-methods" queryString>
|
||||||
<TabItem value="docker-cli" label="Docker CLI">
|
<TabItem value="docker-cli" label="Docker CLI">
|
||||||
```bash
|
```bash
|
||||||
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped fallenbagel/jellyseerr:latest
|
docker run -d --name jellyseerr -e LOG_LEVEL=debug -e TZ=Asia/Tashkent -p 5055:5055 -v "jellyseerr-data:/app/config" --restart unless-stopped ghcr.io/fallenbagel/jellyseerr:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Updating:
|
#### Updating:
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ or for Cloudflare's DNS:
|
|||||||
```bash
|
```bash
|
||||||
--dns=1.1.1.1
|
--dns=1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9 DNS:
|
||||||
|
```bash
|
||||||
|
--dns=9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -45,6 +51,16 @@ services:
|
|||||||
dns:
|
dns:
|
||||||
- 1.1.1.1
|
- 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
services:
|
||||||
|
jellyseerr:
|
||||||
|
dns:
|
||||||
|
- 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
|
You can try them all and see which one works for your network.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -56,7 +72,7 @@ services:
|
|||||||
4. Click on Change adapter settings.
|
4. Click on Change adapter settings.
|
||||||
5. Right-click the network interface connected to the internet and select Properties.
|
5. Right-click the network interface connected to the internet and select Properties.
|
||||||
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
6. Select Internet Protocol Version 4 (TCP/IPv4) and click Properties.
|
||||||
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS.
|
7. Select Use the following DNS server addresses and enter `8.8.8.8` for Google's DNS or `1.1.1.1` for Cloudflare's DNS or `9.9.9.9` for Quad9's DNS.
|
||||||
|
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
@@ -73,41 +89,15 @@ services:
|
|||||||
```bash
|
```bash
|
||||||
nameserver 1.1.1.1
|
nameserver 1.1.1.1
|
||||||
```
|
```
|
||||||
|
or for Quad9's DNS:
|
||||||
|
```bash
|
||||||
|
nameserver 9.9.9.9
|
||||||
|
```
|
||||||
|
|
||||||
</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 setting the `FORCE_IPV4_FIRST` environment variable to `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.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ When disabled, your mediaserver OAuth becomes the only sign-in option, and any "
|
|||||||
|
|
||||||
This setting is **enabled** by default.
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
|
## Enable Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
|
When enabled, users will be able to sign in to Jellyseerr using their Jellyfin/Emby/Plex credentials, provided they have linked their media server accounts.
|
||||||
|
|
||||||
|
When disabled, users will only be able to sign in using their email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||||
|
|
||||||
|
This setting is **enabled** by default.
|
||||||
|
|
||||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||||
|
|
||||||
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
When enabled, users with access to your media server will be able to sign in to Jellyseerr even if they have not yet been imported. Users will be automatically assigned the permissions configured in the [Default Permissions](#default-permissions) setting upon first sign-in.
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
openapi: '3.0.2'
|
openapi: '3.0.2'
|
||||||
info:
|
info:
|
||||||
title: 'Overseerr API'
|
title: 'Jellyseerr API'
|
||||||
version: '1.0.0'
|
version: '1.0.0'
|
||||||
description: |
|
description: |
|
||||||
This is the documentation for the Overseerr API backend.
|
This is the documentation for the Jellyseerr API backend.
|
||||||
|
|
||||||
Two primary authentication methods are supported:
|
Two primary authentication methods are supported:
|
||||||
|
|
||||||
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
|
- **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie.
|
||||||
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Overseerr.
|
- **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Jellyseerr.
|
||||||
tags:
|
tags:
|
||||||
- name: public
|
- name: public
|
||||||
description: Public API endpoints requiring no authentication.
|
description: Public API endpoints requiring no authentication.
|
||||||
- name: settings
|
- name: settings
|
||||||
description: Endpoints related to Overseerr's settings and configuration.
|
description: Endpoints related to Jellyseerr's settings and configuration.
|
||||||
- name: auth
|
- name: auth
|
||||||
description: Endpoints related to logging in or out, and the currently authenticated user.
|
description: Endpoints related to logging in or out, and the currently authenticated user.
|
||||||
- name: users
|
- name: users
|
||||||
@@ -160,16 +160,10 @@ components:
|
|||||||
example: en
|
example: en
|
||||||
applicationTitle:
|
applicationTitle:
|
||||||
type: string
|
type: string
|
||||||
example: Overseerr
|
example: Jellyseerr
|
||||||
applicationUrl:
|
applicationUrl:
|
||||||
type: string
|
type: string
|
||||||
example: https://os.example.com
|
example: https://os.example.com
|
||||||
trustProxy:
|
|
||||||
type: boolean
|
|
||||||
example: true
|
|
||||||
csrfProtection:
|
|
||||||
type: boolean
|
|
||||||
example: false
|
|
||||||
hideAvailable:
|
hideAvailable:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
@@ -191,12 +185,15 @@ components:
|
|||||||
enableSpecialEpisodes:
|
enableSpecialEpisodes:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
forceIpv4First:
|
NetworkSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
csrfProtection:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
dnsServers:
|
trustProxy:
|
||||||
type: string
|
type: boolean
|
||||||
example: '1.1.1.1'
|
example: true
|
||||||
PlexLibrary:
|
PlexLibrary:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1435,7 +1432,7 @@ components:
|
|||||||
example: no-reply@example.com
|
example: no-reply@example.com
|
||||||
senderName:
|
senderName:
|
||||||
type: string
|
type: string
|
||||||
example: Overseerr
|
example: Jellyseerr
|
||||||
smtpHost:
|
smtpHost:
|
||||||
type: string
|
type: string
|
||||||
example: 127.0.0.1
|
example: 127.0.0.1
|
||||||
@@ -1966,8 +1963,8 @@ components:
|
|||||||
paths:
|
paths:
|
||||||
/status:
|
/status:
|
||||||
get:
|
get:
|
||||||
summary: Get Overseerr status
|
summary: Get Jellyseerr status
|
||||||
description: Returns the current Overseerr status in a JSON object.
|
description: Returns the current Jellyseerr status in a JSON object.
|
||||||
security: []
|
security: []
|
||||||
tags:
|
tags:
|
||||||
- public
|
- public
|
||||||
@@ -2045,6 +2042,37 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/MainSettings'
|
$ref: '#/components/schemas/MainSettings'
|
||||||
|
/settings/network:
|
||||||
|
get:
|
||||||
|
summary: Get network settings
|
||||||
|
description: Retrieves all network settings in a JSON object.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MainSettings'
|
||||||
|
post:
|
||||||
|
summary: Update network settings
|
||||||
|
description: Updates network settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NetworkSettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were sucessfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/NetworkSettings'
|
||||||
/settings/main/regenerate:
|
/settings/main/regenerate:
|
||||||
post:
|
post:
|
||||||
summary: Get main settings with newly-generated API key
|
summary: Get main settings with newly-generated API key
|
||||||
@@ -3781,6 +3809,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: includeIds
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: A JSON array of all users
|
description: A JSON array of all users
|
||||||
@@ -3929,6 +3962,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
p256dh:
|
p256dh:
|
||||||
type: string
|
type: string
|
||||||
|
userAgent:
|
||||||
|
type: string
|
||||||
required:
|
required:
|
||||||
- endpoint
|
- endpoint
|
||||||
- auth
|
- auth
|
||||||
@@ -3936,6 +3971,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/{key}:
|
||||||
|
get:
|
||||||
|
summary: Get web push notification settings for a user
|
||||||
|
description: |
|
||||||
|
Returns web push notification settings for a user in a JSON object.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- in: path
|
||||||
|
name: key
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: User web push notification settings in JSON
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
endpoint:
|
||||||
|
type: string
|
||||||
|
p256dh:
|
||||||
|
type: string
|
||||||
|
auth:
|
||||||
|
type: string
|
||||||
|
userAgent:
|
||||||
|
type: string
|
||||||
|
delete:
|
||||||
|
summary: Delete user push subscription by key
|
||||||
|
description: Deletes the user push subscription with the provided key.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- in: path
|
||||||
|
name: key
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Successfully removed user push subscription
|
||||||
/user/{userId}:
|
/user/{userId}:
|
||||||
get:
|
get:
|
||||||
summary: Get user by ID
|
summary: Get user by ID
|
||||||
@@ -4389,6 +4506,104 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: User password updated
|
description: User password updated
|
||||||
|
/user/{userId}/settings/linked-accounts/plex:
|
||||||
|
post:
|
||||||
|
summary: Link the provided Plex account to the current user
|
||||||
|
description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
authToken:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- authToken
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Linking account succeeded
|
||||||
|
'403':
|
||||||
|
description: Invalid credentials
|
||||||
|
'422':
|
||||||
|
description: Account already linked to a user
|
||||||
|
delete:
|
||||||
|
summary: Remove the linked Plex account for a user
|
||||||
|
description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Unlinking account succeeded
|
||||||
|
'400':
|
||||||
|
description: Unlink request invalid
|
||||||
|
'404':
|
||||||
|
description: User does not exist
|
||||||
|
/user/{userId}/settings/linked-accounts/jellyfin:
|
||||||
|
post:
|
||||||
|
summary: Link the provided Jellyfin account to the current user
|
||||||
|
description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
example: 'Mr User'
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
example: 'supersecret'
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Linking account succeeded
|
||||||
|
'403':
|
||||||
|
description: Invalid credentials
|
||||||
|
'422':
|
||||||
|
description: Account already linked to a user
|
||||||
|
delete:
|
||||||
|
summary: Remove the linked Jellyfin account for a user
|
||||||
|
description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: userId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Unlinking account succeeded
|
||||||
|
'400':
|
||||||
|
description: Unlink request invalid
|
||||||
|
'404':
|
||||||
|
description: User does not exist
|
||||||
/user/{userId}/settings/notifications:
|
/user/{userId}/settings/notifications:
|
||||||
get:
|
get:
|
||||||
summary: Get notification settings for a user
|
summary: Get notification settings for a user
|
||||||
2
next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
|||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
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: [
|
||||||
{ hostname: 'gravatar.com' },
|
{ hostname: 'gravatar.com' },
|
||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
{ hostname: 'artworks.thetvdb.com' },
|
{ hostname: 'artworks.thetvdb.com' },
|
||||||
|
{ hostname: 'plex.tv' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
42
package.json
@@ -5,7 +5,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"postinstall": "node postinstall-win.js",
|
"postinstall": "node postinstall-win.js",
|
||||||
"dev": "nodemon -e ts --watch server --watch overseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
"dev": "nodemon -e ts --watch server --watch jellyseerr-api.yml -e .json,.ts,.yml -x ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts",
|
||||||
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
"build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && tsc-alias -p server/tsconfig.json",
|
||||||
"build:next": "next build",
|
"build:next": "next build",
|
||||||
"build": "pnpm build:next && pnpm build:server",
|
"build": "pnpm build:next && pnpm build:server",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dr.pogodin/csurf": "^1.14.1",
|
||||||
"@formatjs/intl-displaynames": "6.2.6",
|
"@formatjs/intl-displaynames": "6.2.6",
|
||||||
"@formatjs/intl-locale": "3.1.1",
|
"@formatjs/intl-locale": "3.1.1",
|
||||||
"@formatjs/intl-pluralrules": "5.1.10",
|
"@formatjs/intl-pluralrules": "5.1.10",
|
||||||
@@ -43,20 +44,22 @@
|
|||||||
"@svgr/webpack": "6.5.1",
|
"@svgr/webpack": "6.5.1",
|
||||||
"@tanem/react-nprogress": "5.0.30",
|
"@tanem/react-nprogress": "5.0.30",
|
||||||
"@types/wink-jaro-distance": "^2.0.2",
|
"@types/wink-jaro-distance": "^2.0.2",
|
||||||
|
"@types/ua-parser-js": "^0.7.36",
|
||||||
"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",
|
||||||
"cookie-parser": "1.4.6",
|
"cookie-parser": "1.4.7",
|
||||||
"copy-to-clipboard": "3.3.3",
|
"copy-to-clipboard": "3.3.3",
|
||||||
"country-flag-icons": "1.5.5",
|
"country-flag-icons": "1.5.5",
|
||||||
"cronstrue": "2.23.0",
|
"cronstrue": "2.23.0",
|
||||||
"csurf": "1.11.0",
|
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.7",
|
||||||
"email-templates": "9.0.0",
|
"email-templates": "12.0.1",
|
||||||
"email-validator": "2.0.4",
|
"email-validator": "2.0.4",
|
||||||
"express": "4.18.2",
|
"express": "4.21.2",
|
||||||
"express-openapi-validator": "4.13.8",
|
"express-openapi-validator": "4.13.8",
|
||||||
"express-rate-limit": "6.7.0",
|
"express-rate-limit": "6.7.0",
|
||||||
"express-session": "1.17.3",
|
"express-session": "1.17.3",
|
||||||
@@ -64,15 +67,15 @@
|
|||||||
"gravatar-url": "3.1.0",
|
"gravatar-url": "3.1.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"mime": "3",
|
"mime": "3",
|
||||||
"next": "^14.2.4",
|
"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",
|
||||||
"nodemailer": "6.9.1",
|
"nodemailer": "6.10.0",
|
||||||
"openpgp": "5.7.0",
|
"openpgp": "5.11.2",
|
||||||
"pg": "8.11.0",
|
"pg": "8.11.0",
|
||||||
"plex-api": "5.3.2",
|
"plex-api": "5.3.2",
|
||||||
"pug": "3.0.2",
|
"pug": "3.0.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-ace": "10.1.0",
|
"react-ace": "10.1.0",
|
||||||
"react-animate-height": "2.1.2",
|
"react-animate-height": "2.1.2",
|
||||||
@@ -86,17 +89,20 @@
|
|||||||
"react-spring": "9.7.1",
|
"react-spring": "9.7.1",
|
||||||
"react-tailwindcss-datepicker-sct": "1.3.4",
|
"react-tailwindcss-datepicker-sct": "1.3.4",
|
||||||
"react-toast-notifications": "2.5.1",
|
"react-toast-notifications": "2.5.1",
|
||||||
|
"react-transition-group": "^4.4.5",
|
||||||
"react-truncate-markup": "5.1.2",
|
"react-truncate-markup": "5.1.2",
|
||||||
"react-use-clipboard": "1.0.9",
|
"react-use-clipboard": "1.0.9",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"secure-random-password": "0.2.3",
|
"secure-random-password": "0.2.3",
|
||||||
"semver": "7.3.8",
|
"semver": "7.7.1",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.33.4",
|
||||||
"sqlite3": "5.1.4",
|
"sqlite3": "5.1.7",
|
||||||
"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",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.11",
|
||||||
"undici": "^6.20.1",
|
"undici": "^7.3.0",
|
||||||
|
"ua-parser-js": "^1.0.35",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"wink-jaro-distance": "^2.0.0",
|
"wink-jaro-distance": "^2.0.0",
|
||||||
"winston": "3.8.2",
|
"winston": "3.8.2",
|
||||||
@@ -104,7 +110,7 @@
|
|||||||
"xml2js": "0.4.23",
|
"xml2js": "0.4.23",
|
||||||
"yamljs": "0.3.0",
|
"yamljs": "0.3.0",
|
||||||
"yup": "0.32.11",
|
"yup": "0.32.11",
|
||||||
"zod": "3.20.6"
|
"zod": "3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "17.4.4",
|
"@commitlint/cli": "17.4.4",
|
||||||
@@ -114,8 +120,8 @@
|
|||||||
"@semantic-release/exec": "6.0.3",
|
"@semantic-release/exec": "6.0.3",
|
||||||
"@semantic-release/git": "10.0.1",
|
"@semantic-release/git": "10.0.1",
|
||||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||||
"@tailwindcss/forms": "0.5.3",
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@types/bcrypt": "5.0.0",
|
"@types/bcrypt": "5.0.0",
|
||||||
"@types/cookie-parser": "1.4.3",
|
"@types/cookie-parser": "1.4.3",
|
||||||
"@types/country-flag-icons": "1.2.0",
|
"@types/country-flag-icons": "1.2.0",
|
||||||
@@ -144,7 +150,7 @@
|
|||||||
"commitizen": "4.3.0",
|
"commitizen": "4.3.0",
|
||||||
"copyfiles": "2.4.1",
|
"copyfiles": "2.4.1",
|
||||||
"cy-mobile-commands": "0.3.0",
|
"cy-mobile-commands": "0.3.0",
|
||||||
"cypress": "12.7.0",
|
"cypress": "14.1.0",
|
||||||
"cz-conventional-changelog": "3.3.0",
|
"cz-conventional-changelog": "3.3.0",
|
||||||
"eslint": "8.35.0",
|
"eslint": "8.35.0",
|
||||||
"eslint-config-next": "^14.2.4",
|
"eslint-config-next": "^14.2.4",
|
||||||
@@ -157,8 +163,8 @@
|
|||||||
"eslint-plugin-react-hooks": "4.6.0",
|
"eslint-plugin-react-hooks": "4.6.0",
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"lint-staged": "13.1.2",
|
"lint-staged": "13.1.2",
|
||||||
"nodemon": "2.0.20",
|
"nodemon": "3.1.9",
|
||||||
"postcss": "8.4.21",
|
"postcss": "8.4.31",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
"prettier-plugin-organize-imports": "3.2.2",
|
"prettier-plugin-organize-imports": "3.2.2",
|
||||||
"prettier-plugin-tailwindcss": "0.2.3",
|
"prettier-plugin-tailwindcss": "0.2.3",
|
||||||
|
|||||||
2862
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |
21
public/sw.js
@@ -3,7 +3,7 @@
|
|||||||
// previously cached resources to be updated from the network.
|
// previously cached resources to be updated from the network.
|
||||||
// This variable is intentionally declared and unused.
|
// This variable is intentionally declared and unused.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const OFFLINE_VERSION = 3;
|
const OFFLINE_VERSION = 4;
|
||||||
const CACHE_NAME = 'offline';
|
const CACHE_NAME = 'offline';
|
||||||
// Customize this with a different URL if needed.
|
// Customize this with a different URL if needed.
|
||||||
const OFFLINE_URL = '/offline.html';
|
const OFFLINE_URL = '/offline.html';
|
||||||
@@ -107,6 +107,25 @@ self.addEventListener('push', (event) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the badge with the amount of pending requests
|
||||||
|
// Only update the badge if the payload confirms they are the admin
|
||||||
|
if (
|
||||||
|
(payload.notificationType === 'MEDIA_APPROVED' ||
|
||||||
|
payload.notificationType === 'MEDIA_DECLINED') &&
|
||||||
|
payload.isAdmin
|
||||||
|
) {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
|
if ('setAppBadge' in navigator) {
|
||||||
|
navigator.setAppBadge(payload.pendingRequestsCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
event.waitUntil(self.registration.showNotification(payload.subject, options));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +67,16 @@ 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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrReleases({
|
public async getJellyseerrReleases({
|
||||||
take = 20,
|
take = 20,
|
||||||
}: {
|
}: {
|
||||||
take?: number;
|
take?: number;
|
||||||
@@ -81,21 +85,23 @@ 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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub releases. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getOverseerrCommits({
|
public async getJellyseerrCommits({
|
||||||
take = 20,
|
take = 20,
|
||||||
branch = 'develop',
|
branch = 'develop',
|
||||||
}: {
|
}: {
|
||||||
@@ -106,15 +112,17 @@ 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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Overseerr can't check if it's on the latest version.",
|
"Failed to retrieve GitHub commits. This may be an issue on GitHub's end. Jellyseerr can't check if it's on the latest version.",
|
||||||
{ label: 'GitHub API', errorMessage: e.message }
|
{ label: 'GitHub API', errorMessage: e.message }
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
import { getAppVersion } from '@server/utils/appVersion';
|
import { getAppVersion } from '@server/utils/appVersion';
|
||||||
@@ -92,15 +94,34 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
DateCreated?: string;
|
DateCreated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JellyfinItemsReponse {
|
||||||
|
Items: JellyfinLibraryItemExtended[];
|
||||||
|
TotalRecordCount: number;
|
||||||
|
StartIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
class JellyfinAPI extends ExternalAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
|
private mediaServerType: MediaServerType;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
jellyfinHost: string,
|
||||||
|
authToken?: string | null,
|
||||||
|
deviceId?: string | null
|
||||||
|
) {
|
||||||
|
const settings = getSettings();
|
||||||
|
const safeDeviceId =
|
||||||
|
deviceId && deviceId.length > 0
|
||||||
|
? deviceId
|
||||||
|
: Buffer.from(`BOT_jellyseerr_fallback_${Date.now()}`).toString(
|
||||||
|
'base64'
|
||||||
|
);
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
|
||||||
let authHeaderVal: string;
|
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(
|
||||||
@@ -109,9 +130,13 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Authorization': authHeaderVal,
|
'X-Emby-Authorization': authHeaderVal,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.mediaServerType = settings.main.mediaServerType;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -120,7 +145,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>(
|
||||||
@@ -129,8 +154,6 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
Username,
|
Username,
|
||||||
Pw: Password,
|
Pw: Password,
|
||||||
},
|
},
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -140,36 +163,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +207,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,11 +220,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,11 +235,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +251,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +275,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,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,16 +316,7 @@ 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>(
|
const libraryItemsResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items`,
|
`/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||||
{
|
|
||||||
SortBy: 'SortName',
|
|
||||||
SortOrder: 'Ascending',
|
|
||||||
IncludeItemTypes: 'Series,Movie,Others',
|
|
||||||
Recursive: 'true',
|
|
||||||
StartIndex: '0',
|
|
||||||
ParentId: id,
|
|
||||||
collapseBoxSetItems: 'false',
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return libraryItemsResponse.Items.filter(
|
return libraryItemsResponse.Items.filter(
|
||||||
@@ -310,32 +324,36 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||||
try {
|
try {
|
||||||
|
const endpoint =
|
||||||
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
|
? `/Items/Latest`
|
||||||
|
: `/Users/${this.userId}/Items/Latest`;
|
||||||
const itemResponse = await this.get<any>(
|
const itemResponse = await this.get<any>(
|
||||||
`/Users/${this.userId}/Items/Latest`,
|
`${endpoint}?Limit=12&ParentId=${id}${
|
||||||
{
|
this.mediaServerType === MediaServerType.JELLYFIN
|
||||||
Limit: '12',
|
? `&userId=${this.userId ?? 'Me'}`
|
||||||
ParentId: id,
|
: ''
|
||||||
}
|
}`
|
||||||
);
|
);
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,23 +361,26 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
id: string
|
id: string
|
||||||
): Promise<JellyfinLibraryItemExtended | undefined> {
|
): Promise<JellyfinLibraryItemExtended | undefined> {
|
||||||
try {
|
try {
|
||||||
const itemResponse = await this.get<any>(
|
const itemResponse = await this.get<JellyfinItemsReponse>(`/Items`, {
|
||||||
`/Users/${this.userId}/Items/${id}`
|
params: {
|
||||||
);
|
ids: id,
|
||||||
|
fields: 'ProviderIds,MediaSources,Width,Height,IsHD,DateCreated',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return itemResponse;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,11 +391,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,10 +405,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(
|
||||||
@@ -395,11 +413,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +430,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);
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class PlexAPI {
|
|||||||
plexSettings,
|
plexSettings,
|
||||||
timeout,
|
timeout,
|
||||||
}: {
|
}: {
|
||||||
plexToken?: string;
|
plexToken?: string | null;
|
||||||
plexSettings?: PlexSettings;
|
plexSettings?: PlexSettings;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -107,7 +107,7 @@ class PlexAPI {
|
|||||||
port: settingsPlex.port,
|
port: settingsPlex.port,
|
||||||
https: settingsPlex.useSsl,
|
https: settingsPlex.useSsl,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
token: plexToken,
|
token: plexToken ?? undefined,
|
||||||
authenticator: {
|
authenticator: {
|
||||||
authenticate: (
|
authenticate: (
|
||||||
_plexApi,
|
_plexApi,
|
||||||
@@ -124,9 +124,9 @@ class PlexAPI {
|
|||||||
// },
|
// },
|
||||||
options: {
|
options: {
|
||||||
identifier: settings.clientId,
|
identifier: settings.clientId,
|
||||||
product: 'Overseerr',
|
product: 'Jellyseerr',
|
||||||
deviceName: 'Overseerr',
|
deviceName: 'Jellyseerr',
|
||||||
platform: 'Overseerr',
|
platform: 'Jellyseerr',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +367,11 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
|
|
||||||
public async pingToken() {
|
public async pingToken() {
|
||||||
try {
|
try {
|
||||||
const data: { pong: unknown } = await this.get(
|
const data: { pong: unknown } = await this.get('/api/v2/ping', {
|
||||||
'/api/v2/ping',
|
headers: {
|
||||||
{},
|
'X-Plex-Client-Identifier': randomUUID(),
|
||||||
undefined,
|
},
|
||||||
{
|
});
|
||||||
headers: {
|
|
||||||
'X-Plex-Client-Identifier': randomUUID(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!data?.pong) {
|
if (!data?.pong) {
|
||||||
throw new Error('No pong response');
|
throw new Error('No pong response');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -99,6 +100,7 @@ interface DiscoverTvOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 +115,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 +129,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 +151,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 +180,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 +207,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 +225,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 +234,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 +248,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,9 +257,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:
|
||||||
|
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -268,7 +275,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;
|
||||||
@@ -277,9 +284,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:
|
||||||
|
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||||
|
include_video_language: language + ', en',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
43200
|
43200
|
||||||
);
|
);
|
||||||
@@ -303,8 +313,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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -317,7 +329,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;
|
||||||
@@ -327,8 +339,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -341,7 +355,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;
|
||||||
@@ -351,8 +365,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -365,7 +381,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;
|
||||||
@@ -375,8 +391,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -389,7 +407,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;
|
||||||
@@ -399,8 +417,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,7 +435,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;
|
||||||
@@ -423,8 +443,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;
|
||||||
@@ -437,7 +459,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,
|
||||||
@@ -465,38 +487,40 @@ 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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -508,7 +532,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,
|
||||||
@@ -538,41 +562,41 @@ 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 || '',
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -583,7 +607,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;
|
||||||
@@ -592,10 +616,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -608,7 +634,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';
|
||||||
@@ -618,9 +644,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -641,7 +669,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -662,7 +692,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -675,7 +707,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
public async getByExternalId({
|
public async getByExternalId({
|
||||||
externalId,
|
externalId,
|
||||||
type,
|
type,
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}:
|
}:
|
||||||
| {
|
| {
|
||||||
externalId: string;
|
externalId: string;
|
||||||
@@ -691,8 +723,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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -704,7 +738,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;
|
||||||
@@ -743,7 +777,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;
|
||||||
@@ -773,7 +807,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;
|
||||||
@@ -782,7 +816,9 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
const data = await this.get<TmdbCollection>(
|
const data = await this.get<TmdbCollection>(
|
||||||
`/collection/${collectionId}`,
|
`/collection/${collectionId}`,
|
||||||
{
|
{
|
||||||
language,
|
params: {
|
||||||
|
language,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -847,7 +883,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getMovieGenres({
|
public async getMovieGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -855,7 +891,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
|
||||||
);
|
);
|
||||||
@@ -867,7 +905,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
|
||||||
);
|
);
|
||||||
@@ -894,7 +934,7 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getTvGenres({
|
public async getTvGenres({
|
||||||
language = 'en',
|
language = this.locale,
|
||||||
}: {
|
}: {
|
||||||
language?: string;
|
language?: string;
|
||||||
} = {}): Promise<TmdbGenre[]> {
|
} = {}): Promise<TmdbGenre[]> {
|
||||||
@@ -902,7 +942,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
|
||||||
);
|
);
|
||||||
@@ -914,7 +956,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
|
||||||
);
|
);
|
||||||
@@ -969,8 +1013,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
|
||||||
);
|
);
|
||||||
@@ -992,8 +1038,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
|
||||||
);
|
);
|
||||||
@@ -1013,7 +1061,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
|
||||||
);
|
);
|
||||||
@@ -1037,8 +1087,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
|
||||||
);
|
);
|
||||||
@@ -1062,8 +1114,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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export enum ApiErrorCode {
|
|||||||
NoAdminUser = 'NO_ADMIN_USER',
|
NoAdminUser = 'NO_ADMIN_USER',
|
||||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
Unknown = 'UNKNOWN',
|
Unknown = 'UNKNOWN',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -734,8 +734,11 @@ export class MediaRequest {
|
|||||||
media.mediaType === MediaType.MOVIE &&
|
media.mediaType === MediaType.MOVIE &&
|
||||||
this.status === MediaRequestStatus.DECLINED
|
this.status === MediaRequestStatus.DECLINED
|
||||||
) {
|
) {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const statusField = this.is4k ? 'status4k' : 'status';
|
||||||
mediaRepository.save(media);
|
await mediaRepository.update(
|
||||||
|
{ id: this.media.id },
|
||||||
|
{ [statusField]: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -752,8 +755,11 @@ export class MediaRequest {
|
|||||||
).length === 0 &&
|
).length === 0 &&
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING
|
||||||
) {
|
) {
|
||||||
media[this.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const statusField = this.is4k ? 'status4k' : 'status';
|
||||||
mediaRepository.save(media);
|
mediaRepository.update(
|
||||||
|
{ id: this.media.id },
|
||||||
|
{ [statusField]: MediaStatus.UNKNOWN }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve child seasons if parent is approved
|
// Approve child seasons if parent is approved
|
||||||
@@ -955,8 +961,10 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
|
||||||
await requestRepository.save(this);
|
await requestRepository.update(this.id, {
|
||||||
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,18 +994,22 @@ export class MediaRequest {
|
|||||||
throw new Error('Media data not found');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
|
const updateFields = {
|
||||||
radarrMovie.id;
|
[this.is4k ? 'externalServiceId4k' : 'externalServiceId']:
|
||||||
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
|
radarrMovie.id,
|
||||||
radarrMovie.titleSlug;
|
[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug']:
|
||||||
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
|
radarrMovie.titleSlug,
|
||||||
await mediaRepository.save(media);
|
[this.is4k ? 'serviceId4k' : 'serviceId']: radarrSettings?.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mediaRepository.update({ id: this.media.id }, updateFields);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
await requestRepository.update(this.id, {
|
||||||
await requestRepository.save(this);
|
status: MediaRequestStatus.FAILED,
|
||||||
|
});
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -1113,8 +1125,9 @@ export class MediaRequest {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
const requestRepository = getRepository(MediaRequest);
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
await requestRepository.update(this.id, {
|
||||||
await requestRepository.save(this);
|
status: MediaRequestStatus.APPROVED,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,11 @@ export class User {
|
|||||||
})
|
})
|
||||||
public email: string;
|
public email: string;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public plexUsername?: string;
|
public plexUsername?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public jellyfinUsername?: string;
|
public jellyfinUsername?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
public username?: string;
|
public username?: string;
|
||||||
@@ -77,20 +77,20 @@ export class User {
|
|||||||
@Column({ type: 'integer', default: UserType.PLEX })
|
@Column({ type: 'integer', default: UserType.PLEX })
|
||||||
public userType: UserType;
|
public userType: UserType;
|
||||||
|
|
||||||
@Column({ nullable: true, select: true })
|
@Column({ type: 'integer', nullable: true, select: true })
|
||||||
public plexId?: number;
|
public plexId?: number | null;
|
||||||
|
|
||||||
@Column({ nullable: true })
|
@Column({ type: 'varchar', nullable: true })
|
||||||
public jellyfinUserId?: string;
|
public jellyfinUserId?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public jellyfinDeviceId?: string;
|
public jellyfinDeviceId?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public jellyfinAuthToken?: string;
|
public jellyfinAuthToken?: string | null;
|
||||||
|
|
||||||
@Column({ nullable: true, select: false })
|
@Column({ type: 'varchar', nullable: true, select: false })
|
||||||
public plexToken?: string;
|
public plexToken?: string | null;
|
||||||
|
|
||||||
@Column({ type: 'integer', default: 0 })
|
@Column({ type: 'integer', default: 0 })
|
||||||
public permissions = 0;
|
public permissions = 0;
|
||||||
@@ -98,6 +98,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;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@@ -18,9 +24,15 @@ 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;
|
||||||
|
|
||||||
|
@CreateDateColumn({ nullable: true })
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<UserPushSubscription>) {
|
constructor(init?: Partial<UserPushSubscription>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import csurf from '@dr.pogodin/csurf';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
import dataSource, { getRepository, isPgsql } from '@server/datasource';
|
||||||
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
import DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
@@ -28,22 +29,19 @@ import restartFlag from '@server/utils/restartFlag';
|
|||||||
import { getClientIp } from '@supercharge/request-ip';
|
import { getClientIp } from '@supercharge/request-ip';
|
||||||
import { TypeormStore } from 'connect-typeorm/out';
|
import { TypeormStore } from 'connect-typeorm/out';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import csurf from 'csurf';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as OpenApiValidator from 'express-openapi-validator';
|
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';
|
||||||
|
|
||||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
const API_SPEC_PATH = path.join(__dirname, '../jellyseerr-api.yml');
|
||||||
|
|
||||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
logger.info(`Starting Jellyseerr version ${getAppVersion()}`);
|
||||||
const dev = process.env.NODE_ENV !== 'production';
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
const app = next({ dev });
|
const app = next({ dev });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
@@ -72,23 +70,11 @@ app
|
|||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = await getSettings().load();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings);
|
||||||
|
|
||||||
// Check if we force IPv4 first
|
|
||||||
if (process.env.forceIpv4First === 'true' || settings.main.forceIpv4First) {
|
|
||||||
dns.setDefaultResultOrder('ipv4first');
|
|
||||||
net.setDefaultAutoSelectFamily(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.main.dnsServers.trim() !== '') {
|
|
||||||
dns.setServers(
|
|
||||||
settings.main.dnsServers.split(',').map((server) => server.trim())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register HTTP proxy
|
// Register HTTP proxy
|
||||||
if (settings.main.proxy.enabled) {
|
if (settings.network.proxy.enabled) {
|
||||||
await createCustomProxyAgent(settings.main.proxy);
|
await createCustomProxyAgent(settings.network.proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
@@ -143,7 +129,7 @@ app
|
|||||||
await DiscoverSlider.bootstrapSliders();
|
await DiscoverSlider.bootstrapSliders();
|
||||||
|
|
||||||
const server = express();
|
const server = express();
|
||||||
if (settings.main.trustProxy) {
|
if (settings.network.trustProxy) {
|
||||||
server.enable('trust proxy');
|
server.enable('trust proxy');
|
||||||
}
|
}
|
||||||
server.use(cookieParser());
|
server.use(cookieParser());
|
||||||
@@ -164,7 +150,7 @@ app
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (settings.main.csrfProtection) {
|
if (settings.network.csrfProtection) {
|
||||||
server.use(
|
server.use(
|
||||||
csurf({
|
csurf({
|
||||||
cookie: {
|
cookie: {
|
||||||
@@ -194,7 +180,7 @@ app
|
|||||||
cookie: {
|
cookie: {
|
||||||
maxAge: 1000 * 60 * 60 * 24 * 30,
|
maxAge: 1000 * 60 * 60 * 24 * 30,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: settings.main.csrfProtection ? 'strict' : 'lax',
|
sameSite: settings.network.csrfProtection ? 'strict' : 'lax',
|
||||||
secure: 'auto',
|
secure: 'auto',
|
||||||
},
|
},
|
||||||
store: new TypeormStore({
|
store: new TypeormStore({
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface PublicSettingsResponse {
|
|||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
series4kEnabled: boolean;
|
series4kEnabled: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
|
|||||||
@@ -404,6 +404,34 @@ class AvailabilitySync {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists &&
|
||||||
|
(media.status === MediaStatus.AVAILABLE ||
|
||||||
|
media.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, false, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!showExists4k &&
|
||||||
|
(media.status4k === MediaStatus.AVAILABLE ||
|
||||||
|
media.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||||
|
) ||
|
||||||
|
media.seasons.some(
|
||||||
|
(season) => season.status4k === MediaStatus.PARTIALLY_AVAILABLE
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
await this.mediaUpdater(media, true, mediaServerType);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Figure out how to run seasonUpdater for each season
|
// TODO: Figure out how to run seasonUpdater for each season
|
||||||
|
|
||||||
if ([...finalSeasons.values()].includes(false)) {
|
if ([...finalSeasons.values()].includes(false)) {
|
||||||
@@ -423,22 +451,6 @@ 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) {
|
||||||
@@ -466,6 +478,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[];
|
||||||
@@ -731,7 +747,11 @@ class AvailabilitySync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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')) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class PreparedEmail extends Email {
|
|||||||
},
|
},
|
||||||
send: true,
|
send: true,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
|
preview: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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 path, { join } from 'path';
|
import path, { join } from 'path';
|
||||||
|
|
||||||
type ImageResponse = {
|
type ImageResponse = {
|
||||||
@@ -131,33 +130,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 +188,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 +264,19 @@ 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(
|
|
||||||
response.headers.get('content-type') ?? ''
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const buffer = Buffer.from(response.data, 'binary');
|
||||||
|
const extension = path.split('.').pop() ?? '';
|
||||||
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,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface NotificationPayload {
|
|||||||
request?: MediaRequest;
|
request?: MediaRequest;
|
||||||
issue?: Issue;
|
issue?: Issue;
|
||||||
comment?: IssueComment;
|
comment?: IssueComment;
|
||||||
|
pendingRequestsCount?: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
export abstract class BaseAgent<T extends NotificationAgentConfig> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -188,7 +189,7 @@ class SlackAgent
|
|||||||
type: 'actions',
|
type: 'actions',
|
||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
action_id: 'open-in-overseerr',
|
action_id: 'open-in-jellyseerr',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
url,
|
url,
|
||||||
text: {
|
text: {
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
import { IssueType, IssueTypeName } from '@server/constants/issue';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
import MediaRequest from '@server/entity/MediaRequest';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
import { UserPushSubscription } from '@server/entity/UserPushSubscription';
|
||||||
import type { NotificationAgentConfig } from '@server/lib/settings';
|
import type { NotificationAgentConfig } from '@server/lib/settings';
|
||||||
@@ -19,6 +20,8 @@ interface PushNotificationPayload {
|
|||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
actionUrlTitle?: string;
|
actionUrlTitle?: string;
|
||||||
requestId?: number;
|
requestId?: number;
|
||||||
|
pendingRequestsCount?: number;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebPushAgent
|
class WebPushAgent
|
||||||
@@ -129,6 +132,8 @@ class WebPushAgent
|
|||||||
requestId: payload.request?.id,
|
requestId: payload.request?.id,
|
||||||
actionUrl,
|
actionUrl,
|
||||||
actionUrlTitle,
|
actionUrlTitle,
|
||||||
|
pendingRequestsCount: payload.pendingRequestsCount,
|
||||||
|
isAdmin: payload.isAdmin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +157,51 @@ class WebPushAgent
|
|||||||
|
|
||||||
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
|
const pendingRequests = await requestRepository.find({
|
||||||
|
where: { status: MediaRequestStatus.PENDING },
|
||||||
|
});
|
||||||
|
|
||||||
|
const webPushNotification = async (
|
||||||
|
pushSub: UserPushSubscription,
|
||||||
|
notificationPayload: Buffer
|
||||||
|
) => {
|
||||||
|
logger.debug('Sending web push notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
recipient: pushSub.user.displayName,
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: pushSub.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: pushSub.auth,
|
||||||
|
p256dh: pushSub.p256dh,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notificationPayload
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
'Error sending web push notification; removing subscription',
|
||||||
|
{
|
||||||
|
label: 'Notifications',
|
||||||
|
recipient: pushSub.user.displayName,
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Failed to send notification so we need to remove the subscription
|
||||||
|
userPushSubRepository.remove(pushSub);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
payload.notifyUser &&
|
payload.notifyUser &&
|
||||||
// Check if user has webpush notifications enabled and fallback to true if undefined
|
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||||
@@ -169,7 +219,11 @@ class WebPushAgent
|
|||||||
pushSubs.push(...notifySubs);
|
pushSubs.push(...notifySubs);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.notifyAdmin) {
|
if (
|
||||||
|
payload.notifyAdmin ||
|
||||||
|
type === Notification.MEDIA_APPROVED ||
|
||||||
|
type === Notification.MEDIA_DECLINED
|
||||||
|
) {
|
||||||
const users = await userRepository.find();
|
const users = await userRepository.find();
|
||||||
|
|
||||||
const manageUsers = users.filter(
|
const manageUsers = users.filter(
|
||||||
@@ -192,7 +246,42 @@ class WebPushAgent
|
|||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
pushSubs.push(...allSubs);
|
// We only want to send the custom notification when type is approved or declined
|
||||||
|
// Otherwise, default to the normal notification
|
||||||
|
if (
|
||||||
|
type === Notification.MEDIA_APPROVED ||
|
||||||
|
type === Notification.MEDIA_DECLINED
|
||||||
|
) {
|
||||||
|
if (mainUser && allSubs.length > 0) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
`mailto:${mainUser.email}`,
|
||||||
|
settings.vapidPublic,
|
||||||
|
settings.vapidPrivate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom payload only for updating the app badge
|
||||||
|
const notificationBadgePayload = Buffer.from(
|
||||||
|
JSON.stringify(
|
||||||
|
this.getNotificationPayload(type, {
|
||||||
|
subject: payload.subject,
|
||||||
|
notifySystem: false,
|
||||||
|
notifyAdmin: true,
|
||||||
|
isAdmin: true,
|
||||||
|
pendingRequestsCount: pendingRequests.length,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
allSubs.map(async (sub) => {
|
||||||
|
webPushNotification(sub, notificationBadgePayload);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pushSubs.push(...allSubs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainUser && pushSubs.length > 0) {
|
if (mainUser && pushSubs.length > 0) {
|
||||||
@@ -202,6 +291,10 @@ class WebPushAgent
|
|||||||
settings.vapidPrivate
|
settings.vapidPrivate
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (type === Notification.MEDIA_PENDING) {
|
||||||
|
payload = { ...payload, pendingRequestsCount: pendingRequests.length };
|
||||||
|
}
|
||||||
|
|
||||||
const notificationPayload = Buffer.from(
|
const notificationPayload = Buffer.from(
|
||||||
JSON.stringify(this.getNotificationPayload(type, payload)),
|
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||||
'utf-8'
|
'utf-8'
|
||||||
@@ -209,39 +302,7 @@ class WebPushAgent
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pushSubs.map(async (sub) => {
|
pushSubs.map(async (sub) => {
|
||||||
logger.debug('Sending web push notification', {
|
webPushNotification(sub, notificationPayload);
|
||||||
label: 'Notifications',
|
|
||||||
recipient: sub.user.displayName,
|
|
||||||
type: Notification[type],
|
|
||||||
subject: payload.subject,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await webpush.sendNotification(
|
|
||||||
{
|
|
||||||
endpoint: sub.endpoint,
|
|
||||||
keys: {
|
|
||||||
auth: sub.auth,
|
|
||||||
p256dh: sub.p256dh,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
notificationPayload
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error(
|
|
||||||
'Error sending web push notification; removing subscription',
|
|
||||||
{
|
|
||||||
label: 'Notifications',
|
|
||||||
recipient: sub.user.displayName,
|
|
||||||
type: Notification[type],
|
|
||||||
subject: payload.subject,
|
|
||||||
errorMessage: e.message,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Failed to send notification so we need to remove the subscription
|
|
||||||
userPushSubRepository.remove(sub);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export interface MainSettings {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
csrfProtection: boolean;
|
|
||||||
cacheImages: boolean;
|
cacheImages: boolean;
|
||||||
defaultPermissions: number;
|
defaultPermissions: number;
|
||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
@@ -124,17 +123,20 @@ export interface MainSettings {
|
|||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
newPlexLogin: boolean;
|
newPlexLogin: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
streamingRegion: string;
|
streamingRegion: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
trustProxy: boolean;
|
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
enableSpecialEpisodes: boolean;
|
||||||
forceIpv4First: boolean;
|
|
||||||
dnsServers: string;
|
|
||||||
locale: string;
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkSettings {
|
||||||
|
csrfProtection: boolean;
|
||||||
|
trustProxy: boolean;
|
||||||
proxy: ProxySettings;
|
proxy: ProxySettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +149,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
localLogin: boolean;
|
localLogin: boolean;
|
||||||
|
mediaServerLogin: boolean;
|
||||||
movie4kEnabled: boolean;
|
movie4kEnabled: boolean;
|
||||||
series4kEnabled: boolean;
|
series4kEnabled: boolean;
|
||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
@@ -250,6 +253,7 @@ export interface NotificationAgentGotify extends NotificationAgentConfig {
|
|||||||
options: {
|
options: {
|
||||||
url: string;
|
url: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
priority: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +317,7 @@ export interface AllSettings {
|
|||||||
public: PublicSettings;
|
public: PublicSettings;
|
||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
jobs: Record<JobId, JobSettings>;
|
jobs: Record<JobId, JobSettings>;
|
||||||
|
network: NetworkSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -331,7 +336,6 @@ class Settings {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
applicationTitle: 'Jellyseerr',
|
applicationTitle: 'Jellyseerr',
|
||||||
applicationUrl: '',
|
applicationUrl: '',
|
||||||
csrfProtection: false,
|
|
||||||
cacheImages: false,
|
cacheImages: false,
|
||||||
defaultPermissions: Permission.REQUEST,
|
defaultPermissions: Permission.REQUEST,
|
||||||
defaultQuotas: {
|
defaultQuotas: {
|
||||||
@@ -340,27 +344,15 @@ class Settings {
|
|||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
localLogin: true,
|
localLogin: true,
|
||||||
|
mediaServerLogin: true,
|
||||||
newPlexLogin: true,
|
newPlexLogin: true,
|
||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
originalLanguage: '',
|
originalLanguage: '',
|
||||||
trustProxy: false,
|
|
||||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
enableSpecialEpisodes: false,
|
enableSpecialEpisodes: false,
|
||||||
forceIpv4First: false,
|
|
||||||
dnsServers: '',
|
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
proxy: {
|
|
||||||
enabled: false,
|
|
||||||
hostname: '',
|
|
||||||
port: 8080,
|
|
||||||
useSsl: false,
|
|
||||||
user: '',
|
|
||||||
password: '',
|
|
||||||
bypassFilter: '',
|
|
||||||
bypassLocalAddresses: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plex: {
|
plex: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -471,6 +463,7 @@ class Settings {
|
|||||||
options: {
|
options: {
|
||||||
url: '',
|
url: '',
|
||||||
token: '',
|
token: '',
|
||||||
|
priority: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -513,6 +506,20 @@ class Settings {
|
|||||||
schedule: '0 0 5 * * *',
|
schedule: '0 0 5 * * *',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
network: {
|
||||||
|
csrfProtection: false,
|
||||||
|
trustProxy: false,
|
||||||
|
proxy: {
|
||||||
|
enabled: false,
|
||||||
|
hostname: '',
|
||||||
|
port: 8080,
|
||||||
|
useSsl: false,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
bypassFilter: '',
|
||||||
|
bypassLocalAddresses: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
if (initialSettings) {
|
if (initialSettings) {
|
||||||
this.data = merge(this.data, initialSettings);
|
this.data = merge(this.data, initialSettings);
|
||||||
@@ -582,6 +589,8 @@ class Settings {
|
|||||||
applicationUrl: this.data.main.applicationUrl,
|
applicationUrl: this.data.main.applicationUrl,
|
||||||
hideAvailable: this.data.main.hideAvailable,
|
hideAvailable: this.data.main.hideAvailable,
|
||||||
localLogin: this.data.main.localLogin,
|
localLogin: this.data.main.localLogin,
|
||||||
|
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||||
|
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||||
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
jellyfinForgotPasswordUrl: this.data.jellyfin.jellyfinForgotPasswordUrl,
|
||||||
movie4kEnabled: this.data.radarr.some(
|
movie4kEnabled: this.data.radarr.some(
|
||||||
(radarr) => radarr.is4k && radarr.isDefault
|
(radarr) => radarr.is4k && radarr.isDefault
|
||||||
@@ -622,6 +631,14 @@ class Settings {
|
|||||||
this.data.jobs = data;
|
this.data.jobs = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get network(): NetworkSettings {
|
||||||
|
return this.data.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
set network(data: NetworkSettings) {
|
||||||
|
this.data.network = data;
|
||||||
|
}
|
||||||
|
|
||||||
get clientId(): string {
|
get clientId(): string {
|
||||||
return this.data.clientId;
|
return this.data.clientId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
|
const migrateNetworkSettings = (settings: any): AllSettings => {
|
||||||
|
if (settings.network) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
const newSettings = { ...settings };
|
||||||
|
newSettings.network = {
|
||||||
|
...settings.network,
|
||||||
|
csrfProtection: settings.main.csrfProtection ?? false,
|
||||||
|
trustProxy: settings.main.trustProxy ?? false,
|
||||||
|
forceIpv4First: settings.main.forceIpv4First ?? false,
|
||||||
|
proxy: settings.main.proxy ?? {
|
||||||
|
enabled: false,
|
||||||
|
hostname: '',
|
||||||
|
port: 8080,
|
||||||
|
useSsl: false,
|
||||||
|
user: '',
|
||||||
|
password: '',
|
||||||
|
bypassFilter: '',
|
||||||
|
bypassLocalAddresses: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
delete settings.main.csrfProtection;
|
||||||
|
delete settings.main.trustProxy;
|
||||||
|
delete settings.main.forceIpv4First;
|
||||||
|
delete settings.main.proxy;
|
||||||
|
return newSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateNetworkSettings;
|
||||||
@@ -130,7 +130,7 @@ class WatchlistSync {
|
|||||||
|
|
||||||
switch (e.constructor) {
|
switch (e.constructor) {
|
||||||
// During watchlist sync, these errors aren't necessarily
|
// During watchlist sync, these errors aren't necessarily
|
||||||
// a problem with Overseerr. Since we are auto syncing these constantly, it's
|
// a problem with Jellyseerr. Since we are auto syncing these constantly, it's
|
||||||
// possible they are unexpectedly at their quota limit, for example. So we'll
|
// possible they are unexpectedly at their quota limit, for example. So we'll
|
||||||
// instead log these as debug messages.
|
// instead log these as debug messages.
|
||||||
case RequestPermissionError:
|
case RequestPermissionError:
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ const logger = winston.createLogger({
|
|||||||
}),
|
}),
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: process.env.CONFIG_DIRECTORY
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
? `${process.env.CONFIG_DIRECTORY}/logs/overseerr-%DATE%.log`
|
? `${process.env.CONFIG_DIRECTORY}/logs/jellyseerr-%DATE%.log`
|
||||||
: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
|
: path.join(__dirname, '../config/logs/jellyseerr-%DATE%.log'),
|
||||||
datePattern: 'YYYY-MM-DD',
|
datePattern: 'YYYY-MM-DD',
|
||||||
zippedArchive: true,
|
zippedArchive: true,
|
||||||
maxSize: '20m',
|
maxSize: '20m',
|
||||||
maxFiles: '7d',
|
maxFiles: '7d',
|
||||||
createSymlink: true,
|
createSymlink: true,
|
||||||
symlinkName: 'overseerr.log',
|
symlinkName: 'jellyseerr.log',
|
||||||
}),
|
}),
|
||||||
new winston.transports.DailyRotateFile({
|
new winston.transports.DailyRotateFile({
|
||||||
filename: process.env.CONFIG_DIRECTORY
|
filename: process.env.CONFIG_DIRECTORY
|
||||||
|
|||||||
29
server/migration/postgres/1743023615532-UpdateWebPush.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1743023615532 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1743023615532';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD "userAgent" character varying`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD "createdAt" TIMESTAMP DEFAULT now()`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth")`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP COLUMN "createdAt"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" DROP COLUMN "userAgent"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserAvatarCacheFields1743107707465
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUserAvatarCacheFields1743107707465';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user" ADD "avatarETag" character varying`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user" ADD "avatarVersion" character varying`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarVersion"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarETag"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
server/migration/sqlite/1743023610704-UpdateWebPush.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class UpdateWebPush1743023610704 implements MigrationInterface {
|
||||||
|
name = 'UpdateWebPush1743023610704';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime DEFAULT (CURRENT_TIMESTAMP), "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "media"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "media"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"), CONSTRAINT "FK_ae34e6b153a90672eb9dc4857d7" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6641da8d831b93dfcb429f8b8bc" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "watchlist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_watchlist" RENAME TO "watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_5f933c8ed6ad2c31739e6b94886" UNIQUE ("tmdbId"), CONSTRAINT "UQ_e49b27917899e01d7aca6b0b15c" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_939f205946256cc0d2a1ac51a8"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "watchlist" RENAME TO "temporary_watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "watchlist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ratingKey" varchar NOT NULL, "mediaType" varchar NOT NULL, "title" varchar NOT NULL, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "requestedById" integer, "mediaId" integer, CONSTRAINT "UNIQUE_USER_DB" UNIQUE ("tmdbId", "requestedById"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "watchlist"("id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId") SELECT "id", "ratingKey", "mediaType", "title", "tmdbId", "createdAt", "updatedAt", "requestedById", "mediaId" FROM "temporary_watchlist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_watchlist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_939f205946256cc0d2a1ac51a8" ON "watchlist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, "jellyfinMediaId" varchar, "jellyfinMediaId4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "status4k", "createdAt", "updatedAt", "lastSeasonChange", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k", "ratingKey", "ratingKey4k", "jellyfinMediaId", "jellyfinMediaId4k" FROM "temporary_media"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_media"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserAvatarCacheFields1743107645301
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddUserAvatarCacheFields1743107645301';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "user"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId" FROM "temporary_user"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (datetime('now')), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { 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 { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import * as EmailValidator from 'email-validator';
|
import * as EmailValidator from 'email-validator';
|
||||||
@@ -56,8 +57,9 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType != MediaServerType.PLEX &&
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
(settings.main.mediaServerLogin === false ||
|
||||||
|
settings.main.mediaServerType != MediaServerType.PLEX)
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Plex login is disabled' });
|
return res.status(500).json({ error: 'Plex login is disabled' });
|
||||||
}
|
}
|
||||||
@@ -157,7 +159,7 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Sign-in attempt from Plex user with access to the media server; creating new Overseerr user',
|
'Sign-in attempt from Plex user with access to the media server; creating new Jellyseerr user',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
@@ -215,6 +217,10 @@ authRoutes.post('/plex', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getUserAvatarUrl(user: User): string {
|
||||||
|
return `/avatarproxy/${user.jellyfinUserId}?v=${user.avatarVersion}`;
|
||||||
|
}
|
||||||
|
|
||||||
authRoutes.post('/jellyfin', async (req, res, next) => {
|
authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
@@ -231,10 +237,13 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
|
|
||||||
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||||
if (
|
if (
|
||||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
// media server not configured, allow login for setup
|
||||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
|
||||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||||
settings.jellyfin.ip !== ''
|
(settings.main.mediaServerLogin === false ||
|
||||||
|
// media server is neither jellyfin or emby
|
||||||
|
(settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||||
|
settings.jellyfin.ip !== ''))
|
||||||
) {
|
) {
|
||||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||||
}
|
}
|
||||||
@@ -270,7 +279,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
if (user) {
|
if (user) {
|
||||||
deviceId = user.jellyfinDeviceId ?? '';
|
deviceId = user.jellyfinDeviceId ?? '';
|
||||||
} else {
|
} else {
|
||||||
deviceId = Buffer.from(`BOT_overseerr_${body.username ?? ''}`).toString(
|
deviceId = Buffer.from(`BOT_jellyseerr_${body.username ?? ''}`).toString(
|
||||||
'base64'
|
'base64'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,12 +348,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 {
|
||||||
@@ -371,7 +380,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
|
||||||
@@ -418,7 +427,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) {
|
||||||
@@ -442,7 +451,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
} else if (!user) {
|
} else if (!user) {
|
||||||
logger.info(
|
logger.info(
|
||||||
'Sign-in attempt from Jellyfin user with access to the media server; creating new Overseerr user',
|
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
@@ -456,12 +465,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;
|
||||||
@@ -471,6 +480,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;
|
||||||
@@ -580,7 +609,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
if (!user || !(await user.passwordMatch(body.password))) {
|
if (!user || !(await user.passwordMatch(body.password))) {
|
||||||
logger.warn('Failed sign-in attempt using invalid Overseerr password', {
|
logger.warn('Failed sign-in attempt using invalid Jellyseerr password', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@@ -670,7 +699,7 @@ authRoutes.post('/local', async (req, res, next) => {
|
|||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'Something went wrong authenticating with Overseerr password',
|
'Something went wrong authenticating with Jellyseerr password',
|
||||||
{
|
{
|
||||||
label: 'API',
|
label: 'API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -837,7 +837,8 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
|||||||
select: ['id', 'plexToken'],
|
select: ['id', 'plexToken'],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activeUser) {
|
if (activeUser && !activeUser?.plexToken) {
|
||||||
|
// Non-Plex users can only see their own watchlist
|
||||||
const [result, total] = await getRepository(Watchlist).findAndCount({
|
const [result, total] = await getRepository(Watchlist).findAndCount({
|
||||||
where: { requestedBy: { id: activeUser?.id } },
|
where: { requestedBy: { id: activeUser?.id } },
|
||||||
relations: {
|
relations: {
|
||||||
@@ -866,6 +867,7 @@ discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// List watchlist from Plex
|
||||||
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
const plexTV = new PlexTvAPI(activeUser.plexToken);
|
||||||
|
|
||||||
const watchlist = await plexTV.getWatchlist({ offset });
|
const watchlist = await plexTV.getWatchlist({ offset });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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,
|
maxRPS: 50,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
|||||||
let commitsBehind = 0;
|
let commitsBehind = 0;
|
||||||
|
|
||||||
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
if (currentVersion.startsWith('develop-') && commitTag !== 'local') {
|
||||||
const commits = await githubApi.getOverseerrCommits();
|
const commits = await githubApi.getJellyseerrCommits();
|
||||||
|
|
||||||
if (commits.length) {
|
if (commits.length) {
|
||||||
const filteredCommits = commits.filter(
|
const filteredCommits = commits.filter(
|
||||||
@@ -74,7 +74,7 @@ router.get<unknown, StatusResponse>('/status', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (commitTag !== 'local') {
|
} else if (commitTag !== 'local') {
|
||||||
const releases = await githubApi.getOverseerrReleases();
|
const releases = await githubApi.getJellyseerrReleases();
|
||||||
|
|
||||||
if (releases.length) {
|
if (releases.length) {
|
||||||
const latestVersion = releases[0];
|
const latestVersion = releases[0];
|
||||||
@@ -403,7 +403,7 @@ router.get('/watchproviders/tv', async (req, res, next) => {
|
|||||||
|
|
||||||
router.get('/', (_req, res) => {
|
router.get('/', (_req, res) => {
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
api: 'Overseerr API',
|
api: 'Jellyseerr API',
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// add profile names to the media requests, with undefined if not found
|
// 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 +212,36 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// add canRemove prop if user has permission
|
||||||
|
if (req.user?.hasPermission(Permission.MANAGE_REQUESTS)) {
|
||||||
|
mappedRequests = mappedRequests.map((r) => {
|
||||||
|
switch (r.type) {
|
||||||
|
case MediaType.MOVIE: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
// check if the radarr server for this request is configured
|
||||||
|
canRemove: radarrServers.some(
|
||||||
|
(server) =>
|
||||||
|
server.id ===
|
||||||
|
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case MediaType.TV: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
// check if the sonarr server for this request is configured
|
||||||
|
canRemove: sonarrServers.some(
|
||||||
|
(server) =>
|
||||||
|
server.id ===
|
||||||
|
(r.is4k ? r.media.serviceId4k : r.media.serviceId)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(requestCount / pageSize),
|
pages: Math.ceil(requestCount / pageSize),
|
||||||
@@ -219,7 +249,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 });
|
||||||
|
|||||||
@@ -78,6 +78,21 @@ settingsRoutes.post('/main', async (req, res) => {
|
|||||||
return res.status(200).json(settings.main);
|
return res.status(200).json(settings.main);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
settingsRoutes.get('/network', (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
res.status(200).json(settings.network);
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsRoutes.post('/network', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
settings.network = merge(settings.network, req.body);
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
return res.status(200).json(settings.network);
|
||||||
|
});
|
||||||
|
|
||||||
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
@@ -337,7 +352,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
const account = await jellyfinClient.getUser();
|
const account = await jellyfinClient.getUser();
|
||||||
|
|
||||||
// Automatic Library grouping is not supported when user views are used to get library
|
// Automatic Library grouping is not supported when user views are used to get library
|
||||||
if (account.Configuration.GroupedFolders.length > 0) {
|
if (account.Configuration.GroupedFolders?.length > 0) {
|
||||||
return next({
|
return next({
|
||||||
status: 501,
|
status: 501,
|
||||||
message: ApiErrorCode.SyncErrorGroupedFolders,
|
message: ApiErrorCode.SyncErrorGroupedFolders,
|
||||||
|
|||||||
@@ -32,7 +32,14 @@ const router = Router();
|
|||||||
|
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
const includeIds = [
|
||||||
|
...new Set(
|
||||||
|
req.query.includeIds ? req.query.includeIds.toString().split(',') : []
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const pageSize = req.query.take
|
||||||
|
? Number(req.query.take)
|
||||||
|
: Math.max(10, includeIds.length);
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const 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() : '';
|
||||||
let query = getRepository(User).createQueryBuilder('user');
|
let query = getRepository(User).createQueryBuilder('user');
|
||||||
@@ -44,27 +51,33 @@ router.get('/', async (req, res, next) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeIds.length > 0) {
|
||||||
|
query.andWhereInIds(includeIds);
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.query.sort) {
|
switch (req.query.sort) {
|
||||||
case 'updated':
|
case 'updated':
|
||||||
query = query.orderBy('user.updatedAt', 'DESC');
|
query = query.orderBy('user.updatedAt', 'DESC');
|
||||||
break;
|
break;
|
||||||
case 'displayname':
|
case 'displayname':
|
||||||
query = query.orderBy(
|
query = query
|
||||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
.addSelect(
|
||||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||||
"user"."email"
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||||
ELSE
|
"user"."email"
|
||||||
LOWER(user.jellyfinUsername)
|
ELSE
|
||||||
END)
|
LOWER(user.jellyfinUsername)
|
||||||
ELSE
|
END)
|
||||||
LOWER(user.jellyfinUsername)
|
ELSE
|
||||||
END)
|
LOWER(user.jellyfinUsername)
|
||||||
ELSE
|
END)
|
||||||
LOWER(user.username)
|
ELSE
|
||||||
END`,
|
LOWER(user.username)
|
||||||
'ASC'
|
END`,
|
||||||
);
|
'displayname_sort_key'
|
||||||
|
)
|
||||||
|
.orderBy('displayname_sort_key', 'ASC');
|
||||||
break;
|
break;
|
||||||
case 'requests':
|
case 'requests':
|
||||||
query = query
|
query = query
|
||||||
@@ -84,6 +97,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
const [users, userCount] = await query
|
const [users, userCount] = await query
|
||||||
.take(pageSize)
|
.take(pageSize)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
|
.distinct(true)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -170,13 +184,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) {
|
||||||
@@ -191,6 +207,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,6 +222,79 @@ router.post<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get<{ userId: number }>(
|
||||||
|
'/:userId/pushSubscriptions',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSubs = await userPushSubRepository.find({
|
||||||
|
relations: { user: true },
|
||||||
|
where: { user: { id: req.params.userId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(userPushSubs);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'User subscriptions not found.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get<{ userId: number; key: string }>(
|
||||||
|
'/:userId/pushSubscription/:key',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
user: { id: req.params.userId },
|
||||||
|
p256dh: req.params.key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json(userPushSub);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: 'User subscription not found.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete<{ userId: number; key: string }>(
|
||||||
|
'/:userId/pushSubscription/:key',
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
|
||||||
|
const userPushSub = await userPushSubRepository.findOneOrFail({
|
||||||
|
relations: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
user: { id: req.params.userId },
|
||||||
|
p256dh: req.params.key,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userPushSubRepository.remove(userPushSub);
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Something went wrong deleting the user push subcription', {
|
||||||
|
label: 'API',
|
||||||
|
key: req.params.key,
|
||||||
|
errorMessage: e.message,
|
||||||
|
});
|
||||||
|
return next({
|
||||||
|
status: 500,
|
||||||
|
message: 'User push subcription not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
router.get<{ id: string }>('/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
|
import PlexTvAPI from '@server/api/plextv';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { UserType } from '@server/constants/user';
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
@@ -12,9 +15,23 @@ 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 { ApiError } from '@server/types/error';
|
import { ApiError } from '@server/types/error';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import net from 'net';
|
||||||
import { canMakePermissionsChange } from '.';
|
import { canMakePermissionsChange } from '.';
|
||||||
|
|
||||||
|
const isOwnProfile = (): Middleware => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.user?.id !== Number(req.params.id)) {
|
||||||
|
return next({
|
||||||
|
status: 403,
|
||||||
|
message: "You do not have permission to view this user's settings.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const isOwnProfileOrAdmin = (): Middleware => {
|
const isOwnProfileOrAdmin = (): Middleware => {
|
||||||
const authMiddleware: Middleware = (req, res, next) => {
|
const authMiddleware: Middleware = (req, res, next) => {
|
||||||
if (
|
if (
|
||||||
@@ -102,28 +119,10 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const oldEmail = user.email;
|
const oldEmail = user.email;
|
||||||
const oldUsername = user.username;
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
if (user.jellyfinUsername) {
|
if (user.userType !== UserType.PLEX) {
|
||||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
}
|
}
|
||||||
// Edge case for local users, because they have no Jellyfin username to fall back on
|
|
||||||
// if the email is not provided
|
|
||||||
if (user.userType === UserType.LOCAL) {
|
|
||||||
if (req.body.email) {
|
|
||||||
user.email = req.body.email;
|
|
||||||
if (
|
|
||||||
!user.username &&
|
|
||||||
user.email !== oldEmail &&
|
|
||||||
!oldEmail.includes('@')
|
|
||||||
) {
|
|
||||||
user.username = oldEmail;
|
|
||||||
}
|
|
||||||
} else if (req.body.username) {
|
|
||||||
user.email = oldUsername || user.email;
|
|
||||||
user.username = req.body.username;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email },
|
||||||
@@ -183,9 +182,8 @@ userSettingsRoutes.post<
|
|||||||
status: e.statusCode,
|
status: e.statusCode,
|
||||||
message: e.errorCode,
|
message: e.errorCode,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
return next({ status: 500, message: e.message });
|
|
||||||
}
|
}
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,6 +288,260 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
userSettingsRoutes.post<{ authToken: string }>(
|
||||||
|
'/linked-accounts/plex',
|
||||||
|
isOwnProfile(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(404).json({ code: ApiErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
|
// Make sure Plex login is enabled
|
||||||
|
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||||
|
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// First we need to use this auth token to get the user's email from plex.tv
|
||||||
|
const plextv = new PlexTvAPI(req.body.authToken);
|
||||||
|
const account = await plextv.getUser();
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (await userRepository.exist({ where: { plexId: account.id } })) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message: 'This Plex account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Emails do not match
|
||||||
|
if (user.email !== account.email) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message:
|
||||||
|
'This Plex account is registered under a different email address.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// valid plex user found, link to current user
|
||||||
|
user.userType = UserType.PLEX;
|
||||||
|
user.plexId = account.id;
|
||||||
|
user.plexUsername = account.username;
|
||||||
|
user.plexToken = account.authToken;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.delete<{ id: string }>(
|
||||||
|
'/linked-accounts/plex',
|
||||||
|
isOwnProfileOrAdmin(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
// Make sure Plex login is enabled
|
||||||
|
if (settings.main.mediaServerType !== MediaServerType.PLEX) {
|
||||||
|
return res.status(500).json({ message: 'Plex login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.password')
|
||||||
|
.where({
|
||||||
|
id: Number(req.params.id),
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id === 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
'Cannot unlink media server accounts for the primary administrator.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email || !user.password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'User does not have a local email or password set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
user.userType = UserType.LOCAL;
|
||||||
|
user.plexId = null;
|
||||||
|
user.plexUsername = null;
|
||||||
|
user.plexToken = null;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.post<{ username: string; password: string }>(
|
||||||
|
'/linked-accounts/jellyfin',
|
||||||
|
isOwnProfile(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||||
|
}
|
||||||
|
// Make sure jellyfin login is enabled
|
||||||
|
if (
|
||||||
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (
|
||||||
|
await userRepository.exist({
|
||||||
|
where: { jellyfinUsername: req.body.username },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message: 'The specified account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
|
const deviceId = Buffer.from(
|
||||||
|
`BOT_jellyseerr_${req.user.username ?? ''}`
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||||
|
|
||||||
|
const ip = req.ip;
|
||||||
|
let clientIp: string | undefined;
|
||||||
|
if (ip) {
|
||||||
|
if (net.isIPv4(ip)) {
|
||||||
|
clientIp = ip;
|
||||||
|
} else if (net.isIPv6(ip)) {
|
||||||
|
clientIp = ip.startsWith('::ffff:') ? ip.substring(7) : ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await jellyfinserver.login(
|
||||||
|
req.body.username,
|
||||||
|
req.body.password,
|
||||||
|
clientIp
|
||||||
|
);
|
||||||
|
|
||||||
|
// Do not allow linking of an already linked account
|
||||||
|
if (
|
||||||
|
await userRepository.exist({
|
||||||
|
where: { jellyfinUserId: account.User.Id },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return res.status(422).json({
|
||||||
|
message:
|
||||||
|
'The specified account is already linked to a Jellyseerr user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// valid jellyfin user found, link to current user
|
||||||
|
user.userType =
|
||||||
|
settings.main.mediaServerType === MediaServerType.EMBY
|
||||||
|
? UserType.EMBY
|
||||||
|
: UserType.JELLYFIN;
|
||||||
|
user.jellyfinUserId = account.User.Id;
|
||||||
|
user.jellyfinUsername = account.User.Name;
|
||||||
|
user.jellyfinAuthToken = account.AccessToken;
|
||||||
|
user.jellyfinDeviceId = deviceId;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to link account to user.', {
|
||||||
|
label: 'API',
|
||||||
|
ip: req.ip,
|
||||||
|
error: e,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
e instanceof ApiError &&
|
||||||
|
e.errorCode === ApiErrorCode.InvalidCredentials
|
||||||
|
) {
|
||||||
|
return res.status(401).json({ code: e.errorCode });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
userSettingsRoutes.delete<{ id: string }>(
|
||||||
|
'/linked-accounts/jellyfin',
|
||||||
|
isOwnProfileOrAdmin(),
|
||||||
|
async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
|
// Make sure jellyfin login is enabled
|
||||||
|
if (
|
||||||
|
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||||
|
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(500)
|
||||||
|
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.addSelect('user.password')
|
||||||
|
.where({
|
||||||
|
id: Number(req.params.id),
|
||||||
|
})
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id === 1) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message:
|
||||||
|
'Cannot unlink media server accounts for the primary administrator.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.email || !user.password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'User does not have a local email or password set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
user.userType = UserType.LOCAL;
|
||||||
|
user.jellyfinUserId = null;
|
||||||
|
user.jellyfinUsername = null;
|
||||||
|
user.jellyfinAuthToken = null;
|
||||||
|
user.jellyfinDeviceId = null;
|
||||||
|
await userRepository.save(user);
|
||||||
|
|
||||||
|
return res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||||
'/notifications',
|
'/notifications',
|
||||||
isOwnProfileOrAdmin(),
|
isOwnProfileOrAdmin(),
|
||||||
|
|||||||
4
server/types/custom.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '@dr.pogodin/csurf' {
|
||||||
|
import csrf = require('csurf');
|
||||||
|
export = csrf;
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
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 type { Dispatcher } from 'undici';
|
import type { Dispatcher } from 'undici';
|
||||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||||
|
|
||||||
export default async function createCustomProxyAgent(
|
export default async function createCustomProxyAgent(
|
||||||
proxySettings: ProxySettings
|
proxySettings: ProxySettings
|
||||||
) {
|
) {
|
||||||
const defaultAgent = new Agent();
|
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||||
|
|
||||||
const skipUrl = (url: string) => {
|
const skipUrl = (url: string | URL) => {
|
||||||
const hostname = new URL(url).hostname;
|
const hostname =
|
||||||
|
typeof url === 'string' ? new URL(url).hostname : url.hostname;
|
||||||
|
|
||||||
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
if (proxySettings.bypassLocalAddresses && isLocalAddress(hostname)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -38,8 +40,7 @@ export default async function createCustomProxyAgent(
|
|||||||
dispatch: Dispatcher['dispatch']
|
dispatch: Dispatcher['dispatch']
|
||||||
): Dispatcher['dispatch'] => {
|
): Dispatcher['dispatch'] => {
|
||||||
return (opts, handler) => {
|
return (opts, handler) => {
|
||||||
const url = opts.origin?.toString();
|
return opts.origin && skipUrl(opts.origin)
|
||||||
return url && skipUrl(url)
|
|
||||||
? defaultAgent.dispatch(opts, handler)
|
? defaultAgent.dispatch(opts, handler)
|
||||||
: dispatch(opts, handler);
|
: dispatch(opts, handler);
|
||||||
};
|
};
|
||||||
@@ -60,12 +61,10 @@ export default async function createCustomProxyAgent(
|
|||||||
':' +
|
':' +
|
||||||
proxySettings.port,
|
proxySettings.port,
|
||||||
token,
|
token,
|
||||||
interceptors: {
|
keepAliveTimeout: 5000,
|
||||||
Client: [noProxyInterceptor],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setGlobalDispatcher(proxyAgent);
|
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||||
} 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',
|
||||||
@@ -75,15 +74,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,
|
||||||
@@ -94,7 +86,11 @@ export default async function createCustomProxyAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isLocalAddress(hostname: string) {
|
function isLocalAddress(hostname: string) {
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
if (
|
||||||
|
hostname === 'localhost' ||
|
||||||
|
hostname === '127.0.0.1' ||
|
||||||
|
hostname === '::1'
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import type { MainSettings } from '@server/lib/settings';
|
import type { AllSettings, NetworkSettings } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
|
||||||
class RestartFlag {
|
class RestartFlag {
|
||||||
private settings: MainSettings;
|
private networkSettings: NetworkSettings;
|
||||||
|
|
||||||
public initializeSettings(settings: MainSettings): void {
|
public initializeSettings(settings: AllSettings): void {
|
||||||
this.settings = { ...settings };
|
this.networkSettings = {
|
||||||
|
...settings.network,
|
||||||
|
proxy: { ...settings.network.proxy },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public isSet(): boolean {
|
public isSet(): boolean {
|
||||||
const settings = getSettings().main;
|
const networkSettings = getSettings().network;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.settings.csrfProtection !== settings.csrfProtection ||
|
this.networkSettings.csrfProtection !== networkSettings.csrfProtection ||
|
||||||
this.settings.trustProxy !== settings.trustProxy ||
|
this.networkSettings.trustProxy !== networkSettings.trustProxy ||
|
||||||
this.settings.proxy.enabled !== settings.proxy.enabled ||
|
this.networkSettings.proxy.enabled !== networkSettings.proxy.enabled
|
||||||
this.settings.forceIpv4First !== settings.forceIpv4First ||
|
|
||||||
this.settings.dnsServers !== settings.dnsServers
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/assets/services/jellyfin-icon.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- ***** BEGIN LICENSE BLOCK *****
|
||||||
|
- Part of the Jellyfin project (https://jellyfin.media)
|
||||||
|
-
|
||||||
|
- All copyright belongs to the Jellyfin contributors; a full list can
|
||||||
|
- be found in the file CONTRIBUTORS.md
|
||||||
|
-
|
||||||
|
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
||||||
|
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
||||||
|
- ***** END LICENSE BLOCK ***** -->
|
||||||
|
<svg version="1.1" id="icon-transparent" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="110.25" y1="213.3" x2="496.14" y2="436.09">
|
||||||
|
<stop offset="0" style="stop-color:#AA5CC3"/>
|
||||||
|
<stop offset="1" style="stop-color:#00A4DC"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<title>icon-transparent</title>
|
||||||
|
<g id="icon-transparent">
|
||||||
|
<path id="inner-shape" d="M256,201.6c-20.4,0-86.2,119.3-76.2,139.4s142.5,19.9,152.4,0S276.5,201.6,256,201.6z" fill="url(#linear-gradient)"/>
|
||||||
|
<path id="outer-shape" d="M256,23.3c-61.6,0-259.8,359.4-229.6,420.1s429.3,60,459.2,0S317.6,23.3,256,23.3z
|
||||||
|
M406.5,390.8c-19.6,39.3-281.1,39.8-300.9,0s110.1-275.3,150.4-275.3S426.1,351.4,406.5,390.8z" fill="url(#linear-gradient)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -1,85 +1,43 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 361 157">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
<svg
|
.cls-2 {
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
fill: #eaaf20;
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
}
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
</style>
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
</defs>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<!-- Generator: Adobe Illustrator 28.7.1, SVG Export Plug-In . SVG Version: 1.2.0 Build 142) -->
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
<g>
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
<g id="Layer_1">
|
||||||
version="1.1"
|
<path id="path4" class="cls-1"
|
||||||
id="plex-logo"
|
d="M60.6,28.8c-14.3,0-23.5,3.9-31.3,13v-10H1.6v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM55.2,104.3c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||||
x="0px"
|
<path id="path6" class="cls-1"
|
||||||
y="0px"
|
d="M148.1,76.5c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.9h28.4l.2,75.6Z" />
|
||||||
viewBox="0 0 1000 460.89727"
|
<polygon id="polygon8" class="cls-2"
|
||||||
xml:space="preserve"
|
points="287.6 78.3 254.1 31.7 288.6 31.7 321.8 78.3 288.6 124.6 254.1 124.6 287.6 78.3" />
|
||||||
sodipodi:docname="plex-logo.svg"
|
<polygon id="polygon10" class="cls-1"
|
||||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
points="330.8 73 360.6 31.7 326.2 31.7 313.8 48.9 330.8 73" />
|
||||||
id="metadata25"><rdf:RDF><cc:Work
|
<path id="path12" class="cls-1"
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
d="M313.8,107.7l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
<path id="path16" class="cls-1"
|
||||||
id="defs23">
|
d="M228.7,97.9c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM211.9,50.7c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||||
</defs><sodipodi:namedview
|
<path id="path4-2" data-name="path4" class="cls-1"
|
||||||
pagecolor="#ffffff"
|
d="M59.3,28.2c-14.3,0-23.5,3.9-31.3,13v-10H.4v123.7s.5.2,1.9.5c1.9.5,12.1,2.5,19.6-3.4,6.5-5.3,8-11.4,8-18.3v-17.8c8,8,17,11.4,29.6,11.4,27.2,0,48-20.8,48-48.4s-20.1-50.7-48.3-50.7h0ZM54,103.8c-15.3,0-27.4-11.9-27.4-26.3s14.3-25.6,27.4-25.6,27.4,11.2,27.4,25.8-12.1,26-27.4,26Z" />
|
||||||
bordercolor="#111111"
|
<path id="path6-2" data-name="path6" class="cls-1"
|
||||||
borderopacity="1"
|
d="M146.9,75.9c0,10.7,1.2,23.7,12.4,37.9.2.2.7.9.7.9-4.6,7.3-10.2,12.3-17.7,12.3s-11.6-3-16.5-8c-5.1-5.5-7.5-12.6-7.5-20.1V.4h28.4l.2,75.6Z" />
|
||||||
objecttolerance="10"
|
<polygon id="polygon8-2" data-name="polygon8" class="cls-2"
|
||||||
gridtolerance="10"
|
points="286.4 77.8 252.9 31.2 287.3 31.2 320.6 77.8 287.3 124.1 252.9 124.1 286.4 77.8" />
|
||||||
guidetolerance="10"
|
<polygon id="polygon10-2" data-name="polygon10" class="cls-1"
|
||||||
inkscape:pageopacity="0"
|
points="329.5 72.5 359.4 31.2 324.9 31.2 312.6 48.3 329.5 72.5" />
|
||||||
inkscape:pageshadow="2"
|
<path id="path12-2" data-name="path12" class="cls-1"
|
||||||
inkscape:window-width="1920"
|
d="M312.6,107.2l5.8,7.5c5.6,8.2,12.9,12.3,21.3,12.3,9-.2,15.3-7.5,17.7-10.3,0,0-4.4-3.7-9.9-9.8-7.5-8.2-17.5-23.3-17.7-24l-17.2,24.2Z" />
|
||||||
inkscape:window-height="1017"
|
<path id="path16-2" data-name="path16" class="cls-1"
|
||||||
id="namedview21"
|
d="M227.4,97.4c-5.8,5-9.7,7.8-17.7,7.8-14.3,0-22.6-9.6-23.8-20.1h75.9c.5-1.4.7-3.2.7-6.2,0-29-22.6-50.7-52.2-50.7s-51.2,22.1-51.2,49.8,23,49.1,51.9,49.1,37.6-10.7,47.1-29.7h-30.8ZM210.7,50.1c12.6,0,22.1,7.8,24.3,18h-48c2.4-10.7,11.4-18,23.8-18h0Z" />
|
||||||
showgrid="false"
|
</g>
|
||||||
fit-margin-top="0"
|
</g>
|
||||||
fit-margin-left="0"
|
|
||||||
fit-margin-right="0"
|
|
||||||
fit-margin-bottom="0"
|
|
||||||
inkscape:zoom="0.27956081"
|
|
||||||
inkscape:cx="783.06912"
|
|
||||||
inkscape:cy="-132.85701"
|
|
||||||
inkscape:window-x="1912"
|
|
||||||
inkscape:window-y="-8"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="plex-logo" />
|
|
||||||
<style
|
|
||||||
type="text/css"
|
|
||||||
id="style2">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
.st1{fill:#EBAF00;}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
class="st0"
|
|
||||||
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
|
|
||||||
id="path4"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
|
|
||||||
id="path6"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
|
|
||||||
class="st1"
|
|
||||||
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
|
|
||||||
id="polygon8"
|
|
||||||
style="fill:#ebaf00"
|
|
||||||
transform="scale(6.7567568)" /><polygon
|
|
||||||
class="st0"
|
|
||||||
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
|
|
||||||
id="polygon10"
|
|
||||||
style="fill:#ffffff"
|
|
||||||
transform="scale(6.7567568)" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
|
|
||||||
id="path12"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
id="path16"
|
|
||||||
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
|
|
||||||
class="st0" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -17,13 +17,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
const dAirDate = new Date(airDate);
|
const dAirDate = new Date(airDate);
|
||||||
const nowDate = new Date();
|
const nowDate = new Date();
|
||||||
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
|
const alreadyAired = dAirDate.getTime() < nowDate.getTime();
|
||||||
|
|
||||||
const compareWeek = new Date(
|
const compareWeek = new Date(
|
||||||
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
|
alreadyAired ? Date.now() - WEEK : Date.now() + WEEK
|
||||||
);
|
);
|
||||||
|
|
||||||
let showRelative = false;
|
let showRelative = false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
|
(alreadyAired && dAirDate.getTime() > compareWeek.getTime()) ||
|
||||||
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
|
(!alreadyAired && dAirDate.getTime() < compareWeek.getTime())
|
||||||
@@ -31,6 +28,10 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
showRelative = true;
|
showRelative = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const diffInDays = Math.round(
|
||||||
|
(dAirDate.getTime() - nowDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge badgeType="light">
|
<Badge badgeType="light">
|
||||||
@@ -48,9 +49,9 @@ const AirDateBadge = ({ airDate }: AirDateBadgeProps) => {
|
|||||||
{
|
{
|
||||||
relativeTime: (
|
relativeTime: (
|
||||||
<FormattedRelativeTime
|
<FormattedRelativeTime
|
||||||
value={(dAirDate.getTime() - Date.now()) / 1000}
|
value={diffInDays}
|
||||||
|
unit="day"
|
||||||
numeric="auto"
|
numeric="auto"
|
||||||
updateIntervalInSeconds={1}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,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';
|
||||||
@@ -238,11 +239,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 +251,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,
|
||||||
@@ -298,7 +297,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
src={
|
src={
|
||||||
title?.posterPath
|
title?.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -7,6 +7,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 +39,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 +51,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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
src={
|
src={
|
||||||
data.posterPath
|
data.posterPath
|
||||||
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
|
||||||
: '/images/overseerr_poster_not_found.png'
|
: '/images/jellyseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt=""
|
alt=""
|
||||||
sizes="100vw"
|
sizes="100vw"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export type ButtonType =
|
export type ButtonType =
|
||||||
| 'default'
|
| 'default'
|
||||||
@@ -97,7 +98,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
if (as === 'a') {
|
if (as === 'a') {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
className={buttonStyle.join(' ')}
|
className={twMerge(buttonStyle)}
|
||||||
{...(props as React.ComponentProps<'a'>)}
|
{...(props as React.ComponentProps<'a'>)}
|
||||||
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
ref={ref as ForwardedRef<HTMLAnchorElement>}
|
||||||
>
|
>
|
||||||
@@ -107,7 +108,7 @@ function Button<P extends ElementTypes = 'button'>(
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={buttonStyle.join(' ')}
|
className={twMerge(buttonStyle)}
|
||||||
{...(props as React.ComponentProps<'button'>)}
|
{...(props as React.ComponentProps<'button'>)}
|
||||||
ref={ref as ForwardedRef<HTMLButtonElement>}
|
ref={ref as ForwardedRef<HTMLButtonElement>}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,77 +1,29 @@
|
|||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import Dropdown from '@app/components/Common/Dropdown';
|
||||||
import { withProperties } from '@app/utils/typeHelpers';
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Menu } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
import type {
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';
|
||||||
AnchorHTMLAttributes,
|
|
||||||
ButtonHTMLAttributes,
|
|
||||||
RefObject,
|
|
||||||
} from 'react';
|
|
||||||
import { Fragment, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
type ButtonWithDropdownProps = {
|
||||||
buttonType?: 'primary' | 'ghost';
|
|
||||||
}
|
|
||||||
|
|
||||||
const DropdownItem = ({
|
|
||||||
children,
|
|
||||||
buttonType = 'primary',
|
|
||||||
...props
|
|
||||||
}: DropdownItemProps) => {
|
|
||||||
let styleClass = 'button-md text-white';
|
|
||||||
|
|
||||||
switch (buttonType) {
|
|
||||||
case 'ghost':
|
|
||||||
styleClass +=
|
|
||||||
' bg-transparent rounded hover:bg-gradient-to-br from-indigo-600 to-purple-600 text-white focus:border-gray-500 focus:text-white';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
styleClass +=
|
|
||||||
' bg-indigo-600 rounded hover:bg-indigo-500 focus:border-indigo-700 focus:text-white';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
className={`flex cursor-pointer items-center px-4 py-2 text-sm leading-5 focus:outline-none ${styleClass}`}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ButtonWithDropdownProps {
|
|
||||||
text: React.ReactNode;
|
text: React.ReactNode;
|
||||||
dropdownIcon?: React.ReactNode;
|
dropdownIcon?: React.ReactNode;
|
||||||
buttonType?: 'primary' | 'ghost';
|
buttonType?: 'primary' | 'ghost';
|
||||||
}
|
} & (
|
||||||
interface ButtonProps
|
| ({ as?: 'button' } & ButtonHTMLAttributes<HTMLButtonElement>)
|
||||||
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
| ({ as: 'a' } & AnchorHTMLAttributes<HTMLAnchorElement>)
|
||||||
ButtonWithDropdownProps {
|
);
|
||||||
as?: 'button';
|
|
||||||
}
|
|
||||||
interface AnchorProps
|
|
||||||
extends AnchorHTMLAttributes<HTMLAnchorElement>,
|
|
||||||
ButtonWithDropdownProps {
|
|
||||||
as: 'a';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ButtonWithDropdown = ({
|
const ButtonWithDropdown = ({
|
||||||
as,
|
|
||||||
text,
|
text,
|
||||||
children,
|
children,
|
||||||
dropdownIcon,
|
dropdownIcon,
|
||||||
className,
|
className,
|
||||||
buttonType = 'primary',
|
buttonType = 'primary',
|
||||||
...props
|
...props
|
||||||
}: ButtonProps | AnchorProps) => {
|
}: ButtonWithDropdownProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
|
|
||||||
useClickOutside(buttonRef, () => setIsOpen(false));
|
|
||||||
|
|
||||||
const styleClasses = {
|
const styleClasses = {
|
||||||
mainButtonClasses: 'button-md text-white border',
|
mainButtonClasses: 'button-md text-white border',
|
||||||
dropdownSideButtonClasses: 'button-md border',
|
dropdownSideButtonClasses: 'button-md border',
|
||||||
dropdownClasses: 'button-md',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (buttonType) {
|
switch (buttonType) {
|
||||||
@@ -79,72 +31,40 @@ const ButtonWithDropdown = ({
|
|||||||
styleClasses.mainButtonClasses +=
|
styleClasses.mainButtonClasses +=
|
||||||
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
' bg-transparent border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
|
||||||
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
styleClasses.dropdownSideButtonClasses = styleClasses.mainButtonClasses;
|
||||||
styleClasses.dropdownClasses +=
|
|
||||||
' bg-gray-800 border border-gray-700 bg-opacity-80 p-1 backdrop-blur';
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
styleClasses.mainButtonClasses +=
|
styleClasses.mainButtonClasses +=
|
||||||
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
' bg-indigo-600 border-indigo-500 bg-opacity-80 hover:bg-opacity-100 hover:border-indigo-500 active:bg-indigo-700 active:border-indigo-700 focus:ring-blue';
|
||||||
styleClasses.dropdownSideButtonClasses +=
|
styleClasses.dropdownSideButtonClasses +=
|
||||||
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
' bg-indigo-600 bg-opacity-80 border-indigo-500 hover:bg-opacity-100 active:bg-opacity-100 focus:ring-blue';
|
||||||
styleClasses.dropdownClasses += ' bg-indigo-600 p-1';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TriggerElement = props.as ?? 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="relative inline-flex h-full rounded-md shadow-sm">
|
<Menu as="div" className="relative z-10 inline-flex">
|
||||||
{as === 'a' ? (
|
<TriggerElement
|
||||||
<a
|
type="button"
|
||||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
||||||
styleClasses.mainButtonClasses
|
styleClasses.mainButtonClasses
|
||||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
||||||
ref={buttonRef as RefObject<HTMLAnchorElement>}
|
{...(props as Record<string, string>)}
|
||||||
{...(props as AnchorHTMLAttributes<HTMLAnchorElement>)}
|
>
|
||||||
>
|
{text}
|
||||||
{text}
|
</TriggerElement>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`relative z-10 inline-flex h-full items-center px-4 py-2 text-sm font-medium leading-5 transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none ${
|
|
||||||
styleClasses.mainButtonClasses
|
|
||||||
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
|
|
||||||
ref={buttonRef as RefObject<HTMLButtonElement>}
|
|
||||||
{...(props as ButtonHTMLAttributes<HTMLButtonElement>)}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{children && (
|
{children && (
|
||||||
<span className="relative -ml-px block">
|
<span className="relative -ml-px block">
|
||||||
<button
|
<Menu.Button
|
||||||
type="button"
|
type="button"
|
||||||
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
className={`relative z-10 inline-flex h-full items-center rounded-r-md px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
|
||||||
aria-label="Expand"
|
aria-label="Expand"
|
||||||
onClick={() => setIsOpen((state) => !state)}
|
|
||||||
>
|
>
|
||||||
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
{dropdownIcon ? dropdownIcon : <ChevronDownIcon />}
|
||||||
</button>
|
</Menu.Button>
|
||||||
<Transition
|
<Dropdown.Items dropdownType={buttonType}>{children}</Dropdown.Items>
|
||||||
as={Fragment}
|
|
||||||
show={isOpen}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<div className="absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md shadow-lg">
|
|
||||||
<div
|
|
||||||
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
|
|
||||||
>
|
|
||||||
<div className="py-1">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</Menu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default withProperties(ButtonWithDropdown, { Item: DropdownItem });
|
export default withProperties(ButtonWithDropdown, { Item: Dropdown.Item });
|
||||||
|
|||||||
117
src/components/Common/Dropdown/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { withProperties } from '@app/utils/typeHelpers';
|
||||||
|
import { Menu, Transition } from '@headlessui/react';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
|
import {
|
||||||
|
Fragment,
|
||||||
|
useRef,
|
||||||
|
type AnchorHTMLAttributes,
|
||||||
|
type ButtonHTMLAttributes,
|
||||||
|
type HTMLAttributes,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
buttonType?: 'primary' | 'ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownItem = ({
|
||||||
|
children,
|
||||||
|
buttonType = 'primary',
|
||||||
|
...props
|
||||||
|
}: DropdownItemProps) => {
|
||||||
|
return (
|
||||||
|
<Menu.Item>
|
||||||
|
<a
|
||||||
|
className={[
|
||||||
|
'button-md flex cursor-pointer items-center rounded px-4 py-2 text-sm leading-5 text-white focus:text-white focus:outline-none',
|
||||||
|
buttonType === 'ghost'
|
||||||
|
? 'bg-transparent from-indigo-600 to-purple-600 hover:bg-gradient-to-br focus:border-gray-500'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-500 focus:border-indigo-700',
|
||||||
|
].join(' ')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
</Menu.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropdownItemsProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
dropdownType: 'primary' | 'ghost';
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownItems = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
dropdownType,
|
||||||
|
...props
|
||||||
|
}: DropdownItemsProps) => {
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Menu.Items
|
||||||
|
className={[
|
||||||
|
'absolute right-0 z-40 mt-2 -mr-1 w-56 origin-top-right rounded-md p-1 shadow-lg',
|
||||||
|
dropdownType === 'ghost'
|
||||||
|
? 'border border-gray-700 bg-gray-800 bg-opacity-80 backdrop-blur'
|
||||||
|
: 'bg-indigo-600',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="py-1">{children}</div>
|
||||||
|
</Menu.Items>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
text: React.ReactNode;
|
||||||
|
dropdownIcon?: React.ReactNode;
|
||||||
|
buttonType?: 'primary' | 'ghost';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown = ({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
dropdownIcon,
|
||||||
|
className,
|
||||||
|
buttonType = 'primary',
|
||||||
|
...props
|
||||||
|
}: DropdownProps) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative z-10">
|
||||||
|
<Menu.Button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'button-md inline-flex h-full items-center space-x-2 rounded-md border px-4 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out hover:z-20 focus:z-20 focus:outline-none',
|
||||||
|
buttonType === 'ghost'
|
||||||
|
? 'border-gray-600 bg-transparent hover:border-gray-200 focus:border-gray-100 active:border-gray-100'
|
||||||
|
: 'focus:ring-blue border-indigo-500 bg-indigo-600 bg-opacity-80 hover:border-indigo-500 hover:bg-opacity-100 active:border-indigo-700 active:bg-indigo-700',
|
||||||
|
className,
|
||||||
|
].join(' ')}
|
||||||
|
ref={buttonRef}
|
||||||
|
disabled={!children}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>{text}</span>
|
||||||
|
{children && (dropdownIcon ? dropdownIcon : <ChevronDownIcon />)}
|
||||||
|
</Menu.Button>
|
||||||
|
{children && (
|
||||||
|
<DropdownItems dropdownType={buttonType}>{children}</DropdownItems>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default withProperties(Dropdown, {
|
||||||
|
Item: DropdownItem,
|
||||||
|
Items: DropdownItems,
|
||||||
|
});
|
||||||
44
src/components/Common/LabeledCheckbox/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Field } from 'formik';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface LabeledCheckboxProps {
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
onChange: () => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={twMerge('relative flex items-start', className)}>
|
||||||
|
<div className="flex h-6 items-center">
|
||||||
|
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3 text-sm leading-6">
|
||||||
|
<label htmlFor="localLogin" className="block">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-white">{label}</span>
|
||||||
|
<span className="font-normal text-gray-400">{description}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
/* can hold child checkboxes */
|
||||||
|
children && <div className="mt-4 pl-10">{children}</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LabeledCheckbox;
|
||||||
@@ -29,11 +29,16 @@ interface ModalProps {
|
|||||||
secondaryDisabled?: boolean;
|
secondaryDisabled?: boolean;
|
||||||
tertiaryDisabled?: boolean;
|
tertiaryDisabled?: boolean;
|
||||||
tertiaryButtonType?: ButtonType;
|
tertiaryButtonType?: ButtonType;
|
||||||
|
okButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
cancelButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
secondaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
tertiaryButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
disableScrollLock?: boolean;
|
disableScrollLock?: boolean;
|
||||||
backgroundClickable?: boolean;
|
backgroundClickable?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
backdrop?: string;
|
backdrop?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
dialogClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
||||||
@@ -61,6 +66,11 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
loading = false,
|
loading = false,
|
||||||
onTertiary,
|
onTertiary,
|
||||||
backdrop,
|
backdrop,
|
||||||
|
dialogClass,
|
||||||
|
okButtonProps,
|
||||||
|
cancelButtonProps,
|
||||||
|
secondaryButtonProps,
|
||||||
|
tertiaryButtonProps,
|
||||||
},
|
},
|
||||||
parentRef
|
parentRef
|
||||||
) => {
|
) => {
|
||||||
@@ -106,7 +116,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<Transition
|
<Transition
|
||||||
className="hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle"
|
className={`hide-scrollbar relative inline-block w-full overflow-auto bg-gray-800 px-4 pt-4 pb-4 text-left align-bottom shadow-xl ring-1 ring-gray-700 transition-all sm:my-8 sm:max-w-3xl sm:rounded-lg sm:align-middle ${dialogClass}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-headline"
|
aria-labelledby="modal-headline"
|
||||||
@@ -189,6 +199,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={okDisabled}
|
disabled={okDisabled}
|
||||||
data-testid="modal-ok-button"
|
data-testid="modal-ok-button"
|
||||||
|
{...okButtonProps}
|
||||||
>
|
>
|
||||||
{okText ? okText : 'Ok'}
|
{okText ? okText : 'Ok'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -200,6 +211,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={secondaryDisabled}
|
disabled={secondaryDisabled}
|
||||||
data-testid="modal-secondary-button"
|
data-testid="modal-secondary-button"
|
||||||
|
{...secondaryButtonProps}
|
||||||
>
|
>
|
||||||
{secondaryText}
|
{secondaryText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -210,6 +222,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
onClick={onTertiary}
|
onClick={onTertiary}
|
||||||
className="ml-3"
|
className="ml-3"
|
||||||
disabled={tertiaryDisabled}
|
disabled={tertiaryDisabled}
|
||||||
|
{...tertiaryButtonProps}
|
||||||
>
|
>
|
||||||
{tertiaryText}
|
{tertiaryText}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,6 +233,7 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="ml-3 sm:ml-0"
|
className="ml-3 sm:ml-0"
|
||||||
data-testid="modal-cancel-button"
|
data-testid="modal-cancel-button"
|
||||||
|
{...cancelButtonProps}
|
||||||
>
|
>
|
||||||
{cancelText
|
{cancelText
|
||||||
? cancelText
|
? cancelText
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Component
|
<Component
|
||||||
|
autoComplete="off"
|
||||||
|
data-form-type="other"
|
||||||
|
data-1pignore="true"
|
||||||
|
data-lpignore="true"
|
||||||
{...componentProps}
|
{...componentProps}
|
||||||
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
||||||
type={
|
type={
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { DiscoverSliderType } from '@server/constants/discover';
|
|||||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -76,9 +77,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
|
|
||||||
const keywords = await Promise.all(
|
const keywords = await Promise.all(
|
||||||
slider.data.split(',').map(async (keywordId) => {
|
slider.data.split(',').map(async (keywordId) => {
|
||||||
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
const keyword = await axios.get<Keyword>(
|
||||||
const keyword: Keyword = await res.json();
|
`/api/v1/keyword/${keywordId}`
|
||||||
return keyword;
|
);
|
||||||
|
|
||||||
|
return keyword.data;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,13 +98,15 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const response = await axios.get<TmdbGenre[]>(
|
||||||
`/api/v1/genres/${
|
`/api/v1/genres/${
|
||||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
const genres: TmdbGenre[] = await res.json();
|
|
||||||
const genre = genres.find((genre) => genre.id === Number(slider.data));
|
const genre = response.data.find(
|
||||||
|
(genre) => genre.id === Number(slider.data)
|
||||||
|
);
|
||||||
|
|
||||||
setDefaultDataValue([
|
setDefaultDataValue([
|
||||||
{
|
{
|
||||||
@@ -116,8 +121,11 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/v1/studio/${slider.data}`);
|
const response = await axios.get<ProductionCompany>(
|
||||||
const studio: ProductionCompany = await res.json();
|
`/api/v1/studio/${slider.data}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const studio = response.data;
|
||||||
|
|
||||||
setDefaultDataValue([
|
setDefaultDataValue([
|
||||||
{
|
{
|
||||||
@@ -160,17 +168,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadKeywordOptions = async (inputValue: string) => {
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
const res = await fetch(
|
const results = await axios.get<TmdbKeywordSearchResponse>(
|
||||||
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`,
|
'/api/v1/search/keyword',
|
||||||
{
|
{
|
||||||
headers: {
|
params: {
|
||||||
'Content-Type': 'application/json',
|
query: encodeURIExtraParams(inputValue),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const results: TmdbKeywordSearchResponse = await res.json();
|
|
||||||
|
|
||||||
return results.results.map((result) => ({
|
return results.data.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
@@ -181,37 +188,38 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const results = await axios.get<TmdbCompanySearchResponse>(
|
||||||
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`,
|
'/api/v1/search/company',
|
||||||
{
|
{
|
||||||
headers: {
|
params: {
|
||||||
'Content-Type': 'application/json',
|
query: encodeURIExtraParams(inputValue),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const results: TmdbCompanySearchResponse = await res.json();
|
|
||||||
|
|
||||||
return results.results.map((result) => ({
|
return results.data.results.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMovieGenreOptions = async () => {
|
const loadMovieGenreOptions = async () => {
|
||||||
const res = await fetch('/api/v1/discover/genreslider/movie');
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
const results: GenreSliderItem[] = await res.json();
|
'/api/v1/discover/genreslider/movie'
|
||||||
|
);
|
||||||
|
|
||||||
return results.map((result) => ({
|
return results.data.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadTvGenreOptions = async () => {
|
const loadTvGenreOptions = async () => {
|
||||||
const res = await fetch('/api/v1/discover/genreslider/tv');
|
const results = await axios.get<GenreSliderItem[]>(
|
||||||
const results: GenreSliderItem[] = await res.json();
|
'/api/v1/discover/genreslider/tv'
|
||||||
|
);
|
||||||
|
|
||||||
return results.map((result) => ({
|
return results.data.map((result) => ({
|
||||||
label: result.name,
|
label: result.name,
|
||||||
value: result.id,
|
value: result.id,
|
||||||
}));
|
}));
|
||||||
@@ -306,31 +314,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
|||||||
onSubmit={async (values, { resetForm }) => {
|
onSubmit={async (values, { resetForm }) => {
|
||||||
try {
|
try {
|
||||||
if (slider) {
|
if (slider) {
|
||||||
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
||||||
method: 'PUT',
|
type: Number(values.sliderType),
|
||||||
headers: {
|
title: values.title,
|
||||||
'Content-Type': 'application/json',
|
data: values.data,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: Number(values.sliderType),
|
|
||||||
title: values.title,
|
|
||||||
data: values.data,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
} else {
|
} else {
|
||||||
const res = await fetch('/api/v1/settings/discover/add', {
|
await axios.post('/api/v1/settings/discover/add', {
|
||||||
method: 'POST',
|
type: Number(values.sliderType),
|
||||||
headers: {
|
title: values.title,
|
||||||
'Content-Type': 'application/json',
|
data: values.data,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: Number(values.sliderType),
|
|
||||||
title: values.title,
|
|
||||||
data: values.data,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { DiscoverSliderType } from '@server/constants/discover';
|
import { DiscoverSliderType } from '@server/constants/discover';
|
||||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||||
|
import axios from 'axios';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useDrag, useDrop } from 'react-aria';
|
import { useDrag, useDrop } from 'react-aria';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -77,10 +78,7 @@ const DiscoverSliderEdit = ({
|
|||||||
|
|
||||||
const deleteSlider = async () => {
|
const deleteSlider = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
addToast(intl.formatMessage(messages.deletesuccess), {
|
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
|
|||||||