mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
56 Commits
gh-pages
...
preview-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7e91c7c9 | ||
|
|
45ef150e36 | ||
|
|
54cfeefe74 | ||
|
|
89e0a831ec | ||
|
|
e57d2654d1 | ||
|
|
a02a0dd176 | ||
|
|
7423bbbffc | ||
|
|
32343f23a3 | ||
|
|
15cb949f1f | ||
|
|
cfd1bc2535 | ||
|
|
80f63017ac | ||
|
|
0c7e652672 | ||
|
|
bd4da6d5fc | ||
|
|
12f908de7f | ||
|
|
61dcd8e487 | ||
|
|
9aee8887d3 | ||
|
|
2348f23f43 | ||
|
|
74a2d25f15 | ||
|
|
a2c2d261fc | ||
|
|
71acfb1b1f | ||
|
|
29a32d0391 | ||
|
|
f7be4789a2 | ||
|
|
181cb19048 | ||
|
|
32c77f9e94 | ||
|
|
b43c1e350e | ||
|
|
64453320d3 | ||
|
|
36d98a2681 | ||
|
|
d5f817e734 | ||
|
|
422085523e | ||
|
|
fccfca6ed0 | ||
|
|
3fc14c9e22 | ||
|
|
62dbde448c | ||
|
|
0116c13e06 | ||
|
|
c96ca6742e | ||
|
|
c80d9a853a | ||
|
|
6cea8bba59 | ||
|
|
2be9c7dcc1 | ||
|
|
5cc4389825 | ||
|
|
dd6dbf1de9 | ||
|
|
c600566ac0 | ||
|
|
4db1df2ba5 | ||
|
|
3a363ae1ff | ||
|
|
084e1b224e | ||
|
|
b36bb3fa58 | ||
|
|
ae955e9e7c | ||
|
|
7a826a1308 | ||
|
|
f201fee1ff | ||
|
|
316c31bd06 | ||
|
|
9dd175bb2e | ||
|
|
31ca32df99 | ||
|
|
df2f05f129 | ||
|
|
5428b19c62 | ||
|
|
8dde04396d | ||
|
|
3f7c01853c | ||
|
|
31472009ab | ||
|
|
73890ac802 |
@@ -403,6 +403,42 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "mobihen",
|
||||
"name": "Nir Israel Hen",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/35529491?v=4",
|
||||
"profile": "https://mobihen.com",
|
||||
"contributions": [
|
||||
"translation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "XDark187",
|
||||
"name": "Baraa",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/39034192?v=4",
|
||||
"profile": "https://github.com/XDark187",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "franciscofsales",
|
||||
"name": "Francisco Sales",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/7977645?v=4",
|
||||
"profile": "https://github.com/franciscofsales",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "myselfolli",
|
||||
"name": "Oliver Laing",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/37535998?v=4",
|
||||
"profile": "https://github.com/myselfolli",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,2 +1,2 @@
|
||||
# Global code ownership
|
||||
* @Fallenbagel
|
||||
* @Fallenbagel @gauthier-th
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,8 +4,8 @@
|
||||
|
||||
#### To-Dos
|
||||
|
||||
- [ ] Successful build `yarn build`
|
||||
- [ ] Translation keys `yarn i18n:extract`
|
||||
- [ ] Successful build `pnpm build`
|
||||
- [ ] Translation keys `pnpm i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
#### Issues Fixed or Closed
|
||||
|
||||
1
.github/workflows/docs-deploy.yml
vendored
1
.github/workflows/docs-deploy.yml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
concurrency: build-deploy-pages
|
||||
|
||||
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||
permissions:
|
||||
|
||||
@@ -8,3 +8,4 @@ pnpm-lock.yaml
|
||||
# assets
|
||||
src/assets/
|
||||
public/
|
||||
docs/
|
||||
|
||||
@@ -3,6 +3,12 @@ module.exports = {
|
||||
singleQuote: true,
|
||||
trailingComma: 'es5',
|
||||
overrides: [
|
||||
{
|
||||
files: 'pnpm-lock.yaml',
|
||||
options: {
|
||||
rangeEnd: 0, // default: Infinity
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'gen-docs/pnpm-lock.yaml',
|
||||
options: {
|
||||
|
||||
@@ -7,9 +7,9 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
### Tools Required
|
||||
|
||||
- HTML/Typescript/Javascript editor
|
||||
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 14.x or higher)
|
||||
- [Yarn](https://yarnpkg.com/)
|
||||
- [VSCode](https://code.visualstudio.com/) is recommended. Upon opening the project, a few extensions will be automatically recommended for install.
|
||||
- [NodeJS](https://nodejs.org/en/download/) (Node 20.x)
|
||||
- [Pnpm](https://pnpm.io/cli/install)
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### Getting Started
|
||||
@@ -18,7 +18,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/jellyseerr.git
|
||||
cd overseerr/
|
||||
cd jellyseerr/
|
||||
```
|
||||
|
||||
2. Add the remote `upstream`:
|
||||
@@ -48,8 +48,8 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
||||
4. Run the development environment:
|
||||
|
||||
```bash
|
||||
yarn
|
||||
yarn dev
|
||||
pnpm
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
|
||||
@@ -93,7 +93,7 @@ When adding new UI text, please try to adhere to the following guidelines:
|
||||
8. If an additional description or "tip" is required for a form field, it should be styled using the global CSS class `label-tip`.
|
||||
9. In full sentences, abbreviations like "info" or "auto" should not be used in place of full words, unless referencing the name/label of a specific setting or option which has an abbreviation in its name.
|
||||
10. Do your best to check for spelling errors and grammatical mistakes.
|
||||
11. Do not misspell "Overseerr."
|
||||
11. Do not misspell "Jellyseerr."
|
||||
|
||||
## Translation
|
||||
|
||||
@@ -103,4 +103,4 @@ We use [Weblate](https://jellyseerr.borgcube.de/projects/jellyseerr/jellyseerr-f
|
||||
|
||||
## Attribution
|
||||
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js) and [Radarr](https://github.com/Radarr/Radarr) contribution guides.
|
||||
This contribution guide was inspired by the [Next.js](https://github.com/vercel/next.js), [Radarr](https://github.com/Radarr/Radarr), and [Overseerr](https://github.com/sct/Overseerr) contribution guides.
|
||||
|
||||
14
README.md
14
README.md
@@ -8,10 +8,10 @@
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/ckbvBtDJgC"><img src="https://img.shields.io/discord/952656177924300932" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/fallenbagel/jellyseerr"><img src="https://img.shields.io/docker/pulls/fallenbagel/jellyseerr" alt="Docker pulls"></a>
|
||||
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/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>
|
||||
<!-- 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-40-orange.svg"/></a>
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||
@@ -37,6 +37,7 @@ With more features on the way! Check out our [issue tracker](https://github.com/
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Jellyseerr:
|
||||
|
||||
https://docs.jellyseerr.dev/getting-started/
|
||||
|
||||
### Packages:
|
||||
@@ -136,6 +137,15 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://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="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://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/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>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const testUser = {
|
||||
displayName: 'Test User',
|
||||
username: 'Test User',
|
||||
emailAddress: 'test@seeerr.dev',
|
||||
password: 'test1234',
|
||||
};
|
||||
@@ -32,7 +32,7 @@ describe('User List', () => {
|
||||
|
||||
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
||||
|
||||
cy.get('#displayName').type(testUser.displayName);
|
||||
cy.get('#username').type(testUser.username);
|
||||
cy.get('#email').type(testUser.emailAddress);
|
||||
cy.get('#password').type(testUser.password);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ A Nginx subfolder workaround configuration is provided below, but it is not offi
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs groupId="nginx-reverse-proxy">
|
||||
<Tabs groupId="nginx-reverse-proxy" queryString>
|
||||
<TabItem value="subdomain" label="Subdomain">
|
||||
Add the following configuration to a new file `/etc/nginx/sites-available/jellyseerr.example.com.conf`:
|
||||
|
||||
@@ -63,9 +63,9 @@ sudo ln -s /etc/nginx/sites-available/jellyseerr.example.com.conf /etc/nginx/sit
|
||||
<TabItem value="subfolder" label="Subfolder">
|
||||
|
||||
:::warning
|
||||
This Nginx subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Overseerr is updated.
|
||||
This Nginx subfolder reverse proxy is an unsupported workaround, and only provided as an example. The filters may stop working when Jellyseerr is updated.
|
||||
|
||||
If you encounter any issues with Overseerr while using this workaround, we may ask you to try to reproduce the problem without the Nginx proxy.
|
||||
If you encounter any issues with Jellyseerr while using this workaround, we may ask you to try to reproduce the problem without the Nginx proxy.
|
||||
:::
|
||||
|
||||
Add the following location block to your existing `nginx.conf` file.
|
||||
@@ -146,9 +146,9 @@ Add a new proxy host with the following settings:
|
||||
|
||||
### Details
|
||||
|
||||
- **Domain Names:** Your desired external Overseerr hostname; e.g., `overseerr.example.com`
|
||||
- **Domain Names:** Your desired external Jellyseerr hostname; e.g., `jellyseerr.example.com`
|
||||
- **Scheme:** `http`
|
||||
- **Forward Hostname / IP:** Internal Overseerr hostname or IP
|
||||
- **Forward Hostname / IP:** Internal Jellyseerr hostname or IP
|
||||
- **Forward Port:** `5055`
|
||||
- **Cache Assets:** yes
|
||||
- **Block Common Exploits:** yes
|
||||
@@ -190,18 +190,18 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
|
||||
|
||||
## Traefik (v2)
|
||||
|
||||
Add the following labels to the Overseerr service in your `docker-compose.yml` file:
|
||||
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
## HTTP Routers
|
||||
- 'traefik.http.routers.overseerr-rtr.entrypoints=https'
|
||||
- 'traefik.http.routers.overseerr-rtr.rule=Host(`overseerr.domain.com`)'
|
||||
- 'traefik.http.routers.overseerr-rtr.tls=true'
|
||||
- 'traefik.http.routers.jellyseerr-rtr.entrypoints=https'
|
||||
- 'traefik.http.routers.jellyseerr-rtr.rule=Host(`jellyseerr.domain.com`)'
|
||||
- 'traefik.http.routers.jellyseerr-rtr.tls=true'
|
||||
## HTTP Services
|
||||
- 'traefik.http.routers.overseerr-rtr.service=overseerr-svc'
|
||||
- 'traefik.http.services.overseerr-svc.loadbalancer.server.port=5055'
|
||||
- 'traefik.http.routers.jellyseerr-rtr.service=jellyseerr-svc'
|
||||
- 'traefik.http.services.jellyseerr-svc.loadbalancer.server.port=5055'
|
||||
```
|
||||
|
||||
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
@@ -17,7 +17,7 @@ To install Jellyseerr from the AUR, you can use an AUR helper like `yay` or `par
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs groupId="aur-methods">
|
||||
<Tabs groupId="aur-methods" queryString>
|
||||
<TabItem value="yay" label="yay">
|
||||
```bash
|
||||
yay -S jellyseerr
|
||||
|
||||
@@ -12,7 +12,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
### Prerequisites
|
||||
<Tabs groupId="versions">
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="Latest">
|
||||
- [Node.js 18.x](https://nodejs.org/en/download/)
|
||||
- [Yarn 1.x](https://classic.yarnpkg.com/lang/en/docs/install)
|
||||
@@ -29,7 +29,7 @@ import TabItem from '@theme/TabItem';
|
||||
|
||||
## Unix (Linux, macOS)
|
||||
### Installation
|
||||
<Tabs groupId="versions">
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="latest">
|
||||
1. Assuming you want the working directory to be `/opt/jellyseerr`, create the directory and navigate to it:
|
||||
```bash
|
||||
@@ -79,13 +79,13 @@ pnpm start
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
|
||||
:::info
|
||||
You can now access Jellyseerr by visiting `http://localhost:5055` in your web browser.
|
||||
:::
|
||||
|
||||
#### Extending the installation
|
||||
<Tabs groupId="unix-extensions">
|
||||
<Tabs groupId="unix-extensions" queryString>
|
||||
<TabItem value="linux" label="Linux">
|
||||
To run jellyseerr as a systemd service:
|
||||
1. create the environment file at `/etc/jellyseerr/jellyseerr.conf`:
|
||||
@@ -99,6 +99,9 @@ PORT=5055
|
||||
|
||||
## Uncomment if your media server is emby instead of jellyfin.
|
||||
# JELLYFIN_TYPE=emby
|
||||
|
||||
## Uncomment if you want to force Node.js to resolve IPv4 before IPv6 (advanced users only)
|
||||
# FORCE_IPV4_FIRST=true
|
||||
```
|
||||
2. Then run the following commands:
|
||||
```bash
|
||||
@@ -231,7 +234,7 @@ pm2 status jellyseerr
|
||||
|
||||
## Windows
|
||||
### Installation
|
||||
<Tabs groupId="versions">
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="latest">
|
||||
1. Assuming you want the working directory to be `C:\jellyseerr`, create the directory and navigate to it:
|
||||
```powershell
|
||||
@@ -245,6 +248,7 @@ git checkout main
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```powershell
|
||||
npm install -g win-node-env
|
||||
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
||||
```
|
||||
4. Build the project:
|
||||
@@ -269,6 +273,7 @@ git checkout develop # by default, you are on the develop branch so this step is
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```powershell
|
||||
npm install -g win-node-env
|
||||
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
|
||||
```
|
||||
4. Build the project:
|
||||
@@ -291,7 +296,7 @@ You can now access Jellyseerr by visiting `http://localhost:5055` in your web br
|
||||
:::
|
||||
|
||||
#### Extending the installation
|
||||
<Tabs groupId="windows-extensions">
|
||||
<Tabs groupId="windows-extensions" queryString>
|
||||
<TabItem value="task-scheduler" label="Task Scheduler">
|
||||
To run jellyseerr as a bat script:
|
||||
1. Create a file named `start-jellyseerr.bat` in the jellyseerr directory:
|
||||
@@ -312,7 +317,7 @@ node dist/index.js
|
||||
|
||||
Now, Jellyseerr will start when the computer boots up in the background.
|
||||
</TabItem>
|
||||
|
||||
|
||||
<TabItem value="nssm" label="NSSM">
|
||||
To run jellyseerr as a service:
|
||||
1. Download the [Non-Sucking Service Manager](https://nssm.cc/download)
|
||||
|
||||
@@ -20,7 +20,7 @@ Be sure to replace `/path/to/appdata/config` in the below examples with a valid
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs groupId="docker-methods">
|
||||
<Tabs groupId="docker-methods" queryString>
|
||||
<TabItem value="docker-cli" label="Docker CLI">
|
||||
For details on the Docker CLI, please [review the official `docker run` documentation](https://docs.docker.com/engine/reference/run/).
|
||||
|
||||
@@ -37,6 +37,11 @@ docker run -d \
|
||||
--restart unless-stopped \
|
||||
fallenbagel/jellyseerr
|
||||
```
|
||||
:::tip
|
||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
||||
|
||||
`-e JELLYFIN_TYPE=emby`
|
||||
:::
|
||||
|
||||
To run the container as a specific user/group, you may optionally add `--user=[ user | user:group | uid | uid:gid | user:gid | uid:group ]` to the above command.
|
||||
|
||||
@@ -83,6 +88,10 @@ services:
|
||||
- /path/to/appdata/config:/app/config
|
||||
restart: unless-stopped
|
||||
```
|
||||
:::tip
|
||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
||||
:::
|
||||
|
||||
Then, start all services defined in the Compose file:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
@@ -97,7 +106,6 @@ Then, restart all services defined in the Compose file:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
:::tip
|
||||
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.
|
||||
:::
|
||||
@@ -133,7 +141,7 @@ or the Docker Desktop app:
|
||||
4. Enter a name for the volume (example: `jellyseerr-data`) and hit "Create"
|
||||
|
||||
Then, create and start the Jellyseerr container:
|
||||
<Tabs groupId="docker-methods">
|
||||
<Tabs groupId="docker-methods" queryString>
|
||||
<TabItem value="docker-cli" label="Docker CLI">
|
||||
```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
|
||||
@@ -161,24 +169,14 @@ volumes:
|
||||
external: true
|
||||
```
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="docker-desktop" label="Docker Desktop">
|
||||
1. Open the Docker Desktop app
|
||||
2. Head to the Containers/Apps tab
|
||||
3. Click on the "Add Container/App" button near the top right
|
||||
4. Fill in the container details:
|
||||
- **Name**: `jellyseerr`
|
||||
- **Image**: `fallenbagel/jellyseerr:latest`
|
||||
- **Port**: `5055:5055`
|
||||
- **Volume**: `jellyseerr-data:/app/config`
|
||||
- **Environment Variables**:
|
||||
- **LOG_LEVEL**: `debug`
|
||||
- **TZ**: `Asia/Tashkent`
|
||||
- **Restart Policy**: `unless-stopped`
|
||||
5. Click on the "Run" button
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
If you are using a named volume, then you can safely **ignore** the warning about the `/app/config` folder being incorrectly mounted.
|
||||
|
||||
If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable to `emby`.
|
||||
:::
|
||||
|
||||
To access the files inside the volume created above, navigate to `\\wsl$\docker-desktop-data\version-pack-data\community\docker\volumes\jellyseerr-data\_data` using File Explorer.
|
||||
|
||||
:::info
|
||||
|
||||
@@ -22,7 +22,7 @@ export const VersionMismatchWarning = () => {
|
||||
<>
|
||||
{!isUpToDate ? (
|
||||
<Admonition type="warning">
|
||||
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package">override the package derivation</a>.
|
||||
The <a href="https://github.com/NixOS/nixpkgs/blob/nixos-unstable/pkgs/servers/jellyseerr/default.nix#L14">upstream Jellyseerr Nix Package (v{nixpkgVersion})</a> is not <b>up-to-date</b>. If you want to use <b>Jellyseerr v{jellyseerrVersion}</b>, you will need to <a href="#overriding-the-package-derivation">override the package derivation</a>.
|
||||
</Admonition>
|
||||
) : (
|
||||
<Admonition type="success">
|
||||
@@ -95,12 +95,12 @@ export const VersionMatch = () => {
|
||||
};
|
||||
|
||||
offlineCache = pkgs.fetchYarnDeps {
|
||||
sha256 = pkgs.lib.fakeSha256;
|
||||
sha256 = pkgs.lib.fakeSha256;
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
}`;
|
||||
|
||||
|
||||
const module = `{ config, pkgs, lib, ... }:
|
||||
|
||||
with lib;
|
||||
|
||||
@@ -12,19 +12,21 @@ This is your Jellyseerr API key, which can be used to integrate Jellyseerr with
|
||||
|
||||
If you need to generate a new API key for any reason, simply click the button to the right of the text box.
|
||||
|
||||
If you want to set the API key, rather than letting it be randomly generated, you can use the API_KEY environment variable. Whatever that variable is set to will be your API key.
|
||||
|
||||
## Application Title
|
||||
|
||||
If you aren't a huge fan of the name "Jellyseerr" and would like to display something different to your users, you can customize the application title!
|
||||
|
||||
## Application URL
|
||||
|
||||
Set this to the externally-accessible URL of your Overseerr instance.
|
||||
Set this to the externally-accessible URL of your Jellyseerr instance.
|
||||
|
||||
You must configure this setting in order to enable password reset and generation emails.
|
||||
|
||||
## Enable Proxy Support
|
||||
|
||||
If you have Overseerr behind a reverse proxy, enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
|
||||
If you have Jellyseerr behind a reverse proxy, enable this setting to allow Jellyseerr to correctly register client IP addresses. For details, please see the [Express Documentation](https://expressjs.com/en/guide/behind-proxies.html).
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
@@ -40,7 +42,7 @@ If you do not use Jellyseerr integrations with third-party applications to add/m
|
||||
|
||||
One caveat, however, is that HTTPS is required, meaning that once this setting is enabled, you will no longer be able to access your Jellyseerr instance over _HTTP_ (including using an IP address and port number).
|
||||
|
||||
If you enable this setting and find yourself unable to access Overseerr, you can disable the setting by modifying `settings.json` in `/app/config`.
|
||||
If you enable this setting and find yourself unable to access Jellyseerr, you can disable the setting by modifying `settings.json` in `/app/config`.
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ sidebar_position: 3
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs groupId="media-server-type">
|
||||
<Tabs groupId="media-server-type" queryString>
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
:::info
|
||||
To set up Jellyfin, make sure you log in using an account with administrative privileges.
|
||||
@@ -32,7 +32,7 @@ Jellyseerr will perform a full scan of your Jellyfin libraries once every 24 hou
|
||||
|
||||
This section is where you configure the connection to your Jellyfin server.
|
||||
|
||||
<Tabs groupId="versions">
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="Latest">
|
||||
|
||||
#### Internal URL
|
||||
@@ -126,7 +126,7 @@ Jellyseerr will perform a full scan of your Emby libraries once every 24 hours (
|
||||
|
||||
This section is where you configure the connection to your Emby server.
|
||||
|
||||
<Tabs groupId="versions">
|
||||
<Tabs groupId="versions" queryString>
|
||||
<TabItem value="latest" label="Latest">
|
||||
|
||||
#### Internal URL
|
||||
|
||||
@@ -16,7 +16,7 @@ This setting is **enabled** by default.
|
||||
|
||||
## Enable New Jellyfin/Emby/Plex Sign-In
|
||||
|
||||
When enabled, users with access to your media server will be able to sign in to Overseerr 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.
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
|
||||
@@ -6,14 +6,14 @@ sidebar_position: 2
|
||||
|
||||
# Adding Users
|
||||
|
||||
There are currently two methods to add users to Overseerr: importing Plex users and creating "local users." All new users are created with the [default permissions](/using-jellyseerr/settings/users#default-permissions) defined in **Settings → Users**.
|
||||
There are currently two methods to add users to Jellyseerr: importing Mediaserver users and creating "local users." All new users are created with the [default permissions](/using-jellyseerr/settings/users#default-permissions) defined in **Settings → Users**.
|
||||
|
||||
### Importing Mediaserver Users
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs groupId="media-server-type">
|
||||
<Tabs groupId="media-server-type" queryString>
|
||||
<TabItem value="jellyfin" label="Jellyfin">
|
||||
Clicking the **Import Jellyfin Users** button on the **User List** page will fetch the list of users with access to the Jellyfin server and add them to Jellyseerr automatically.
|
||||
|
||||
@@ -25,9 +25,9 @@ To disable new Jellyfin sign-ins, navigate to **Settings → Users** and unch
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="emby" label="Emby">
|
||||
Clicking the **Import Emby Users** button on the **User List** page will fetch the list of users with access to the Emby server and add them to Overseerr automatically.
|
||||
Clicking the **Import Emby Users** button on the **User List** page will fetch the list of users with access to the Emby server and add them to Jellyseerr automatically.
|
||||
|
||||
Importing Emby users is not required, however. Any user with access to the Emby server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](/using-jellyseerr/settings/users#default-permissions) upon their first login.
|
||||
Importing Emby users is not required, however. Any user with access to the Emby server can log in to Jellyseerr even if they have not been imported, and will be assigned the configured [default permissions](/using-jellyseerr/settings/users#default-permissions) upon their first login.
|
||||
|
||||
:::tip
|
||||
To disable new Emby sign-ins, navigate to **Settings → Users** and uncheck the [**Enable New Emby Sign-In**](/using-jellyseerr/settings/users#enable-new-jellyfinembyplex-sign-in) box.
|
||||
@@ -36,9 +36,9 @@ To disable new Emby sign-ins, navigate to **Settings → Users** and uncheck
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="plex" label="Plex">
|
||||
Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Overseerr automatically.
|
||||
Clicking the **Import Plex Users** button on the **User List** page will fetch the list of users with access to the Plex server from [plex.tv](https://www.plex.tv/), and add them to Jellyseerr automatically.
|
||||
|
||||
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Overseerr even if they have not been imported, and will be assigned the configured [default permissions](/using-jellyseerr/settings/users#default-permissions) upon their first login.
|
||||
Importing Plex users is not required, however. Any user with access to the Plex server can log in to Jellyseerr even if they have not been imported, and will be assigned the configured [default permissions](/using-jellyseerr/settings/users#default-permissions) upon their first login.
|
||||
|
||||
:::tip
|
||||
To disable new Plex sign-ins, navigate to **Settings → Users** and uncheck the [**Enable New Plex Sign-In**](/using-jellyseerr/settings/users#enable-new-jellyfinembyplex-sign-in) box.
|
||||
@@ -49,7 +49,7 @@ To disable new Plex sign-ins, navigate to **Settings → Users** and uncheck
|
||||
|
||||
### Creating Local Users
|
||||
|
||||
If you would like to grant Overseerr access to a user who doesn't have their own Plex account and/or access to the Plex server, you can manually add them by clicking the **Create Local User** button.
|
||||
If you would like to grant Jellyseerr access to a user who doesn't have their own Plex account and/or access to the Plex server, you can manually add them by clicking the **Create Local User** button.
|
||||
|
||||
#### Email Address
|
||||
|
||||
@@ -57,7 +57,7 @@ Enter a valid email address at which the user can receive messages pertaining to
|
||||
|
||||
#### Automatically Generate Password
|
||||
|
||||
If an [application URL](/using-jellyseerr/settings/general#application-url) is set and [email notifications](/using-jellyseerr/notifications/email) have been configured and enabled, Overseerr can automatically generate a password for the new user.
|
||||
If an [application URL](/using-jellyseerr/settings/general#application-url) is set and [email notifications](/using-jellyseerr/notifications/email) have been configured and enabled, Jellyseerr can automatically generate a password for the new user.
|
||||
|
||||
#### Password
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Users can override the [global display language](/using-jellyseerr/settings/gene
|
||||
|
||||
### Discover Region & Discover Language
|
||||
|
||||
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region-and-discover-language) to suit their own preferences.
|
||||
Users can override the [global filter settings](/using-jellyseerr/settings/general#discover-region--discover-language) to suit their own preferences.
|
||||
|
||||
### Movie Request Limit & Series Request Limit
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Jellyseerr docs is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
Jellyseerr docs will be available at [docs.jellyseerr.com](https://docs.jellyseerr.dev).
|
||||
Jellyseerr docs will be available at [docs.jellyseerr.dev](https://docs.jellyseerr.dev).
|
||||
|
||||
### Installation
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const config: Config = {
|
||||
|
||||
organizationName: 'Fallenbagel',
|
||||
projectName: 'Jellyseerr',
|
||||
deploymentBranch: 'gh-pages',
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
@@ -57,7 +58,6 @@ const config: Config = {
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
disableSwitch: true,
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
commitTag: process.env.COMMIT_TAG || 'local',
|
||||
},
|
||||
publicRuntimeConfig: {
|
||||
// Will be available on both server and client
|
||||
JELLYFIN_TYPE: process.env.JELLYFIN_TYPE,
|
||||
forceIpv4First: process.env.FORCE_IPV4_FIRST === 'true' ? 'true' : 'false',
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -3586,6 +3586,8 @@ paths:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
serverType:
|
||||
type: number
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
@@ -4862,6 +4864,11 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
example: 8|9
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
example: 3|4
|
||||
responses:
|
||||
'200':
|
||||
description: Results
|
||||
|
||||
@@ -43,8 +43,6 @@
|
||||
"@svgr/webpack": "6.5.1",
|
||||
"@tanem/react-nprogress": "5.0.30",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.3.4",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
"connect-typeorm": "1.1.4",
|
||||
@@ -121,7 +119,7 @@
|
||||
"@types/express": "4.17.17",
|
||||
"@types/express-session": "1.17.6",
|
||||
"@types/lodash": "4.14.191",
|
||||
"@types/node": "17.0.36",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node-schedule": "2.1.0",
|
||||
"@types/nodemailer": "6.4.7",
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
12799
pnpm-lock.yaml
generated
12799
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import fs, { promises as fsp } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
import xml2js from 'xml2js';
|
||||
|
||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||
@@ -161,13 +162,18 @@ class AnimeListMapping {
|
||||
label: 'Anime-List Sync',
|
||||
});
|
||||
try {
|
||||
const response = await axios.get(MAPPING_URL, {
|
||||
responseType: 'stream',
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const response = await fetch(MAPPING_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.statusText}`);
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const writer = fs.createWriteStream(LOCAL_PATH);
|
||||
writer.on('finish', resolve);
|
||||
response.data.pipe(writer);
|
||||
writer.on('error', reject);
|
||||
if (!response.body) return reject();
|
||||
Readable.fromWeb(response.body as ReadableStream<Uint8Array>).pipe(
|
||||
writer
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'axios-rate-limit';
|
||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||
import rateLimit from '@server/utils/rateLimit';
|
||||
import type NodeCache from 'node-cache';
|
||||
|
||||
// 5 minute default TTL (in seconds)
|
||||
@@ -12,71 +11,87 @@ const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
rateLimit?: {
|
||||
maxRPS: number;
|
||||
maxRequests: number;
|
||||
};
|
||||
rateLimit?: RateLimitOptions;
|
||||
}
|
||||
|
||||
class ExternalAPI {
|
||||
protected axios: AxiosInstance;
|
||||
protected fetch: typeof fetch;
|
||||
protected params: Record<string, string>;
|
||||
protected defaultHeaders: { [key: string]: string };
|
||||
private baseUrl: string;
|
||||
private cache?: NodeCache;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
params: Record<string, unknown>,
|
||||
params: Record<string, string> = {},
|
||||
options: ExternalAPIOptions = {}
|
||||
) {
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
params,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (options.rateLimit) {
|
||||
this.axios = rateLimit(this.axios, {
|
||||
maxRequests: options.rateLimit.maxRequests,
|
||||
maxRPS: options.rateLimit.maxRPS,
|
||||
});
|
||||
this.fetch = rateLimit(fetch, options.rateLimit);
|
||||
} else {
|
||||
this.fetch = fetch;
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.params = params;
|
||||
this.defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
this.cache = options.nodeCache;
|
||||
}
|
||||
|
||||
protected async get<T>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
params?: Record<string, string>,
|
||||
ttl?: number,
|
||||
config?: RequestInit
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
...this.params,
|
||||
...params,
|
||||
});
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
if (cachedItem) {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.get<T>(endpoint, config);
|
||||
const url = this.formatUrl(endpoint, params);
|
||||
const response = await this.fetch(url, {
|
||||
...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);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
}
|
||||
|
||||
protected async post<T>(
|
||||
endpoint: string,
|
||||
data: Record<string, unknown>,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
data?: Record<string, unknown>,
|
||||
params?: Record<string, string>,
|
||||
ttl?: number,
|
||||
config?: RequestInit
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: config?.params,
|
||||
config: { ...this.params, ...params },
|
||||
data,
|
||||
});
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
@@ -84,21 +99,117 @@ class ExternalAPI {
|
||||
return cachedItem;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<T>(endpoint, data, config);
|
||||
const url = this.formatUrl(endpoint, params);
|
||||
const response = await this.fetch(url, {
|
||||
method: 'POST',
|
||||
...config,
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...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) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return resData;
|
||||
}
|
||||
|
||||
protected async put<T>(
|
||||
endpoint: string,
|
||||
data: Record<string, unknown>,
|
||||
params?: Record<string, string>,
|
||||
ttl?: number,
|
||||
config?: RequestInit
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
config: { ...this.params, ...params },
|
||||
data,
|
||||
});
|
||||
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: {
|
||||
...this.defaultHeaders,
|
||||
...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) {
|
||||
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>(
|
||||
endpoint: string,
|
||||
config?: AxiosRequestConfig,
|
||||
ttl?: number
|
||||
params?: Record<string, string>,
|
||||
ttl?: number,
|
||||
config?: RequestInit,
|
||||
overwriteBaseUrl?: string
|
||||
): Promise<T> {
|
||||
const cacheKey = this.serializeCacheKey(endpoint, config?.params);
|
||||
const cacheKey = this.serializeCacheKey(endpoint, {
|
||||
...this.params,
|
||||
...params,
|
||||
});
|
||||
const cachedItem = this.cache?.get<T>(cacheKey);
|
||||
|
||||
if (cachedItem) {
|
||||
@@ -109,20 +220,78 @@ class ExternalAPI {
|
||||
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
|
||||
Date.now() - DEFAULT_ROLLING_BUFFER
|
||||
) {
|
||||
this.axios.get<T>(endpoint, config).then((response) => {
|
||||
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||
this.fetch(url, {
|
||||
...config,
|
||||
headers: {
|
||||
...this.defaultHeaders,
|
||||
...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;
|
||||
}
|
||||
|
||||
const response = await this.axios.get<T>(endpoint, config);
|
||||
const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
|
||||
const response = await this.fetch(url, {
|
||||
...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);
|
||||
|
||||
if (this.cache) {
|
||||
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
|
||||
this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -135,6 +304,29 @@ class ExternalAPI {
|
||||
|
||||
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface GitHubRelease {
|
||||
url: string;
|
||||
@@ -67,10 +67,6 @@ class GithubAPI extends ExternalAPI {
|
||||
'https://api.github.com',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('github').data,
|
||||
}
|
||||
);
|
||||
@@ -85,9 +81,7 @@ class GithubAPI extends ExternalAPI {
|
||||
const data = await this.get<GitHubRelease[]>(
|
||||
'/repos/fallenbagel/jellyseerr/releases',
|
||||
{
|
||||
params: {
|
||||
per_page: take,
|
||||
},
|
||||
per_page: take.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -112,10 +106,8 @@ class GithubAPI extends ExternalAPI {
|
||||
const data = await this.get<GithubCommit[]>(
|
||||
'/repos/fallenbagel/jellyseerr/commits',
|
||||
{
|
||||
params: {
|
||||
per_page: take,
|
||||
branch,
|
||||
},
|
||||
per_page: take.toString(),
|
||||
branch,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -93,9 +93,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
}
|
||||
|
||||
class JellyfinAPI extends ExternalAPI {
|
||||
private authToken?: string;
|
||||
private userId?: string;
|
||||
private jellyfinHost: string;
|
||||
|
||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||
let authHeaderVal: string;
|
||||
@@ -111,14 +109,9 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization': authHeaderVal,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.jellyfinHost = jellyfinHost;
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
public async login(
|
||||
@@ -127,7 +120,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
ClientIP?: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
const authenticate = async (useHeaders: boolean) => {
|
||||
const headers =
|
||||
const headers: { [key: string]: string } =
|
||||
useHeaders && ClientIP ? { 'X-Forwarded-For': ClientIP } : {};
|
||||
|
||||
return this.post<JellyfinLoginResponse>(
|
||||
@@ -136,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
|
||||
Username,
|
||||
Pw: Password,
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ headers }
|
||||
);
|
||||
};
|
||||
@@ -152,7 +147,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
try {
|
||||
return await authenticate(false);
|
||||
} catch (e) {
|
||||
const status = e.response?.status;
|
||||
const status = e.cause?.status;
|
||||
|
||||
const networkErrorCodes = new Set([
|
||||
'ECONNREFUSED',
|
||||
@@ -190,7 +185,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
|
||||
return systemInfoResponse;
|
||||
} catch (e) {
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +202,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +217,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +233,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +291,16 @@ class JellyfinAPI extends ExternalAPI {
|
||||
public async getLibraryContents(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const libraryItemsResponse = await this.get<any>(
|
||||
`/Users/${this.userId}/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false`
|
||||
`/Users/${this.userId}/Items`,
|
||||
{
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
IncludeItemTypes: 'Series,Movie,Others',
|
||||
Recursive: 'true',
|
||||
StartIndex: '0',
|
||||
ParentId: id,
|
||||
collapseBoxSetItems: 'false',
|
||||
}
|
||||
);
|
||||
|
||||
return libraryItemsResponse.Items.filter(
|
||||
@@ -308,14 +312,18 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async getRecentlyAdded(id: string): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const itemResponse = await this.get<any>(
|
||||
`/Users/${this.userId}/Items/Latest?Limit=12&ParentId=${id}`
|
||||
`/Users/${this.userId}/Items/Latest`,
|
||||
{
|
||||
Limit: '12',
|
||||
ParentId: id,
|
||||
}
|
||||
);
|
||||
|
||||
return itemResponse;
|
||||
@@ -325,7 +333,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +348,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
return itemResponse;
|
||||
} catch (e) {
|
||||
if (availabilitySync.running) {
|
||||
if (e.response && e.response.status === 500) {
|
||||
if (e.cause?.status === 500) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -349,7 +357,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,7 +372,7 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +382,10 @@ class JellyfinAPI extends ExternalAPI {
|
||||
): Promise<JellyfinLibraryItem[]> {
|
||||
try {
|
||||
const episodeResponse = await this.get<any>(
|
||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||
`/Shows/${seriesID}/Episodes`,
|
||||
{
|
||||
seasonId: seasonID,
|
||||
}
|
||||
);
|
||||
|
||||
return episodeResponse.Items.filter(
|
||||
@@ -386,6 +397,23 @@ class JellyfinAPI extends ExternalAPI {
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async createApiToken(appName: string): Promise<string> {
|
||||
try {
|
||||
await this.post(`/Auth/Keys?App=${appName}`);
|
||||
const apiKeys = await this.get<any>(`/Auth/Keys`);
|
||||
return apiKeys.Items.reverse().find(
|
||||
(item: any) => item.AppName === appName
|
||||
).AccessToken;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
|
||||
{ label: 'Jellyfin API' }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import xml2js from 'xml2js';
|
||||
import ExternalAPI from './externalapi';
|
||||
|
||||
interface PlexAccountResponse {
|
||||
user: PlexUser;
|
||||
@@ -137,8 +137,6 @@ class PlexTvAPI extends ExternalAPI {
|
||||
{
|
||||
headers: {
|
||||
'X-Plex-Token': authToken,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('plextv').data,
|
||||
}
|
||||
@@ -149,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
|
||||
|
||||
public async getDevices(): Promise<PlexDevice[]> {
|
||||
try {
|
||||
const devicesResp = await this.axios.get(
|
||||
'/api/resources?includeHttps=1',
|
||||
{
|
||||
transformResponse: [],
|
||||
responseType: 'text',
|
||||
}
|
||||
);
|
||||
const devicesResp = await this.get('/api/resources', {
|
||||
includeHttps: '1',
|
||||
});
|
||||
const parsedXml = await xml2js.parseStringPromise(
|
||||
devicesResp.data as DeviceResponse
|
||||
devicesResp as DeviceResponse
|
||||
);
|
||||
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
|
||||
name: pxml.$.name,
|
||||
@@ -205,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
|
||||
|
||||
public async getUser(): Promise<PlexUser> {
|
||||
try {
|
||||
const account = await this.axios.get<PlexAccountResponse>(
|
||||
const account = await this.get<PlexAccountResponse>(
|
||||
'/users/account.json'
|
||||
);
|
||||
|
||||
return account.data.user;
|
||||
return account.user;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting the account from plex.tv: ${e.message}`,
|
||||
@@ -249,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
|
||||
}
|
||||
|
||||
public async getUsers(): Promise<UsersResponse> {
|
||||
const response = await this.axios.get('/api/users', {
|
||||
transformResponse: [],
|
||||
responseType: 'text',
|
||||
});
|
||||
const data = await this.get('/api/users');
|
||||
|
||||
const parsedXml = (await xml2js.parseStringPromise(
|
||||
response.data
|
||||
data as string
|
||||
)) as UsersResponse;
|
||||
return parsedXml;
|
||||
}
|
||||
@@ -270,49 +261,49 @@ class PlexTvAPI extends ExternalAPI {
|
||||
items: PlexWatchlistItem[];
|
||||
}> {
|
||||
try {
|
||||
const response = await this.axios.get<WatchlistResponse>(
|
||||
'/library/sections/watchlist/all',
|
||||
const params = new URLSearchParams({
|
||||
'X-Plex-Container-Start': offset.toString(),
|
||||
'X-Plex-Container-Size': size.toString(),
|
||||
});
|
||||
const response = await this.fetch(
|
||||
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
|
||||
{
|
||||
params: {
|
||||
'X-Plex-Container-Start': offset,
|
||||
'X-Plex-Container-Size': size,
|
||||
},
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
headers: this.defaultHeaders,
|
||||
}
|
||||
);
|
||||
const data = (await response.json()) as WatchlistResponse;
|
||||
|
||||
const watchlistDetails = await Promise.all(
|
||||
(response.data.MediaContainer.Metadata ?? []).map(
|
||||
async (watchlistItem) => {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{},
|
||||
undefined,
|
||||
{},
|
||||
'https://metadata.provider.plex.tv'
|
||||
);
|
||||
|
||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||
|
||||
const tmdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tmdb')
|
||||
);
|
||||
const tvdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tvdb')
|
||||
);
|
||||
const tmdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tmdb')
|
||||
);
|
||||
const tvdbString = metadata.Guid.find((guid) =>
|
||||
guid.id.startsWith('tvdb')
|
||||
);
|
||||
|
||||
return {
|
||||
ratingKey: metadata.ratingKey,
|
||||
// This should always be set? But I guess it also cannot be?
|
||||
// We will filter out the 0's afterwards
|
||||
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||
tvdbId: tvdbString
|
||||
? Number(tvdbString.id.split('//')[1])
|
||||
: undefined,
|
||||
title: metadata.title,
|
||||
type: metadata.type,
|
||||
};
|
||||
}
|
||||
)
|
||||
return {
|
||||
ratingKey: metadata.ratingKey,
|
||||
// This should always be set? But I guess it also cannot be?
|
||||
// We will filter out the 0's afterwards
|
||||
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
|
||||
tvdbId: tvdbString
|
||||
? Number(tvdbString.id.split('//')[1])
|
||||
: undefined,
|
||||
title: metadata.title,
|
||||
type: metadata.type,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
||||
@@ -320,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
|
||||
return {
|
||||
offset,
|
||||
size,
|
||||
totalSize: response.data.MediaContainer.totalSize,
|
||||
totalSize: data.MediaContainer.totalSize,
|
||||
items: filteredList,
|
||||
};
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ExternalAPI from './externalapi';
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
|
||||
interface PushoverSoundsResponse {
|
||||
sounds: {
|
||||
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
|
||||
|
||||
class PushoverAPI extends ExternalAPI {
|
||||
constructor() {
|
||||
super(
|
||||
'https://api.pushover.net/1',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
super('https://api.pushover.net/1');
|
||||
}
|
||||
|
||||
public async getSounds(appToken: string): Promise<PushoverSound[]> {
|
||||
try {
|
||||
const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
|
||||
params: {
|
||||
token: appToken,
|
||||
},
|
||||
token: appToken,
|
||||
});
|
||||
|
||||
return mapSounds(data.sounds);
|
||||
|
||||
@@ -155,13 +155,13 @@ export interface IMDBRating {
|
||||
*/
|
||||
class IMDBRadarrProxy extends ExternalAPI {
|
||||
constructor() {
|
||||
super('https://api.radarr.video/v1', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
nodeCache: cacheManager.getCache('imdb').data,
|
||||
});
|
||||
super(
|
||||
'https://api.radarr.video/v1',
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache('imdb').data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +175,11 @@ class IMDBRadarrProxy extends ExternalAPI {
|
||||
`/movie/imdb/${IMDBid}`
|
||||
);
|
||||
|
||||
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||
if (
|
||||
!data?.length ||
|
||||
data[0].ImdbId !== IMDBid ||
|
||||
!data[0].MovieRatings.Imdb
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,15 +63,12 @@ class RottenTomatoes extends ExternalAPI {
|
||||
super(
|
||||
'https://79frdp12pn-dsn.algolia.net/1/indexes/*',
|
||||
{
|
||||
'x-algolia-agent':
|
||||
'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||
'x-algolia-agent': 'Algolia for JavaScript (4.14.3); Browser (lite)',
|
||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||
'x-algolia-application-id': '79FRDP12PN',
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'x-algolia-usertoken': settings.clientId,
|
||||
},
|
||||
nodeCache: cacheManager.getCache('rt').data,
|
||||
|
||||
@@ -113,9 +113,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public getSystemStatus = async (): Promise<SystemStatus> => {
|
||||
try {
|
||||
const response = await this.axios.get<SystemStatus>('/system/status');
|
||||
const data = await this.get<SystemStatus>('/system/status');
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve system status: ${e.message}`
|
||||
@@ -157,16 +157,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
|
||||
`/queue`,
|
||||
{
|
||||
params: {
|
||||
includeEpisode: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await this.get<QueueResponse<QueueItemAppendT>>(`/queue`, {
|
||||
includeEpisode: 'true',
|
||||
});
|
||||
|
||||
return response.data.records;
|
||||
return data.records;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
|
||||
@@ -176,9 +171,9 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public getTags = async (): Promise<Tag[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<Tag[]>(`/tag`);
|
||||
const data = await this.get<Tag[]>(`/tag`);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
|
||||
@@ -188,11 +183,11 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
|
||||
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
|
||||
try {
|
||||
const response = await this.axios.post<Tag>(`/tag`, {
|
||||
const data = await this.post<Tag>(`/tag`, {
|
||||
label,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
|
||||
}
|
||||
@@ -203,7 +198,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
options: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.axios.post(`/command`, {
|
||||
await this.post(`/command`, {
|
||||
name: commandName,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -37,9 +37,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
public getMovies = async (): Promise<RadarrMovie[]> => {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrMovie[]>('/movie');
|
||||
const data = await this.get<RadarrMovie[]>('/movie');
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve movies: ${e.message}`);
|
||||
}
|
||||
@@ -47,9 +47,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
public getMovie = async ({ id }: { id: number }): Promise<RadarrMovie> => {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrMovie>(`/movie/${id}`);
|
||||
const data = await this.get<RadarrMovie>(`/movie/${id}`);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Radarr] Failed to retrieve movie: ${e.message}`);
|
||||
}
|
||||
@@ -57,17 +57,15 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
|
||||
try {
|
||||
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
|
||||
params: {
|
||||
term: `tmdb:${id}`,
|
||||
},
|
||||
const data = await this.get<RadarrMovie[]>('/movie/lookup', {
|
||||
term: `tmdb:${id}`,
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
if (!data[0]) {
|
||||
throw new Error('Movie not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
return data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving movie by TMDB ID', {
|
||||
label: 'Radarr API',
|
||||
@@ -97,7 +95,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
|
||||
// movie exists in Radarr but is neither downloaded nor monitored
|
||||
if (movie.id && !movie.monitored) {
|
||||
const response = await this.axios.put<RadarrMovie>(`/movie`, {
|
||||
const data = await this.put<RadarrMovie>(`/movie`, {
|
||||
...movie,
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
@@ -114,25 +112,25 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.monitored) {
|
||||
if (data.monitored) {
|
||||
logger.info(
|
||||
'Found existing title in Radarr and set it to monitored.',
|
||||
{
|
||||
label: 'Radarr',
|
||||
movieId: response.data.id,
|
||||
movieTitle: response.data.title,
|
||||
movieId: data.id,
|
||||
movieTitle: data.title,
|
||||
}
|
||||
);
|
||||
logger.debug('Radarr update details', {
|
||||
label: 'Radarr',
|
||||
movie: response.data,
|
||||
movie: data,
|
||||
});
|
||||
|
||||
if (options.searchNow) {
|
||||
this.searchMovie(response.data.id);
|
||||
this.searchMovie(data.id);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} else {
|
||||
logger.error('Failed to update existing movie in Radarr.', {
|
||||
label: 'Radarr',
|
||||
@@ -150,7 +148,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
return movie;
|
||||
}
|
||||
|
||||
const response = await this.axios.post<RadarrMovie>(`/movie`, {
|
||||
const data = await this.post<RadarrMovie>(`/movie`, {
|
||||
title: options.title,
|
||||
qualityProfileId: options.qualityProfileId,
|
||||
profileId: options.profileId,
|
||||
@@ -166,11 +164,11 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.id) {
|
||||
if (data.id) {
|
||||
logger.info('Radarr accepted request', { label: 'Radarr' });
|
||||
logger.debug('Radarr add details', {
|
||||
label: 'Radarr',
|
||||
movie: response.data,
|
||||
movie: data,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to add movie to Radarr', {
|
||||
@@ -179,15 +177,22 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
});
|
||||
throw new Error('Failed to add movie to Radarr');
|
||||
}
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
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.',
|
||||
{
|
||||
label: 'Radarr',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
response: e?.response?.data,
|
||||
response: errorData,
|
||||
}
|
||||
);
|
||||
throw new Error('Failed to add movie to Radarr');
|
||||
@@ -216,11 +221,9 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
||||
public removeMovie = async (movieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getMovieByTmdbId(movieId);
|
||||
await this.axios.delete(`/movie/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
await this.delete(`/movie/${id}`, {
|
||||
deleteFiles: 'true',
|
||||
addImportExclusion: 'false',
|
||||
});
|
||||
logger.info(`[Radarr] Removed movie ${title}`);
|
||||
} catch (e) {
|
||||
|
||||
@@ -117,9 +117,9 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
public async getSeries(): Promise<SonarrSeries[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series');
|
||||
const data = await this.get<SonarrSeries[]>('/series');
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
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> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries>(`/series/${id}`);
|
||||
const data = await this.get<SonarrSeries>(`/series/${id}`);
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[Sonarr] Failed to retrieve series by ID: ${e.message}`);
|
||||
}
|
||||
@@ -137,17 +137,15 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
params: {
|
||||
term: title,
|
||||
},
|
||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
||||
term: title,
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
if (!data[0]) {
|
||||
throw new Error('No series found');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by series title', {
|
||||
label: 'Sonarr API',
|
||||
@@ -160,17 +158,15 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
|
||||
try {
|
||||
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
|
||||
params: {
|
||||
term: `tvdb:${id}`,
|
||||
},
|
||||
const data = await this.get<SonarrSeries[]>('/series/lookup', {
|
||||
term: `tvdb:${id}`,
|
||||
});
|
||||
|
||||
if (!response.data[0]) {
|
||||
if (!data[0]) {
|
||||
throw new Error('Series not found');
|
||||
}
|
||||
|
||||
return response.data[0];
|
||||
return data[0];
|
||||
} catch (e) {
|
||||
logger.error('Error retrieving series by tvdb ID', {
|
||||
label: 'Sonarr API',
|
||||
@@ -191,27 +187,27 @@ class SonarrAPI extends ServarrBase<{
|
||||
series.tags = options.tags ?? series.tags;
|
||||
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
|
||||
|
||||
const newSeriesResponse = await this.axios.put<SonarrSeries>(
|
||||
const newSeriesData = await this.put<SonarrSeries>(
|
||||
'/series',
|
||||
series
|
||||
series as any
|
||||
);
|
||||
|
||||
if (newSeriesResponse.data.id) {
|
||||
if (newSeriesData.id) {
|
||||
logger.info('Updated existing series in Sonarr.', {
|
||||
label: 'Sonarr',
|
||||
seriesId: newSeriesResponse.data.id,
|
||||
seriesTitle: newSeriesResponse.data.title,
|
||||
seriesId: newSeriesData.id,
|
||||
seriesTitle: newSeriesData.title,
|
||||
});
|
||||
logger.debug('Sonarr update details', {
|
||||
label: 'Sonarr',
|
||||
movie: newSeriesResponse.data,
|
||||
movie: newSeriesData,
|
||||
});
|
||||
|
||||
if (options.searchNow) {
|
||||
this.searchSeries(newSeriesResponse.data.id);
|
||||
this.searchSeries(newSeriesData.id);
|
||||
}
|
||||
|
||||
return newSeriesResponse.data;
|
||||
return newSeriesData;
|
||||
} else {
|
||||
logger.error('Failed to update series in Sonarr', {
|
||||
label: 'Sonarr',
|
||||
@@ -221,38 +217,35 @@ class SonarrAPI extends ServarrBase<{
|
||||
}
|
||||
}
|
||||
|
||||
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
|
||||
'/series',
|
||||
{
|
||||
tvdbId: options.tvdbid,
|
||||
title: options.title,
|
||||
qualityProfileId: options.profileId,
|
||||
languageProfileId: options.languageProfileId,
|
||||
seasons: this.buildSeasonList(
|
||||
options.seasons,
|
||||
series.seasons.map((season) => ({
|
||||
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,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
seriesType: options.seriesType,
|
||||
addOptions: {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
},
|
||||
} as Partial<SonarrSeries>
|
||||
);
|
||||
const createdSeriesData = await this.post<SonarrSeries>('/series', {
|
||||
tvdbId: options.tvdbid,
|
||||
title: options.title,
|
||||
qualityProfileId: options.profileId,
|
||||
languageProfileId: options.languageProfileId,
|
||||
seasons: this.buildSeasonList(
|
||||
options.seasons,
|
||||
series.seasons.map((season) => ({
|
||||
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,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
seriesType: options.seriesType,
|
||||
addOptions: {
|
||||
ignoreEpisodesWithFiles: true,
|
||||
searchForMissingEpisodes: options.searchNow,
|
||||
},
|
||||
} as Partial<SonarrSeries>);
|
||||
|
||||
if (createdSeriesResponse.data.id) {
|
||||
if (createdSeriesData.id) {
|
||||
logger.info('Sonarr accepted request', { label: 'Sonarr' });
|
||||
logger.debug('Sonarr add details', {
|
||||
label: 'Sonarr',
|
||||
movie: createdSeriesResponse.data,
|
||||
movie: createdSeriesData,
|
||||
});
|
||||
} else {
|
||||
logger.error('Failed to add movie to Sonarr', {
|
||||
@@ -262,13 +255,20 @@ class SonarrAPI extends ServarrBase<{
|
||||
throw new Error('Failed to add series to Sonarr');
|
||||
}
|
||||
|
||||
return createdSeriesResponse.data;
|
||||
return createdSeriesData;
|
||||
} 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.', {
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
options,
|
||||
response: e?.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
throw new Error('Failed to add series');
|
||||
}
|
||||
@@ -303,10 +303,10 @@ class SonarrAPI extends ServarrBase<{
|
||||
});
|
||||
|
||||
try {
|
||||
await this.runCommand('SeriesSearch', { seriesId });
|
||||
await this.runCommand('MissingEpisodeSearch', { seriesId });
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong while executing Sonarr series search.',
|
||||
'Something went wrong while executing Sonarr missing episode search.',
|
||||
{
|
||||
label: 'Sonarr API',
|
||||
errorMessage: e.message,
|
||||
@@ -340,14 +340,13 @@ class SonarrAPI extends ServarrBase<{
|
||||
|
||||
return newSeasons;
|
||||
}
|
||||
|
||||
public removeSerie = async (serieId: number): Promise<void> => {
|
||||
try {
|
||||
const { id, title } = await this.getSeriesByTvdbId(serieId);
|
||||
await this.axios.delete(`/series/${id}`, {
|
||||
params: {
|
||||
deleteFiles: true,
|
||||
addImportExclusion: false,
|
||||
},
|
||||
await this.delete(`/series/${id}`, {
|
||||
deleteFiles: 'true',
|
||||
addImportExclusion: 'false',
|
||||
});
|
||||
logger.info(`[Radarr] Removed serie ${title}`);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { TautulliSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
export interface TautulliHistoryRecord {
|
||||
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
|
||||
};
|
||||
}
|
||||
|
||||
class TautulliAPI {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
class TautulliAPI extends ExternalAPI {
|
||||
constructor(settings: TautulliSettings) {
|
||||
this.axios = axios.create({
|
||||
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||
super(
|
||||
`${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
|
||||
settings.port
|
||||
}${settings.urlBase ?? ''}`,
|
||||
params: { apikey: settings.apiKey },
|
||||
});
|
||||
{
|
||||
apikey: settings.apiKey || '',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public async getInfo(): Promise<TautulliInfo> {
|
||||
try {
|
||||
return (
|
||||
await this.axios.get<TautulliInfoResponse>('/api/v2', {
|
||||
params: { cmd: 'get_tautulli_info' },
|
||||
await this.get<TautulliInfoResponse>('/api/v2', {
|
||||
cmd: 'get_tautulli_info',
|
||||
})
|
||||
).data.response.data;
|
||||
).response.data;
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching Tautulli server info', {
|
||||
label: 'Tautulli API',
|
||||
@@ -148,14 +147,12 @@ class TautulliAPI {
|
||||
): Promise<TautulliWatchStats[]> {
|
||||
try {
|
||||
return (
|
||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_item_watch_time_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: 1,
|
||||
},
|
||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
cmd: 'get_item_watch_time_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: '1',
|
||||
})
|
||||
).data.response.data;
|
||||
).response.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching media watch stats from Tautulli',
|
||||
@@ -176,14 +173,12 @@ class TautulliAPI {
|
||||
): Promise<TautulliWatchUser[]> {
|
||||
try {
|
||||
return (
|
||||
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_item_user_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: 1,
|
||||
},
|
||||
await this.get<TautulliWatchUsersResponse>('/api/v2', {
|
||||
cmd: 'get_item_user_stats',
|
||||
rating_key: ratingKey,
|
||||
grouping: '1',
|
||||
})
|
||||
).data.response.data;
|
||||
).response.data;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching media watch users from Tautulli',
|
||||
@@ -206,15 +201,13 @@ class TautulliAPI {
|
||||
}
|
||||
|
||||
return (
|
||||
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_user_watch_time_stats',
|
||||
user_id: user.plexId,
|
||||
query_days: 0,
|
||||
grouping: 1,
|
||||
},
|
||||
await this.get<TautulliWatchStatsResponse>('/api/v2', {
|
||||
cmd: 'get_user_watch_time_stats',
|
||||
user_id: user.plexId.toString(),
|
||||
query_days: '0',
|
||||
grouping: '1',
|
||||
})
|
||||
).data.response.data[0];
|
||||
).response.data[0];
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
'Something went wrong fetching user watch stats from Tautulli',
|
||||
@@ -245,19 +238,17 @@ class TautulliAPI {
|
||||
|
||||
while (results.length < 20) {
|
||||
const tautulliData = (
|
||||
await this.axios.get<TautulliHistoryResponse>('/api/v2', {
|
||||
params: {
|
||||
cmd: 'get_history',
|
||||
grouping: 1,
|
||||
order_column: 'date',
|
||||
order_dir: 'desc',
|
||||
user_id: user.plexId,
|
||||
media_type: 'movie,episode',
|
||||
length: take,
|
||||
start,
|
||||
},
|
||||
await this.get<TautulliHistoryResponse>('/api/v2', {
|
||||
cmd: 'get_history',
|
||||
grouping: '1',
|
||||
order_column: 'date',
|
||||
order_dir: 'desc',
|
||||
user_id: user.plexId.toString(),
|
||||
media_type: 'movie,episode',
|
||||
length: take.toString(),
|
||||
start: start.toString(),
|
||||
})
|
||||
).data.response.data.data;
|
||||
).response.data.data;
|
||||
|
||||
if (!tautulliData.length) {
|
||||
return results;
|
||||
|
||||
@@ -95,6 +95,7 @@ interface DiscoverTvOptions {
|
||||
sortBy?: SortOptions;
|
||||
watchRegion?: string;
|
||||
watchProviders?: string;
|
||||
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
@@ -112,8 +113,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
{
|
||||
nodeCache: cacheManager.getCache('tmdb').data,
|
||||
rateLimit: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
id: 'tmdb',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -129,7 +130,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
}: SearchOptions): Promise<TmdbSearchMultiResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
|
||||
params: { query, page, include_adult: includeAdult, language },
|
||||
query,
|
||||
page: page.toString(),
|
||||
include_adult: includeAdult ? 'true' : 'false',
|
||||
language,
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -152,13 +156,11 @@ class TheMovieDb extends ExternalAPI {
|
||||
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
primary_release_year: year,
|
||||
},
|
||||
query,
|
||||
page: page.toString(),
|
||||
include_adult: includeAdult ? 'true' : 'false',
|
||||
language,
|
||||
primary_release_year: year?.toString() || '',
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -181,13 +183,11 @@ class TheMovieDb extends ExternalAPI {
|
||||
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
first_air_date_year: year,
|
||||
},
|
||||
query,
|
||||
page: page.toString(),
|
||||
include_adult: includeAdult ? 'true' : 'false',
|
||||
language,
|
||||
first_air_date_year: year?.toString() || '',
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -210,7 +210,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
}): Promise<TmdbPersonDetails> => {
|
||||
try {
|
||||
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
|
||||
params: { language },
|
||||
language,
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -230,7 +230,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbPersonCombinedCredits>(
|
||||
`/person/${personId}/combined_credits`,
|
||||
{
|
||||
params: { language },
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -253,11 +253,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbMovieDetails>(
|
||||
`/movie/${movieId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
},
|
||||
language,
|
||||
append_to_response:
|
||||
'credits,external_ids,videos,keywords,release_dates,watch/providers',
|
||||
},
|
||||
43200
|
||||
);
|
||||
@@ -279,11 +277,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbTvDetails>(
|
||||
`/tv/${tvId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response:
|
||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||
},
|
||||
language,
|
||||
append_to_response:
|
||||
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
|
||||
},
|
||||
43200
|
||||
);
|
||||
@@ -307,10 +303,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSeasonWithEpisodes>(
|
||||
`/tv/${tvId}/season/${seasonNumber}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
append_to_response: 'external_ids',
|
||||
},
|
||||
language: language || '',
|
||||
append_to_response: 'external_ids',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -333,10 +327,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -359,10 +351,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/movie/${movieId}/similar`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -385,10 +375,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/keyword/${keywordId}/movies`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -411,10 +399,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchTvResponse>(
|
||||
`/tv/${tvId}/recommendations`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -437,10 +423,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
}): Promise<TmdbSearchTvResponse> {
|
||||
try {
|
||||
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -481,40 +465,38 @@ class TheMovieDb extends ExternalAPI {
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
include_adult: includeAdult,
|
||||
language,
|
||||
region: this.region,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||
? defaultPastDate
|
||||
: primaryReleaseDateGte,
|
||||
'primary_release_date.lte':
|
||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||
? defaultFutureDate
|
||||
: primaryReleaseDateLte,
|
||||
with_genres: genre,
|
||||
with_companies: studio,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
watch_region: watchRegion,
|
||||
with_watch_providers: watchProviders,
|
||||
},
|
||||
sort_by: sortBy,
|
||||
page: page.toString(),
|
||||
include_adult: includeAdult ? 'true' : 'false',
|
||||
language,
|
||||
region: this.region || '',
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? ''
|
||||
: this.originalLanguage || '',
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'primary_release_date.gte':
|
||||
!primaryReleaseDateGte && primaryReleaseDateLte
|
||||
? defaultPastDate
|
||||
: primaryReleaseDateGte || '',
|
||||
'primary_release_date.lte':
|
||||
!primaryReleaseDateLte && primaryReleaseDateGte
|
||||
? defaultFutureDate
|
||||
: primaryReleaseDateLte || '',
|
||||
with_genres: genre || '',
|
||||
with_companies: studio || '',
|
||||
with_keywords: keywords || '',
|
||||
'with_runtime.gte': withRuntimeGte || '',
|
||||
'with_runtime.lte': withRuntimeLte || '',
|
||||
'vote_average.gte': voteAverageGte || '',
|
||||
'vote_average.lte': voteAverageLte || '',
|
||||
'vote_count.gte': voteCountGte || '',
|
||||
'vote_count.lte': voteCountLte || '',
|
||||
watch_region: watchRegion || '',
|
||||
with_watch_providers: watchProviders || '',
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -542,6 +524,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
voteCountLte,
|
||||
watchProviders,
|
||||
watchRegion,
|
||||
withStatus,
|
||||
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
|
||||
try {
|
||||
const defaultFutureDate = new Date(
|
||||
@@ -555,40 +538,41 @@ class TheMovieDb extends ExternalAPI {
|
||||
.split('T')[0];
|
||||
|
||||
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
|
||||
params: {
|
||||
sort_by: sortBy,
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'first_air_date.gte':
|
||||
!firstAirDateGte && firstAirDateLte
|
||||
? defaultPastDate
|
||||
: firstAirDateGte,
|
||||
'first_air_date.lte':
|
||||
!firstAirDateLte && firstAirDateGte
|
||||
? defaultFutureDate
|
||||
: firstAirDateLte,
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? undefined
|
||||
: this.originalLanguage,
|
||||
include_null_first_air_dates: includeEmptyReleaseDate,
|
||||
with_genres: genre,
|
||||
with_networks: network,
|
||||
with_keywords: keywords,
|
||||
'with_runtime.gte': withRuntimeGte,
|
||||
'with_runtime.lte': withRuntimeLte,
|
||||
'vote_average.gte': voteAverageGte,
|
||||
'vote_average.lte': voteAverageLte,
|
||||
'vote_count.gte': voteCountGte,
|
||||
'vote_count.lte': voteCountLte,
|
||||
with_watch_providers: watchProviders,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
sort_by: sortBy,
|
||||
page: page.toString(),
|
||||
language,
|
||||
region: this.region || '',
|
||||
// Set our release date values, but check if one is set and not the other,
|
||||
// so we can force a past date or a future date. TMDB Requires both values if one is set!
|
||||
'first_air_date.gte':
|
||||
!firstAirDateGte && firstAirDateLte
|
||||
? defaultPastDate
|
||||
: firstAirDateGte || '',
|
||||
'first_air_date.lte':
|
||||
!firstAirDateLte && firstAirDateGte
|
||||
? defaultFutureDate
|
||||
: firstAirDateLte || '',
|
||||
with_original_language:
|
||||
originalLanguage && originalLanguage !== 'all'
|
||||
? originalLanguage
|
||||
: originalLanguage === 'all'
|
||||
? ''
|
||||
: this.originalLanguage || '',
|
||||
include_null_first_air_dates: includeEmptyReleaseDate
|
||||
? 'true'
|
||||
: 'false',
|
||||
with_genres: genre || '',
|
||||
with_networks: network?.toString() || '',
|
||||
with_keywords: keywords || '',
|
||||
'with_runtime.gte': withRuntimeGte || '',
|
||||
'with_runtime.lte': withRuntimeLte || '',
|
||||
'vote_average.gte': voteAverageGte || '',
|
||||
'vote_average.lte': voteAverageLte || '',
|
||||
'vote_count.gte': voteCountGte || '',
|
||||
'vote_count.lte': voteCountLte || '',
|
||||
with_watch_providers: watchProviders || '',
|
||||
watch_region: watchRegion || '',
|
||||
with_status: withStatus || '',
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -608,12 +592,10 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbUpcomingMoviesResponse>(
|
||||
'/movie/upcoming',
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
originalLanguage: this.originalLanguage,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
region: this.region || '',
|
||||
originalLanguage: this.originalLanguage || '',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -636,11 +618,9 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchMultiResponse>(
|
||||
`/trending/all/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
language,
|
||||
region: this.region,
|
||||
},
|
||||
page: page.toString(),
|
||||
language,
|
||||
region: this.region || '',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -661,9 +641,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchMovieResponse>(
|
||||
`/trending/movie/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
page: page.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -684,9 +662,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbSearchTvResponse>(
|
||||
`/trending/tv/${timeWindow}`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
},
|
||||
page: page.toString(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -715,10 +691,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbExternalIdResponse>(
|
||||
`/find/${externalId}`,
|
||||
{
|
||||
params: {
|
||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||
language,
|
||||
},
|
||||
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -808,9 +782,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbCollection>(
|
||||
`/collection/${collectionId}`,
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
language,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -883,9 +855,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/movie/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
language,
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -897,9 +867,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const englishData = await this.get<TmdbGenresResult>(
|
||||
'/genre/movie/list',
|
||||
{
|
||||
params: {
|
||||
language: 'en',
|
||||
},
|
||||
language: 'en',
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -934,9 +902,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbGenresResult>(
|
||||
'/genre/tv/list',
|
||||
{
|
||||
params: {
|
||||
language,
|
||||
},
|
||||
language,
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -948,9 +914,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const englishData = await this.get<TmdbGenresResult>(
|
||||
'/genre/tv/list',
|
||||
{
|
||||
params: {
|
||||
language: 'en',
|
||||
},
|
||||
language: 'en',
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -1005,10 +969,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbKeywordSearchResponse>(
|
||||
'/search/keyword',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
query,
|
||||
page: page.toString(),
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -1030,10 +992,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<TmdbCompanySearchResponse>(
|
||||
'/search/company',
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
page,
|
||||
},
|
||||
query,
|
||||
page: page.toString(),
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -1053,9 +1013,7 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
|
||||
'/watch/providers/regions',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
},
|
||||
language: language ? this.originalLanguage || '' : '',
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -1079,10 +1037,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/movie',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
language: language ? this.originalLanguage || '' : '',
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
@@ -1106,10 +1062,8 @@ class TheMovieDb extends ExternalAPI {
|
||||
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
|
||||
'/watch/providers/tv',
|
||||
{
|
||||
params: {
|
||||
language: language ?? this.originalLanguage,
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
language: language ? this.originalLanguage || '' : '',
|
||||
watch_region: watchRegion,
|
||||
},
|
||||
86400 // 24 hours
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum ApiErrorCode {
|
||||
InvalidUrl = 'INVALID_URL',
|
||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||
InvalidAuthToken = 'INVALID_AUTH_TOKEN',
|
||||
InvalidEmail = 'INVALID_EMAIL',
|
||||
NotAdmin = 'NOT_ADMIN',
|
||||
SyncErrorGroupedFolders = 'SYNC_ERROR_GROUPED_FOLDERS',
|
||||
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
|
||||
|
||||
@@ -4,3 +4,8 @@ export enum MediaServerType {
|
||||
EMBY,
|
||||
NOT_CONFIGURED,
|
||||
}
|
||||
|
||||
export enum ServerType {
|
||||
JELLYFIN = 'Jellyfin',
|
||||
EMBY = 'Emby',
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export enum UserType {
|
||||
PLEX = 1,
|
||||
LOCAL = 2,
|
||||
JELLYFIN = 3,
|
||||
EMBY = 4,
|
||||
}
|
||||
|
||||
@@ -211,9 +211,10 @@ class Media {
|
||||
}
|
||||
} else {
|
||||
const pageName =
|
||||
process.env.JELLYFIN_TYPE === 'emby' ? 'item' : 'details';
|
||||
getSettings().main.mediaServerType == MediaServerType.EMBY
|
||||
? 'item'
|
||||
: 'details';
|
||||
const { serverId, externalHostname } = getSettings().jellyfin;
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
|
||||
@@ -32,10 +32,17 @@ import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import type { Store } from 'express-session';
|
||||
import session from 'express-session';
|
||||
import next from 'next';
|
||||
import dns from 'node:dns';
|
||||
import net from 'node:net';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import YAML from 'yamljs';
|
||||
|
||||
if (process.env.forceIpv4First === 'true') {
|
||||
dns.setDefaultResultOrder('ipv4first');
|
||||
net.setDefaultAutoSelectFamily(false);
|
||||
}
|
||||
|
||||
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
|
||||
|
||||
logger.info(`Starting Overseerr version ${getAppVersion()}`);
|
||||
@@ -56,7 +63,7 @@ app
|
||||
}
|
||||
|
||||
// Load Settings
|
||||
const settings = getSettings().load();
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings.main);
|
||||
|
||||
// Migrate library types
|
||||
|
||||
@@ -8,3 +8,16 @@ interface PageInfo {
|
||||
export interface PaginatedResponse {
|
||||
pageInfo: PageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the keys of an object that are not functions
|
||||
*/
|
||||
type NonFunctionPropertyNames<T> = {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
[K in keyof T]: T[K] extends Function ? never : K;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* Get the properties of an object that are not functions
|
||||
*/
|
||||
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { MediaType } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { PaginatedResponse } from './common';
|
||||
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||
|
||||
export interface RequestResultsResponse extends PaginatedResponse {
|
||||
results: MediaRequest[];
|
||||
results: NonFunctionProperties<MediaRequest>[];
|
||||
}
|
||||
|
||||
export type MediaRequestBody = {
|
||||
@@ -14,6 +14,7 @@ export type MediaRequestBody = {
|
||||
is4k?: boolean;
|
||||
serverId?: number;
|
||||
profileId?: number;
|
||||
profileName?: string;
|
||||
rootFolder?: string;
|
||||
languageProfileId?: number;
|
||||
userId?: number;
|
||||
|
||||
@@ -63,12 +63,7 @@ class AvailabilitySync {
|
||||
) {
|
||||
admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
select: [
|
||||
'id',
|
||||
'jellyfinAuthToken',
|
||||
'jellyfinUserId',
|
||||
'jellyfinDeviceId',
|
||||
],
|
||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -86,7 +81,7 @@ class AvailabilitySync {
|
||||
if (admin) {
|
||||
this.jellyfinClient = new JellyfinAPI(
|
||||
getHostname(),
|
||||
admin.jellyfinAuthToken,
|
||||
settings.jellyfin.apiKey,
|
||||
admin.jellyfinDeviceId
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface DownloadingItem {
|
||||
timeLeft: string;
|
||||
estimatedCompletionTime: Date;
|
||||
title: string;
|
||||
downloadId: string;
|
||||
episode?: EpisodeNumberResult;
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ class DownloadTracker {
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
@@ -172,6 +174,7 @@ class DownloadTracker {
|
||||
timeLeft: item.timeleft,
|
||||
title: item.title,
|
||||
episode: item.episode,
|
||||
downloadId: item.downloadId,
|
||||
}));
|
||||
|
||||
if (queueItems.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||
import type { RateLimitOptions } from '@server/utils/rateLimit';
|
||||
import rateLimit from '@server/utils/rateLimit';
|
||||
import { createHash } from 'crypto';
|
||||
import { promises } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
@@ -98,26 +98,29 @@ class ImageProxy {
|
||||
return files.length;
|
||||
}
|
||||
|
||||
private axios;
|
||||
private fetch: typeof fetch;
|
||||
private cacheVersion;
|
||||
private key;
|
||||
private baseUrl;
|
||||
|
||||
constructor(
|
||||
key: string,
|
||||
baseUrl: string,
|
||||
options: {
|
||||
cacheVersion?: number;
|
||||
rateLimitOptions?: rateLimitOptions;
|
||||
rateLimitOptions?: RateLimitOptions;
|
||||
} = {}
|
||||
) {
|
||||
this.cacheVersion = options.cacheVersion ?? 1;
|
||||
this.baseUrl = baseUrl;
|
||||
this.key = key;
|
||||
this.axios = axios.create({
|
||||
baseURL: baseUrl,
|
||||
});
|
||||
|
||||
if (options.rateLimitOptions) {
|
||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||
this.fetch = rateLimit(fetch, {
|
||||
...options.rateLimitOptions,
|
||||
});
|
||||
} else {
|
||||
this.fetch = fetch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,17 +185,20 @@ class ImageProxy {
|
||||
): Promise<ImageResponse | null> {
|
||||
try {
|
||||
const directory = join(this.getCacheDirectory(), cacheKey);
|
||||
const response = await this.axios.get(path, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
const href =
|
||||
this.baseUrl +
|
||||
(this.baseUrl.endsWith('/') ? '' : '/') +
|
||||
(path.startsWith('/') ? path.slice(1) : path);
|
||||
const response = await this.fetch(href);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const buffer = Buffer.from(response.data, 'binary');
|
||||
const extension = path.split('.').pop() ?? '';
|
||||
const maxAge = Number(
|
||||
(response.headers['cache-control'] ?? '0').split('=')[1]
|
||||
(response.headers.get('cache-control') ?? '0').split('=')[1]
|
||||
);
|
||||
const expireAt = Date.now() + maxAge * 1000;
|
||||
const etag = (response.headers.etag ?? '').replace(/"/g, '');
|
||||
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
|
||||
|
||||
await this.writeToCacheDir(
|
||||
directory,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentDiscord } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
@@ -292,23 +291,39 @@ class DiscordAgent
|
||||
}
|
||||
}
|
||||
|
||||
await axios.post(settings.options.webhookUrl, {
|
||||
username: settings.options.botUsername
|
||||
? settings.options.botUsername
|
||||
: getSettings().main.applicationTitle,
|
||||
avatar_url: settings.options.botAvatarUrl,
|
||||
embeds: [this.buildEmbed(type, payload)],
|
||||
content: userMentions.join(' '),
|
||||
} as DiscordWebhookPayload);
|
||||
const response = await fetch(settings.options.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: settings.options.botUsername
|
||||
? settings.options.botUsername
|
||||
: 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;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Discord notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import type { NotificationAgentGotify } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
@@ -133,16 +132,32 @@ class GotifyAgent
|
||||
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
await axios.post(endpoint, notificationPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(notificationPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Gotify notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import type { NotificationAgentLunaSea } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
@@ -101,28 +100,39 @@ class LunaSeaAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
settings.options.webhookUrl,
|
||||
this.buildPayload(type, payload),
|
||||
settings.options.profileName
|
||||
const response = await fetch(settings.options.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: settings.options.profileName
|
||||
? {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${settings.options.profileName}:`
|
||||
).toString('base64')}`,
|
||||
},
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
: 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;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending LunaSea notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushbullet } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
@@ -123,22 +122,34 @@ class PushbulletAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
endpoint,
|
||||
{ ...notificationPayload, channel_tag: settings.options.channelTag },
|
||||
{
|
||||
headers: {
|
||||
'Access-Token': settings.options.accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'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) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -163,19 +174,32 @@ class PushbulletAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Token': payload.notifyUser.settings.pushbulletAccessToken,
|
||||
},
|
||||
body: JSON.stringify(notificationPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -211,19 +235,32 @@ class PushbulletAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, notificationPayload, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Token': user.settings.pushbulletAccessToken,
|
||||
},
|
||||
body: JSON.stringify(notificationPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentPushover } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
@@ -52,12 +51,15 @@ class PushoverAgent
|
||||
imageUrl: string
|
||||
): Promise<Partial<PushoverImagePayload>> {
|
||||
try {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||
const contentType = (
|
||||
response.headers['Content-Type'] || response.headers['content-type']
|
||||
response.headers.get('Content-Type') ||
|
||||
response.headers.get('content-type')
|
||||
)?.toString();
|
||||
|
||||
return {
|
||||
@@ -65,10 +67,17 @@ class PushoverAgent
|
||||
attachment_type: contentType,
|
||||
};
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error getting image payload', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
return {};
|
||||
}
|
||||
@@ -201,19 +210,35 @@ class PushoverAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: settings.options.accessToken,
|
||||
user: settings.options.userToken,
|
||||
sound: settings.options.sound,
|
||||
} as PushoverPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...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) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -241,20 +266,36 @@ class PushoverAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: payload.notifyUser.settings.pushoverApplicationToken,
|
||||
user: payload.notifyUser.settings.pushoverUserKey,
|
||||
sound: payload.notifyUser.settings.pushoverSound,
|
||||
} as PushoverPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...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) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -291,19 +332,35 @@ class PushoverAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
token: user.settings.pushoverApplicationToken,
|
||||
user: user.settings.pushoverUserKey,
|
||||
} as PushoverPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Pushover notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,6 @@ import { IssueStatus, IssueTypeName } from '@server/constants/issue';
|
||||
import type { NotificationAgentSlack } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
import { BaseAgent } from './agent';
|
||||
@@ -238,19 +237,32 @@ class SlackAgent
|
||||
subject: payload.subject,
|
||||
});
|
||||
try {
|
||||
await axios.post(
|
||||
settings.options.webhookUrl,
|
||||
this.buildEmbed(type, payload)
|
||||
);
|
||||
const response = await fetch(settings.options.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.buildEmbed(type, payload)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Slack notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { User } from '@server/entity/User';
|
||||
import type { NotificationAgentTelegram } from '@server/lib/settings';
|
||||
import { getSettings, NotificationAgentKey } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
hasNotificationType,
|
||||
Notification,
|
||||
@@ -175,18 +174,34 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: settings.options.chatId,
|
||||
disable_notification: !!settings.options.sendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...notificationPayload,
|
||||
chat_id: settings.options.chatId,
|
||||
disable_notification: !!settings.options.sendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -210,20 +225,36 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||
disable_notification:
|
||||
!!payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...notificationPayload,
|
||||
chat_id: payload.notifyUser.settings.telegramChatId,
|
||||
disable_notification:
|
||||
!!payload.notifyUser.settings.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
recipient: payload.notifyUser.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
@@ -257,19 +288,35 @@ class TelegramAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(endpoint, {
|
||||
...notificationPayload,
|
||||
chat_id: user.settings.telegramChatId,
|
||||
disable_notification: !!user.settings?.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload);
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...notificationPayload,
|
||||
chat_id: user.settings.telegramChatId,
|
||||
disable_notification: !!user.settings?.telegramSendSilently,
|
||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending Telegram notification', {
|
||||
label: 'Notifications',
|
||||
recipient: user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import type { NotificationAgentWebhook } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||
@@ -178,26 +177,35 @@ class WebhookAgent
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
settings.options.webhookUrl,
|
||||
this.buildPayload(type, payload),
|
||||
settings.options.authHeader
|
||||
? {
|
||||
headers: {
|
||||
Authorization: settings.options.authHeader,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
const response = await fetch(settings.options.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(settings.options.authHeader
|
||||
? { Authorization: settings.options.authHeader }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText, { cause: response });
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
logger.error('Error sending webhook notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e.response?.data,
|
||||
response: errorData,
|
||||
});
|
||||
|
||||
return false;
|
||||
|
||||
@@ -567,7 +567,10 @@ class JellyfinScanner {
|
||||
public async run(): Promise<void> {
|
||||
const settings = getSettings();
|
||||
|
||||
if (settings.main.mediaServerType != MediaServerType.JELLYFIN) {
|
||||
if (
|
||||
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType != MediaServerType.EMBY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -582,12 +585,7 @@ class JellyfinScanner {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
select: [
|
||||
'id',
|
||||
'jellyfinAuthToken',
|
||||
'jellyfinUserId',
|
||||
'jellyfinDeviceId',
|
||||
],
|
||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -595,11 +593,9 @@ class JellyfinScanner {
|
||||
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
|
||||
this.jfClient = new JellyfinAPI(
|
||||
hostname,
|
||||
admin.jellyfinAuthToken,
|
||||
getHostname(),
|
||||
settings.jellyfin.apiKey,
|
||||
admin.jellyfinDeviceId
|
||||
);
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface JellyfinSettings {
|
||||
jellyfinForgotPasswordUrl?: string;
|
||||
libraries: Library[];
|
||||
serverId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
export interface TautulliSettings {
|
||||
hostname?: string;
|
||||
@@ -342,6 +343,7 @@ class Settings {
|
||||
jellyfinForgotPasswordUrl: '',
|
||||
libraries: [],
|
||||
serverId: '',
|
||||
apiKey: '',
|
||||
},
|
||||
tautulli: {},
|
||||
radarr: [],
|
||||
@@ -609,7 +611,11 @@ class Settings {
|
||||
}
|
||||
|
||||
private generateApiKey(): string {
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
if (process.env.API_KEY) {
|
||||
return process.env.API_KEY;
|
||||
} else {
|
||||
return Buffer.from(`${Date.now()}${randomUUID()}`).toString('base64');
|
||||
}
|
||||
}
|
||||
|
||||
private generateVapidKeys(force = false): void {
|
||||
@@ -629,7 +635,7 @@ class Settings {
|
||||
* @param overrideSettings If passed in, will override all existing settings with these
|
||||
* values
|
||||
*/
|
||||
public load(overrideSettings?: AllSettings): Settings {
|
||||
public async load(overrideSettings?: AllSettings): Promise<Settings> {
|
||||
if (overrideSettings) {
|
||||
this.data = overrideSettings;
|
||||
return this;
|
||||
@@ -642,10 +648,16 @@ class Settings {
|
||||
|
||||
if (data) {
|
||||
const parsedJson = JSON.parse(data);
|
||||
this.data = runMigrations(parsedJson);
|
||||
this.data = await runMigrations(parsedJson);
|
||||
|
||||
this.data = merge(this.data, parsedJson);
|
||||
|
||||
if (process.env.API_KEY) {
|
||||
if (this.main.apiKey != process.env.API_KEY) {
|
||||
this.main.apiKey = process.env.API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
return this;
|
||||
|
||||
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
|
||||
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
|
||||
const mediaServerType = settings.main.mediaServerType;
|
||||
if (
|
||||
!settings.jellyfin.apiKey &&
|
||||
(mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY)
|
||||
) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
if (!admin) {
|
||||
return settings;
|
||||
}
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
getHostname(settings.jellyfin),
|
||||
admin.jellyfinAuthToken,
|
||||
admin.jellyfinDeviceId
|
||||
);
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||
settings.jellyfin.apiKey = apiKey;
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
export default migrateApiTokens;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const migrateHostname = (settings: any): AllSettings => {
|
||||
const oldMediaServerType = settings.main.mediaServerType;
|
||||
if (
|
||||
oldMediaServerType === MediaServerType.JELLYFIN &&
|
||||
process.env.JELLYFIN_TYPE === 'emby'
|
||||
) {
|
||||
settings.main.mediaServerType = MediaServerType.EMBY;
|
||||
}
|
||||
|
||||
return settings;
|
||||
};
|
||||
|
||||
export default migrateHostname;
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
|
||||
export const runMigrations = (settings: AllSettings): AllSettings => {
|
||||
export const runMigrations = async (
|
||||
settings: AllSettings
|
||||
): Promise<AllSettings> => {
|
||||
const migrations = fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||
@@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => {
|
||||
|
||||
let migrated = settings;
|
||||
|
||||
for (const migration of migrations) {
|
||||
migrated = migration(migrated);
|
||||
try {
|
||||
for (const migration of migrations) {
|
||||
migrated = await migration(migrated);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while running settings migrations: ${e.message}`,
|
||||
{ label: 'Settings Migrator' }
|
||||
);
|
||||
}
|
||||
|
||||
return migrated;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface MovieDetails {
|
||||
mediaUrl?: string;
|
||||
watchProviders?: WatchProviders[];
|
||||
keywords: Keyword[];
|
||||
onUserWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export const mapProductionCompany = (
|
||||
@@ -101,7 +102,8 @@ export const mapProductionCompany = (
|
||||
|
||||
export const mapMovieDetails = (
|
||||
movie: TmdbMovieDetails,
|
||||
media?: Media
|
||||
media?: Media,
|
||||
userWatchlist?: boolean
|
||||
): MovieDetails => ({
|
||||
id: movie.id,
|
||||
adult: movie.adult,
|
||||
@@ -148,4 +150,5 @@ export const mapMovieDetails = (
|
||||
id: keyword.id,
|
||||
name: keyword.name,
|
||||
})),
|
||||
onUserWatchlist: userWatchlist,
|
||||
});
|
||||
|
||||
@@ -111,6 +111,7 @@ export interface TvDetails {
|
||||
keywords: Keyword[];
|
||||
mediaInfo?: Media;
|
||||
watchProviders?: WatchProviders[];
|
||||
onUserWatchlist?: boolean;
|
||||
}
|
||||
|
||||
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||
@@ -161,7 +162,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
|
||||
|
||||
export const mapTvDetails = (
|
||||
show: TmdbTvDetails,
|
||||
media?: Media
|
||||
media?: Media,
|
||||
userWatchlist?: boolean
|
||||
): TvDetails => ({
|
||||
createdBy: show.created_by,
|
||||
episodeRunTime: show.episode_run_time,
|
||||
@@ -223,4 +225,5 @@ export const mapTvDetails = (
|
||||
})),
|
||||
mediaInfo: media,
|
||||
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||
onUserWatchlist: userWatchlist,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -227,15 +227,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
urlBase?: string;
|
||||
useSsl?: boolean;
|
||||
email?: string;
|
||||
serverType?: number;
|
||||
};
|
||||
|
||||
//Make sure jellyfin login is enabled, but only if jellyfin is not already configured
|
||||
//Make sure jellyfin login is enabled, but only if jellyfin && Emby is not already configured
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY &&
|
||||
settings.main.mediaServerType != MediaServerType.NOT_CONFIGURED &&
|
||||
settings.jellyfin.ip !== ''
|
||||
) {
|
||||
return res.status(500).json({ error: 'Jellyfin login is disabled' });
|
||||
} else if (!body.username) {
|
||||
}
|
||||
|
||||
if (!body.username) {
|
||||
return res.status(500).json({ error: 'You must provide an username' });
|
||||
} else if (settings.jellyfin.ip !== '' && body.hostname) {
|
||||
return res
|
||||
@@ -273,7 +278,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
|
||||
// First we need to attempt to log the user in to jellyfin
|
||||
const jellyfinserver = new JellyfinAPI(hostname, undefined, deviceId);
|
||||
const jellyfinserver = new JellyfinAPI(hostname ?? '', undefined, deviceId);
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
@@ -317,20 +323,55 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
);
|
||||
|
||||
// User doesn't exist, and there are no users in the database, we'll create the user
|
||||
// with admin permission
|
||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||
user = new User({
|
||||
email: body.email,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
// with admin permissions
|
||||
switch (body.serverType) {
|
||||
case MediaServerType.EMBY:
|
||||
settings.main.mediaServerType = MediaServerType.EMBY;
|
||||
user = new User({
|
||||
email: body.email || account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType: UserType.EMBY,
|
||||
});
|
||||
break;
|
||||
case MediaServerType.JELLYFIN:
|
||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||
user = new User({
|
||||
email: body.email || account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: Permission.ADMIN,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType: UserType.JELLYFIN,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('select_server_type');
|
||||
}
|
||||
|
||||
// Create an API key on Jellyfin from this admin user
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
hostname,
|
||||
account.AccessToken,
|
||||
deviceId
|
||||
);
|
||||
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||
|
||||
const serverName = await jellyfinserver.getServerName();
|
||||
|
||||
@@ -340,6 +381,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
settings.jellyfin.port = body.port ?? 8096;
|
||||
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||
settings.jellyfin.apiKey = apiKey;
|
||||
settings.save();
|
||||
startJobs();
|
||||
|
||||
@@ -350,12 +392,12 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
logger.info(
|
||||
`Found matching ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby'
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
} user; updating user with ${
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby'
|
||||
? ServerType.JELLYFIN
|
||||
: ServerType.EMBY
|
||||
}`,
|
||||
{
|
||||
label: 'API',
|
||||
@@ -363,15 +405,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
// Let's check if their authtoken is up to date
|
||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
}
|
||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||
if (account.User.PrimaryImageTag) {
|
||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||
} else {
|
||||
user.avatar = gravatarUrl(user.email, {
|
||||
user.avatar = gravatarUrl(user.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
@@ -382,12 +420,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
user.username = '';
|
||||
}
|
||||
|
||||
// TODO: If JELLYFIN_TYPE is set to 'emby' then set mediaServerType to EMBY
|
||||
// if (process.env.JELLYFIN_TYPE === 'emby') {
|
||||
// settings.main.mediaServerType = MediaServerType.EMBY;
|
||||
// settings.save();
|
||||
// }
|
||||
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
@@ -413,21 +445,22 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
);
|
||||
|
||||
if (!body.email) {
|
||||
throw new Error('add_email');
|
||||
}
|
||||
|
||||
user = new User({
|
||||
email: body.email,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
jellyfinAuthToken: account.AccessToken,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
avatar: account.User.PrimaryImageTag
|
||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
|
||||
userType: UserType.JELLYFIN,
|
||||
: gravatarUrl(body.email || account.User.Name, {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
//initialize Jellyfin/Emby users with local login
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
@@ -730,6 +763,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
user.recoveryLinkExpirationDate = null;
|
||||
await user.setPassword(req.body.password);
|
||||
userRepository.save(user);
|
||||
logger.info('Successfully reset password', {
|
||||
label: 'API',
|
||||
|
||||
@@ -71,6 +71,7 @@ const QueryFilterOptions = z.object({
|
||||
network: z.coerce.string().optional(),
|
||||
watchProviders: z.coerce.string().optional(),
|
||||
watchRegion: z.coerce.string().optional(),
|
||||
status: z.coerce.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -385,6 +386,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
voteCountLte: query.voteCountLte,
|
||||
watchProviders: query.watchProviders,
|
||||
watchRegion: query.watchRegion,
|
||||
withStatus: query.status,
|
||||
});
|
||||
|
||||
const media = await Media.getRelatedMedia(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Router } from 'express';
|
||||
const router = Router();
|
||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { mapMovieDetails } from '@server/models/Movie';
|
||||
import { mapMovieResult } from '@server/models/Search';
|
||||
@@ -22,7 +24,24 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
||||
|
||||
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
where: {
|
||||
tmdbId: Number(req.params.id),
|
||||
requestedBy: {
|
||||
id: req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving movie', {
|
||||
label: 'API',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
@@ -19,6 +21,7 @@ import type {
|
||||
RequestResultsResponse,
|
||||
} from '@server/interfaces/api/requestInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
@@ -143,6 +146,62 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
// get all quality profiles for every configured sonarr server
|
||||
const sonarrServers = await Promise.all(
|
||||
settings.sonarr.map(async (sonarrSetting) => {
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSetting.apiKey,
|
||||
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
|
||||
});
|
||||
|
||||
return {
|
||||
id: sonarrSetting.id,
|
||||
profiles: await sonarr.getProfiles(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// get all quality profiles for every configured radarr server
|
||||
const radarrServers = await Promise.all(
|
||||
settings.radarr.map(async (radarrSetting) => {
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSetting.apiKey,
|
||||
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
|
||||
});
|
||||
|
||||
return {
|
||||
id: radarrSetting.id,
|
||||
profiles: await radarr.getProfiles(),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// add profile names to the media requests, with undefined if not found
|
||||
const requestsWithProfileNames = requests.map((r) => {
|
||||
switch (r.type) {
|
||||
case MediaType.MOVIE: {
|
||||
const profileName = radarrServers
|
||||
.find((serverr) => serverr.id === r.serverId)
|
||||
?.profiles.find((profile) => profile.id === r.profileId)?.name;
|
||||
|
||||
return {
|
||||
...r,
|
||||
profileName,
|
||||
};
|
||||
}
|
||||
case MediaType.TV: {
|
||||
return {
|
||||
...r,
|
||||
profileName: sonarrServers
|
||||
.find((serverr) => serverr.id === r.serverId)
|
||||
?.profiles.find((profile) => profile.id === r.profileId)?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(requestCount / pageSize),
|
||||
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
||||
results: requestCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: requests,
|
||||
results: requestsWithProfileNames,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
|
||||
@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
try {
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
getHostname(tempJellyfinSettings),
|
||||
admin.jellyfinAuthToken ?? '',
|
||||
tempJellyfinSettings.apiKey,
|
||||
admin.jellyfinDeviceId ?? ''
|
||||
);
|
||||
|
||||
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
if (req.query.sync) {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
where: { id: 1 },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
getHostname(),
|
||||
admin.jellyfinAuthToken ?? '',
|
||||
settings.jellyfin.apiKey,
|
||||
admin.jellyfinDeviceId ?? ''
|
||||
);
|
||||
|
||||
@@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
||||
});
|
||||
|
||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
const settings = getSettings();
|
||||
const { externalHostname } = settings.jellyfin;
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
? externalHostname
|
||||
@@ -384,12 +385,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
where: { id: 1 },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
admin.jellyfinAuthToken ?? '',
|
||||
getHostname(),
|
||||
settings.jellyfin.apiKey,
|
||||
admin.jellyfinDeviceId ?? ''
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import logger from '@server/logger';
|
||||
import { mapTvResult } from '@server/models/Search';
|
||||
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
|
||||
@@ -19,7 +21,24 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||
|
||||
return res.status(200).json(mapTvDetails(tv, media));
|
||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
where: {
|
||||
tmdbId: Number(req.params.id),
|
||||
requestedBy: {
|
||||
id: req.user?.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = mapTvDetails(tv, media, onUserWatchlist);
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (e) {
|
||||
logger.debug('Something went wrong retrieving series', {
|
||||
label: 'API',
|
||||
|
||||
@@ -2,6 +2,7 @@ import JellyfinAPI from '@server/api/jellyfin';
|
||||
import PlexTvAPI from '@server/api/plextv';
|
||||
import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -41,7 +42,19 @@ router.get('/', async (req, res, next) => {
|
||||
break;
|
||||
case 'displayname':
|
||||
query = query.orderBy(
|
||||
"(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
|
||||
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||
user.email
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
'ASC'
|
||||
);
|
||||
break;
|
||||
@@ -90,12 +103,13 @@ router.post(
|
||||
const settings = getSettings();
|
||||
|
||||
const body = req.body;
|
||||
const email = body.email || body.username;
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const existingUser = await userRepository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.email = :email', {
|
||||
email: body.email.toLowerCase(),
|
||||
email: email.toLowerCase(),
|
||||
})
|
||||
.getOne();
|
||||
|
||||
@@ -108,7 +122,7 @@ router.post(
|
||||
}
|
||||
|
||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
|
||||
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
|
||||
|
||||
if (
|
||||
!passedExplicitPassword &&
|
||||
@@ -118,9 +132,9 @@ router.post(
|
||||
}
|
||||
|
||||
const user = new User({
|
||||
email,
|
||||
avatar: body.avatar ?? avatar,
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
plexToken: '',
|
||||
@@ -488,16 +502,14 @@ router.post(
|
||||
// taken from auth.ts
|
||||
const admin = await userRepository.findOneOrFail({
|
||||
where: { id: 1 },
|
||||
select: [
|
||||
'id',
|
||||
'jellyfinAuthToken',
|
||||
'jellyfinDeviceId',
|
||||
'jellyfinUserId',
|
||||
],
|
||||
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
const hostname = getHostname();
|
||||
const jellyfinClient = new JellyfinAPI(
|
||||
admin.jellyfinAuthToken ?? '',
|
||||
hostname,
|
||||
settings.jellyfin.apiKey,
|
||||
admin.jellyfinDeviceId ?? ''
|
||||
);
|
||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||
@@ -505,7 +517,6 @@ router.post(
|
||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||
const createdUsers: User[] = [];
|
||||
const { externalHostname } = getSettings().jellyfin;
|
||||
const hostname = getHostname();
|
||||
|
||||
const jellyfinHost =
|
||||
externalHostname && externalHostname.length > 0
|
||||
@@ -540,7 +551,10 @@ router.post(
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
}),
|
||||
userType: UserType.JELLYFIN,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
|
||||
await userRepository.save(newUser);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserSettings } from '@server/entity/UserSettings';
|
||||
@@ -9,6 +10,7 @@ import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { Router } from 'express';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
@@ -98,7 +100,17 @@ userSettingsRoutes.post<
|
||||
}
|
||||
|
||||
user.username = req.body.username;
|
||||
user.email = req.body.email ?? user.email;
|
||||
const oldEmail = user.email;
|
||||
if (user.jellyfinUsername) {
|
||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||
}
|
||||
|
||||
const existingUser = await userRepository.findOne({
|
||||
where: { email: user.email },
|
||||
});
|
||||
if (oldEmail !== user.email && existingUser) {
|
||||
throw new ApiError(400, ApiErrorCode.InvalidEmail);
|
||||
}
|
||||
|
||||
// Update quota values only if the user has the correct permissions
|
||||
if (
|
||||
@@ -143,7 +155,14 @@ userSettingsRoutes.post<
|
||||
email: savedUser.email,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
if (e.errorCode) {
|
||||
return next({
|
||||
status: e.statusCode,
|
||||
message: e.errorCode,
|
||||
});
|
||||
} else {
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
68
server/utils/rateLimit.ts
Normal file
68
server/utils/rateLimit.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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();
|
||||
});
|
||||
};
|
||||
}
|
||||
47
src/assets/services/emby-icon-only.svg
Normal file
47
src/assets/services/emby-icon-only.svg
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
viewBox="0 0 712.60077 712.5481"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<rect
|
||||
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||
id="rect249"
|
||||
width="712.60077"
|
||||
height="712.5481"
|
||||
x="-0.00071160076"
|
||||
y="2.0223413e-11" />
|
||||
<rect
|
||||
style="fill:#ffffff"
|
||||
id="rect289"
|
||||
width="230.18982"
|
||||
height="229.82355"
|
||||
x="241.20476"
|
||||
y="241.36227" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||
<path
|
||||
id="path3427"
|
||||
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
|
||||
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -1,46 +1,131 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
viewBox="0 0 712.60077 712.5481"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs4" />
|
||||
<metadata
|
||||
id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<rect
|
||||
style="opacity:0;fill:#ffffff;stroke-width:4.12543"
|
||||
id="rect249"
|
||||
width="712.60077"
|
||||
height="712.5481"
|
||||
x="-0.00071160076"
|
||||
y="2.0223413e-11" />
|
||||
<rect
|
||||
style="fill:#ffffff"
|
||||
id="rect289"
|
||||
width="230.18982"
|
||||
height="229.82355"
|
||||
x="241.20476"
|
||||
y="241.36227" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="matrix(0.70249853,0,0,0.70249853,88.770447,96.84571)">
|
||||
<path
|
||||
id="path3427"
|
||||
d="m 327.06546,642.18589 c -45.39663,-45.86009 -82.73776,-83.3683 -82.98029,-83.3516 -0.24253,0.0167 -7.23324,6.65975 -15.53493,14.7623 l -15.09396,14.73193 -40.13624,-40.38805 C 151.24511,525.72706 108.73555,482.86504 78.854363,452.69158 l -54.329437,-54.86086 83.720394,-82.90796 83.72039,-82.90797 -15.19316,-15.20441 -15.19315,-15.20443 95.18008,-94.29313 c 52.34904,-51.86121 95.35849,-94.293118 95.57653,-94.293118 0.21805,0 37.39519,37.357576 82.61589,83.016832 45.22068,45.659256 82.53772,83.131956 82.92673,83.272666 0.38901,0.14071 7.46336,-6.49498 15.72077,-14.746 l 15.01348,-15.00184 7.14591,7.19902 c 73.95232,74.50189 181.50599,183.56427 181.36678,183.9109 -0.10065,0.25064 -37.54056,37.44106 -83.19981,82.64536 -45.65926,45.2043 -83.10802,82.41429 -83.21946,82.68884 -0.11145,0.27456 6.50478,7.34753 14.70272,15.71771 l 14.90534,15.21851 -15.3888,15.28883 c -21.09609,20.95904 -162.95155,161.27018 -169.79551,167.947 l -5.52526,5.39033 z m 89.8523,-204.1566 c 64.84836,-37.53366 117.81919,-68.54793 117.71294,-68.92058 -0.15927,-0.55862 -233.55022,-136.2489 -236.27084,-137.3646 -0.68441,-0.28068 -0.85761,27.45642 -0.85761,137.33982 0,99.83563 0.20749,137.62237 0.75471,137.43996 0.41509,-0.13837 53.81245,-30.96093 118.6608,-68.4946 z"
|
||||
style="fill:#52b54b;fill-opacity:1;stroke:none" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" width="100%" viewBox="0 0 617 188" enable-background="new 0 0 617 188" xml:space="preserve">
|
||||
<path fill="#52B54B" opacity="1.000000" stroke="none" d="
|
||||
M89.583336,1.000000
|
||||
C90.189529,1.685005 90.166168,2.574803 90.599510,3.025271
|
||||
C103.718315,16.662701 116.882103,30.256845 129.948212,43.764053
|
||||
C130.577850,43.523941 130.916519,43.491173 131.111343,43.306595
|
||||
C138.657471,36.157455 138.655273,36.156090 146.005478,43.505203
|
||||
C159.538589,57.036308 173.016449,70.623535 186.654617,84.047913
|
||||
C189.264145,86.616562 189.414017,88.253456 186.716782,90.895164
|
||||
C174.709808,102.655037 162.893280,114.609337 151.008514,126.493958
|
||||
C146.073502,131.428925 146.076691,131.427155 151.017944,136.523712
|
||||
C151.698944,137.226120 152.340485,137.966812 153.259171,138.973434
|
||||
C151.947098,140.380035 150.766312,141.712204 149.516266,142.975861
|
||||
C134.544815,158.110641 119.563087,173.235260 104.792023,188.681274
|
||||
C103.611107,189.000000 102.222221,189.000000 100.624634,188.681274
|
||||
C86.361732,174.796494 72.307518,161.230438 57.702755,147.132965
|
||||
C56.157101,149.136856 54.135899,151.757263 51.994804,154.533112
|
||||
C35.932781,138.457108 20.569420,123.048477 5.141897,107.704361
|
||||
C3.997114,106.565773 2.391420,105.890610 1.000000,105.000000
|
||||
C1.000000,103.611107 1.000000,102.222221 1.318741,100.624641
|
||||
C15.203506,86.361694 28.769531,72.307434 42.867004,57.702602
|
||||
C40.863205,56.156994 38.242813,54.135792 35.425343,51.962570
|
||||
C51.518696,35.908516 66.939468,20.557360 82.295547,5.141749
|
||||
C83.434830,3.998048 84.109390,2.391417 85.000000,0.999999
|
||||
C86.388893,1.000000 87.777779,1.000000 89.583336,1.000000
|
||||
M73.196465,79.500702
|
||||
C73.196465,96.254150 73.196465,113.007599 73.196465,130.872055
|
||||
C94.273178,118.764557 114.417175,107.192863 135.221664,95.241745
|
||||
C114.247169,83.251732 94.091187,71.729622 73.196594,59.785294
|
||||
C73.196594,66.631348 73.196594,72.566254 73.196465,79.500702
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M618.000000,60.571537
|
||||
C617.004395,62.042580 615.613281,62.912964 615.073181,64.153824
|
||||
C608.143372,80.073746 601.328613,96.043816 594.498169,112.006920
|
||||
C586.973572,129.592300 579.343018,147.133865 571.999390,164.794601
|
||||
C568.632385,172.892075 568.893372,173.002594 560.133972,172.999832
|
||||
C555.470825,172.998367 550.807617,172.994385 546.144592,172.969360
|
||||
C545.841980,172.967712 545.540466,172.775543 544.836609,172.534256
|
||||
C548.592896,163.531219 551.714905,154.222061 556.286133,145.689255
|
||||
C559.733765,139.253830 559.138794,134.062668 556.454224,127.695969
|
||||
C546.360352,103.757523 536.803345,79.592712 526.837830,55.000847
|
||||
C534.817078,55.000847 542.437622,54.725182 550.003540,55.244331
|
||||
C551.436218,55.342628 553.169678,58.412052 553.885010,60.423309
|
||||
C558.720520,74.018005 563.307556,87.700912 568.003784,101.345413
|
||||
C569.107483,104.551987 570.321045,107.720764 571.976196,112.255157
|
||||
C573.889587,107.365631 575.415283,103.375916 577.007935,99.413109
|
||||
C582.693298,85.266724 588.344238,71.105591 594.218018,57.037624
|
||||
C594.650513,56.001743 596.734497,55.132927 598.079773,55.089733
|
||||
C604.401855,54.886726 610.734131,54.999401 617.531372,54.999699
|
||||
C618.000000,56.714359 618.000000,58.428715 618.000000,60.571537
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M430.000122,99.002235
|
||||
C430.000122,112.477097 430.000122,125.452438 430.000122,138.713440
|
||||
C423.048126,138.713440 416.308685,138.713440 408.999878,138.713440
|
||||
C408.999878,129.350739 409.120758,119.916939 408.962219,110.487823
|
||||
C408.832153,102.753624 409.088898,94.909142 407.866791,87.324188
|
||||
C406.440887,78.474220 401.302399,74.201607 394.304291,74.000290
|
||||
C387.617249,73.807938 380.317963,79.297188 378.047363,86.438652
|
||||
C377.420715,88.409592 377.055725,90.550858 377.044647,92.616508
|
||||
C376.962494,107.913475 377.000122,123.211082 377.000122,138.753479
|
||||
C369.630646,138.753479 362.559692,138.753479 354.999878,138.753479
|
||||
C354.999878,123.256836 355.044769,107.816956 354.977661,92.377571
|
||||
C354.951050,86.251518 352.748199,80.799278 347.911346,77.066116
|
||||
C339.239685,70.373154 327.811401,74.635170 324.084412,84.471092
|
||||
C322.793915,87.876816 322.147491,91.713402 322.090881,95.366882
|
||||
C321.868958,109.685005 322.000122,124.008591 322.000122,138.665009
|
||||
C314.823853,138.665009 307.760773,138.665009 300.346558,138.665009
|
||||
C300.346558,111.006645 300.346558,83.281189 300.346558,55.001301
|
||||
C306.163818,55.001301 312.104645,54.855133 318.024780,55.139343
|
||||
C319.060455,55.189068 320.450378,56.891682 320.882477,58.112110
|
||||
C321.380768,59.519447 320.998291,61.238617 320.998291,64.136040
|
||||
C328.715179,54.407440 338.407898,52.804527 348.408875,54.206123
|
||||
C356.403381,55.326527 361.770447,57.638248 366.682190,66.544373
|
||||
C372.325470,62.972542 377.601440,58.269657 383.771973,56.014080
|
||||
C396.273407,51.444298 408.602570,53.673611 419.067657,61.818150
|
||||
C426.629364,67.703125 429.037811,76.770744 429.932556,86.011482
|
||||
C430.332214,90.138710 430.000122,94.336792 430.000122,99.002235
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M462.000427,35.006332
|
||||
C462.000427,44.815434 462.000427,54.126144 462.000427,64.132019
|
||||
C468.844696,58.319965 476.100769,54.654530 484.669739,53.656227
|
||||
C496.686127,52.256294 507.565582,54.979622 516.927185,62.503853
|
||||
C534.236755,76.416115 535.360107,106.231667 523.651062,123.341644
|
||||
C516.745056,133.433182 506.539673,139.485458 493.555267,140.111023
|
||||
C483.836304,140.579254 474.670624,139.889420 466.610413,133.799713
|
||||
C465.039795,132.613068 463.390686,131.530289 461.957214,130.525391
|
||||
C461.633789,132.375305 461.105469,135.397171 460.522095,138.733841
|
||||
C454.446686,138.733841 448.017822,138.733841 441.292542,138.733841
|
||||
C441.292542,99.722672 441.292542,60.652122 441.292542,21.290209
|
||||
C447.943787,21.290209 454.684204,21.290209 462.000427,21.290209
|
||||
C462.000427,25.636984 462.000427,30.072460 462.000427,35.006332
|
||||
M480.890228,119.974937
|
||||
C485.426086,119.681152 490.365997,120.444260 494.421356,118.893707
|
||||
C506.182587,114.396866 510.858643,104.919495 509.036591,92.234833
|
||||
C507.422546,80.997993 496.539307,71.772278 483.551605,73.864754
|
||||
C469.724976,76.092384 464.376770,85.538391 463.152863,96.752327
|
||||
C462.120667,106.209480 469.961761,116.189537 480.890228,119.974937
|
||||
z" />
|
||||
<path fill="#FDFDFD" opacity="1.000000" stroke="none" d="
|
||||
M234.797928,54.654831
|
||||
C244.856339,52.605957 254.504562,52.040043 264.239868,54.923946
|
||||
C279.600891,59.474377 286.402191,68.163963 289.768585,81.937614
|
||||
C291.530579,89.146889 290.954620,96.927589 291.469940,105.005005
|
||||
C269.550385,105.005005 248.375092,105.005005 227.094437,105.005005
|
||||
C229.577957,116.288628 239.741562,120.764336 248.594757,121.034813
|
||||
C256.790771,121.285217 264.390472,119.882645 271.081848,114.731178
|
||||
C271.774902,114.197632 273.962708,114.659111 274.786041,115.402222
|
||||
C278.726318,118.958458 282.435333,122.770882 286.509888,126.770363
|
||||
C281.309174,132.968170 274.787445,135.946014 267.542938,138.175064
|
||||
C253.746231,142.420120 240.209259,142.317459 227.237503,135.935410
|
||||
C212.712891,128.789368 205.730453,116.523628 204.973831,100.473404
|
||||
C204.537735,91.222557 205.503754,82.283119 210.008469,74.017265
|
||||
C215.396210,64.131088 223.372589,57.511646 234.797928,54.654831
|
||||
M266.971497,78.708908
|
||||
C259.384399,70.789909 249.920425,70.480316 240.489548,73.410858
|
||||
C234.405487,75.301414 229.437546,79.631561 227.800247,86.722244
|
||||
C242.152313,86.722244 256.002747,86.722244 270.947815,86.722244
|
||||
C269.410950,83.870155 268.228943,81.676651 266.971497,78.708908
|
||||
z" />
|
||||
<path fill="#FCFEFC" opacity="1.000000" stroke="none" d="
|
||||
M73.196533,79.000931
|
||||
C73.196594,72.566254 73.196594,66.631348 73.196594,59.785294
|
||||
C94.091187,71.729622 114.247169,83.251732 135.221664,95.241745
|
||||
C114.417175,107.192863 94.273178,118.764557 73.196465,130.872055
|
||||
C73.196465,113.007599 73.196465,96.254150 73.196533,79.000931
|
||||
z" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -1 +1,85 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="plex-logo"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 1000 460.89727"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="plex-logo.svg"
|
||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
||||
id="metadata25"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
||||
id="defs23">
|
||||
</defs><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#111111"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
id="namedview21"
|
||||
showgrid="false"
|
||||
fit-margin-top="0"
|
||||
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>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -14,7 +14,6 @@ import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||
import type { Keyword, ProductionCompany } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -77,11 +76,9 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
|
||||
const keywords = await Promise.all(
|
||||
slider.data.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
||||
const keyword: Keyword = await res.json();
|
||||
return keyword;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -98,15 +95,13 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<TmdbGenre[]>(
|
||||
const res = await fetch(
|
||||
`/api/v1/genres/${
|
||||
slider.type === DiscoverSliderType.TMDB_MOVIE_GENRE ? 'movie' : 'tv'
|
||||
}`
|
||||
);
|
||||
|
||||
const genre = response.data.find(
|
||||
(genre) => genre.id === Number(slider.data)
|
||||
);
|
||||
const genres: TmdbGenre[] = await res.json();
|
||||
const genre = genres.find((genre) => genre.id === Number(slider.data));
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
@@ -121,11 +116,8 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await axios.get<ProductionCompany>(
|
||||
`/api/v1/studio/${slider.data}`
|
||||
);
|
||||
|
||||
const studio = response.data;
|
||||
const res = await fetch(`/api/v1/studio/${slider.data}`);
|
||||
const studio: ProductionCompany = await res.json();
|
||||
|
||||
setDefaultDataValue([
|
||||
{
|
||||
@@ -168,16 +160,17 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
);
|
||||
|
||||
const loadKeywordOptions = async (inputValue: string) => {
|
||||
const results = await axios.get<TmdbKeywordSearchResponse>(
|
||||
'/api/v1/search/keyword',
|
||||
const res = await fetch(
|
||||
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`,
|
||||
{
|
||||
params: {
|
||||
query: encodeURIExtraParams(inputValue),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
const results: TmdbKeywordSearchResponse = await res.json();
|
||||
|
||||
return results.data.results.map((result) => ({
|
||||
return results.results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
@@ -188,38 +181,37 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = await axios.get<TmdbCompanySearchResponse>(
|
||||
'/api/v1/search/company',
|
||||
const res = await fetch(
|
||||
`/api/v1/search/company?query=${encodeURIExtraParams(inputValue)}`,
|
||||
{
|
||||
params: {
|
||||
query: encodeURIExtraParams(inputValue),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
const results: TmdbCompanySearchResponse = await res.json();
|
||||
|
||||
return results.data.results.map((result) => ({
|
||||
return results.results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadMovieGenreOptions = async () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
'/api/v1/discover/genreslider/movie'
|
||||
);
|
||||
const res = await fetch('/api/v1/discover/genreslider/movie');
|
||||
const results: GenreSliderItem[] = await res.json();
|
||||
|
||||
return results.data.map((result) => ({
|
||||
return results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
};
|
||||
|
||||
const loadTvGenreOptions = async () => {
|
||||
const results = await axios.get<GenreSliderItem[]>(
|
||||
'/api/v1/discover/genreslider/tv'
|
||||
);
|
||||
const res = await fetch('/api/v1/discover/genreslider/tv');
|
||||
const results: GenreSliderItem[] = await res.json();
|
||||
|
||||
return results.data.map((result) => ({
|
||||
return results.map((result) => ({
|
||||
label: result.name,
|
||||
value: result.id,
|
||||
}));
|
||||
@@ -314,17 +306,31 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
try {
|
||||
if (slider) {
|
||||
await axios.put(`/api/v1/settings/discover/${slider.id}`, {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} else {
|
||||
await axios.post('/api/v1/settings/discover/add', {
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
const res = await fetch('/api/v1/settings/discover/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: Number(values.sliderType),
|
||||
title: values.title,
|
||||
data: values.data,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
}
|
||||
|
||||
addToast(
|
||||
|
||||
@@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.network.logoPath ? (
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||
alt={firstResultData.network.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
className="object-contain"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import axios from 'axios';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useDrag, useDrop } from 'react-aria';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -78,7 +77,10 @@ const DiscoverSliderEdit = ({
|
||||
|
||||
const deleteSlider = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/settings/discover/${slider.id}`);
|
||||
const res = await fetch(`/api/v1/settings/discover/${slider.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
addToast(intl.formatMessage(messages.deletesuccess), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
|
||||
@@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
{firstResultData?.studio.logoPath ? (
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||
alt={firstResultData.studio.name}
|
||||
className="max-h-24 sm:max-h-32"
|
||||
className="object-contain"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CompanySelector,
|
||||
GenreSelector,
|
||||
KeywordSelector,
|
||||
StatusSelector,
|
||||
WatchProviderSelector,
|
||||
} from '@app/components/Selector';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
@@ -40,6 +41,7 @@ const messages = defineMessages('components.Discover.FilterSlideover', {
|
||||
runtime: 'Runtime',
|
||||
streamingservices: 'Streaming Services',
|
||||
voteCount: 'Number of votes between {minValue} and {maxValue}',
|
||||
status: 'Status',
|
||||
});
|
||||
|
||||
type FilterSlideoverProps = {
|
||||
@@ -150,6 +152,23 @@ const FilterSlideover = ({
|
||||
updateQueryParams('genre', value?.map((v) => v.value).join(','));
|
||||
}}
|
||||
/>
|
||||
{type === 'tv' && (
|
||||
<>
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.status)}
|
||||
</span>
|
||||
<StatusSelector
|
||||
defaultValue={currentFilters.status}
|
||||
isMulti
|
||||
onChange={(value) => {
|
||||
updateQueryParams(
|
||||
'status',
|
||||
value?.map((v) => v.value).join('|')
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="text-lg font-semibold">
|
||||
{intl.formatMessage(messages.keywords)}
|
||||
</span>
|
||||
|
||||
@@ -108,6 +108,7 @@ export const QueryFilterOptions = z.object({
|
||||
voteCountGte: z.string().optional(),
|
||||
watchRegion: z.string().optional(),
|
||||
watchProviders: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FilterOptions = z.infer<typeof QueryFilterOptions>;
|
||||
@@ -147,6 +148,10 @@ export const prepareFilterValues = (
|
||||
filterValues.genre = values.genre;
|
||||
}
|
||||
|
||||
if (values.status) {
|
||||
filterValues.status = values.status;
|
||||
}
|
||||
|
||||
if (values.keywords) {
|
||||
filterValues.keywords = values.keywords;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { DiscoverSliderType } from '@server/constants/discover';
|
||||
import type DiscoverSlider from '@server/entity/DiscoverSlider';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
@@ -76,7 +75,14 @@ const Discover = () => {
|
||||
|
||||
const updateSliders = async () => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/discover', sliders);
|
||||
const res = await fetch('/api/v1/settings/discover', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(sliders),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
addToast(intl.formatMessage(messages.updatesuccess), {
|
||||
appearance: 'success',
|
||||
@@ -94,7 +100,10 @@ const Discover = () => {
|
||||
|
||||
const resetSliders = async () => {
|
||||
try {
|
||||
await axios.get('/api/v1/settings/discover/reset');
|
||||
const res = await fetch('/api/v1/settings/discover/reset', {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
addToast(intl.formatMessage(messages.resetsuccess), {
|
||||
appearance: 'success',
|
||||
|
||||
@@ -11,7 +11,6 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import getConfig from 'next/config';
|
||||
|
||||
interface ExternalLinkBlockProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
@@ -31,7 +30,6 @@ const ExternalLinkBlock = ({
|
||||
mediaUrl,
|
||||
}: ExternalLinkBlockProps) => {
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { locale } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -45,7 +43,8 @@ const ExternalLinkBlock = ({
|
||||
>
|
||||
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
|
||||
<PlexLogo />
|
||||
) : publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? (
|
||||
) : settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY ? (
|
||||
<EmbyLogo />
|
||||
) : (
|
||||
<JellyfinLogo />
|
||||
|
||||
@@ -5,7 +5,6 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
|
||||
import type { default as IssueCommentType } from '@server/entity/IssueComment';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -49,7 +48,10 @@ const IssueComment = ({
|
||||
|
||||
const deleteComment = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/issueComment/${comment.id}`);
|
||||
const res = await fetch(`/api/v1/issueComment/${comment.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} catch (e) {
|
||||
// something went wrong deleting the comment
|
||||
} finally {
|
||||
@@ -175,9 +177,17 @@ const IssueComment = ({
|
||||
<Formik
|
||||
initialValues={{ newMessage: comment.message }}
|
||||
onSubmit={async (values) => {
|
||||
await axios.put(`/api/v1/issueComment/${comment.id}`, {
|
||||
message: values.newMessage,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/v1/issueComment/${comment.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: values.newMessage }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
|
||||
@@ -11,7 +11,7 @@ import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import ErrorPage from '@app/pages/_error';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import {
|
||||
@@ -27,9 +27,7 @@ import { MediaServerType } from '@server/constants/server';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -109,14 +107,13 @@ const IssueDetails = () => {
|
||||
(opt) => opt.issueType === issueData?.issueType
|
||||
);
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!data || !issueData) {
|
||||
return <Error statusCode={404} />;
|
||||
return <ErrorPage statusCode={404} />;
|
||||
}
|
||||
|
||||
const belongsToUser = issueData.createdBy.id === currentUser?.id;
|
||||
@@ -125,9 +122,14 @@ const IssueDetails = () => {
|
||||
|
||||
const editFirstComment = async (newMessage: string) => {
|
||||
try {
|
||||
await axios.put(`/api/v1/issueComment/${firstComment.id}`, {
|
||||
message: newMessage,
|
||||
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: newMessage }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
addToast(intl.formatMessage(messages.toasteditdescriptionsuccess), {
|
||||
appearance: 'success',
|
||||
@@ -144,7 +146,10 @@ const IssueDetails = () => {
|
||||
|
||||
const updateIssueStatus = async (newStatus: 'open' | 'resolved') => {
|
||||
try {
|
||||
await axios.post(`/api/v1/issue/${issueData.id}/${newStatus}`);
|
||||
const res = await fetch(`/api/v1/issue/${issueData.id}/${newStatus}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
addToast(intl.formatMessage(messages.toaststatusupdated), {
|
||||
appearance: 'success',
|
||||
@@ -161,7 +166,10 @@ const IssueDetails = () => {
|
||||
|
||||
const deleteIssue = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/v1/issue/${issueData.id}`);
|
||||
const res = await fetch(`/api/v1/issue/${issueData.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
addToast(intl.formatMessage(messages.toastissuedeleted), {
|
||||
appearance: 'success',
|
||||
@@ -380,7 +388,8 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -427,7 +436,8 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -490,9 +500,17 @@ const IssueDetails = () => {
|
||||
}}
|
||||
validationSchema={CommentSchema}
|
||||
onSubmit={async (values, { resetForm }) => {
|
||||
await axios.post(`/api/v1/issue/${issueData?.id}/comment`, {
|
||||
message: values.message,
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/v1/issue/${issueData?.id}/comment`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message: values.message }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
revalidateIssue();
|
||||
resetForm();
|
||||
}}
|
||||
@@ -644,7 +662,8 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.playonplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
@@ -690,7 +709,8 @@ const IssueDetails = () => {
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>
|
||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
{settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? intl.formatMessage(messages.play4konplex, {
|
||||
mediaServerName: 'Emby',
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import { MediaStatus } from '@server/constants/media';
|
||||
import type Issue from '@server/entity/Issue';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -101,14 +100,22 @@ const CreateIssueModal = ({
|
||||
validationSchema={CreateIssueModalSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
const newIssue = await axios.post<Issue>('/api/v1/issue', {
|
||||
issueType: values.selectedIssue.issueType,
|
||||
message: values.message,
|
||||
mediaId: data?.mediaInfo?.id,
|
||||
problemSeason: values.problemSeason,
|
||||
problemEpisode:
|
||||
values.problemSeason > 0 ? values.problemEpisode : 0,
|
||||
const res = await fetch('/api/v1/issue', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
issueType: values.selectedIssue.issueType,
|
||||
message: values.message,
|
||||
mediaId: data?.mediaInfo?.id,
|
||||
problemSeason: values.problemSeason,
|
||||
problemEpisode:
|
||||
values.problemSeason > 0 ? values.problemEpisode : 0,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const newIssue: Issue = await res.json();
|
||||
|
||||
if (data) {
|
||||
addToast(
|
||||
@@ -119,7 +126,7 @@ const CreateIssueModal = ({
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</div>
|
||||
<Link href={`/issues/${newIssue.data.id}`} legacyBehavior>
|
||||
<Link href={`/issues/${newIssue.id}`} legacyBehavior>
|
||||
<Button as="a" className="mt-4">
|
||||
<span>{intl.formatMessage(messages.toastviewissue)}</span>
|
||||
<ArrowRightCircleIcon />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||
import axios from 'axios';
|
||||
import Image from 'next/image';
|
||||
import type { LinkProps } from 'next/link';
|
||||
import Link from 'next/link';
|
||||
@@ -39,9 +38,13 @@ const UserDropdown = () => {
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const logout = async () => {
|
||||
const response = await axios.post('/api/v1/auth/logout');
|
||||
const res = await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
if (response.data?.status === 'ok') {
|
||||
if (data?.status === 'ok') {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
@@ -87,9 +90,11 @@ const UserDropdown = () => {
|
||||
<span className="truncate text-xl font-semibold text-gray-200">
|
||||
{user?.displayName}
|
||||
</span>
|
||||
<span className="truncate text-sm text-gray-400">
|
||||
{user?.email}
|
||||
</span>
|
||||
{user?.displayName?.toLowerCase() !== user?.email && (
|
||||
<span className="truncate text-sm text-gray-400">
|
||||
{user?.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user && <MiniQuotaDisplay userId={user?.id} />}
|
||||
|
||||
@@ -17,47 +17,45 @@ interface UserWarningsProps {
|
||||
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
||||
const intl = useIntl();
|
||||
const { user } = useUser();
|
||||
if (!user) {
|
||||
//check if a user has warnings
|
||||
if (!user || !user.warnings || user.warnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let res = null;
|
||||
|
||||
//check if a user has warnings
|
||||
if (user.warnings.length > 0) {
|
||||
user.warnings.forEach((warning) => {
|
||||
let link = '';
|
||||
let warningText = '';
|
||||
let warningTitle = '';
|
||||
switch (warning) {
|
||||
case 'userEmailRequired':
|
||||
link = '/profile/settings/';
|
||||
warningTitle = 'Profile is incomplete';
|
||||
warningText = intl.formatMessage(messages.emailRequired);
|
||||
}
|
||||
user.warnings.forEach((warning) => {
|
||||
let link = '';
|
||||
let warningText = '';
|
||||
let warningTitle = '';
|
||||
switch (warning) {
|
||||
case 'userEmailRequired':
|
||||
link = '/profile/settings/';
|
||||
warningTitle = 'Profile is incomplete';
|
||||
warningText = intl.formatMessage(messages.emailRequired);
|
||||
}
|
||||
|
||||
res = (
|
||||
<Link
|
||||
href={link}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||
<span className="font-bold">{warningTitle}</span>
|
||||
<span className="truncate">{warningText}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
}
|
||||
res = (
|
||||
<Link
|
||||
href={link}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && onClick) {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
||||
>
|
||||
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||
<span className="font-bold">{warningTitle}</span>
|
||||
<span className="truncate">{warningText}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import axios from 'axios';
|
||||
import { Field, Formik } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
import * as Yup from 'yup';
|
||||
@@ -58,11 +57,18 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
|
||||
validationSchema={EmailSettingsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/jellyfin', {
|
||||
username: username,
|
||||
password: password,
|
||||
email: values.email,
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
email: values.email,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
onSave();
|
||||
} catch (e) {
|
||||
|
||||
@@ -4,10 +4,9 @@ import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import axios from 'axios';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import getConfig from 'next/config';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -27,6 +26,7 @@ const messages = defineMessages('components.Login', {
|
||||
validationemailformat: 'Valid email required',
|
||||
validationusernamerequired: 'Username required',
|
||||
validationpasswordrequired: 'Password required',
|
||||
validationservertyperequired: 'Please select a server type',
|
||||
validationHostnameRequired: 'You must provide a valid hostname or IP address',
|
||||
validationPortRequired: 'You must provide a valid port number',
|
||||
validationUrlTrailingSlash: 'URL must not end in a trailing slash',
|
||||
@@ -41,42 +41,51 @@ const messages = defineMessages('components.Login', {
|
||||
initialsigningin: 'Connecting…',
|
||||
initialsignin: 'Connect',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
servertype: 'Server Type',
|
||||
back: 'Go back',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
revalidate: () => void;
|
||||
initial?: boolean;
|
||||
serverType?: MediaServerType;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
initial,
|
||||
serverType,
|
||||
onCancel,
|
||||
}) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
serverType === MediaServerType.JELLYFIN
|
||||
? ServerType.JELLYFIN
|
||||
: serverType === MediaServerType.EMBY
|
||||
? ServerType.EMBY
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
if (initial) {
|
||||
const LoginSchema = Yup.object().shape({
|
||||
hostname: Yup.string().required(
|
||||
intl.formatMessage(messages.validationhostrequired, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
})
|
||||
intl.formatMessage(
|
||||
messages.validationhostrequired,
|
||||
mediaServerFormatValues
|
||||
)
|
||||
),
|
||||
port: Yup.number().required(
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
urlBase: Yup.string()
|
||||
.matches(
|
||||
/^(\/[^/].*[^/]$)/,
|
||||
intl.formatMessage(messages.validationUrlBaseLeadingSlash)
|
||||
)
|
||||
.matches(
|
||||
/^(.*[^/])$/,
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||
),
|
||||
urlBase: Yup.string().matches(
|
||||
/^(.*[^/])$/,
|
||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
||||
),
|
||||
email: Yup.string()
|
||||
.email(intl.formatMessage(messages.validationemailformat))
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
@@ -86,11 +95,6 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
password: Yup.string(),
|
||||
});
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby' ? 'Emby' : 'Jellyfin',
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
@@ -105,18 +109,38 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/jellyfin', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
// Check if serverType is either 'Jellyfin' or 'Emby'
|
||||
// if (serverType !== 'Jellyfin' && serverType !== 'Emby') {
|
||||
// throw new Error('Invalid serverType'); // You can customize the error message
|
||||
// }
|
||||
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
hostname: values.hostname,
|
||||
port: values.port,
|
||||
useSsl: values.useSsl,
|
||||
urlBase: values.urlBase,
|
||||
email: values.email,
|
||||
serverType: serverType,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
} catch (e) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
let errorMessage = null;
|
||||
switch (e.response?.data?.message) {
|
||||
switch (errorData?.message) {
|
||||
case ApiErrorCode.InvalidUrl:
|
||||
errorMessage = messages.invalidurlerror;
|
||||
break;
|
||||
@@ -299,7 +323,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-row-reverse justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
@@ -311,6 +335,13 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: intl.formatMessage(messages.signin)}
|
||||
</Button>
|
||||
</span>
|
||||
{onCancel && (
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button buttonType="default" onClick={() => onCancel()}>
|
||||
<FormattedMessage {...messages.back} />
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
@@ -339,11 +370,18 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/jellyfin', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.username,
|
||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
email: values.username,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} catch (e) {
|
||||
toasts.addToast(
|
||||
intl.formatMessage(
|
||||
@@ -409,7 +447,8 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
jellyfinForgotPasswordUrl
|
||||
? `${jellyfinForgotPasswordUrl}`
|
||||
: `${baseUrl}/web/index.html#!/${
|
||||
process.env.JELLYFIN_TYPE === 'emby'
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? 'startup/'
|
||||
: ''
|
||||
}forgotpassword.html`
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
LifebuoyIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
@@ -56,10 +55,17 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => {
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/local', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
const res = await fetch('/api/v1/auth/local', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
} catch (e) {
|
||||
setLoginError(intl.formatMessage(messages.loginerror));
|
||||
} finally {
|
||||
|
||||
@@ -10,8 +10,6 @@ import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { XCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -35,7 +33,6 @@ const Login = () => {
|
||||
const { user, revalidate } = useUser();
|
||||
const router = useRouter();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
|
||||
// We take the token and attempt to sign in. If we get a success message, we will
|
||||
@@ -44,13 +41,28 @@ const Login = () => {
|
||||
const login = async () => {
|
||||
setProcessing(true);
|
||||
try {
|
||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
||||
const res = await fetch('/api/v1/auth/plex', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ authToken }),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||
const data = await res.json();
|
||||
|
||||
if (response.data?.id) {
|
||||
if (data?.id) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.response.data.message);
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await e.cause?.text();
|
||||
errorData = JSON.parse(errorData);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
setError(errorData?.message);
|
||||
setAuthToken(undefined);
|
||||
setProcessing(false);
|
||||
}
|
||||
@@ -74,6 +86,15 @@ const Login = () => {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: settings.currentSettings.mediaServerType === MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-gray-900 py-14">
|
||||
<PageTitle title={intl.formatMessage(messages.signin)} />
|
||||
@@ -140,12 +161,10 @@ const Login = () => {
|
||||
{settings.currentSettings.mediaServerType ==
|
||||
MediaServerType.PLEX
|
||||
? intl.formatMessage(messages.signinwithplex)
|
||||
: intl.formatMessage(messages.signinwithjellyfin, {
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
? 'Emby'
|
||||
: 'Jellyfin',
|
||||
})}
|
||||
: intl.formatMessage(
|
||||
messages.signinwithjellyfin,
|
||||
mediaServerFormatValues
|
||||
)}
|
||||
</button>
|
||||
<AccordionContent isOpen={openIndexes.includes(0)}>
|
||||
<div className="px-10 py-8">
|
||||
|
||||
@@ -26,8 +26,6 @@ import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfa
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import getConfig from 'next/config';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -96,7 +94,6 @@ const ManageSlideOver = ({
|
||||
const { user: currentUser, hasPermission } = useUser();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const { data: watchData } = useSWR<MediaWatchDataResponse>(
|
||||
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
|
||||
data.mediaInfo &&
|
||||
@@ -113,16 +110,29 @@ const ManageSlideOver = ({
|
||||
|
||||
const deleteMedia = async () => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
const res = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
revalidate();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMediaFile = async () => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}/file`);
|
||||
await axios.delete(`/api/v1/media/${data.mediaInfo.id}`);
|
||||
const res1 = await fetch(`/api/v1/media/${data.mediaInfo.id}/file`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res1.ok) throw new Error();
|
||||
|
||||
const res2 = await fetch(`/api/v1/media/${data.mediaInfo.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res2.ok) throw new Error();
|
||||
|
||||
revalidate();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,9 +159,16 @@ const ManageSlideOver = ({
|
||||
|
||||
const markAvailable = async (is4k = false) => {
|
||||
if (data.mediaInfo) {
|
||||
await axios.post(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||
is4k,
|
||||
const res = await fetch(`/api/v1/media/${data.mediaInfo?.id}/available`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is4k,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
@@ -642,7 +659,8 @@ const ManageSlideOver = ({
|
||||
mediaType === 'movie' ? messages.movie : messages.tvshow
|
||||
),
|
||||
mediaServerName:
|
||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||
settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.EMBY
|
||||
? 'Emby'
|
||||
: settings.currentSettings.mediaServerType ===
|
||||
MediaServerType.PLEX
|
||||
|
||||
@@ -57,44 +57,48 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||
<div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
|
||||
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
||||
{posters[0] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<div className="">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
fill
|
||||
className="rounded-md"
|
||||
width={300}
|
||||
height={450}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[1] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<div className="">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
fill
|
||||
className="rounded-md"
|
||||
width={300}
|
||||
height={450}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[2] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<div className="">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
fill
|
||||
className="rounded-md"
|
||||
width={300}
|
||||
height={450}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[3] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<div className="">
|
||||
<Image
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
fill
|
||||
className="rounded-md"
|
||||
width={300}
|
||||
height={450}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||
import Spinner from '@app/assets/spinner.svg';
|
||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
@@ -25,7 +26,7 @@ import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import Error from '@app/pages/_error';
|
||||
import ErrorPage from '@app/pages/_error';
|
||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||
@@ -41,20 +42,22 @@ import {
|
||||
import {
|
||||
ChevronDoubleDownIcon,
|
||||
ChevronDoubleUpIcon,
|
||||
MinusCircleIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { type RatingResponse } from '@server/api/ratings';
|
||||
import { IssueStatus } from '@server/constants/issue';
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||
import { countries } from 'country-flag-icons';
|
||||
import 'country-flag-icons/3x2/flags.css';
|
||||
import { uniqBy } from 'lodash';
|
||||
import getConfig from 'next/config';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const messages = defineMessages('components.MovieDetails', {
|
||||
@@ -94,6 +97,12 @@ const messages = defineMessages('components.MovieDetails', {
|
||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||
tmdbuserscore: 'TMDB User Score',
|
||||
imdbuserscore: 'IMDB User Score',
|
||||
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
|
||||
watchlistDeleted:
|
||||
'<strong>{title}</strong> Removed from watchlist successfully!',
|
||||
watchlistError: 'Something went wrong try again.',
|
||||
removefromwatchlist: 'Remove From Watchlist',
|
||||
addtowatchlist: 'Add To Watchlist',
|
||||
});
|
||||
|
||||
interface MovieDetailsProps {
|
||||
@@ -112,7 +121,11 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
const minStudios = 3;
|
||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||
!movie?.onUserWatchlist
|
||||
);
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -154,7 +167,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Error statusCode={404} />;
|
||||
return <ErrorPage statusCode={404} />;
|
||||
}
|
||||
|
||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||
@@ -264,7 +277,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
?.flatrate ?? [];
|
||||
|
||||
function getAvalaibleMediaServerName() {
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
@@ -276,8 +289,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
}
|
||||
|
||||
function getAvalaible4kMediaServerName() {
|
||||
if (publicRuntimeConfig.JELLYFIN_TYPE === 'emby') {
|
||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Emby' });
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
|
||||
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
|
||||
}
|
||||
|
||||
if (settings.currentSettings.mediaServerType === MediaServerType.PLEX) {
|
||||
@@ -287,6 +300,80 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
|
||||
}
|
||||
|
||||
const onClickWatchlistBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
|
||||
const res = await fetch('/api/v1/watchlist', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tmdbId: movie?.id,
|
||||
mediaType: MediaType.MOVIE,
|
||||
title: movie?.title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
addToast(intl.formatMessage(messages.watchlistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
|
||||
setIsUpdating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.watchlistSuccess, {
|
||||
title: movie?.title,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'success', autoDismiss: true }
|
||||
);
|
||||
}
|
||||
|
||||
setIsUpdating(false);
|
||||
setToggleWatchlist((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
if (res.status === 204) {
|
||||
addToast(
|
||||
<span>
|
||||
{intl.formatMessage(messages.watchlistDeleted, {
|
||||
title: movie?.title,
|
||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||
})}
|
||||
</span>,
|
||||
{ appearance: 'info', autoDismiss: true }
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.watchlistError), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setToggleWatchlist((prevState) => !prevState);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="media-page"
|
||||
@@ -408,6 +495,40 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="media-actions">
|
||||
<>
|
||||
{toggleWatchlist ? (
|
||||
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
||||
<Button
|
||||
buttonType={'ghost'}
|
||||
className="z-40 mr-2"
|
||||
buttonSize={'md'}
|
||||
onClick={onClickWatchlistBtn}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Spinner className="h-3" />
|
||||
) : (
|
||||
<StarIcon className={'h-3 text-amber-300'} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.removefromwatchlist)}
|
||||
>
|
||||
<Button
|
||||
className="z-40 mr-2"
|
||||
buttonSize={'md'}
|
||||
onClick={onClickDeleteWatchlistBtn}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Spinner className="h-3" />
|
||||
) : (
|
||||
<MinusCircleIcon className={'h-3'} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<PlayButton links={mediaLinks} />
|
||||
<RequestButton
|
||||
mediaType="movie"
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -53,7 +52,10 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
|
||||
const updateRequest = async (type: 'approve' | 'decline'): Promise<void> => {
|
||||
setIsUpdating(true);
|
||||
await axios.post(`/api/v1/request/${request.id}/${type}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
@@ -63,7 +65,10 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
|
||||
|
||||
const deleteRequest = async () => {
|
||||
setIsUpdating(true);
|
||||
await axios.delete(`/api/v1/request/${request.id}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
if (onUpdate) {
|
||||
onUpdate();
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||
import type Media from '@server/entity/Media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import axios from 'axios';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
@@ -94,9 +93,13 @@ const RequestButton = ({
|
||||
request: MediaRequest,
|
||||
type: 'approve' | 'decline'
|
||||
) => {
|
||||
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
if (response) {
|
||||
if (data) {
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
@@ -111,7 +114,11 @@ const RequestButton = ({
|
||||
|
||||
await Promise.all(
|
||||
requests.map(async (request) => {
|
||||
return axios.post(`/api/v1/request/${request.id}/${type}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -59,7 +59,7 @@ const RequestCardPlaceholder = () => {
|
||||
};
|
||||
|
||||
interface RequestCardErrorProps {
|
||||
requestData?: MediaRequest;
|
||||
requestData?: NonFunctionProperties<MediaRequest>;
|
||||
}
|
||||
|
||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
@@ -74,7 +74,10 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
});
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
|
||||
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded');
|
||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||
};
|
||||
@@ -211,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||
};
|
||||
|
||||
interface RequestCardProps {
|
||||
request: MediaRequest;
|
||||
request: NonFunctionProperties<MediaRequest>;
|
||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||
}
|
||||
|
||||
@@ -236,16 +239,19 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
data: requestData,
|
||||
error: requestError,
|
||||
mutate: revalidate,
|
||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
{
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||
mediaUrl: requestData?.media?.mediaUrl,
|
||||
@@ -255,15 +261,22 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
});
|
||||
|
||||
const modifyRequest = async (type: 'approve' | 'decline') => {
|
||||
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
if (response) {
|
||||
if (data) {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/request/${request.id}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0');
|
||||
};
|
||||
|
||||
@@ -271,9 +284,13 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
setRetrying(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/api/v1/request/${request.id}/retry`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
if (response) {
|
||||
if (data) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
import axios from 'axios';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
@@ -43,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
||||
tmdbid: 'TMDB ID',
|
||||
tvdbid: 'TheTVDB ID',
|
||||
unknowntitle: 'Unknown Title',
|
||||
profileName: 'Profile',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -50,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
};
|
||||
|
||||
interface RequestItemErrorProps {
|
||||
requestData?: MediaRequest;
|
||||
requestData?: NonFunctionProperties<MediaRequest>;
|
||||
revalidateList: () => void;
|
||||
}
|
||||
|
||||
@@ -62,7 +63,10 @@ const RequestItemError = ({
|
||||
const { hasPermission } = useUser();
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/media/${requestData?.media.id}`);
|
||||
const res = await fetch(`/api/v1/media/${requestData?.media.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
revalidateList();
|
||||
};
|
||||
|
||||
@@ -283,7 +287,7 @@ const RequestItemError = ({
|
||||
};
|
||||
|
||||
interface RequestItemProps {
|
||||
request: MediaRequest;
|
||||
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||
revalidateList: () => void;
|
||||
}
|
||||
|
||||
@@ -302,32 +306,38 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||
inView ? url : null
|
||||
);
|
||||
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
|
||||
`/api/v1/request/${request.id}`,
|
||||
{
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
}
|
||||
);
|
||||
const { data: requestData, mutate: revalidate } = useSWR<
|
||||
NonFunctionProperties<MediaRequest>
|
||||
>(`/api/v1/request/${request.id}`, {
|
||||
fallbackData: request,
|
||||
refreshInterval: refreshIntervalHelper(
|
||||
{
|
||||
downloadStatus: request.media.downloadStatus,
|
||||
downloadStatus4k: request.media.downloadStatus4k,
|
||||
},
|
||||
15000
|
||||
),
|
||||
});
|
||||
|
||||
const [isRetrying, setRetrying] = useState(false);
|
||||
|
||||
const modifyRequest = async (type: 'approve' | 'decline') => {
|
||||
const response = await axios.post(`/api/v1/request/${request.id}/${type}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/${type}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
if (response) {
|
||||
if (data) {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRequest = async () => {
|
||||
await axios.delete(`/api/v1/request/${request.id}`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
|
||||
revalidateList();
|
||||
};
|
||||
@@ -336,7 +346,12 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
setRetrying(true);
|
||||
|
||||
try {
|
||||
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
|
||||
const res = await fetch(`/api/v1/request/${request.id}/retry`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const result = await res.json();
|
||||
|
||||
revalidate(result.data);
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.failedretry), {
|
||||
@@ -387,7 +402,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-2 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
@@ -468,7 +483,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center gap-1 overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(globalMessages.status)}
|
||||
@@ -618,6 +633,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{request.profileName && (
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.profileName)}
|
||||
</span>
|
||||
<span className="flex truncate text-sm text-gray-300">
|
||||
{request.profileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user