Compare commits
7 Commits
preview-OI
...
preview-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7529dc07 | ||
|
|
cbee8fd843 | ||
|
|
b9435427dc | ||
|
|
8ceec0f9c4 | ||
|
|
5a1040bb61 | ||
|
|
a97a3f3512 | ||
|
|
1dbacec4f9 |
@@ -4,7 +4,6 @@ dist/
|
||||
config/
|
||||
CHANGELOG.md
|
||||
pnpm-lock.yaml
|
||||
cypress/config/settings.cypress.json
|
||||
|
||||
# assets
|
||||
src/assets/
|
||||
|
||||
@@ -21,11 +21,5 @@ module.exports = {
|
||||
rangeEnd: 0, // default: Infinity
|
||||
},
|
||||
},
|
||||
{
|
||||
files: 'cypress/config/settings.cypress.json',
|
||||
options: {
|
||||
rangeEnd: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ kubeVersion: ">=1.23.0-0"
|
||||
name: jellyseerr-chart
|
||||
description: Jellyseerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 2.6.2
|
||||
appVersion: "2.7.3"
|
||||
version: 2.5.0
|
||||
appVersion: "2.6.0"
|
||||
maintainers:
|
||||
- name: Jellyseerr
|
||||
url: https://github.com/Fallenbagel/jellyseerr
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jellyseerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Jellyseerr helm chart for Kubernetes
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"apiKey": "testkey",
|
||||
"applicationTitle": "Jellyseerr",
|
||||
"applicationUrl": "",
|
||||
"csrfProtection": false,
|
||||
"cacheImages": false,
|
||||
"defaultPermissions": 32,
|
||||
"defaultQuotas": {
|
||||
@@ -82,6 +83,13 @@
|
||||
"enableMentions": true
|
||||
}
|
||||
},
|
||||
"lunasea": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
"options": {
|
||||
"webhookUrl": ""
|
||||
}
|
||||
},
|
||||
"slack": {
|
||||
"enabled": false,
|
||||
"types": 0,
|
||||
@@ -179,26 +187,5 @@
|
||||
"image-cache-cleanup": {
|
||||
"schedule": "0 0 5 * * *"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"csrfProtection": false,
|
||||
"trustProxy": false,
|
||||
"forceIpv4First": false,
|
||||
"dnsServers": "",
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"hostname": "",
|
||||
"port": 8080,
|
||||
"useSsl": false,
|
||||
"user": "",
|
||||
"password": "",
|
||||
"bypassFilter": "",
|
||||
"bypassLocalAddresses": true
|
||||
},
|
||||
"dnsCache": {
|
||||
"enabled": false,
|
||||
"forceMinTtl": 0,
|
||||
"forceMaxTtl": -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
describe('TVDB Integration', () => {
|
||||
// Constants for routes and selectors
|
||||
const ROUTES = {
|
||||
home: '/',
|
||||
metadataSettings: '/settings/metadata',
|
||||
tomorrowIsOursTvShow: '/tv/72879',
|
||||
monsterTvShow: '/tv/225634',
|
||||
dragonnBallZKaiAnime: '/tv/61709',
|
||||
};
|
||||
|
||||
const SELECTORS = {
|
||||
sidebarToggle: '[data-testid=sidebar-toggle]',
|
||||
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
|
||||
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
|
||||
metadataTestButton: 'button[type="button"]:contains("Test")',
|
||||
metadataSaveButton: '[data-testid="metadata-save-button"]',
|
||||
tmdbStatus: '[data-testid="tmdb-status"]',
|
||||
tvdbStatus: '[data-testid="tvdb-status"]',
|
||||
tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]',
|
||||
animeMetadataProviderSelector:
|
||||
'[data-testid="anime-metadata-provider-selector"]',
|
||||
seasonSelector: '[data-testid="season-selector"]',
|
||||
season1: 'Season 1',
|
||||
season2: 'Season 2',
|
||||
season3: 'Season 3',
|
||||
episodeList: '[data-testid="episode-list"]',
|
||||
episode9: '9 - Hang Men',
|
||||
};
|
||||
|
||||
// Reusable commands
|
||||
const navigateToMetadataSettings = () => {
|
||||
cy.visit(ROUTES.home);
|
||||
cy.get(SELECTORS.sidebarToggle).click();
|
||||
cy.get(SELECTORS.sidebarSettingsMobile).click();
|
||||
cy.get(
|
||||
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]`
|
||||
).click();
|
||||
};
|
||||
|
||||
const testAndVerifyMetadataConnection = () => {
|
||||
cy.intercept('POST', '/api/v1/settings/metadatas/test').as(
|
||||
'testConnection'
|
||||
);
|
||||
cy.get(SELECTORS.metadataTestButton).click();
|
||||
return cy.wait('@testConnection');
|
||||
};
|
||||
|
||||
const saveMetadataSettings = (customBody = null) => {
|
||||
if (customBody) {
|
||||
cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => {
|
||||
req.body = customBody;
|
||||
}).as('saveMetadata');
|
||||
} else {
|
||||
// Else just intercept without modifying body
|
||||
cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata');
|
||||
}
|
||||
|
||||
cy.get(SELECTORS.metadataSaveButton).click();
|
||||
return cy.wait('@saveMetadata');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Perform login
|
||||
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||
|
||||
// Navigate to Metadata settings
|
||||
navigateToMetadataSettings();
|
||||
|
||||
// Verify we're on the correct settings page
|
||||
cy.contains('h3', 'Metadata Providers').should('be.visible');
|
||||
|
||||
// Configure TVDB as TV provider and test connection
|
||||
cy.get(SELECTORS.tvMetadataProviderSelector).click();
|
||||
|
||||
// get id react-select-4-option-1
|
||||
cy.get('[class*="react-select__option"]').contains('TheTVDB').click();
|
||||
|
||||
// Test the connection
|
||||
testAndVerifyMetadataConnection().then(({ response }) => {
|
||||
expect(response.statusCode).to.equal(200);
|
||||
// Check TVDB connection status
|
||||
cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational');
|
||||
});
|
||||
|
||||
// Save settings
|
||||
saveMetadataSettings({
|
||||
anime: 'tvdb',
|
||||
tv: 'tvdb',
|
||||
}).then(({ response }) => {
|
||||
expect(response.statusCode).to.equal(200);
|
||||
expect(response.body.tv).to.equal('tvdb');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.tomorrowIsOursTvShow);
|
||||
|
||||
// Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple)
|
||||
// cy.get(SELECTORS.seasonSelector).should('exist');
|
||||
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||
// Select Season 2 and verify it loads
|
||||
cy.contains(SELECTORS.season2)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// Verify that episodes are displayed for Season 2
|
||||
cy.contains('260 - Episode 506').should('be.visible');
|
||||
});
|
||||
|
||||
it('Should display "Monster" show information correctly when not existing on TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.monsterTvShow);
|
||||
|
||||
// Intercept season 1 request
|
||||
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||
|
||||
// Select Season 1
|
||||
cy.contains(SELECTORS.season1)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// Wait for the season data to load
|
||||
cy.wait('@season1');
|
||||
|
||||
// Verify specific episode exists
|
||||
cy.contains(SELECTORS.episode9).should('be.visible');
|
||||
});
|
||||
|
||||
it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => {
|
||||
// Navigate to the TV show
|
||||
cy.visit(ROUTES.dragonnBallZKaiAnime);
|
||||
|
||||
// Intercept season 1 request
|
||||
cy.intercept('/api/v1/tv/61709/season/1').as('season1');
|
||||
|
||||
// Select Season 2 and verify it visible
|
||||
cy.contains(SELECTORS.season2)
|
||||
.should('be.visible')
|
||||
.scrollIntoView()
|
||||
.click();
|
||||
|
||||
// select season 3 and verify it not visible
|
||||
cy.contains(SELECTORS.season3).should('not.exist');
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ describe('User List', () => {
|
||||
cy.get('#email').type(testUser.emailAddress);
|
||||
cy.get('#password').type(testUser.password);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
cy.intercept('/api/v1/user*').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').click();
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('User List', () => {
|
||||
|
||||
cy.get('[data-testid=modal-title]').should('contain', `Delete User`);
|
||||
|
||||
cy.intercept('/api/v1/user?take=10&skip=0&sort=displayname').as('user');
|
||||
cy.intercept('/api/v1/user*').as('user');
|
||||
|
||||
cy.get('[data-testid=modal-ok-button]').should('contain', 'Delete').click();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Jellyseerr supports SQLite and PostgreSQL. The database connection can be config
|
||||
If you want to use SQLite, you can simply set the `DB_TYPE` environment variable to `sqlite`. This is the default configuration so even if you don't set any other options, SQLite will be used.
|
||||
|
||||
```dotenv
|
||||
DB_TYPE=sqlite # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||
DB_TYPE="sqlite" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
||||
CONFIG_DIRECTORY="config" # (optional) The path to the config directory where the db file is stored. The default is "config".
|
||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
||||
```
|
||||
@@ -24,7 +24,7 @@ DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging.
|
||||
If your PostgreSQL server is configured to accept TCP connections, you can specify the host and port using the `DB_HOST` and `DB_PORT` environment variables. This is useful for remote connections where the server uses a network host and port.
|
||||
|
||||
```dotenv
|
||||
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
||||
DB_HOST="localhost" # (optional) The host (URL) of the database. The default is "localhost".
|
||||
DB_PORT="5432" # (optional) The port to connect to. The default is "5432".
|
||||
DB_USER= # (required) Username used to connect to the database.
|
||||
@@ -38,7 +38,7 @@ DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging.
|
||||
If your PostgreSQL server is configured to accept Unix socket connections, you can specify the path to the socket directory using the `DB_SOCKET_PATH` environment variable. This is useful for local connections where the server uses a Unix socket.
|
||||
|
||||
```dotenv
|
||||
DB_TYPE=postgres # Which DB engine to use, either sqlite or postgres. The default is sqlite.
|
||||
DB_TYPE="postgres" # Which DB engine to use, either "sqlite" or "postgres". The default is "sqlite".
|
||||
DB_SOCKET_PATH="/var/run/postgresql" # (required) The path to the PostgreSQL Unix socket directory.
|
||||
DB_USER= # (required) Username used to connect to the database.
|
||||
DB_PASS= # (optional) Password of the user used to connect to the database, depending on the server's authentication configuration.
|
||||
@@ -46,27 +46,6 @@ DB_NAME="jellyseerr" # (optional) The name of the database to connect to. The de
|
||||
DB_LOG_QUERIES="false" # (optional) Whether to log the DB queries for debugging. The default is "false".
|
||||
```
|
||||
|
||||
:::info
|
||||
**Finding Your PostgreSQL Socket Path**
|
||||
|
||||
The PostgreSQL socket path varies by operating system and installation method:
|
||||
|
||||
- **Ubuntu/Debian**: `/var/run/postgresql`
|
||||
- **CentOS/RHEL/Fedora**: `/var/run/postgresql`
|
||||
- **macOS (Homebrew)**: `/tmp` or `/opt/homebrew/var/postgresql`
|
||||
- **macOS (Postgres.app)**: `/tmp`
|
||||
- **Windows**: Not applicable (uses TCP connections)
|
||||
|
||||
You can find your socket path by running:
|
||||
```bash
|
||||
# Find PostgreSQL socket directory
|
||||
find /tmp /var/run /run -name ".s.PGSQL.*" 2>/dev/null | head -1 | xargs dirname
|
||||
|
||||
# Or check PostgreSQL configuration
|
||||
sudo -u postgres psql -c "SHOW unix_socket_directories;"
|
||||
```
|
||||
:::
|
||||
|
||||
### SSL configuration
|
||||
|
||||
The following options can be used to further configure ssl. Certificates can be provided as a string or a file path, with the string version taking precedence.
|
||||
@@ -77,11 +56,10 @@ DB_SSL_REJECT_UNAUTHORIZED="true" # (optional) Whether to reject ssl connections
|
||||
DB_SSL_CA= # (optional) The CA certificate to verify the connection, provided as a string. The default is "".
|
||||
DB_SSL_CA_FILE= # (optional) The path to a CA certificate to verify the connection. The default is "".
|
||||
DB_SSL_KEY= # (optional) The private key for the connection in PEM format, provided as a string. The default is "".
|
||||
DB_SSL_KEY_FILE= # (optional) Path to the private key for the connection in PEM format. The default is "".
|
||||
DB_SSL_KEY_FILE= # (optinal) Path to the private key for the connection in PEM format. The default is "".
|
||||
DB_SSL_CERT= # (optional) Certificate chain in pem format for the private key, provided as a string. The default is "".
|
||||
DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the private key. The default is "".
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Migrating from SQLite to PostgreSQL
|
||||
@@ -90,76 +68,15 @@ DB_SSL_CERT_FILE= # (optional) Path to certificate chain in pem format for the p
|
||||
2. Run Jellyseerr to create the tables in the PostgreSQL database
|
||||
3. Stop Jellyseerr
|
||||
4. Run the following command to export the data from the SQLite database and import it into the PostgreSQL database:
|
||||
|
||||
:::info
|
||||
Edit the postgres connection string (without the \{\{ and \}\} brackets) to match your setup.
|
||||
Edit the postgres connection string to match your setup.
|
||||
|
||||
If you don't have or don't want to use docker, you can build the working pgloader version [in this PR](https://github.com/dimitri/pgloader/pull/1531) from source and use the same options as below.
|
||||
:::
|
||||
|
||||
:::caution
|
||||
The most recent release of pgloader has an issue quoting the table columns. Use the version in the docker container to avoid this issue.
|
||||
:::
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="docker" label="Using pgloader Container (Recommended)" default>
|
||||
|
||||
**Recommended method**: Use the pgloader container even for standalone Jellyseerr installations. This avoids building from source and ensures compatibility.
|
||||
|
||||
```bash
|
||||
# For standalone installations (no Docker network needed)
|
||||
docker run --rm \
|
||||
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
|
||||
ghcr.io/ralgar/pgloader:pr-1531 \
|
||||
pgloader --with "quote identifiers" --with "data only" \
|
||||
/db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
```
|
||||
|
||||
**For Docker Compose setups**: Add the network parameter if your PostgreSQL is also in a container:
|
||||
```bash
|
||||
docker run --rm \
|
||||
--network your-jellyseerr-network \
|
||||
-v /path/to/your/config/db.sqlite3:/db.sqlite3:ro \
|
||||
ghcr.io/ralgar/pgloader:pr-1531 \
|
||||
pgloader --with "quote identifiers" --with "data only" \
|
||||
/db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="standalone" label="Building pgloader from Source">
|
||||
|
||||
For users who prefer not to use Docker or need a custom build:
|
||||
|
||||
```bash
|
||||
# Clone the repository and checkout the working version
|
||||
git clone https://github.com/dimitri/pgloader.git
|
||||
cd pgloader
|
||||
git fetch origin pull/1531/head:pr-1531
|
||||
git checkout pr-1531
|
||||
|
||||
# Follow the official installation instructions
|
||||
# See: https://github.com/dimitri/pgloader/blob/master/INSTALL.md
|
||||
```
|
||||
|
||||
:::info
|
||||
**Building pgloader from source requires following the complete installation process outlined in the [official pgloader INSTALL.md](https://github.com/dimitri/pgloader/blob/master/INSTALL.md).**
|
||||
|
||||
Please refer to the official documentation for detailed, up-to-date installation instructions.
|
||||
:::
|
||||
|
||||
Once pgloader is built, run the migration:
|
||||
|
||||
```bash
|
||||
# Run migration (adjust path to your config directory)
|
||||
./pgloader --with "quote identifiers" --with "data only" \
|
||||
/path/to/your/config/db.sqlite3 \
|
||||
postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
docker run --rm -v config/db.sqlite3:/db.sqlite3:ro ghcr.io/ralgar/pgloader:pr-1531 pgloader --with "quote identifiers" --with "data only" /db.sqlite3 postgresql://{{DB_USER}}:{{DB_PASS}}@{{DB_HOST}}:{{DB_PORT}}/{{DB_NAME}}
|
||||
```
|
||||
5. Start Jellyseerr
|
||||
|
||||
@@ -207,62 +207,3 @@ labels:
|
||||
```
|
||||
|
||||
For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/).
|
||||
|
||||
## Apache2 HTTP Server
|
||||
|
||||
<Tabs groupId="apache2-reverse-proxy" queryString>
|
||||
<TabItem value="subdomain" label="Subdomain">
|
||||
|
||||
Add the following Location block to your existing Server configuration.
|
||||
|
||||
```apache
|
||||
# Jellyseerr
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
|
||||
ProxyPassReverse http://localhost:5055 /
|
||||
RequestHeader set Connection ""
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
<TabItem value="subfolder" label="Subfolder">
|
||||
|
||||
:::warning
|
||||
This Apache2 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 Jellyseerr while using this workaround, we may ask you to try to reproduce the problem without the Apache2 proxy.
|
||||
:::
|
||||
|
||||
Add the following Location block to your existing Server configuration.
|
||||
|
||||
```apache
|
||||
# Jellyseerr
|
||||
# We will use "/jellyseerr" as subfolder
|
||||
# You can replace it with any that you like
|
||||
<Location /jellyseerr>
|
||||
ProxyPreserveHost On
|
||||
ProxyPass http://localhost:5055 retry=0 connectiontimeout=5 timeout=30 keepalive=on
|
||||
ProxyPassReverse http://localhost:5055
|
||||
RequestHeader set Connection ""
|
||||
|
||||
# Header update, to support subfolder
|
||||
# Please Replace "FQDN" with your domain
|
||||
Header edit location ^/login https://FQDN/jellyseerr/login
|
||||
Header edit location ^/setup https://FQDN/jellyseerr/setup
|
||||
|
||||
AddOutputFilterByType INFLATE;SUBSTITUTE text/html application/javascript application/json
|
||||
SubstituteMaxLineLength 2000K
|
||||
# This is HTML and JS update
|
||||
# Please update "/jellyseerr" if needed
|
||||
Substitute "s|href=\"|href=\"/jellyseerr|inq"
|
||||
Substitute "s|src=\"|src=\"/jellyseerr|inq"
|
||||
Substitute "s|/api/|/jellyseerr/api/|inq"
|
||||
Substitute "s|\"/_next/|\"/jellyseerr/_next/|inq"
|
||||
# This is JSON update
|
||||
Substitute "s|\"/avatarproxy/|\"/jellyseerr/avatarproxy/|inq"
|
||||
</Location>
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
@@ -33,31 +33,20 @@ docker run -d \
|
||||
--name jellyseerr \
|
||||
-e LOG_LEVEL=debug \
|
||||
-e TZ=Asia/Tashkent \
|
||||
-e PORT=5055 \
|
||||
-e PORT=5055 `#optional` \
|
||||
-p 5055:5055 \
|
||||
-v /path/to/appdata/config:/app/config \
|
||||
--restart unless-stopped \
|
||||
fallenbagel/jellyseerr
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||
```
|
||||
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
|
||||
--health-start-period 20s \
|
||||
--health-timeout 3s \
|
||||
--health-interval 15s \
|
||||
--health-retries 3 \
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
#### Updating:
|
||||
|
||||
Stop and remove the existing container:
|
||||
```bash
|
||||
docker stop jellyseerr && docker rm jellyseerr
|
||||
docker stop jellyseerr && docker rm Jellyseerr
|
||||
```
|
||||
Pull the latest image:
|
||||
```bash
|
||||
@@ -94,12 +83,6 @@ services:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- /path/to/appdata/config:/app/config
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
|
||||
start_period: 20s
|
||||
timeout: 3s
|
||||
interval: 15s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -154,26 +137,7 @@ Then, create and start the Jellyseerr container:
|
||||
<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 \
|
||||
-e PORT=5055 \
|
||||
-p 5055:5055 \
|
||||
-v jellyseerr-data:/app/config \
|
||||
--restart unless-stopped \
|
||||
fallenbagel/jellyseerr
|
||||
```
|
||||
|
||||
The argument `-e PORT=5055` is optional.
|
||||
|
||||
If you want to add a healthcheck to the above command, you can add the following flags :
|
||||
```
|
||||
--health-cmd "wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1" \
|
||||
--health-start-period 20s \
|
||||
--health-timeout 3s \
|
||||
--health-interval 15s \
|
||||
--health-retries 3 \
|
||||
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
|
||||
```
|
||||
|
||||
#### Updating:
|
||||
@@ -201,12 +165,6 @@ services:
|
||||
- 5055:5055
|
||||
volumes:
|
||||
- jellyseerr-data:/app/config
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:5055/api/v1/status || exit 1
|
||||
start_period: 20s
|
||||
timeout: 3s
|
||||
interval: 15s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -105,12 +105,6 @@ In some places (like China), the ISP blocks not only the DNS resolution but also
|
||||
|
||||
You can configure Jellyseerr to use a proxy with the [HTTP(S) Proxy](/using-jellyseerr/settings/general#https-proxy) setting.
|
||||
|
||||
### Option 3: Force IPV4 resolution first
|
||||
|
||||
Sometimes there are configuration issues with IPV6 that prevent the hostname resolution from working correctly.
|
||||
|
||||
You can try to force the resolution to use IPV4 first by going to `Settings > Networking > Advanced Networking` and enabling `Force IPv4 Resolution First` setting and restarting Jellyseerr.
|
||||
|
||||
### Option 4: Check that your server can reach TMDB API
|
||||
|
||||
Make sure that your server can reach the TMDB API by running the following command:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
title: Gotify
|
||||
description: Configure Gotify notifications.
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Gotify
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server URL
|
||||
|
||||
Set this to the URL of your Gotify server.
|
||||
|
||||
### Application Token
|
||||
|
||||
Add an application to your Gotify server, and set this field to the generated application token.
|
||||
|
||||
:::info
|
||||
Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications.
|
||||
:::
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
title: ntfy.sh
|
||||
description: Configure ntfy.sh notifications.
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# ntfy.sh
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Root URL
|
||||
|
||||
Set this to the URL of your ntfy.sh server.
|
||||
|
||||
### Topic
|
||||
|
||||
Set this to the topic you want to send notifications to.
|
||||
|
||||
### Username + Password authentication (optional)
|
||||
|
||||
Set this to the username and password for your ntfy.sh server.
|
||||
|
||||
### Token authentication (optional)
|
||||
|
||||
Set this to the token for your ntfy.sh server.
|
||||
|
||||
:::info
|
||||
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
|
||||
:::
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
title: Pushbullet
|
||||
description: Configure Pushbullet notifications.
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# Pushbullet
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
### Access Token
|
||||
|
||||
[Create an access token](https://www.pushbullet.com/#settings) and set it here to grant Jellyseerr access to the Pushbullet API.
|
||||
|
||||
### Channel Tag (optional)
|
||||
|
||||
Optionally, [create a channel](https://www.pushbullet.com/my-channel) to allow other users to follow the notification feed using the specified channel tag.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: Pushover
|
||||
description: Configure Pushover notifications.
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
# Pushover
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
### Application/API Token
|
||||
|
||||
[Register an application](https://pushover.net/apps/build) and enter the API token in this field. (You can use one of the [official icons in our GitHub repository](https://github.com/fallenbagel/jellyseerr/tree/develop/public) when configuring the application.)
|
||||
|
||||
For more details on registering applications or the API token, please see the [Pushover API documentation](https://pushover.net/api#registration).
|
||||
|
||||
### User Key
|
||||
|
||||
Set this to the user key for your Pushover account. Alternatively, you can set this to a group key to deliver notifications to multiple users.
|
||||
|
||||
For more details, please see the [Pushover API documentation](https://pushover.net/api#identifiers).
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Slack
|
||||
description: Configure Slack notifications.
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# Slack
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
|
||||
Simply [create a webhook](https://my.slack.com/services/new/incoming-webhook/) and enter the URL in this field.
|
||||
|
||||
:::info
|
||||
Please refer to the [Slack API documentation](https://api.slack.com/messaging/webhooks) for more details on configuring these notifications.
|
||||
:::
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
title: Telegram
|
||||
description: Configure Telegram notifications.
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
# Telegram
|
||||
|
||||
:::info
|
||||
Users can optionally configure personal notifications in their user settings.
|
||||
|
||||
User notifications are separate from system notifications, and the available notification types are dependent on user permissions.
|
||||
:::
|
||||
|
||||
## Configuration
|
||||
|
||||
:::info
|
||||
In order to configure Telegram notifications, you first need to [create a bot](https://telegram.me/BotFather).
|
||||
|
||||
Bots **cannot** initiate conversations with users, so users must have your bot added to a conversation in order to receive notifications.
|
||||
:::
|
||||
|
||||
### Bot Username (optional)
|
||||
|
||||
If this value is configured, users will be able to click a link to start a chat with your bot and configure their own personal notifications.
|
||||
|
||||
The bot username should end with `_bot`, and the `@` prefix should be omitted.
|
||||
|
||||
### Bot Authentication Token
|
||||
|
||||
At the end of the bot creation process, [@BotFather](https://telegram.me/botfather) will provide an authentication token.
|
||||
|
||||
### Chat ID
|
||||
|
||||
To obtain your chat ID, simply create a new group chat, add [@get_id_bot](https://telegram.me/get_id_bot), and issue the `/my_id` command.
|
||||
|
||||
### Send Silently (optional)
|
||||
|
||||
Optionally, notifications can be sent silently. Silent notifications send messages without notification sounds.
|
||||
@@ -1,138 +0,0 @@
|
||||
---
|
||||
title: Webhook
|
||||
description: Configure webhook notifications.
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Webhook
|
||||
|
||||
The webhook notification agent enables you to send a custom JSON payload to any endpoint for specific notification events.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Webhook URL
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
### Authorization Header (optional)
|
||||
|
||||
:::info
|
||||
This is typically not needed. Please refer to your webhook provider's documentation for details.
|
||||
:::
|
||||
|
||||
This value will be sent as an `Authorization` HTTP header.
|
||||
|
||||
### JSON Payload
|
||||
|
||||
Customize the JSON payload to suit your needs. Jellyseerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.
|
||||
|
||||
## Template Variables
|
||||
|
||||
### General
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
|
||||
| `{{event}}` | A friendly description of the notification event |
|
||||
| `{{subject}}` | The notification subject (typically the media title) |
|
||||
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
|
||||
| `{{image}}` | The notification image (typically the media poster) |
|
||||
|
||||
### Notify User
|
||||
|
||||
These variables are for the target recipient of the notification.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ------------------------------------------------------------- |
|
||||
| `{{notifyuser_username}}` | The target notification recipient's username |
|
||||
| `{{notifyuser_email}}` | The target notification recipient's email address |
|
||||
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
|
||||
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
|
||||
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
|
||||
|
||||
:::info
|
||||
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
|
||||
|
||||
- Request Pending Approval
|
||||
- Request Automatically Approved
|
||||
- Request Processing Failed
|
||||
|
||||
On the other hand, the `notifyuser` variables _will_ be replaced with the requesting user's information for the below notification types:
|
||||
|
||||
- Request Approved
|
||||
- Request Declined
|
||||
- Request Available
|
||||
|
||||
If you would like to use the requesting user's information in your webhook, please instead include the relevant variables from the [Request](#request) section below.
|
||||
:::
|
||||
|
||||
### Special
|
||||
|
||||
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
|
||||
|
||||
| Variable | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `{{media}}` | The relevant media object |
|
||||
| `{{request}}` | The relevant request object |
|
||||
| `{{issue}}` | The relevant issue object |
|
||||
| `{{comment}}` | The relevant issue comment object |
|
||||
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
|
||||
|
||||
#### Media
|
||||
|
||||
The `{{media}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
These following special variables are only included in media-related notifications, such as requests.
|
||||
|
||||
| Variable | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{media_type}}` | The media type (`movie` or `tv`) |
|
||||
| `{{media_tmdbid}}` | The media's TMDB ID |
|
||||
| `{{media_tvdbid}}` | The media's TheTVDB ID |
|
||||
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
|
||||
|
||||
#### Request
|
||||
|
||||
The `{{request}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in request-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{request_id}}` | The request ID |
|
||||
| `{{requestedBy_username}}` | The requesting user's username |
|
||||
| `{{requestedBy_email}}` | The requesting user's email address |
|
||||
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Issue
|
||||
|
||||
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `{{issue_id}}` | The issue ID |
|
||||
| `{{reportedBy_username}}` | The requesting user's username |
|
||||
| `{{reportedBy_email}}` | The requesting user's email address |
|
||||
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
|
||||
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
|
||||
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
|
||||
|
||||
#### Comment
|
||||
|
||||
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
|
||||
|
||||
The following special variables are only included in issue comment-related notifications.
|
||||
|
||||
| Variable | Value |
|
||||
| ----------------------------------------- | ----------------------------------------------- |
|
||||
| `{{comment_message}}` | The comment message |
|
||||
| `{{commentedBy_username}}` | The commenting user's username |
|
||||
| `{{commentedBy_email}}` | The commenting user's email address |
|
||||
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
|
||||
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
|
||||
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"label": "Plex Integration",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"title": "Plex Integration",
|
||||
"description": "Learn about Jellyseerr's Plex integration features"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: Overview
|
||||
description: Learn about Jellyseerr's Plex integration features
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Plex Features Overview
|
||||
|
||||
Jellyseerr provides integration features that connect with your Plex media server to automate media management tasks.
|
||||
|
||||
## Available Features
|
||||
|
||||
- [Watchlist Auto Request](./plex/watchlist-auto-request) - Automatically request media from your Plex Watchlist
|
||||
- More features coming soon!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
:::info Authentication Required
|
||||
To use any Plex integration features, you must have logged into Jellyseerr at least once with your Plex account.
|
||||
:::
|
||||
|
||||
**Requirements:**
|
||||
- Plex account with access to the configured Plex server
|
||||
- Jellyseerr configured with Plex as the media server
|
||||
- User authentication via Plex login
|
||||
- Appropriate user permissions for specific features
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Authenticate at least once using your Plex credentials
|
||||
2. Verify you have the necessary permissions for desired features
|
||||
3. Follow individual feature guides for setup instructions
|
||||
|
||||
:::note Server Configuration
|
||||
Plex server configuration is handled by your administrator. If you cannot log in with your Plex account, contact your administrator to verify the server setup.
|
||||
:::
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
title: Watchlist Auto Request
|
||||
description: Learn how to use the Plex Watchlist Auto Request feature
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Watchlist Auto Request
|
||||
|
||||
The Plex Watchlist Auto Request feature allows Jellyseerr to automatically create requests for media items you add to your Plex Watchlist. Simply add content to your Plex Watchlist, and Jellyseerr will automatically request it for you.
|
||||
|
||||
:::info
|
||||
This feature is only available for Plex users. Local users cannot use the Watchlist Auto Request feature.
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- You must have logged into Jellyseerr at least once with your Plex account
|
||||
- Your administrator must have granted you the necessary permissions
|
||||
- Your Plex account must have access to the Plex server configured in Jellyseerr
|
||||
|
||||
## Permission System
|
||||
|
||||
The Watchlist Auto Request feature uses a two-tier permission system:
|
||||
|
||||
### Administrator Permissions (Required)
|
||||
Your administrator must grant you these permissions in your user profile:
|
||||
- **Auto-Request** (master permission)
|
||||
- **Auto-Request Movies** (for movie auto-requests)
|
||||
- **Auto-Request Series** (for TV series auto-requests)
|
||||
|
||||
### User Activation (Required)
|
||||
You must enable the feature in your own profile settings:
|
||||
- **Auto-Request Movies** toggle
|
||||
- **Auto-Request Series** toggle
|
||||
|
||||
:::warning Two-Step Process
|
||||
Both administrator permissions AND user activation are required. Having permissions doesn't automatically enable the feature - you must also activate it in your profile.
|
||||
:::
|
||||
|
||||
## How to Enable
|
||||
|
||||
### Step 1: Check Your Permissions
|
||||
Contact your administrator to verify you have been granted:
|
||||
- `Auto-Request` permission
|
||||
- `Auto-Request Movies` and/or `Auto-Request Series` permissions
|
||||
|
||||
### Step 2: Activate the Feature
|
||||
1. Go to your user profile settings
|
||||
2. Navigate to the "General" section
|
||||
3. Find the "Auto-Request" options
|
||||
4. Enable the toggles for:
|
||||
- **Auto-Request Movies** - to automatically request movies from your watchlist
|
||||
- **Auto-Request Series** - to automatically request TV series from your watchlist
|
||||
|
||||
### Step 3: Start Using
|
||||
- Add movies and TV shows to your Plex Watchlist
|
||||
- Jellyseerr will automatically create requests for new items
|
||||
- You'll receive notifications when items are auto-requested
|
||||
|
||||
## How It Works
|
||||
|
||||
Once properly configured, Jellyseerr will:
|
||||
|
||||
1. Periodically checks your Plex Watchlist for new items
|
||||
2. Verify if the content already exists in your media libraries
|
||||
3. Automatically submits requests for new items that aren't already available
|
||||
4. Only requests content types you have permissions for
|
||||
5. Notifiy you when auto-requests are created
|
||||
|
||||
:::info Content Limitations
|
||||
Auto-request only works for standard quality content. 4K content must be requested manually if you have 4K permissions.
|
||||
:::
|
||||
|
||||
## For Administrators
|
||||
|
||||
### Granting Permissions
|
||||
1. Navigate to **Users** > **[Select User]** > **Permissions**
|
||||
2. Enable the required permissions:
|
||||
- **Auto-Request** (master toggle)
|
||||
- **Auto-Request Movies** (for movie auto-requests)
|
||||
- **Auto-Request Series** (for TV series auto-requests)
|
||||
3. Optionally enable **Auto-Approve** permissions for automatic approval
|
||||
|
||||
### Default Permissions
|
||||
- Go to **Settings** > **Users** > **Default Permissions**
|
||||
- Configure auto-request permissions for new users
|
||||
- This sets the default permissions but users still need to activate the feature individually
|
||||
|
||||
## Limitations
|
||||
|
||||
- Local users cannot use this feature
|
||||
- 4K content requires manual requests
|
||||
- Users must have logged into Jellyseerr with their Plex account
|
||||
- Respects user request limits and quotas
|
||||
- Won't request content already in your libraries
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: DNS Caching
|
||||
description: Configure DNS caching settings.
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# DNS Caching
|
||||
|
||||
Jellyseerr uses DNS caching to improve performance and reduce the number of DNS lookups required for external API calls. This can help speed up response times and reduce load on DNS servers, when something like a Pi-hole is used as a DNS resolver.
|
||||
|
||||
## Configuration
|
||||
|
||||
You can enable the DNS caching settings in the Network tab of the Jellyseerr settings. The default values follow the standard DNS caching behavior.
|
||||
|
||||
- **Force Minimum TTL**: Set a minimum time-to-live (TTL) in seconds for DNS cache entries. This ensures that frequently accessed DNS records are cached for a longer period, reducing the need for repeated lookups. Default is 0.
|
||||
- **Force Maximum TTL**: Set a maximum time-to-live (TTL) in seconds for DNS cache entries. This prevents infrequently accessed DNS records from being cached indefinitely, allowing for more up-to-date information to be retrieved. Default is -1 (unlimited).
|
||||
@@ -1,7 +1,6 @@
|
||||
---
|
||||
title: Jobs & Cache
|
||||
description: Configure jobs and cache settings.
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Jobs & Cache
|
||||
|
||||
@@ -22,14 +22,6 @@ When disabled, users will only be able to sign in using their email address. Use
|
||||
|
||||
This setting is **enabled** by default.
|
||||
|
||||
## Enable OpenID Connect Sign-In
|
||||
|
||||
When enabled, users will be able to sign in to Jellyseerr using their OpenID Connect credentials, provided they have linked their OpenID Connect accounts. Once enabled, the [OpenID Connect settings](./oidc.md) can be accessed using the settings cog to the right of this option, and OpenID Connect providers can be configured.
|
||||
|
||||
When disabled, users will only be able to sign in using their Jellyseerr username or email address. Users without a password set will not be able to sign in to Jellyseerr.
|
||||
|
||||
This setting is **disabled** 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 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.
|
||||
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 285 KiB |
@@ -1,82 +0,0 @@
|
||||
---
|
||||
title: OpenID Connect
|
||||
description: Configure OpenID Connect settings.
|
||||
sidebar_position: 2.5
|
||||
---
|
||||
|
||||
# OpenID Connect
|
||||
|
||||
Jellyseerr supports OpenID Connect (OIDC) for authentication and authorization. To begin setting up OpenID Connect, follow these steps:
|
||||
|
||||
1. First, enable OpenID Connect [on the User settings page](./index.md#enable-openid-connect-sign-in).
|
||||
2. Once enabled, access OpenID Connect settings using the cog icon to the right.
|
||||
3. Add a new provider by clicking the "Add Provider" button.
|
||||
4. Configure the provider with the options described below.
|
||||
5. Link your OpenID Connect account to your Jellyseerr account using the "Link Account" button on the Linked Accounts page in your user's settings.
|
||||
6. Finally, you should be able to log in using your OpenID Connect account.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Provider Name
|
||||
|
||||
Name of the provider which appears on the login screen.
|
||||
|
||||
Configuring this setting will automatically determine the [provider slug](#provider-slug), unless it is manually specified.
|
||||
|
||||
### Logo
|
||||
|
||||
The logo to display for the provider. Should be a URL or base64 encoded image.
|
||||
|
||||
:::tip
|
||||
|
||||
The search icon at the right of the logo field opens the [selfh.st/icons](https://selfh.st/icons) database. These icons include popular self-hosted OpenID Connect providers.
|
||||
|
||||
:::
|
||||
|
||||
### Issuer URL
|
||||
The base URL of the identity provider's OpenID Connect endpoint
|
||||
|
||||
### Client ID
|
||||
|
||||
The Client ID assigned to Jellyseerr
|
||||
|
||||
### Client Secret
|
||||
|
||||
The Client Secret assigned to Jellyseerr
|
||||
|
||||
### Provider Slug
|
||||
|
||||
Unique identifier for the provider
|
||||
|
||||
### Scopes
|
||||
|
||||
Space-separated list of scopes to request from the provider
|
||||
|
||||
### Required Claims
|
||||
|
||||
Space-separated list of boolean claims that are required to log in
|
||||
|
||||
### Allow New Users
|
||||
|
||||
Create accounts for new users logging in with this provider
|
||||
|
||||
## Provider Guides
|
||||
|
||||
### Keycloak
|
||||
|
||||
To set up Keycloak, follow these steps:
|
||||
|
||||
1. First, create a new client in Keycloak.
|
||||

|
||||
|
||||
1. Set the client ID to `jellyseerr`, and set the name to "Jellyseerr" (or whatever you prefer).
|
||||

|
||||
|
||||
1. Next, be sure to enable "Client authentication" in the capabilities section. The remaining defaults should be fine.
|
||||

|
||||
|
||||
1. Finally, set the root url to your Jellyseerr instance's URL, and add the login page as a valid redirect URL.
|
||||

|
||||
|
||||
1. With all that set up, you should be able to configure Jellyseerr to use Keycloak for authentication. Be sure to copy the client secret from the credentials page, as shown above. The issuer URL can be obtained from the "Realm Settings" page, by copying the link titled "OpenID Endpoint Configuration".
|
||||

|
||||
@@ -141,83 +141,14 @@ components:
|
||||
UserSettings:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
nullable: true
|
||||
example: 'Mr User'
|
||||
email:
|
||||
type: string
|
||||
example: 'user@example.com'
|
||||
discordId:
|
||||
type: string
|
||||
nullable: true
|
||||
example: '123456789'
|
||||
locale:
|
||||
type: string
|
||||
nullable: true
|
||||
example: 'en'
|
||||
discoverRegion:
|
||||
type: string
|
||||
nullable: true
|
||||
example: 'US'
|
||||
streamingRegion:
|
||||
type: string
|
||||
nullable: true
|
||||
example: 'US'
|
||||
originalLanguage:
|
||||
type: string
|
||||
nullable: true
|
||||
example: 'en'
|
||||
movieQuotaLimit:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Maximum number of movie requests allowed'
|
||||
example: 10
|
||||
movieQuotaDays:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Time period in days for movie quota'
|
||||
example: 30
|
||||
tvQuotaLimit:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Maximum number of TV requests allowed'
|
||||
example: 5
|
||||
tvQuotaDays:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Time period in days for TV quota'
|
||||
example: 14
|
||||
globalMovieQuotaDays:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Global movie quota days setting'
|
||||
example: 30
|
||||
globalMovieQuotaLimit:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Global movie quota limit setting'
|
||||
example: 10
|
||||
globalTvQuotaLimit:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Global TV quota limit setting'
|
||||
example: 5
|
||||
globalTvQuotaDays:
|
||||
type: number
|
||||
nullable: true
|
||||
description: 'Global TV quota days setting'
|
||||
example: 14
|
||||
watchlistSyncMovies:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: 'Enable watchlist sync for movies'
|
||||
example: true
|
||||
watchlistSyncTv:
|
||||
type: boolean
|
||||
nullable: true
|
||||
description: 'Enable watchlist sync for TV'
|
||||
example: false
|
||||
streamingRegion:
|
||||
type: string
|
||||
MainSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -254,92 +185,15 @@ components:
|
||||
enableSpecialEpisodes:
|
||||
type: boolean
|
||||
example: false
|
||||
OidcProvider:
|
||||
type: object
|
||||
properties:
|
||||
slug:
|
||||
type: string
|
||||
readOnly: true
|
||||
name:
|
||||
type: string
|
||||
issuerUrl:
|
||||
type: string
|
||||
clientId:
|
||||
type: string
|
||||
clientSecret:
|
||||
type: string
|
||||
logo:
|
||||
type: string
|
||||
requiredClaims:
|
||||
type: string
|
||||
scopes:
|
||||
type: string
|
||||
newUserLogin:
|
||||
type: boolean
|
||||
required:
|
||||
- slug
|
||||
- name
|
||||
- issuerUrl
|
||||
- clientId
|
||||
- clientSecret
|
||||
OidcSettings:
|
||||
type: object
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/OidcProvider'
|
||||
NetworkSettings:
|
||||
type: object
|
||||
properties:
|
||||
csrfProtection:
|
||||
type: boolean
|
||||
example: false
|
||||
forceIpv4First:
|
||||
type: boolean
|
||||
example: false
|
||||
trustProxy:
|
||||
type: boolean
|
||||
example: false
|
||||
proxy:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
hostname:
|
||||
type: string
|
||||
example: ''
|
||||
port:
|
||||
type: number
|
||||
example: 8080
|
||||
useSsl:
|
||||
type: boolean
|
||||
example: false
|
||||
user:
|
||||
type: string
|
||||
example: ''
|
||||
password:
|
||||
type: string
|
||||
example: ''
|
||||
bypassFilter:
|
||||
type: string
|
||||
example: ''
|
||||
bypassLocalAddresses:
|
||||
type: boolean
|
||||
example: true
|
||||
dnsCache:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
forceMinTtl:
|
||||
type: number
|
||||
example: 0
|
||||
forceMaxTtl:
|
||||
type: number
|
||||
example: -1
|
||||
example: true
|
||||
PlexLibrary:
|
||||
type: object
|
||||
properties:
|
||||
@@ -554,20 +408,6 @@ components:
|
||||
serverID:
|
||||
type: string
|
||||
readOnly: true
|
||||
MetadataSettings:
|
||||
type: object
|
||||
properties:
|
||||
settings:
|
||||
type: object
|
||||
properties:
|
||||
tv:
|
||||
type: string
|
||||
enum: [tvdb, tmdb]
|
||||
example: 'tvdb'
|
||||
anime:
|
||||
type: string
|
||||
enum: [tvdb, tmdb]
|
||||
example: 'tvdb'
|
||||
TautulliSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1585,6 +1425,22 @@ components:
|
||||
type: boolean
|
||||
token:
|
||||
type: string
|
||||
LunaSeaSettings:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: false
|
||||
types:
|
||||
type: number
|
||||
example: 2
|
||||
options:
|
||||
type: object
|
||||
properties:
|
||||
webhookUrl:
|
||||
type: string
|
||||
profileName:
|
||||
type: string
|
||||
NotificationEmailSettings:
|
||||
type: object
|
||||
properties:
|
||||
@@ -2247,64 +2103,6 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MainSettings'
|
||||
/settings/oidc:
|
||||
get:
|
||||
summary: Get OpenID Connect settings
|
||||
description: Retrieves all OpenID Connect settings in a JSON object.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OidcSettings'
|
||||
/settings/oidc/{provider}:
|
||||
put:
|
||||
summary: Update OpenID Connect provider
|
||||
description: Updates an existing OpenID Connect provider with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: provider
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Provider slug
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OidcProvider'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Radarr instance updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RadarrSettings'
|
||||
delete:
|
||||
summary: Delete OpenID Connect provider
|
||||
description: Deletes an existing OpenID Connect provider based on the provider slug parameter.
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: provider
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Provider slug
|
||||
responses:
|
||||
'200':
|
||||
description: 'OpenID Connect provider deleted'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OidcSettings'
|
||||
/settings/network:
|
||||
get:
|
||||
summary: Get network settings
|
||||
@@ -2675,67 +2473,6 @@ paths:
|
||||
type: string
|
||||
thumb:
|
||||
type: string
|
||||
/settings/metadatas:
|
||||
get:
|
||||
summary: Get Metadata settings
|
||||
description: Retrieves current Metadata settings.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
put:
|
||||
summary: Update Metadata settings
|
||||
description: Updates Metadata settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were successfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MetadataSettings'
|
||||
/settings/metadatas/test:
|
||||
post:
|
||||
summary: Test Provider configuration
|
||||
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tmdb:
|
||||
type: boolean
|
||||
example: true
|
||||
tvdb:
|
||||
type: boolean
|
||||
example: true
|
||||
responses:
|
||||
'200':
|
||||
description: Succesfully connected to TVDB
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: 'Successfully connected to TVDB'
|
||||
/settings/tautulli:
|
||||
get:
|
||||
summary: Get Tautulli settings
|
||||
@@ -3177,68 +2914,6 @@ paths:
|
||||
imageCount:
|
||||
type: number
|
||||
example: 123
|
||||
dnsCache:
|
||||
type: object
|
||||
properties:
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
size:
|
||||
type: number
|
||||
example: 1
|
||||
maxSize:
|
||||
type: number
|
||||
example: 500
|
||||
hits:
|
||||
type: number
|
||||
example: 19
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
failures:
|
||||
type: number
|
||||
example: 0
|
||||
ipv4Fallbacks:
|
||||
type: number
|
||||
example: 0
|
||||
hitRate:
|
||||
type: number
|
||||
example: 0.95
|
||||
entries:
|
||||
type: array
|
||||
additionalProperties:
|
||||
type: object
|
||||
properties:
|
||||
addresses:
|
||||
type: object
|
||||
properties:
|
||||
ipv4:
|
||||
type: number
|
||||
example: 1
|
||||
ipv6:
|
||||
type: number
|
||||
example: 1
|
||||
activeAddress:
|
||||
type: string
|
||||
example: 127.0.0.1
|
||||
family:
|
||||
type: number
|
||||
example: 4
|
||||
age:
|
||||
type: number
|
||||
example: 10
|
||||
ttl:
|
||||
type: number
|
||||
example: 10
|
||||
networkErrors:
|
||||
type: number
|
||||
example: 0
|
||||
hits:
|
||||
type: number
|
||||
example: 1
|
||||
misses:
|
||||
type: number
|
||||
example: 1
|
||||
apiCaches:
|
||||
type: array
|
||||
items:
|
||||
@@ -3278,21 +2953,6 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed cache'
|
||||
/settings/cache/dns/{dnsEntry}/flush:
|
||||
post:
|
||||
summary: Flush a specific DNS cache entry
|
||||
description: Flushes a specific DNS cache entry
|
||||
tags:
|
||||
- settings
|
||||
parameters:
|
||||
- in: path
|
||||
name: dnsEntry
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: 'Flushed dns cache'
|
||||
/settings/logs:
|
||||
get:
|
||||
summary: Returns logs
|
||||
@@ -3439,6 +3099,52 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/lunasea:
|
||||
get:
|
||||
summary: Get LunaSea notification settings
|
||||
description: Returns current LunaSea notification settings in a JSON object.
|
||||
tags:
|
||||
- settings
|
||||
responses:
|
||||
'200':
|
||||
description: Returned LunaSea settings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
post:
|
||||
summary: Update LunaSea notification settings
|
||||
description: Updates LunaSea notification settings with the provided values.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
responses:
|
||||
'200':
|
||||
description: 'Values were sucessfully updated'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
/settings/notifications/lunasea/test:
|
||||
post:
|
||||
summary: Test LunaSea settings
|
||||
description: Sends a test notification to the LunaSea agent.
|
||||
tags:
|
||||
- settings
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/LunaSeaSettings'
|
||||
responses:
|
||||
'204':
|
||||
description: Test notification attempted
|
||||
/settings/notifications/pushbullet:
|
||||
get:
|
||||
summary: Get Pushbullet notification settings
|
||||
@@ -4098,81 +3804,6 @@ paths:
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
/auth/oidc/login/{slug}:
|
||||
get:
|
||||
security: []
|
||||
summary: Redirect to the OpenID Connect provider
|
||||
description: Constructs the redirect URL to the OpenID Connect provider, and redirects the user to it.
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: path
|
||||
name: slug
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'authentik'
|
||||
responses:
|
||||
'200':
|
||||
description: Authentication redirect url for the OpenID Connect provider
|
||||
headers:
|
||||
Set-Cookie:
|
||||
schema:
|
||||
type: string
|
||||
example: 'oidc-state=123456789; HttpOnly; max-age=60000; Secure'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
redirectUrl:
|
||||
type: string
|
||||
example: https://example.com/auth/oidc/callback?response_type=code&client_id=client_id&redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Foidc%2Fcallback&scope=openid%20email&state=state
|
||||
/auth/oidc/callback/{slug}:
|
||||
get:
|
||||
security: []
|
||||
summary: The callback endpoint for the OpenID Connect provider redirect
|
||||
description: Takes the `code` and `state` parameters from the OpenID Connect provider, and exchanges them for a token.
|
||||
x-allow-unknown-query-parameters: true
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: path
|
||||
name: slug
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 'authentik'
|
||||
- in: query
|
||||
name: code
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '123456789'
|
||||
- in: query
|
||||
name: state
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '123456789'
|
||||
- in: cookie
|
||||
name: oidc-state
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: '123456789'
|
||||
responses:
|
||||
'302':
|
||||
description: A redirect to the home page if successful or back to the login page if not
|
||||
headers:
|
||||
Location:
|
||||
schema:
|
||||
type: string
|
||||
example: /
|
||||
Set-Cookie:
|
||||
schema:
|
||||
type: string
|
||||
example: 'oidc-state=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
||||
/auth/logout:
|
||||
post:
|
||||
summary: Sign out and clear session cookie
|
||||
@@ -4278,8 +3909,14 @@ paths:
|
||||
name: sort
|
||||
schema:
|
||||
type: string
|
||||
enum: [created, updated, requests, displayname]
|
||||
enum: [created, updated, requests, displayname, usertype, role]
|
||||
default: created
|
||||
- in: query
|
||||
name: sortDirection
|
||||
schema:
|
||||
type: string
|
||||
enum: [asc, desc]
|
||||
default: desc
|
||||
- in: query
|
||||
name: q
|
||||
required: false
|
||||
@@ -4900,7 +4537,11 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
post:
|
||||
summary: Update general settings for a user
|
||||
description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||
@@ -4917,14 +4558,22 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
nullable: true
|
||||
responses:
|
||||
'200':
|
||||
description: Updated user general settings returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserSettings'
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
example: 'Mr User'
|
||||
/user/{userId}/settings/password:
|
||||
get:
|
||||
summary: Get password page informatiom
|
||||
@@ -4976,23 +4625,6 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: User password updated
|
||||
/user/{userId}/settings/linked-accounts:
|
||||
get:
|
||||
summary: Lists the user's linked OpenID Connect accounts
|
||||
description: Lists the user's linked OpenID Connect accounts
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'200':
|
||||
description: List of linked accounts
|
||||
'403':
|
||||
description: Invalid credentials
|
||||
/user/{userId}/settings/linked-accounts/plex:
|
||||
post:
|
||||
summary: Link the provided Plex account to the current user
|
||||
@@ -5091,28 +4723,6 @@ paths:
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/{acctId}:
|
||||
delete:
|
||||
summary: Remove a linked account for a user
|
||||
description: Removes the linked account with the given ID for a specific user. Requires `MANAGE_USERS` permission if editing other users.
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- in: path
|
||||
name: acctId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
responses:
|
||||
'204':
|
||||
description: Unlinking account succeeded
|
||||
'404':
|
||||
description: User or linked account does not exist
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
@@ -6754,7 +6364,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TvDetails'
|
||||
/tv/{tvId}/season/{seasonNumber}:
|
||||
/tv/{tvId}/season/{seasonId}:
|
||||
get:
|
||||
summary: Get season details and episode list
|
||||
description: Returns season details with a list of episodes in a JSON object.
|
||||
@@ -6768,11 +6378,11 @@ paths:
|
||||
type: number
|
||||
example: 76479
|
||||
- in: path
|
||||
name: seasonNumber
|
||||
name: seasonId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
example: 123456
|
||||
example: 1
|
||||
- in: query
|
||||
name: language
|
||||
schema:
|
||||
@@ -7057,16 +6667,9 @@ paths:
|
||||
example: '1'
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: is4k
|
||||
description: Whether to remove from 4K service instance (true) or regular service instance (false)
|
||||
required: false
|
||||
example: false
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
'204':
|
||||
description: Successfully removed media item
|
||||
description: Succesfully removed media item
|
||||
/media/{mediaId}/{status}:
|
||||
post:
|
||||
summary: Update media status
|
||||
@@ -7733,22 +7336,11 @@ paths:
|
||||
example: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Keyword returned (null if not found)
|
||||
description: Keyword returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
$ref: '#/components/schemas/Keyword'
|
||||
'500':
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: 'Unable to retrieve keyword data.'
|
||||
/watchproviders/regions:
|
||||
get:
|
||||
summary: Get watch provider regions
|
||||
|
||||
71
package.json
@@ -46,7 +46,7 @@
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/wink-jaro-distance": "^2.0.2",
|
||||
"ace-builds": "1.15.2",
|
||||
"axios": "1.10.0",
|
||||
"axios": "1.3.4",
|
||||
"axios-rate-limit": "1.3.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bowser": "2.11.0",
|
||||
@@ -57,7 +57,6 @@
|
||||
"cronstrue": "2.23.0",
|
||||
"date-fns": "2.29.3",
|
||||
"dayjs": "1.11.7",
|
||||
"dns-caching": "^0.2.5",
|
||||
"email-templates": "12.0.1",
|
||||
"email-validator": "2.0.4",
|
||||
"express": "4.21.2",
|
||||
@@ -68,7 +67,6 @@
|
||||
"gravatar-url": "3.1.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"mime": "3",
|
||||
"next": "^14.2.25",
|
||||
@@ -117,10 +115,11 @@
|
||||
"zod": "3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codedependant/semantic-release-docker": "^5.1.0",
|
||||
"@commitlint/cli": "17.4.4",
|
||||
"@commitlint/config-conventional": "17.4.4",
|
||||
"@semantic-release/changelog": "6.0.3",
|
||||
"@semantic-release/changelog": "6.0.2",
|
||||
"@semantic-release/commit-analyzer": "9.0.2",
|
||||
"@semantic-release/exec": "6.0.3",
|
||||
"@semantic-release/git": "10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "0.4.2",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
@@ -171,7 +170,8 @@
|
||||
"prettier": "2.8.4",
|
||||
"prettier-plugin-organize-imports": "3.2.2",
|
||||
"prettier-plugin-tailwindcss": "0.2.3",
|
||||
"semantic-release": "24.2.7",
|
||||
"semantic-release": "19.0.5",
|
||||
"semantic-release-docker-buildx": "1.0.1",
|
||||
"tailwindcss": "3.2.7",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "1.8.2",
|
||||
@@ -226,49 +226,7 @@
|
||||
"message": "chore(release): ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@codedependant/semantic-release-docker",
|
||||
{
|
||||
"dockerArgs": {
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
},
|
||||
"dockerLogin": false,
|
||||
"dockerProject": "fallenbagel",
|
||||
"dockerImage": "jellyseerr",
|
||||
"dockerTags": [
|
||||
"latest",
|
||||
"{{major}}",
|
||||
"{{major}}.{{minor}}",
|
||||
"{{major}}.{{minor}}.{{patch}}"
|
||||
],
|
||||
"dockerPlatform": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@codedependant/semantic-release-docker",
|
||||
{
|
||||
"dockerArgs": {
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
},
|
||||
"dockerLogin": false,
|
||||
"dockerRegistry": "ghcr.io",
|
||||
"dockerProject": "fallenbagel",
|
||||
"dockerImage": "jellyseerr",
|
||||
"dockerTags": [
|
||||
"latest",
|
||||
"{{major}}",
|
||||
"{{major}}.{{minor}}",
|
||||
"{{major}}.{{minor}}.{{patch}}"
|
||||
],
|
||||
"dockerPlatform": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"semantic-release-docker-buildx",
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
@@ -281,7 +239,20 @@
|
||||
],
|
||||
"npmPublish": false,
|
||||
"publish": [
|
||||
"@codedependant/semantic-release-docker",
|
||||
{
|
||||
"path": "semantic-release-docker-buildx",
|
||||
"buildArgs": {
|
||||
"COMMIT_TAG": "$GIT_SHA"
|
||||
},
|
||||
"imageNames": [
|
||||
"fallenbagel/jellyseerr",
|
||||
"ghcr.io/fallenbagel/jellyseerr"
|
||||
],
|
||||
"platforms": [
|
||||
"linux/amd64",
|
||||
"linux/arm64"
|
||||
]
|
||||
},
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
|
||||
2544
pnpm-lock.yaml
generated
@@ -1,57 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.0"
|
||||
width="120"
|
||||
height="120"
|
||||
id="svg2593"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="OpenID_logo_2.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="false"
|
||||
inkscape:zoom="5.3731101"
|
||||
inkscape:cx="69.60587"
|
||||
inkscape:cy="65.883631"
|
||||
inkscape:window-width="1512"
|
||||
inkscape:window-height="945"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="37"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg2593" /><defs
|
||||
id="defs2596"><clipPath
|
||||
id="clipPath2616"><path
|
||||
d="M 0,14400 H 14400 V 0 H 0 Z"
|
||||
id="path2618" /></clipPath></defs><g
|
||||
transform="matrix(1.25,0,0,-1.25,-8601.6804,9121.1624)"
|
||||
id="g2602"><g
|
||||
transform="matrix(0.375,0,0,0.375,4301.4506,4557.5812)"
|
||||
id="g2734"><g
|
||||
id="g2726"><g
|
||||
transform="translate(6998.0969,7259.1135)"
|
||||
id="g2604"><path
|
||||
d="M 0,0 V -159.939 -180 l 32,15.061 V 15.633 Z"
|
||||
id="path2606"
|
||||
style="fill:#f8931e;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
|
||||
transform="translate(7108.9192,7206.3137)"
|
||||
id="g2608"><path
|
||||
d="M 0,0 4.417,-45.864 -57.466,-32.4"
|
||||
id="path2610"
|
||||
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g><g
|
||||
transform="translate(6934.0969,7147.6213)"
|
||||
id="g2620"><path
|
||||
d="M 0,0 C 0,22.674 24.707,41.769 58.383,47.598 V 67.923 C 6.873,61.697 -32,33.656 -32,0 -32,-34.869 9.725,-63.709 64,-68.508 v 20.061 C 27.484,-43.869 0,-23.919 0,0 M 101.617,67.915 V 47.598 c 13.399,-2.319 25.385,-6.727 34.951,-12.64 l 22.627,13.984 c -15.42,9.531 -35.322,16.283 -57.578,18.973"
|
||||
id="path2622"
|
||||
style="fill:#b3b3b3;fill-opacity:1;fill-rule:nonzero;stroke:none" /></g></g></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,4 +1,3 @@
|
||||
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import rateLimit from 'axios-rate-limit';
|
||||
@@ -10,7 +9,7 @@ const DEFAULT_TTL = 300;
|
||||
// 10 seconds default rolling buffer (in ms)
|
||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||
|
||||
export interface ExternalAPIOptions {
|
||||
interface ExternalAPIOptions {
|
||||
nodeCache?: NodeCache;
|
||||
headers?: Record<string, unknown>;
|
||||
rateLimit?: {
|
||||
@@ -38,7 +37,6 @@ class ExternalAPI {
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
this.axios.interceptors.request.use(requestInterceptorFunction);
|
||||
|
||||
if (options.rateLimit) {
|
||||
this.axios = rateLimit(this.axios, {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Tvdb from '@server/api/tvdb';
|
||||
import { getSettings, MetadataProviderType } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
export const getMetadataProvider = async (
|
||||
mediaType: 'movie' | 'tv' | 'anime'
|
||||
): Promise<TvShowProvider> => {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
|
||||
if (mediaType == 'movie') {
|
||||
return new TheMovieDb();
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType == 'tv' &&
|
||||
settings.metadataSettings.tv == MetadataProviderType.TVDB
|
||||
) {
|
||||
return await Tvdb.getInstance();
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType == 'anime' &&
|
||||
settings.metadataSettings.anime == MetadataProviderType.TVDB
|
||||
) {
|
||||
return await Tvdb.getInstance();
|
||||
}
|
||||
|
||||
return new TheMovieDb();
|
||||
} catch (e) {
|
||||
logger.error('Failed to get metadata provider', {
|
||||
label: 'Metadata',
|
||||
message: e.message,
|
||||
});
|
||||
return new TheMovieDb();
|
||||
}
|
||||
};
|
||||
@@ -291,7 +291,7 @@ class PlexTvAPI extends ExternalAPI {
|
||||
headers: {
|
||||
'If-None-Match': cachedWatchlist?.etag,
|
||||
},
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
|
||||
}
|
||||
);
|
||||
@@ -315,7 +315,7 @@ class PlexTvAPI extends ExternalAPI {
|
||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||
{
|
||||
baseURL: 'https://discover.provider.plex.tv',
|
||||
baseURL: 'https://metadata.provider.plex.tv',
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
|
||||
export interface TvShowProvider {
|
||||
getTvShow({
|
||||
tvId,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails>;
|
||||
getTvSeason({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes>;
|
||||
getShowByTvdbId({
|
||||
tvdbId,
|
||||
language,
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails>;
|
||||
}
|
||||
@@ -145,7 +145,6 @@ export interface IMDBRating {
|
||||
title: string;
|
||||
url: string;
|
||||
criticsScore: number;
|
||||
criticsScoreCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,7 +187,6 @@ class IMDBRadarrProxy extends ExternalAPI {
|
||||
title: data[0].Title,
|
||||
url: `https://www.imdb.com/title/${data[0].ImdbId}`,
|
||||
criticsScore: data[0].MovieRatings.Imdb.Value,
|
||||
criticsScoreCount: data[0].MovieRatings.Imdb.Count,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { TautulliSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { uniqWith } from 'lodash';
|
||||
@@ -124,7 +123,6 @@ class TautulliAPI {
|
||||
}${settings.urlBase ?? ''}`,
|
||||
params: { apikey: settings.apiKey },
|
||||
});
|
||||
this.axios.interceptors.request.use(requestInterceptorFunction);
|
||||
}
|
||||
|
||||
public async getInfo(): Promise<TautulliInfo> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { sortBy } from 'lodash';
|
||||
@@ -121,7 +120,7 @@ interface DiscoverTvOptions {
|
||||
certificationCountry?: string;
|
||||
}
|
||||
|
||||
class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
class TheMovieDb extends ExternalAPI {
|
||||
private locale: string;
|
||||
private discoverRegion?: string;
|
||||
private originalLanguage?: string;
|
||||
@@ -342,13 +341,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
}
|
||||
);
|
||||
|
||||
data.episodes = data.episodes.map((episode) => {
|
||||
if (episode.still_path) {
|
||||
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
|
||||
}
|
||||
return episode;
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||
@@ -1062,7 +1054,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
keywordId,
|
||||
}: {
|
||||
keywordId: number;
|
||||
}): Promise<TmdbKeyword | null> {
|
||||
}): Promise<TmdbKeyword> {
|
||||
try {
|
||||
const data = await this.get<TmdbKeyword>(
|
||||
`/keyword/${keywordId}`,
|
||||
@@ -1072,9 +1064,6 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`[TMDB] Failed to fetch keyword: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
|
||||
show_id: number;
|
||||
still_path: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
vote_cuont: number;
|
||||
}
|
||||
|
||||
export interface TmdbTvSeasonResult {
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
import ExternalAPI from '@server/api/externalapi';
|
||||
import type { TvShowProvider } from '@server/api/provider';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import type {
|
||||
TmdbSeasonWithEpisodes,
|
||||
TmdbTvDetails,
|
||||
TmdbTvEpisodeResult,
|
||||
TmdbTvSeasonResult,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import {
|
||||
convertTmdbLanguageToTvdbWithFallback,
|
||||
type TvdbBaseResponse,
|
||||
type TvdbEpisode,
|
||||
type TvdbLoginResponse,
|
||||
type TvdbSeasonDetails,
|
||||
type TvdbTvDetails,
|
||||
} from '@server/api/tvdb/interfaces';
|
||||
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface TvdbConfig {
|
||||
baseUrl: string;
|
||||
maxRequestsPerSecond: number;
|
||||
maxRequests: number;
|
||||
cachePrefix: AvailableCacheIds;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: TvdbConfig = {
|
||||
baseUrl: 'https://api4.thetvdb.com/v4',
|
||||
maxRequestsPerSecond: 50,
|
||||
maxRequests: 20,
|
||||
cachePrefix: 'tvdb' as const,
|
||||
};
|
||||
|
||||
const enum TvdbIdStatus {
|
||||
INVALID = -1,
|
||||
}
|
||||
|
||||
type TvdbId = number;
|
||||
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
|
||||
|
||||
class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||
static instance: Tvdb;
|
||||
private readonly tmdb: TheMovieDb;
|
||||
private static readonly DEFAULT_CACHE_TTL = 43200;
|
||||
private static readonly DEFAULT_LANGUAGE = 'eng';
|
||||
private token: string;
|
||||
private pin?: string;
|
||||
|
||||
constructor(pin?: string) {
|
||||
const finalConfig = { ...DEFAULT_CONFIG };
|
||||
super(
|
||||
finalConfig.baseUrl,
|
||||
{},
|
||||
{
|
||||
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
|
||||
rateLimit: {
|
||||
maxRequests: finalConfig.maxRequests,
|
||||
maxRPS: finalConfig.maxRequestsPerSecond,
|
||||
},
|
||||
}
|
||||
);
|
||||
this.pin = pin;
|
||||
this.tmdb = new TheMovieDb();
|
||||
}
|
||||
|
||||
public static async getInstance(): Promise<Tvdb> {
|
||||
if (!this.instance) {
|
||||
this.instance = new Tvdb();
|
||||
await this.instance.login();
|
||||
}
|
||||
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<void> {
|
||||
try {
|
||||
if (!this.token) {
|
||||
await this.login();
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Url = this.token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payload = JSON.parse(Buffer.from(base64, 'base64').toString());
|
||||
|
||||
if (!payload.exp) {
|
||||
await this.login();
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = payload.exp - now;
|
||||
|
||||
// refresh token 1 week before expiration
|
||||
if (diff < 604800) {
|
||||
await this.login();
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to refresh token', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async test(): Promise<void> {
|
||||
try {
|
||||
await this.login();
|
||||
} catch (error) {
|
||||
this.handleError('Login failed', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(): Promise<TvdbLoginResponse> {
|
||||
let body: { apiKey: string; pin?: string } = {
|
||||
apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405',
|
||||
};
|
||||
|
||||
if (this.pin) {
|
||||
body = {
|
||||
...body,
|
||||
pin: this.pin,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await this.post<TvdbBaseResponse<TvdbLoginResponse>>(
|
||||
'/login',
|
||||
{
|
||||
...body,
|
||||
}
|
||||
);
|
||||
|
||||
this.token = response.data.token;
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getShowByTvdbId({
|
||||
tvdbId,
|
||||
language,
|
||||
}: {
|
||||
tvdbId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: tvdbId,
|
||||
language,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (this.isValidTvdbId(validTvdbId)) {
|
||||
return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId);
|
||||
}
|
||||
|
||||
return tmdbTvShow;
|
||||
} catch (error) {
|
||||
return tmdbTvShow;
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvShow({
|
||||
tvId,
|
||||
language,
|
||||
}: {
|
||||
tvId: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (this.isValidTvdbId(tvdbId)) {
|
||||
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
|
||||
}
|
||||
|
||||
return tmdbTvShow;
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
return tmdbTvShow;
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV show details', error);
|
||||
return this.tmdb.getTvShow({ tvId, language });
|
||||
}
|
||||
}
|
||||
|
||||
public async getTvSeason({
|
||||
tvId,
|
||||
seasonNumber,
|
||||
language = Tvdb.DEFAULT_LANGUAGE,
|
||||
}: {
|
||||
tvId: number;
|
||||
seasonNumber: number;
|
||||
language?: string;
|
||||
}): Promise<TmdbSeasonWithEpisodes> {
|
||||
try {
|
||||
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||
|
||||
if (!this.isValidTvdbId(tvdbId)) {
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
}
|
||||
|
||||
return await this.getTvdbSeasonData(
|
||||
tvdbId,
|
||||
seasonNumber,
|
||||
tvId,
|
||||
language
|
||||
);
|
||||
} catch (error) {
|
||||
this.handleError('Failed to fetch TV season details', error);
|
||||
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[TVDB] Failed to fetch TV season details: ${error.message}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichTmdbShowWithTvdbData(
|
||||
tmdbTvShow: TmdbTvDetails,
|
||||
tvdbId: ValidTvdbId
|
||||
): Promise<TmdbTvDetails> {
|
||||
try {
|
||||
await this.refreshToken();
|
||||
|
||||
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||
const seasons = this.processSeasons(tvdbData);
|
||||
|
||||
if (!seasons.length) {
|
||||
return tmdbTvShow;
|
||||
}
|
||||
|
||||
return { ...tmdbTvShow, seasons };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}`
|
||||
);
|
||||
return tmdbTvShow;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbTvDetails>>(
|
||||
`/series/${tvdbId}/extended?meta=episodes&short=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
},
|
||||
Tvdb.DEFAULT_CACHE_TTL
|
||||
);
|
||||
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
|
||||
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seasons = tvdbData.seasons
|
||||
.filter((season) => season.type && season.type.type === 'official')
|
||||
.sort((a, b) => a.number - b.number)
|
||||
.map((season) => this.createSeasonData(season, tvdbData))
|
||||
.filter(
|
||||
(season) => season && season.season_number >= 0
|
||||
) as TmdbTvSeasonResult[];
|
||||
|
||||
return seasons;
|
||||
}
|
||||
|
||||
private createSeasonData(
|
||||
season: TvdbSeasonDetails,
|
||||
tvdbData: TvdbTvDetails
|
||||
): TmdbTvSeasonResult {
|
||||
const seasonNumber = season.number ?? -1;
|
||||
if (seasonNumber < 0) {
|
||||
return {
|
||||
id: 0,
|
||||
episode_count: 0,
|
||||
name: '',
|
||||
overview: '',
|
||||
season_number: -1,
|
||||
poster_path: '',
|
||||
air_date: '',
|
||||
};
|
||||
}
|
||||
|
||||
const episodeCount = tvdbData.episodes.filter(
|
||||
(episode) => episode.seasonNumber === season.number
|
||||
).length;
|
||||
|
||||
return {
|
||||
id: tvdbData.id,
|
||||
episode_count: episodeCount,
|
||||
name: `${season.number}`,
|
||||
overview: '',
|
||||
season_number: season.number,
|
||||
poster_path: '',
|
||||
air_date: '',
|
||||
};
|
||||
}
|
||||
|
||||
private async getTvdbSeasonData(
|
||||
tvdbId: number,
|
||||
seasonNumber: number,
|
||||
tvId: number,
|
||||
language: string = Tvdb.DEFAULT_LANGUAGE
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||
|
||||
if (!tvdbData) {
|
||||
logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
// get season id
|
||||
const season = tvdbData.seasons.find(
|
||||
(season) =>
|
||||
season.number === seasonNumber &&
|
||||
season.type.type &&
|
||||
season.type.type === 'official'
|
||||
);
|
||||
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
|
||||
language,
|
||||
Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
// check if translation is available for the season
|
||||
const availableTranslation = season.nameTranslations.filter(
|
||||
(translation) =>
|
||||
translation === wantedTranslation ||
|
||||
translation === Tvdb.DEFAULT_LANGUAGE
|
||||
);
|
||||
|
||||
if (!availableTranslation) {
|
||||
return this.getSeasonWithOriginalLanguage(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season
|
||||
);
|
||||
}
|
||||
|
||||
return this.getSeasonWithTranslation(
|
||||
tvdbId,
|
||||
tvId,
|
||||
seasonNumber,
|
||||
season,
|
||||
wantedTranslation
|
||||
);
|
||||
}
|
||||
|
||||
private async getSeasonWithTranslation(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails,
|
||||
language: string
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const allEpisodes = [] as TvdbEpisode[];
|
||||
let page = 0;
|
||||
// Limit to max 50 pages to avoid infinite loops.
|
||||
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
|
||||
const maxPages = 50;
|
||||
|
||||
while (page < maxPages) {
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/series/${tvdbId}/episodes/default/${language}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
params: {
|
||||
page: page,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp?.data?.episodes) {
|
||||
logger.warn(
|
||||
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
const { episodes } = resp.data;
|
||||
|
||||
if (!episodes) {
|
||||
logger.debug(
|
||||
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
allEpisodes.push(...episodes);
|
||||
|
||||
const hasNextPage = resp.links?.next && episodes.length > 0;
|
||||
|
||||
if (!hasNextPage) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
logger.warn(
|
||||
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
|
||||
);
|
||||
}
|
||||
|
||||
const episodes = this.processEpisodes(
|
||||
{ ...season, episodes: allEpisodes },
|
||||
seasonNumber,
|
||||
tvId
|
||||
);
|
||||
|
||||
return {
|
||||
episodes,
|
||||
external_ids: { tvdb_id: tvdbId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: season.id,
|
||||
air_date: season.firstAired,
|
||||
season_number: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async getSeasonWithOriginalLanguage(
|
||||
tvdbId: number,
|
||||
tvId: number,
|
||||
seasonNumber: number,
|
||||
season: TvdbSeasonDetails
|
||||
): Promise<TmdbSeasonWithEpisodes> {
|
||||
if (!season) {
|
||||
logger.error(
|
||||
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||
);
|
||||
return this.createEmptySeasonResponse(tvId);
|
||||
}
|
||||
|
||||
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||
`/seasons/${season.id}/extended`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const seasons = resp.data;
|
||||
|
||||
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
|
||||
|
||||
return {
|
||||
episodes,
|
||||
external_ids: { tvdb_id: tvdbId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: seasons.id,
|
||||
air_date: seasons.firstAired,
|
||||
season_number: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
private processEpisodes(
|
||||
tvdbSeason: TvdbSeasonDetails,
|
||||
seasonNumber: number,
|
||||
tvId: number
|
||||
): TmdbTvEpisodeResult[] {
|
||||
if (!tvdbSeason || !tvdbSeason.episodes) {
|
||||
logger.error('No episodes found in TVDB season data');
|
||||
return [];
|
||||
}
|
||||
|
||||
return tvdbSeason.episodes
|
||||
.filter((episode) => episode.seasonNumber === seasonNumber)
|
||||
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
|
||||
}
|
||||
|
||||
private createEpisodeData(
|
||||
episode: TvdbEpisode,
|
||||
index: number,
|
||||
tvId: number
|
||||
): TmdbTvEpisodeResult {
|
||||
return {
|
||||
id: episode.id,
|
||||
air_date: episode.aired,
|
||||
episode_number: episode.number,
|
||||
name: episode.name || `Episode ${index + 1}`,
|
||||
overview: episode.overview || '',
|
||||
season_number: episode.seasonNumber,
|
||||
production_code: '',
|
||||
show_id: tvId,
|
||||
still_path:
|
||||
episode.image && !episode.image.startsWith('https://')
|
||||
? 'https://artworks.thetvdb.com' + episode.image
|
||||
: '',
|
||||
vote_average: 1,
|
||||
vote_count: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
|
||||
return {
|
||||
episodes: [],
|
||||
external_ids: { tvdb_id: tvId },
|
||||
name: '',
|
||||
overview: '',
|
||||
id: 0,
|
||||
air_date: '',
|
||||
season_number: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
|
||||
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
|
||||
}
|
||||
|
||||
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
|
||||
return tvdbId !== TvdbIdStatus.INVALID;
|
||||
}
|
||||
|
||||
private handleError(context: string, error: Error): void {
|
||||
throw new Error(`[TVDB] ${context}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default Tvdb;
|
||||
@@ -1,216 +0,0 @@
|
||||
import { type AvailableLocale } from '@server/types/languages';
|
||||
|
||||
export interface TvdbBaseResponse<T> {
|
||||
data: T;
|
||||
errors: string;
|
||||
links?: TvdbPagination;
|
||||
}
|
||||
|
||||
export interface TvdbPagination {
|
||||
prev?: string;
|
||||
self: string;
|
||||
next?: string;
|
||||
totalItems: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TvdbLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface TvDetailsAliases {
|
||||
language: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TvDetailsStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
recordType: string;
|
||||
keepUpdated: boolean;
|
||||
}
|
||||
|
||||
export interface TvdbTvDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
nameTranslations: string[];
|
||||
overwiewTranslations: string[];
|
||||
aliases: TvDetailsAliases[];
|
||||
firstAired: Date;
|
||||
lastAired: Date;
|
||||
nextAired: Date | string;
|
||||
score: number;
|
||||
status: TvDetailsStatus;
|
||||
originalCountry: string;
|
||||
originalLanguage: string;
|
||||
defaultSeasonType: string;
|
||||
isOrderRandomized: boolean;
|
||||
lastUpdated: Date;
|
||||
averageRuntime: number;
|
||||
seasons: TvdbSeasonDetails[];
|
||||
episodes: TvdbEpisode[];
|
||||
}
|
||||
|
||||
interface TvdbCompanyType {
|
||||
companyTypeId: number;
|
||||
companyTypeName: string;
|
||||
}
|
||||
|
||||
interface TvdbParentCompany {
|
||||
id?: number;
|
||||
name?: string;
|
||||
relation?: {
|
||||
id?: number;
|
||||
typeName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TvdbCompany {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
nameTranslations?: string[];
|
||||
overviewTranslations?: string[];
|
||||
aliases?: string[];
|
||||
country: string;
|
||||
primaryCompanyType: number;
|
||||
activeDate: string;
|
||||
inactiveDate?: string;
|
||||
companyType: TvdbCompanyType;
|
||||
parentCompany: TvdbParentCompany;
|
||||
tagOptions?: string[];
|
||||
}
|
||||
|
||||
interface TvdbType {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
alternateName?: string;
|
||||
}
|
||||
|
||||
interface TvdbArtwork {
|
||||
id: number;
|
||||
image: string;
|
||||
thumbnail: string;
|
||||
language: string;
|
||||
type: number;
|
||||
score: number;
|
||||
width: number;
|
||||
height: number;
|
||||
includesText: boolean;
|
||||
}
|
||||
|
||||
export interface TvdbEpisode {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
name: string;
|
||||
aired: string;
|
||||
runtime: number;
|
||||
nameTranslations: string[];
|
||||
overview?: string;
|
||||
overviewTranslations: string[];
|
||||
image: string;
|
||||
imageType: number;
|
||||
isMovie: number;
|
||||
seasons?: string[];
|
||||
number: number;
|
||||
absoluteNumber: number;
|
||||
seasonNumber: number;
|
||||
lastUpdated: string;
|
||||
finaleType?: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface TvdbSeasonDetails {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
type: TvdbType;
|
||||
number: number;
|
||||
nameTranslations: string[];
|
||||
overviewTranslations: string[];
|
||||
image: string;
|
||||
imageType: number;
|
||||
companies: {
|
||||
studio: TvdbCompany[];
|
||||
network: TvdbCompany[];
|
||||
production: TvdbCompany[];
|
||||
distributor: TvdbCompany[];
|
||||
special_effects: TvdbCompany[];
|
||||
};
|
||||
lastUpdated: string;
|
||||
year: string;
|
||||
episodes: TvdbEpisode[];
|
||||
trailers: string[];
|
||||
artwork: TvdbArtwork[];
|
||||
tagOptions?: string[];
|
||||
firstAired: string;
|
||||
}
|
||||
|
||||
export interface TvdbEpisodeTranslation {
|
||||
name: string;
|
||||
overview: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
|
||||
[key in AvailableLocale]: string;
|
||||
} = {
|
||||
ar: 'ara', // Arabic
|
||||
bg: 'bul', // Bulgarian
|
||||
ca: 'cat', // Catalan
|
||||
cs: 'ces', // Czech
|
||||
da: 'dan', // Danish
|
||||
de: 'deu', // German
|
||||
el: 'ell', // Greek
|
||||
en: 'eng', // English
|
||||
es: 'spa', // Spanish
|
||||
fi: 'fin', // Finnish
|
||||
fr: 'fra', // French
|
||||
he: 'heb', // Hebrew
|
||||
hi: 'hin', // Hindi
|
||||
hr: 'hrv', // Croatian
|
||||
hu: 'hun', // Hungarian
|
||||
it: 'ita', // Italian
|
||||
ja: 'jpn', // Japanese
|
||||
ko: 'kor', // Korean
|
||||
lt: 'lit', // Lithuanian
|
||||
nl: 'nld', // Dutch
|
||||
pl: 'pol', // Polish
|
||||
ro: 'ron', // Romanian
|
||||
ru: 'rus', // Russian
|
||||
sq: 'sqi', // Albanian
|
||||
sr: 'srp', // Serbian
|
||||
sv: 'swe', // Swedish
|
||||
tr: 'tur', // Turkish
|
||||
uk: 'ukr', // Ukrainian
|
||||
|
||||
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
|
||||
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
|
||||
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
|
||||
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
|
||||
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
|
||||
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
|
||||
};
|
||||
|
||||
export function convertTMDBToTVDB(tmdbCode: string): string | null {
|
||||
const normalizedCode = tmdbCode.toLowerCase();
|
||||
|
||||
return (
|
||||
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
|
||||
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function convertTmdbLanguageToTvdbWithFallback(
|
||||
tmdbCode: string,
|
||||
fallback: string
|
||||
): string {
|
||||
// First try exact match
|
||||
const tvdbCode = convertTMDBToTVDB(tmdbCode);
|
||||
if (tvdbCode) return tvdbCode;
|
||||
|
||||
return tvdbCode || fallback || 'eng'; // Default to English if no match found
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity('linked_accounts')
|
||||
export class LinkedAccount {
|
||||
constructor(options: Omit<LinkedAccount, 'id'>) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.linkedAccounts, { onDelete: 'CASCADE' })
|
||||
user: User;
|
||||
|
||||
/** Slug of the OIDC provider. */
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
provider: string;
|
||||
|
||||
/** Unique ID from the OAuth provider */
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
sub: string;
|
||||
|
||||
/** Account username from the OAuth provider */
|
||||
@Column()
|
||||
username: string;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MediaRequestStatus, MediaType } from '@server/constants/media';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { LinkedAccount } from '@server/entity/LinkedAccount';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
@@ -92,9 +91,6 @@ export class User {
|
||||
@Column({ type: 'varchar', nullable: true, select: false })
|
||||
public plexToken?: string | null;
|
||||
|
||||
@OneToMany(() => LinkedAccount, (link) => link.user)
|
||||
public linkedAccounts: LinkedAccount[];
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
public permissions = 0;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import notificationManager from '@server/lib/notifications';
|
||||
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||
@@ -25,10 +26,8 @@ import imageproxy from '@server/routes/imageproxy';
|
||||
import { appDataPermissions } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import createCustomProxyAgent from '@server/utils/customProxyAgent';
|
||||
import { initializeDnsCache } from '@server/utils/dnsCache';
|
||||
import restartFlag from '@server/utils/restartFlag';
|
||||
import { getClientIp } from '@supercharge/request-ip';
|
||||
import axios from 'axios';
|
||||
import { TypeormStore } from 'connect-typeorm/out';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
@@ -36,8 +35,6 @@ import express from 'express';
|
||||
import * as OpenApiValidator from 'express-openapi-validator';
|
||||
import type { Store } from 'express-session';
|
||||
import session from 'express-session';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import next from 'next';
|
||||
import path from 'path';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
@@ -76,19 +73,6 @@ app
|
||||
const settings = await getSettings().load();
|
||||
restartFlag.initializeSettings(settings);
|
||||
|
||||
if (settings.network.forceIpv4First) {
|
||||
axios.defaults.httpAgent = new http.Agent({ family: 4 });
|
||||
axios.defaults.httpsAgent = new https.Agent({ family: 4 });
|
||||
}
|
||||
|
||||
// Add DNS caching
|
||||
if (settings.network.dnsCache) {
|
||||
initializeDnsCache({
|
||||
forceMinTtl: settings.network.dnsCache.forceMinTtl,
|
||||
forceMaxTtl: settings.network.dnsCache.forceMaxTtl,
|
||||
});
|
||||
}
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
@@ -121,6 +105,7 @@ app
|
||||
new EmailAgent(),
|
||||
new GotifyAgent(),
|
||||
new NtfyAgent(),
|
||||
new LunaSeaAgent(),
|
||||
new PushbulletAgent(),
|
||||
new PushoverAgent(),
|
||||
new SlackAgent(),
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
type Mandatory<T, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect discovery document.
|
||||
*
|
||||
* @public
|
||||
* @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
*/
|
||||
export interface OidcProviderMetadata {
|
||||
issuer: string; // REQUIRED
|
||||
|
||||
authorization_endpoint: string; // REQUIRED
|
||||
|
||||
token_endpoint: string; // REQUIRED
|
||||
|
||||
token_endpoint_auth_methods_supported?: string[]; // OPTIONAL
|
||||
|
||||
token_endpoint_auth_signing_alg_values_supported?: string[]; // OPTIONAL
|
||||
|
||||
userinfo_endpoint: string; // RECOMMENDED
|
||||
|
||||
check_session_iframe: string; // REQUIRED
|
||||
|
||||
end_session_endpoint: string; // REQUIRED
|
||||
|
||||
jwks_uri: string; // REQUIRED
|
||||
|
||||
registration_endpoint: string; // RECOMMENDED
|
||||
|
||||
scopes_supported: string[]; // RECOMMENDED
|
||||
|
||||
response_types_supported: string[]; // REQUIRED
|
||||
|
||||
acr_values_supported?: string[]; // OPTIONAL
|
||||
|
||||
subject_types_supported: string[]; // REQUIRED
|
||||
|
||||
request_object_signing_alg_values_supported?: string[]; // OPTIONAL
|
||||
|
||||
display_values_supported?: string[]; // OPTIONAL
|
||||
|
||||
claim_types_supported?: string[]; // OPTIONAL
|
||||
|
||||
claims_supported: string[]; // RECOMMENDED
|
||||
|
||||
claims_parameter_supported?: boolean; // OPTIONAL
|
||||
|
||||
service_documentation?: string; // OPTIONAL
|
||||
|
||||
ui_locales_supported?: string[]; // OPTIONAL
|
||||
|
||||
revocation_endpoint: string; // REQUIRED
|
||||
|
||||
introspection_endpoint: string; // REQUIRED
|
||||
|
||||
frontchannel_logout_supported?: boolean; // OPTIONAL
|
||||
|
||||
frontchannel_logout_session_supported?: boolean; // OPTIONAL
|
||||
|
||||
backchannel_logout_supported?: boolean; // OPTIONAL
|
||||
|
||||
backchannel_logout_session_supported?: boolean; // OPTIONAL
|
||||
|
||||
grant_types_supported?: string[]; // OPTIONAL
|
||||
|
||||
response_modes_supported?: string[]; // OPTIONAL
|
||||
|
||||
code_challenge_methods_supported?: string[]; // OPTIONAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect address claim.
|
||||
* The Address Claim represents a physical mailing address.
|
||||
*
|
||||
* @public
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim
|
||||
*/
|
||||
export interface OidcAddressClaim {
|
||||
/** Full mailing address, formatted for display or use on a mailing label. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\\r\\n") or as a single line feed character ("\\n"). */
|
||||
formatted?: string;
|
||||
/** Full street address component, which MAY include house number, street name, Post Office Box, and multi-line extended street address information. This field MAY contain multiple lines, separated by newlines. Newlines can be represented either as a carriage return/line feed pair ("\\r\\n") or as a single line feed character ("\\n"). */
|
||||
street_address?: string;
|
||||
/** City or locality component. */
|
||||
locality?: string;
|
||||
/** State, province, prefecture, or region component. */
|
||||
region?: string;
|
||||
/** Zip code or postal code component. */
|
||||
postal_code?: string;
|
||||
/** Country name component. */
|
||||
country?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard OpenID Connect claims.
|
||||
* They can be requested to be returned either in the UserInfo Response or in the ID Token.
|
||||
*
|
||||
* @public
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
*/
|
||||
export interface OidcStandardClaims {
|
||||
/** Subject - Identifier for the End-User at the Issuer. */
|
||||
sub?: string;
|
||||
/** End-User's full name in displayable form including all name parts, possibly including titles and suffixes, ordered according to the End-User's locale and preferences. */
|
||||
name?: string;
|
||||
/** Given name(s) or first name(s) of the End-User. Note that in some cultures, people can have multiple given names; all can be present, with the names being separated by space characters. */
|
||||
given_name?: string;
|
||||
/** Surname(s) or last name(s) of the End-User. Note that in some cultures, people can have multiple family names or no family name; all can be present, with the names being separated by space characters. */
|
||||
family_name?: string;
|
||||
/** Middle name(s) of the End-User. Note that in some cultures, people can have multiple middle names; all can be present, with the names being separated by space characters. Also note that in some cultures, middle names are not used. */
|
||||
middle_name?: string;
|
||||
/** Casual name of the End-User that may or may not be the same as the given_name. For instance, a nickname value of Mike might be returned alongside a given_name value of Michael. */
|
||||
nickname?: string;
|
||||
/** Shorthand name by which the End-User wishes to be referred to at the RP, such as janedoe or j.doe. This value MAY be any valid JSON string including special characters such as \@, /, or whitespace. */
|
||||
preferred_username?: string;
|
||||
/** URL of the End-User's profile page. The contents of this Web page SHOULD be about the End-User. */
|
||||
profile?: string;
|
||||
/** URL of the End-User's profile picture. This URL MUST refer to an image file (for example, a PNG, JPEG, or GIF image file), rather than to a Web page containing an image. Note that this URL SHOULD specifically reference a profile photo of the End-User suitable for displaying when describing the End-User, rather than an arbitrary photo taken by the End-User. */
|
||||
picture?: string;
|
||||
/** URL of the End-User's Web page or blog. This Web page SHOULD contain information published by the End-User or an organization that the End-User is affiliated with. */
|
||||
website?: string;
|
||||
/** End-User's preferred e-mail address. Its value MUST conform to the RFC 5322 addr-spec syntax. */
|
||||
email?: string;
|
||||
/** True if the End-User's e-mail address has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this e-mail address was controlled by the End-User at the time the verification was performed. The means by which an e-mail address is verified is context-specific, and dependent upon the trust framework or contractual agreements within which the parties are operating. */
|
||||
email_verified?: boolean;
|
||||
/** End-User's gender. Values defined by this specification are female and male. Other values MAY be used when neither of the defined values are applicable. */
|
||||
gender?: string;
|
||||
/** End-User's birthday, represented as an ISO 8601:2004 [ISO8601‑2004] YYYY-MM-DD format. The year MAY be 0000, indicating that it is omitted. To represent only the year, YYYY format is allowed. Note that depending on the underlying platform's date related function, providing just year can result in varying month and day, so the implementers need to take this factor into account to correctly process the dates. */
|
||||
birthdate?: string;
|
||||
/** String from zoneinfo [zoneinfo] time zone database representing the End-User's time zone. For example, Europe/Paris or America/Los_Angeles. */
|
||||
zoneinfo?: string;
|
||||
/** End-User's locale, represented as a BCP47 [RFC5646] language tag. This is typically an ISO 639-1 Alpha-2 [ISO639‑1] language code in lowercase and an ISO 3166-1 Alpha-2 [ISO3166‑1] country code in uppercase, separated by a dash. For example, en-US or fr-CA. As a compatibility note, some implementations have used an underscore as the separator rather than a dash, for example, en_US; */
|
||||
locale?: string;
|
||||
/** End-User's preferred telephone number. E.164 [E.164] is RECOMMENDED as the format of this Claim, for example, +1 (425) 555-1212 or +56 (2) 687 2400. If the phone number contains an extension, it is RECOMMENDED that the extension be represented using the RFC 3966 [RFC3966] extension syntax, for example, +1 (604) 555-1234;ext=5678. */
|
||||
phone_number?: string;
|
||||
/** True if the End-User's phone number has been verified; otherwise false. When this Claim Value is true, this means that the OP took affirmative steps to ensure that this phone number was controlled by the End-User at the time the verification was performed. The means by which a phone number is verified is context-specific, and dependent upon the trust framework or contractual agreements within which the parties are operating. When true, the phone_number Claim MUST be in E.164 format and any extensions MUST be represented in RFC 3966 format. */
|
||||
phone_number_verified?: boolean;
|
||||
/** End-User's preferred postal address. The value of the address member is a JSON [RFC4627] structure containing some or all of the members defined in Section 5.1.1. */
|
||||
address?: OidcAddressClaim;
|
||||
/** Time the End-User's information was last updated. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. */
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard JWT claims.
|
||||
*
|
||||
* @public
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
|
||||
*/
|
||||
export interface JwtClaims {
|
||||
[claim: string]: unknown;
|
||||
|
||||
/** The "iss" (issuer) claim identifies the principal that issued the JWT. The processing of this claim is generally application specific. The "iss" value is a case-sensitive string containing a StringOrURI value. */
|
||||
iss?: string;
|
||||
/** The "sub" (subject) claim identifies the principal that is the subject of the JWT. The claims in a JWT are normally statements about the subject. The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique. The processing of this claim is generally application specific. The "sub" value is a case-sensitive string containing a StringOrURI value. */
|
||||
sub?: string;
|
||||
/** The "aud" (audience) claim identifies the recipients that the JWT is intended for. Each principal intended to process the JWT MUST identify itself with a value in the audience claim. If the principal processing the claim does not identify itself with a value in the "aud" claim when this claim is present, then the JWT MUST be rejected. In the general case, the "aud" value is an array of case-sensitive strings, each containing a StringOrURI value. In the special case when the JWT has one audience, the "aud" value MAY be a single case-sensitive string containing a StringOrURI value. The interpretation of audience values is generally application specific. */
|
||||
aud?: string | string[];
|
||||
/** The "exp" (expiration time) claim identifies the expiration time on or after which the JWT MUST NOT be accepted for processing. The processing of the "exp" claim requires that the current date/time MUST be before the expiration date/time listed in the "exp" claim. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value MUST be a number containing a NumericDate value. */
|
||||
exp?: number;
|
||||
/** The "nbf" (not before) claim identifies the time before which the JWT MUST NOT be accepted for processing. The processing of the "nbf" claim requires that the current date/time MUST be after or equal to the not-before date/time listed in the "nbf" claim. Implementers MAY provide for some small leeway, usually no more than a few minutes, to account for clock skew. Its value MUST be a number containing a NumericDate value. */
|
||||
nbf?: number;
|
||||
/** The "iat" (issued at) claim identifies the time at which the JWT was issued. This claim can be used to determine the age of the JWT. Its value MUST be a number containing a NumericDate value. */
|
||||
iat?: number;
|
||||
/** The "jti" (JWT ID) claim provides a unique identifier for the JWT. The identifier value MUST be assigned in a manner that ensures that there is a negligible probability that the same value will be accidentally assigned to a different data object; if the application uses multiple issuers, collisions MUST be prevented among values produced by different issuers as well. The "jti" claim can be used to prevent the JWT from being replayed. The "jti" value is a case-sensitive string. */
|
||||
jti?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard ID Token claims.
|
||||
*
|
||||
* @public
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
*/
|
||||
export interface IdTokenClaims
|
||||
extends Mandatory<OidcStandardClaims, 'sub'>,
|
||||
Mandatory<JwtClaims, 'iss' | 'sub' | 'aud' | 'exp' | 'iat'> {
|
||||
[claim: string]: unknown;
|
||||
|
||||
/** Time when the End-User authentication occurred. Its value is a JSON number representing the number of seconds from 1970-01-01T0:0:0Z as measured in UTC until the date/time. When a max_age request is made or when auth_time is requested as an Essential Claim, then this Claim is REQUIRED; otherwise, its inclusion is OPTIONAL. (The auth_time Claim semantically corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] auth_time response parameter.) */
|
||||
auth_time?: number;
|
||||
/** String value used to associate a Client session with an ID Token, and to mitigate replay attacks. The value is passed through unmodified from the Authentication Request to the ID Token. If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the value of the nonce parameter sent in the Authentication Request. If present in the Authentication Request, Authorization Servers MUST include a nonce Claim in the ID Token with the Claim Value being the nonce value sent in the Authentication Request. Authorization Servers SHOULD perform no other processing on nonce values used. The nonce value is a case sensitive string. */
|
||||
nonce?: string;
|
||||
/** Authentication Context Class Reference. String specifying an Authentication Context Class Reference value that identifies the Authentication Context Class that the authentication performed satisfied. The value "0" indicates the End-User authentication did not meet the requirements of ISO/IEC 29115 [ISO29115] level 1. Authentication using a long-lived browser cookie, for instance, is one example where the use of "level 0" is appropriate. Authentications with level 0 SHOULD NOT be used to authorize access to any resource of any monetary value. (This corresponds to the OpenID 2.0 PAPE [OpenID.PAPE] nist_auth_level 0.) An absolute URI or an RFC 6711 [RFC6711] registered name SHOULD be used as the acr value; registered names MUST NOT be used with a different meaning than that which is registered. Parties using this claim will need to agree upon the meanings of the values used, which may be context-specific. The acr value is a case sensitive string. */
|
||||
acr?: string;
|
||||
/** Authentication Methods References. JSON array of strings that are identifiers for authentication methods used in the authentication. For instance, values might indicate that both password and OTP authentication methods were used. The definition of particular values to be used in the amr Claim is beyond the scope of this specification. Parties using this claim will need to agree upon the meanings of the values used, which may be context-specific. The amr value is an array of case sensitive strings. */
|
||||
amr?: unknown;
|
||||
/** Authorized party - the party to which the ID Token was issued. If present, it MUST contain the OAuth 2.0 Client ID of this party. This Claim is only needed when the ID Token has a single audience value and that audience is different than the authorized party. It MAY be included even when the authorized party is the same as the sole audience. The azp value is a case sensitive string containing a StringOrURI value. */
|
||||
azp?: string;
|
||||
/**
|
||||
* Session ID - String identifier for a Session. This represents a Session of a User Agent or device for a logged-in End-User at an RP. Different sid values are used to identify distinct sessions at an OP. The sid value need only be unique in the context of a particular issuer. Its contents are opaque to the RP. Its syntax is the same as an OAuth 2.0 Client Identifier.
|
||||
* @see https://openid.net/specs/openid-connect-frontchannel-1_0.html#OPLogout
|
||||
* */
|
||||
sid?: string;
|
||||
}
|
||||
|
||||
type OidcTokenSuccessResponse = {
|
||||
/**
|
||||
* REQUIRED. ID Token value associated with the authenticated session.
|
||||
*
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
*/
|
||||
id_token: string;
|
||||
/**
|
||||
* REQUIRED. The access token issued by the authorization server.
|
||||
*/
|
||||
access_token: string;
|
||||
/**
|
||||
* REQUIRED. The type of the token issued as described in
|
||||
* Section 7.1. Value is case insensitive.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-7.1
|
||||
*/
|
||||
token_type: string;
|
||||
/**
|
||||
* RECOMMENDED. The lifetime in seconds of the access token. For
|
||||
* example, the value "3600" denotes that the access token will
|
||||
* expire in one hour from the time the response was generated.
|
||||
* If omitted, the authorization server SHOULD provide the
|
||||
* expiration time via other means or document the default value.
|
||||
*/
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
type OidcTokenErrorResponse = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard response from the OpenID Connect token request endpoint.
|
||||
*
|
||||
* @public
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse
|
||||
*/
|
||||
export type OidcTokenResponse =
|
||||
| OidcTokenSuccessResponse
|
||||
| OidcTokenErrorResponse;
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { PublicOidcProvider } from '@server/lib/settings';
|
||||
import type { DnsEntries, DnsStats } from 'dns-caching';
|
||||
import type { PaginatedResponse } from './common';
|
||||
|
||||
export type LogMessage = {
|
||||
@@ -49,7 +47,6 @@ export interface PublicSettingsResponse {
|
||||
emailEnabled: boolean;
|
||||
newPlexLogin: boolean;
|
||||
youtubeUrl: string;
|
||||
openIdProviders: PublicOidcProvider[];
|
||||
}
|
||||
|
||||
export interface CacheItem {
|
||||
@@ -67,10 +64,6 @@ export interface CacheItem {
|
||||
export interface CacheResponse {
|
||||
apiCaches: CacheItem[];
|
||||
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
|
||||
dnsCache: {
|
||||
stats: DnsStats | undefined;
|
||||
entries: DnsEntries | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
NotificationAgentKey,
|
||||
PublicOidcProvider,
|
||||
} from '@server/lib/settings';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
@@ -42,11 +39,3 @@ export interface UserSettingsNotificationsResponse {
|
||||
webPushEnabled?: boolean;
|
||||
notificationTypes: Partial<NotificationAgentTypes>;
|
||||
}
|
||||
|
||||
export type UserSettingsLinkedAccount = {
|
||||
id: number;
|
||||
username: string;
|
||||
provider: PublicOidcProvider;
|
||||
};
|
||||
|
||||
export type UserSettingsLinkedAccountResponse = UserSettingsLinkedAccount[];
|
||||
|
||||
@@ -72,7 +72,6 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||
|
||||
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||
const invalidKeywords = new Set<string>();
|
||||
|
||||
if (blacklistedTags.length === 0) {
|
||||
return;
|
||||
@@ -88,19 +87,6 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
|
||||
// Iterate for each tag
|
||||
for (const tag of blacklistedTagsArr) {
|
||||
const keywordDetails = await tmdb.getKeywordDetails({
|
||||
keywordId: Number(tag),
|
||||
});
|
||||
|
||||
if (keywordDetails === null) {
|
||||
logger.warn('Skipping invalid keyword in blacklisted tags', {
|
||||
label: 'Blacklisted Tags Processor',
|
||||
keywordId: tag,
|
||||
});
|
||||
invalidKeywords.add(tag);
|
||||
continue;
|
||||
}
|
||||
|
||||
let queryMax = pageLimit * SortOptionsIterable.length;
|
||||
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
||||
|
||||
@@ -116,51 +102,24 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
throw new AbortTransaction();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDiscover({
|
||||
page,
|
||||
sortBy,
|
||||
keywords: tag,
|
||||
});
|
||||
const response = await getDiscover({
|
||||
page,
|
||||
sortBy,
|
||||
keywords: tag,
|
||||
});
|
||||
await this.processResults(response, tag, type, em);
|
||||
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||
|
||||
await this.processResults(response, tag, type, em);
|
||||
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||
|
||||
this.progress++;
|
||||
if (page === 1 && response.total_pages <= queryMax) {
|
||||
// We will finish the tag with less queries than expected, move progress accordingly
|
||||
this.progress += queryMax - response.total_pages;
|
||||
fixedSortMode = true;
|
||||
queryMax = response.total_pages;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing keyword in blacklisted tags', {
|
||||
label: 'Blacklisted Tags Processor',
|
||||
keywordId: tag,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
this.progress++;
|
||||
if (page === 1 && response.total_pages <= queryMax) {
|
||||
// We will finish the tag with less queries than expected, move progress accordingly
|
||||
this.progress += queryMax - response.total_pages;
|
||||
fixedSortMode = true;
|
||||
queryMax = response.total_pages;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidKeywords.size > 0) {
|
||||
const currentTags = blacklistedTagsArr.filter(
|
||||
(tag) => !invalidKeywords.has(tag)
|
||||
);
|
||||
const cleanedTags = currentTags.join(',');
|
||||
|
||||
if (cleanedTags !== blacklistedTags) {
|
||||
settings.main.blacklistedTags = cleanedTags;
|
||||
await settings.save();
|
||||
|
||||
logger.info('Cleaned up invalid keywords from settings', {
|
||||
label: 'Blacklisted Tags Processor',
|
||||
removedKeywords: Array.from(invalidKeywords),
|
||||
newBlacklistedTags: cleanedTags,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async processResults(
|
||||
|
||||
@@ -9,8 +9,7 @@ export type AvailableCacheIds =
|
||||
| 'github'
|
||||
| 'plexguid'
|
||||
| 'plextv'
|
||||
| 'plexwatchlist'
|
||||
| 'tvdb';
|
||||
| 'plexwatchlist';
|
||||
|
||||
const DEFAULT_TTL = 300;
|
||||
const DEFAULT_CHECK_PERIOD = 120;
|
||||
@@ -71,10 +70,6 @@ class CacheManager {
|
||||
checkPeriod: 60,
|
||||
}),
|
||||
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
||||
tvdb: new Cache('tvdb', 'The TVDB API', {
|
||||
stdTtl: 21600,
|
||||
checkPeriod: 60 * 30,
|
||||
}),
|
||||
};
|
||||
|
||||
public getCache(id: AvailableCacheIds): Cache {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import logger from '@server/logger';
|
||||
import { requestInterceptorFunction } from '@server/utils/customProxyAgent';
|
||||
import axios from 'axios';
|
||||
import rateLimit, { type rateLimitOptions } from 'axios-rate-limit';
|
||||
import { createHash } from 'crypto';
|
||||
@@ -151,7 +150,6 @@ class ImageProxy {
|
||||
baseURL: baseUrl,
|
||||
headers: options.headers,
|
||||
});
|
||||
this.axios.interceptors.request.use(requestInterceptorFunction);
|
||||
|
||||
if (options.rateLimitOptions) {
|
||||
this.axios = rateLimit(this.axios, options.rateLimitOptions);
|
||||
|
||||
@@ -109,9 +109,7 @@ class DiscordAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): DiscordRichEmbed {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.discord;
|
||||
const { applicationUrl } = getSettings().main;
|
||||
|
||||
const appUrl =
|
||||
applicationUrl || `http://localhost:${process.env.port || 5055}`;
|
||||
@@ -225,11 +223,9 @@ class DiscordAgent
|
||||
}
|
||||
: undefined,
|
||||
fields,
|
||||
thumbnail: embedPoster
|
||||
? {
|
||||
url: payload.image,
|
||||
}
|
||||
: undefined,
|
||||
thumbnail: {
|
||||
url: payload.image,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,7 @@ class EmailAgent
|
||||
recipientEmail: string,
|
||||
recipientName?: string
|
||||
): EmailOptions | undefined {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.email;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
if (type === Notification.TEST_NOTIFICATION) {
|
||||
return {
|
||||
@@ -131,7 +129,7 @@ class EmailAgent
|
||||
body,
|
||||
mediaName: payload.subject,
|
||||
mediaExtra: payload.extra ?? [],
|
||||
imageUrl: embedPoster ? payload.image : undefined,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
requestedBy: payload.request.requestedBy.displayName,
|
||||
actionUrl: applicationUrl
|
||||
@@ -178,7 +176,7 @@ class EmailAgent
|
||||
issueComment: payload.comment?.message,
|
||||
mediaName: payload.subject,
|
||||
extra: payload.extra ?? [],
|
||||
imageUrl: embedPoster ? payload.image : undefined,
|
||||
imageUrl: payload.image,
|
||||
timestamp: new Date().toTimeString(),
|
||||
actionUrl: applicationUrl
|
||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||
|
||||
@@ -35,7 +35,7 @@ class GotifyAgent
|
||||
settings.enabled &&
|
||||
settings.options.url &&
|
||||
settings.options.token &&
|
||||
settings.options.priority !== undefined
|
||||
settings.options.priority
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
133
server/lib/notifications/agents/lunasea.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { IssueStatus, IssueType } from '@server/constants/issue';
|
||||
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';
|
||||
|
||||
class LunaSeaAgent
|
||||
extends BaseAgent<NotificationAgentLunaSea>
|
||||
implements NotificationAgent
|
||||
{
|
||||
protected getSettings(): NotificationAgentLunaSea {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.lunasea;
|
||||
}
|
||||
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
return {
|
||||
notification_type: Notification[type],
|
||||
event: payload.event,
|
||||
subject: payload.subject,
|
||||
message: payload.message,
|
||||
image: payload.image ?? null,
|
||||
email: payload.notifyUser?.email,
|
||||
username: payload.notifyUser?.displayName,
|
||||
avatar: payload.notifyUser?.avatar,
|
||||
media: payload.media
|
||||
? {
|
||||
media_type: payload.media.mediaType,
|
||||
tmdbId: payload.media.tmdbId,
|
||||
tvdbId: payload.media.tvdbId,
|
||||
status: MediaStatus[payload.media.status],
|
||||
status4k: MediaStatus[payload.media.status4k],
|
||||
}
|
||||
: null,
|
||||
extra: payload.extra ?? [],
|
||||
request: payload.request
|
||||
? {
|
||||
request_id: payload.request.id,
|
||||
requestedBy_email: payload.request.requestedBy.email,
|
||||
requestedBy_username: payload.request.requestedBy.displayName,
|
||||
requestedBy_avatar: payload.request.requestedBy.avatar,
|
||||
}
|
||||
: null,
|
||||
issue: payload.issue
|
||||
? {
|
||||
issue_id: payload.issue.id,
|
||||
issue_type: IssueType[payload.issue.issueType],
|
||||
issue_status: IssueStatus[payload.issue.status],
|
||||
createdBy_email: payload.issue.createdBy.email,
|
||||
createdBy_username: payload.issue.createdBy.displayName,
|
||||
createdBy_avatar: payload.issue.createdBy.avatar,
|
||||
}
|
||||
: null,
|
||||
comment: payload.comment
|
||||
? {
|
||||
comment_message: payload.comment.message,
|
||||
commentedBy_email: payload.comment.user.email,
|
||||
commentedBy_username: payload.comment.user.displayName,
|
||||
commentedBy_avatar: payload.comment.user.avatar,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (settings.enabled && settings.options.webhookUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
!payload.notifySystem ||
|
||||
!hasNotificationType(type, settings.types ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug('Sending LunaSea notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
settings.options.webhookUrl,
|
||||
this.buildPayload(type, payload),
|
||||
settings.options.profileName
|
||||
? {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${settings.options.profileName}:`
|
||||
).toString('base64')}`,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending LunaSea notification', {
|
||||
label: 'Notifications',
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
response: e?.response?.data,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LunaSeaAgent;
|
||||
@@ -22,9 +22,7 @@ class NtfyAgent
|
||||
}
|
||||
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.ntfy;
|
||||
const { applicationUrl } = getSettings().main;
|
||||
|
||||
const topic = this.getSettings().options.topic;
|
||||
const priority = 3;
|
||||
@@ -74,7 +72,7 @@ class NtfyAgent
|
||||
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||
}
|
||||
|
||||
const attach = embedPoster ? payload.image : undefined;
|
||||
const attach = payload.image;
|
||||
|
||||
let click;
|
||||
if (applicationUrl && payload.media) {
|
||||
|
||||
@@ -78,9 +78,7 @@ class PushoverAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<Partial<PushoverPayload>> {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.pushover;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const title = payload.event ?? payload.subject;
|
||||
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||
@@ -157,7 +155,7 @@ class PushoverAgent
|
||||
|
||||
let attachment_base64;
|
||||
let attachment_type;
|
||||
if (embedPoster && payload.image) {
|
||||
if (payload.image) {
|
||||
const imagePayload = await this.getImagePayload(payload.image);
|
||||
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
||||
attachment_base64 = imagePayload.attachment_base64;
|
||||
|
||||
@@ -63,9 +63,7 @@ class SlackAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): SlackBlockEmbed {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.slack;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
const fields: EmbedField[] = [];
|
||||
|
||||
@@ -161,14 +159,13 @@ class SlackAgent
|
||||
type: 'mrkdwn',
|
||||
text: payload.message,
|
||||
},
|
||||
accessory:
|
||||
embedPoster && payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
accessory: payload.image
|
||||
? {
|
||||
type: 'image',
|
||||
image_url: payload.image,
|
||||
alt_text: payload.subject,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -65,9 +65,7 @@ class TelegramAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||
const settings = getSettings();
|
||||
const { applicationUrl, applicationTitle } = settings.main;
|
||||
const { embedPoster } = settings.notifications.agents.telegram;
|
||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
let message = `\*${this.escapeText(
|
||||
@@ -144,7 +142,7 @@ class TelegramAgent
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
return embedPoster && payload.image
|
||||
return payload.image
|
||||
? {
|
||||
photo: payload.image,
|
||||
caption: message,
|
||||
@@ -162,7 +160,7 @@ class TelegramAgent
|
||||
): Promise<boolean> {
|
||||
const settings = this.getSettings();
|
||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||
settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
||||
}`;
|
||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||
|
||||
|
||||
@@ -42,8 +42,6 @@ class WebPushAgent
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): PushNotificationPayload {
|
||||
const { embedPoster } = getSettings().notifications.agents.webpush;
|
||||
|
||||
const mediaType = payload.media
|
||||
? payload.media.mediaType === MediaType.MOVIE
|
||||
? 'movie'
|
||||
@@ -130,7 +128,7 @@ class WebPushAgent
|
||||
notificationType: Notification[type],
|
||||
subject: payload.subject,
|
||||
message,
|
||||
image: embedPoster ? payload.image : undefined,
|
||||
image: payload.image,
|
||||
requestId: payload.request?.id,
|
||||
actionUrl,
|
||||
actionUrlTitle,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
@@ -48,7 +43,6 @@ class JellyfinScanner {
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
@@ -198,42 +192,6 @@ class JellyfinScanner {
|
||||
}
|
||||
}
|
||||
|
||||
private async getTvShow({
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
}: {
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
let tvShow;
|
||||
|
||||
if (tmdbId) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
} else if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(tvdbId),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No ID provided');
|
||||
}
|
||||
|
||||
const metadataProvider = tvShow.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||
tvShow = await metadataProvider.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
}
|
||||
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
@@ -254,8 +212,8 @@ class JellyfinScanner {
|
||||
|
||||
if (metadata.ProviderIds.Tmdb) {
|
||||
try {
|
||||
tvShow = await this.getTvShow({
|
||||
tmdbId: Number(metadata.ProviderIds.Tmdb),
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(metadata.ProviderIds.Tmdb),
|
||||
});
|
||||
} catch {
|
||||
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
||||
@@ -265,7 +223,7 @@ class JellyfinScanner {
|
||||
}
|
||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||
try {
|
||||
tvShow = await this.getTvShow({
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||
import PlexAPI from '@server/api/plexapi';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import cacheManager from '@server/lib/cache';
|
||||
@@ -255,42 +249,6 @@ class PlexScanner
|
||||
});
|
||||
}
|
||||
|
||||
private async getTvShow({
|
||||
tmdbId,
|
||||
tvdbId,
|
||||
}: {
|
||||
tmdbId?: number;
|
||||
tvdbId?: number;
|
||||
}): Promise<TmdbTvDetails> {
|
||||
let tvShow;
|
||||
|
||||
if (tmdbId) {
|
||||
tvShow = await this.tmdb.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
} else if (tvdbId) {
|
||||
tvShow = await this.tmdb.getShowByTvdbId({
|
||||
tvdbId: Number(tvdbId),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No ID provided');
|
||||
}
|
||||
|
||||
const metadataProvider = tvShow.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
|
||||
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||
tvShow = await metadataProvider.getTvShow({
|
||||
tvId: Number(tmdbId),
|
||||
});
|
||||
}
|
||||
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processPlexShow(plexitem: PlexLibraryItem) {
|
||||
const ratingKey =
|
||||
plexitem.grandparentRatingKey ??
|
||||
@@ -315,9 +273,7 @@ class PlexScanner
|
||||
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
||||
}
|
||||
|
||||
const tvShow = await this.getTvShow({
|
||||
tmdbId: mediaIds.tmdbId,
|
||||
});
|
||||
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
|
||||
|
||||
const seasons = tvShow.seasons;
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
@@ -49,25 +49,6 @@ export interface JellyfinSettings {
|
||||
serverId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export type OidcProvider = {
|
||||
slug: string;
|
||||
name: string;
|
||||
issuerUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
logo?: string;
|
||||
requiredClaims?: string;
|
||||
scopes?: string;
|
||||
newUserLogin?: boolean;
|
||||
};
|
||||
|
||||
export type PublicOidcProvider = Pick<OidcProvider, 'slug' | 'name' | 'logo'>;
|
||||
|
||||
export interface OidcSettings {
|
||||
providers: OidcProvider[];
|
||||
}
|
||||
|
||||
export interface TautulliSettings {
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
@@ -119,16 +100,6 @@ interface Quota {
|
||||
quotaDays?: number;
|
||||
}
|
||||
|
||||
export enum MetadataProviderType {
|
||||
TMDB = 'tmdb',
|
||||
TVDB = 'tvdb',
|
||||
}
|
||||
|
||||
export interface MetadataSettings {
|
||||
tv: MetadataProviderType;
|
||||
anime: MetadataProviderType;
|
||||
}
|
||||
|
||||
export interface ProxySettings {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
@@ -154,7 +125,6 @@ export interface MainSettings {
|
||||
hideBlacklisted: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
oidcLogin: boolean;
|
||||
newPlexLogin: boolean;
|
||||
discoverRegion: string;
|
||||
streamingRegion: string;
|
||||
@@ -168,29 +138,10 @@ export interface MainSettings {
|
||||
youtubeUrl: string;
|
||||
}
|
||||
|
||||
export interface ProxySettings {
|
||||
enabled: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
useSsl: boolean;
|
||||
user: string;
|
||||
password: string;
|
||||
bypassFilter: string;
|
||||
bypassLocalAddresses: boolean;
|
||||
}
|
||||
|
||||
export interface DnsCacheSettings {
|
||||
enabled: boolean;
|
||||
forceMinTtl?: number;
|
||||
forceMaxTtl?: number;
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
csrfProtection: boolean;
|
||||
forceIpv4First: boolean;
|
||||
trustProxy: boolean;
|
||||
proxy: ProxySettings;
|
||||
dnsCache: DnsCacheSettings;
|
||||
}
|
||||
|
||||
interface PublicSettings {
|
||||
@@ -223,12 +174,10 @@ interface FullPublicSettings extends PublicSettings {
|
||||
userEmailRequired: boolean;
|
||||
newPlexLogin: boolean;
|
||||
youtubeUrl: string;
|
||||
openIdProviders: PublicOidcProvider[];
|
||||
}
|
||||
|
||||
export interface NotificationAgentConfig {
|
||||
enabled: boolean;
|
||||
embedPoster: boolean;
|
||||
types?: number;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
@@ -266,6 +215,13 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentLunaSea extends NotificationAgentConfig {
|
||||
options: {
|
||||
webhookUrl: string;
|
||||
profileName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NotificationAgentTelegram extends NotificationAgentConfig {
|
||||
options: {
|
||||
botUsername?: string;
|
||||
@@ -337,6 +293,7 @@ interface NotificationAgents {
|
||||
email: NotificationAgentEmail;
|
||||
gotify: NotificationAgentGotify;
|
||||
ntfy: NotificationAgentNtfy;
|
||||
lunasea: NotificationAgentLunaSea;
|
||||
pushbullet: NotificationAgentPushbullet;
|
||||
pushover: NotificationAgentPushover;
|
||||
slack: NotificationAgentSlack;
|
||||
@@ -375,7 +332,6 @@ export interface AllSettings {
|
||||
main: MainSettings;
|
||||
plex: PlexSettings;
|
||||
jellyfin: JellyfinSettings;
|
||||
oidc: OidcSettings;
|
||||
tautulli: TautulliSettings;
|
||||
radarr: RadarrSettings[];
|
||||
sonarr: SonarrSettings[];
|
||||
@@ -383,7 +339,6 @@ export interface AllSettings {
|
||||
notifications: NotificationSettings;
|
||||
jobs: Record<JobId, JobSettings>;
|
||||
network: NetworkSettings;
|
||||
metadataSettings: MetadataSettings;
|
||||
}
|
||||
|
||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||
@@ -412,7 +367,6 @@ class Settings {
|
||||
hideBlacklisted: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
oidcLogin: false,
|
||||
newPlexLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
@@ -444,14 +398,7 @@ class Settings {
|
||||
serverId: '',
|
||||
apiKey: '',
|
||||
},
|
||||
oidc: {
|
||||
providers: [],
|
||||
},
|
||||
tautulli: {},
|
||||
metadataSettings: {
|
||||
tv: MetadataProviderType.TMDB,
|
||||
anime: MetadataProviderType.TMDB,
|
||||
},
|
||||
radarr: [],
|
||||
sonarr: [],
|
||||
public: {
|
||||
@@ -461,7 +408,6 @@ class Settings {
|
||||
agents: {
|
||||
email: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
options: {
|
||||
userEmailRequired: false,
|
||||
emailFrom: '',
|
||||
@@ -476,7 +422,6 @@ class Settings {
|
||||
},
|
||||
discord: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -484,9 +429,15 @@ class Settings {
|
||||
enableMentions: true,
|
||||
},
|
||||
},
|
||||
lunasea: {
|
||||
enabled: false,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -494,7 +445,6 @@ class Settings {
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
botAPI: '',
|
||||
@@ -505,7 +455,6 @@ class Settings {
|
||||
},
|
||||
pushbullet: {
|
||||
enabled: false,
|
||||
embedPoster: false,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
@@ -513,7 +462,6 @@ class Settings {
|
||||
},
|
||||
pushover: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
accessToken: '',
|
||||
@@ -523,7 +471,6 @@ class Settings {
|
||||
},
|
||||
webhook: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
webhookUrl: '',
|
||||
@@ -533,12 +480,10 @@ class Settings {
|
||||
},
|
||||
webpush: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
options: {},
|
||||
},
|
||||
gotify: {
|
||||
enabled: false,
|
||||
embedPoster: false,
|
||||
types: 0,
|
||||
options: {
|
||||
url: '',
|
||||
@@ -548,7 +493,6 @@ class Settings {
|
||||
},
|
||||
ntfy: {
|
||||
enabled: false,
|
||||
embedPoster: true,
|
||||
types: 0,
|
||||
options: {
|
||||
url: '',
|
||||
@@ -600,7 +544,6 @@ class Settings {
|
||||
},
|
||||
network: {
|
||||
csrfProtection: false,
|
||||
forceIpv4First: false,
|
||||
trustProxy: false,
|
||||
proxy: {
|
||||
enabled: false,
|
||||
@@ -612,11 +555,6 @@ class Settings {
|
||||
bypassFilter: '',
|
||||
bypassLocalAddresses: true,
|
||||
},
|
||||
dnsCache: {
|
||||
enabled: false,
|
||||
forceMinTtl: 0,
|
||||
forceMaxTtl: -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (initialSettings) {
|
||||
@@ -648,14 +586,6 @@ class Settings {
|
||||
this.data.jellyfin = data;
|
||||
}
|
||||
|
||||
get oidc(): OidcSettings {
|
||||
return this.data.oidc;
|
||||
}
|
||||
|
||||
set oidc(data: OidcSettings) {
|
||||
this.data.oidc = data;
|
||||
}
|
||||
|
||||
get tautulli(): TautulliSettings {
|
||||
return this.data.tautulli;
|
||||
}
|
||||
@@ -664,14 +594,6 @@ class Settings {
|
||||
this.data.tautulli = data;
|
||||
}
|
||||
|
||||
get metadataSettings(): MetadataSettings {
|
||||
return this.data.metadataSettings;
|
||||
}
|
||||
|
||||
set metadataSettings(data: MetadataSettings) {
|
||||
this.data.metadataSettings = data;
|
||||
}
|
||||
|
||||
get radarr(): RadarrSettings[] {
|
||||
return this.data.radarr;
|
||||
}
|
||||
@@ -728,13 +650,6 @@ class Settings {
|
||||
this.data.notifications.agents.email.options.userEmailRequired,
|
||||
newPlexLogin: this.data.main.newPlexLogin,
|
||||
youtubeUrl: this.data.main.youtubeUrl,
|
||||
openIdProviders: this.data.main.oidcLogin
|
||||
? this.data.oidc.providers.map((p) => ({
|
||||
slug: p.slug,
|
||||
name: p.name,
|
||||
logo: p.logo,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { AllSettings } from '@server/lib/settings';
|
||||
|
||||
const removeLunaSeaSetting = (settings: any): AllSettings => {
|
||||
if (
|
||||
settings.notifications &&
|
||||
settings.notifications.agents &&
|
||||
settings.notifications.agents.lunasea
|
||||
) {
|
||||
delete settings.notifications.agents.lunasea;
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
export default removeLunaSeaSetting;
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLinkedAccount1742858617989 implements MigrationInterface {
|
||||
name = 'AddLinkedAccount1742858617989';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "linked_accounts" ("id" SERIAL NOT NULL, "provider" character varying(255) NOT NULL, "sub" character varying(255) NOT NULL, "username" character varying NOT NULL, "userId" integer, CONSTRAINT "PK_445bf7a50aeeb7f0084052935a6" PRIMARY KEY ("id"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "linked_accounts" ADD CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "linked_accounts" DROP CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "linked_accounts"`);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLinkedAccounts1742858484395 implements MigrationInterface {
|
||||
name = 'AddLinkedAccounts1742858484395';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "linked_accounts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "provider" varchar(255) NOT NULL, "sub" varchar(255) NOT NULL, "username" varchar NOT NULL, "userId" integer, CONSTRAINT "FK_2c77d2a0c06eeab6e62dc35af64" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "linked_accounts"`);
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||
seasonNumber: episode.season_number,
|
||||
showId: episode.show_id,
|
||||
voteAverage: episode.vote_average,
|
||||
voteCount: episode.vote_count,
|
||||
voteCount: episode.vote_cuont,
|
||||
stillPath: episode.still_path,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { LinkedAccount } from '@server/entity/LinkedAccount';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { IdTokenClaims } from '@server/interfaces/api/oidcInterfaces';
|
||||
import { startJobs } from '@server/job/schedule';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
@@ -16,21 +14,9 @@ import { checkAvatarChanged } from '@server/routes/avatarproxy';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import {
|
||||
createIdTokenSchema,
|
||||
fetchOpenIdTokenData,
|
||||
getOpenIdConfiguration,
|
||||
getOpenIdRedirectUrl,
|
||||
getOpenIdUserInfo,
|
||||
validateUserClaims,
|
||||
type FullUserInfo,
|
||||
} from '@server/utils/oidc';
|
||||
import axios from 'axios';
|
||||
import { randomBytes } from 'crypto';
|
||||
import * as EmailValidator from 'email-validator';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import net from 'net';
|
||||
|
||||
const authRoutes = Router();
|
||||
@@ -735,287 +721,6 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/oidc/login/:slug', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const provider = settings.oidc.providers.find(
|
||||
(p) => p.slug === req.params.slug
|
||||
);
|
||||
|
||||
if (!settings.main.oidcLogin || !provider) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'OpenID Connect sign-in is disabled.',
|
||||
});
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString('hex');
|
||||
|
||||
let redirectUrl;
|
||||
try {
|
||||
redirectUrl = await getOpenIdRedirectUrl(req, provider, state);
|
||||
} catch (err) {
|
||||
logger.info('Failed OpenID Connect login attempt', {
|
||||
cause: 'Failed to fetch OpenID Connect redirect url',
|
||||
ip: req.ip,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Configuration error.',
|
||||
});
|
||||
}
|
||||
|
||||
res.cookie('oidc-state', state, {
|
||||
maxAge: 60000,
|
||||
httpOnly: true,
|
||||
secure: req.protocol === 'https',
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
redirectUrl,
|
||||
});
|
||||
});
|
||||
|
||||
authRoutes.get('/oidc/callback/:slug', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const provider = settings.oidc.providers.find(
|
||||
(p) => p.slug === req.params.slug
|
||||
);
|
||||
|
||||
if (!settings.main.oidcLogin || !provider) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'OpenID Connect sign-in is disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const requiredClaims = (provider.requiredClaims ?? '')
|
||||
.split(' ')
|
||||
.filter((s) => !!s);
|
||||
|
||||
const cookieState = req.cookies['oidc-state'];
|
||||
const url = new URL(req.url, `${req.protocol}://${req.hostname}`);
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
try {
|
||||
// Check that the request belongs to the correct state
|
||||
if (state && cookieState === state) {
|
||||
res.clearCookie('oidc-state');
|
||||
} else {
|
||||
logger.info('Failed OpenID Connect login attempt', {
|
||||
cause: 'Invalid state',
|
||||
ip: req.ip,
|
||||
state: state,
|
||||
cookieState: cookieState,
|
||||
});
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Authorization failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Check that a code has been issued
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
logger.info('Failed OpenID Connect login attempt', {
|
||||
cause: 'Invalid code',
|
||||
ip: req.ip,
|
||||
code: code,
|
||||
});
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Authorization failed',
|
||||
});
|
||||
}
|
||||
|
||||
const wellKnownInfo = await getOpenIdConfiguration(provider.issuerUrl);
|
||||
|
||||
// Fetch the token data
|
||||
const body = await fetchOpenIdTokenData(req, provider, wellKnownInfo, code);
|
||||
|
||||
// Validate that the token response is valid and not manipulated
|
||||
if ('error' in body) {
|
||||
logger.info('Failed OIDC login attempt', {
|
||||
cause: 'Invalid token response',
|
||||
ip: req.ip,
|
||||
body: body,
|
||||
});
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Authorization failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the ID token and access token
|
||||
const { id_token: idToken, access_token: accessToken } = body;
|
||||
|
||||
// Attempt to decode ID token jwt
|
||||
let decoded: IdTokenClaims;
|
||||
try {
|
||||
decoded = jwtDecode(idToken);
|
||||
} catch (err) {
|
||||
logger.info('Failed OIDC login attempt', {
|
||||
cause: 'Invalid jwt',
|
||||
ip: req.ip,
|
||||
idToken: idToken,
|
||||
err,
|
||||
});
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Authorization failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Merge claims from JWT with data from userinfo endpoint
|
||||
const userInfo = await getOpenIdUserInfo(wellKnownInfo, accessToken);
|
||||
const fullUserInfo: FullUserInfo = { ...decoded, ...userInfo };
|
||||
|
||||
// Validate ID token jwt and user info
|
||||
try {
|
||||
const idTokenSchema = createIdTokenSchema({
|
||||
oidcClientId: provider.clientId,
|
||||
oidcDomain: provider.issuerUrl,
|
||||
requiredClaims,
|
||||
});
|
||||
await idTokenSchema.validate(fullUserInfo);
|
||||
} catch (err) {
|
||||
logger.info('Failed OIDC login attempt', {
|
||||
cause: 'Invalid jwt or missing claims',
|
||||
ip: req.ip,
|
||||
idToken: idToken,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Authorization failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that user meets required claims
|
||||
try {
|
||||
validateUserClaims(fullUserInfo, requiredClaims);
|
||||
} catch (error) {
|
||||
logger.info('Failed OIDC login attempt', {
|
||||
cause: 'Failed to validate required claims',
|
||||
error,
|
||||
ip: req.ip,
|
||||
requiredClaims: provider.requiredClaims,
|
||||
});
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Insufficient permissions',
|
||||
});
|
||||
}
|
||||
|
||||
// Map identifier to linked account
|
||||
const userRepository = getRepository(User);
|
||||
const linkedAccountsRepository = getRepository(LinkedAccount);
|
||||
|
||||
const linkedAccount = await linkedAccountsRepository.findOne({
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
provider: provider.slug,
|
||||
sub: fullUserInfo.sub,
|
||||
},
|
||||
});
|
||||
let user = linkedAccount?.user;
|
||||
|
||||
// If there is already a user logged in, and no linked account, link the account.
|
||||
if (req.user != null && linkedAccount == null) {
|
||||
const linkedAccount = new LinkedAccount({
|
||||
user: req.user,
|
||||
provider: provider.slug,
|
||||
sub: fullUserInfo.sub,
|
||||
username: fullUserInfo.preferred_username ?? req.user.displayName,
|
||||
});
|
||||
|
||||
await linkedAccountsRepository.save(linkedAccount);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ status: 'ok', to: '/profile/settings/linked-accounts' });
|
||||
}
|
||||
|
||||
// Create user if one doesn't already exist
|
||||
if (!user && fullUserInfo.email != null && provider.newUserLogin) {
|
||||
// Check if a user with this email already exists
|
||||
const existingUser = await userRepository.findOne({
|
||||
where: { email: fullUserInfo.email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
// If a user with the email exists, throw a 409 Conflict error
|
||||
return next({
|
||||
status: 409,
|
||||
message: 'A user with this email address already exists.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Creating user for ${fullUserInfo.email}`, {
|
||||
ip: req.ip,
|
||||
email: fullUserInfo.email,
|
||||
});
|
||||
|
||||
const avatar =
|
||||
fullUserInfo.picture ??
|
||||
gravatarUrl(fullUserInfo.email, { default: 'mm', size: 200 });
|
||||
user = new User({
|
||||
avatar: avatar,
|
||||
username: fullUserInfo.preferred_username,
|
||||
email: fullUserInfo.email,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
plexToken: '',
|
||||
userType: UserType.LOCAL,
|
||||
});
|
||||
await userRepository.save(user);
|
||||
|
||||
const linkedAccount = new LinkedAccount({
|
||||
user,
|
||||
provider: provider.slug,
|
||||
sub: fullUserInfo.sub,
|
||||
username: fullUserInfo.preferred_username ?? fullUserInfo.email,
|
||||
});
|
||||
await linkedAccountsRepository.save(linkedAccount);
|
||||
|
||||
user.linkedAccounts = [linkedAccount];
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
logger.debug('Failed OIDC sign-up attempt', {
|
||||
cause: provider.newUserLogin
|
||||
? 'User did not have an account, and was missing an associated email address.'
|
||||
: 'User did not have an account, and new user login was disabled.',
|
||||
});
|
||||
return next({
|
||||
status: 400,
|
||||
message: provider.newUserLogin
|
||||
? 'Unable to create new user account (missing email address)'
|
||||
: 'Unable to create new user account (new user login is disabled)',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session and return
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
// Success!
|
||||
return res.status(200).json({ status: 'ok', to: '/' });
|
||||
} catch (error) {
|
||||
logger.error('Failed OIDC login attempt', {
|
||||
cause: 'Unknown error',
|
||||
ip: req.ip,
|
||||
error,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'An unknown error occurred',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/logout', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.session?.userId;
|
||||
|
||||
@@ -128,15 +128,11 @@ discoverRoutes.get('/movies', async (req, res, next) => {
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
const keywordResults = await Promise.all(
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
|
||||
keywordData = keywordResults.filter(
|
||||
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
@@ -419,15 +415,11 @@ discoverRoutes.get('/tv', async (req, res, next) => {
|
||||
if (keywords) {
|
||||
const splitKeywords = keywords.split(',');
|
||||
|
||||
const keywordResults = await Promise.all(
|
||||
keywordData = await Promise.all(
|
||||
splitKeywords.map(async (keywordId) => {
|
||||
return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) });
|
||||
})
|
||||
);
|
||||
|
||||
keywordData = keywordResults.filter(
|
||||
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
|
||||
@@ -4,40 +4,27 @@ import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Delay the initialization of ImageProxy instances until the proxy (if any) is properly configured
|
||||
let _tmdbImageProxy: ImageProxy;
|
||||
function initTmdbImageProxy() {
|
||||
if (!_tmdbImageProxy) {
|
||||
_tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
}
|
||||
return _tmdbImageProxy;
|
||||
}
|
||||
let _tvdbImageProxy: ImageProxy;
|
||||
function initTvdbImageProxy() {
|
||||
if (!_tvdbImageProxy) {
|
||||
_tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
}
|
||||
return _tvdbImageProxy;
|
||||
}
|
||||
const tmdbImageProxy = new ImageProxy('tmdb', 'https://image.tmdb.org', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
const tvdbImageProxy = new ImageProxy('tvdb', 'https://artworks.thetvdb.com', {
|
||||
rateLimitOptions: {
|
||||
maxRequests: 20,
|
||||
maxRPS: 50,
|
||||
},
|
||||
});
|
||||
|
||||
router.get('/:type/*', async (req, res) => {
|
||||
const imagePath = req.path.replace(/^\/\w+/, '');
|
||||
try {
|
||||
let imageData;
|
||||
if (req.params.type === 'tmdb') {
|
||||
imageData = await initTmdbImageProxy().getImage(imagePath);
|
||||
imageData = await tmdbImageProxy.getImage(imagePath);
|
||||
} else if (req.params.type === 'tvdb') {
|
||||
imageData = await initTvdbImageProxy().getImage(imagePath);
|
||||
imageData = await tvdbImageProxy.getImage(imagePath);
|
||||
} else {
|
||||
logger.error('Unsupported image type', {
|
||||
imagePath,
|
||||
|
||||
@@ -54,7 +54,6 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||
.leftJoinAndSelect('issue.media', 'media')
|
||||
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
||||
.leftJoinAndSelect('issue.comments', 'comments')
|
||||
.where('issue.status IN (:...issueStatus)', {
|
||||
issueStatus: statusFilter,
|
||||
});
|
||||
|
||||
@@ -197,10 +197,8 @@ mediaRoutes.delete(
|
||||
const media = await mediaRepository.findOneOrFail({
|
||||
where: { id: Number(req.params.id) },
|
||||
});
|
||||
|
||||
const is4k = req.query.is4k === 'true';
|
||||
const is4k = media.serviceUrl4k !== undefined;
|
||||
const isMovie = media.mediaType === MediaType.MOVIE;
|
||||
|
||||
let serviceSettings;
|
||||
if (isMovie) {
|
||||
serviceSettings = settings.radarr.find(
|
||||
@@ -227,7 +225,6 @@ mediaRoutes.delete(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serviceSettings) {
|
||||
logger.warn(
|
||||
`There is no default ${
|
||||
@@ -242,7 +239,6 @@ mediaRoutes.delete(
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let service;
|
||||
if (isMovie) {
|
||||
service = new RadarrAPI({
|
||||
|
||||
@@ -28,9 +28,7 @@ import discoverSettingRoutes from '@server/routes/settings/discover';
|
||||
import { ApiError } from '@server/types/error';
|
||||
import { appDataPath } from '@server/utils/appDataVolume';
|
||||
import { getAppVersion } from '@server/utils/appVersion';
|
||||
import { dnsCache } from '@server/utils/dnsCache';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import type { DnsEntries, DnsStats } from 'dns-caching';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import fs from 'fs';
|
||||
@@ -39,7 +37,6 @@ import { rescheduleJob } from 'node-schedule';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
import { URL } from 'url';
|
||||
import metadataRoutes from './metadata';
|
||||
import notificationRoutes from './notifications';
|
||||
import radarrRoutes from './radarr';
|
||||
import sonarrRoutes from './sonarr';
|
||||
@@ -50,7 +47,6 @@ settingsRoutes.use('/notifications', notificationRoutes);
|
||||
settingsRoutes.use('/radarr', radarrRoutes);
|
||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||
settingsRoutes.use('/metadatas', metadataRoutes);
|
||||
|
||||
const filteredMainSettings = (
|
||||
user: User,
|
||||
@@ -109,45 +105,6 @@ settingsRoutes.post('/main/regenerate', async (req, res, next) => {
|
||||
return res.status(200).json(filteredMainSettings(req.user, main));
|
||||
});
|
||||
|
||||
settingsRoutes.get('/oidc', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
return res.status(200).json(settings.oidc);
|
||||
});
|
||||
|
||||
settingsRoutes.put('/oidc/:slug', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
let provider = settings.oidc.providers.findIndex(
|
||||
(p) => p.slug === req.params.slug
|
||||
);
|
||||
|
||||
if (provider !== -1) {
|
||||
Object.assign(settings.oidc.providers[provider], req.body);
|
||||
} else {
|
||||
settings.oidc.providers.push({ slug: req.params.slug, ...req.body });
|
||||
provider = settings.oidc.providers.length - 1;
|
||||
}
|
||||
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.oidc.providers[provider]);
|
||||
});
|
||||
|
||||
settingsRoutes.delete('/oidc/:slug', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const provider = settings.oidc.providers.findIndex(
|
||||
(p) => p.slug === req.params.slug
|
||||
);
|
||||
|
||||
if (provider === -1)
|
||||
return res.status(404).json({ message: 'Provider not found' });
|
||||
|
||||
settings.oidc.providers.splice(provider, 1);
|
||||
await settings.save();
|
||||
|
||||
return res.status(200).json(settings.oidc);
|
||||
});
|
||||
|
||||
settingsRoutes.get('/plex', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
@@ -798,19 +755,12 @@ settingsRoutes.get('/cache', async (_req, res) => {
|
||||
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
|
||||
const avatarImageCache = await ImageProxy.getImageStats('avatar');
|
||||
|
||||
const stats: DnsStats | undefined = dnsCache?.getStats();
|
||||
const entries: DnsEntries | undefined = dnsCache?.getCacheEntries();
|
||||
|
||||
return res.status(200).json({
|
||||
apiCaches,
|
||||
imageCache: {
|
||||
tmdb: tmdbImageCache,
|
||||
avatar: avatarImageCache,
|
||||
},
|
||||
dnsCache: {
|
||||
stats,
|
||||
entries,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -828,20 +778,6 @@ settingsRoutes.post<{ cacheId: AvailableCacheIds }>(
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post<{ dnsEntry: string }>(
|
||||
'/cache/dns/:dnsEntry/flush',
|
||||
(req, res, next) => {
|
||||
const dnsEntry = req.params.dnsEntry;
|
||||
|
||||
if (dnsCache) {
|
||||
dnsCache.clear(dnsEntry);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
next({ status: 404, message: 'Cache not found.' });
|
||||
}
|
||||
);
|
||||
|
||||
settingsRoutes.post(
|
||||
'/initialize',
|
||||
isAuthenticated(Permission.ADMIN),
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import Tvdb from '@server/api/tvdb';
|
||||
import {
|
||||
getSettings,
|
||||
MetadataProviderType,
|
||||
type MetadataSettings,
|
||||
} from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import { Router } from 'express';
|
||||
|
||||
function getTestResultString(testValue: number): string {
|
||||
if (testValue === -1) return 'not tested';
|
||||
if (testValue === 0) return 'failed';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
const metadataRoutes = Router();
|
||||
|
||||
metadataRoutes.get('/', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
res.status(200).json({
|
||||
tv: settings.metadataSettings.tv,
|
||||
anime: settings.metadataSettings.anime,
|
||||
});
|
||||
});
|
||||
|
||||
metadataRoutes.put('/', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const body = req.body as MetadataSettings;
|
||||
|
||||
let tvdbTest = -1;
|
||||
let tmdbTest = -1;
|
||||
|
||||
try {
|
||||
if (
|
||||
body.tv === MetadataProviderType.TVDB ||
|
||||
body.anime === MetadataProviderType.TVDB
|
||||
) {
|
||||
tvdbTest = 0;
|
||||
const tvdb = await Tvdb.getInstance();
|
||||
await tvdb.test();
|
||||
tvdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'Metadata',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
body.tv === MetadataProviderType.TMDB ||
|
||||
body.anime === MetadataProviderType.TMDB
|
||||
) {
|
||||
tmdbTest = 0;
|
||||
const tmdb = new TheMovieDb();
|
||||
await tmdb.getTvShow({ tvId: 1054 });
|
||||
tmdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
// If a test failed, return the test results
|
||||
if (tvdbTest === 0 || tmdbTest === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
tests: {
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
settings.metadataSettings = {
|
||||
tv: body.tv,
|
||||
anime: body.anime,
|
||||
};
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
tv: body.tv,
|
||||
anime: body.anime,
|
||||
tests: {
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
metadataRoutes.post('/test', async (req, res) => {
|
||||
let tvdbTest = -1;
|
||||
let tmdbTest = -1;
|
||||
|
||||
try {
|
||||
const body = req.body as { tmdb: boolean; tvdb: boolean };
|
||||
|
||||
try {
|
||||
if (body.tmdb) {
|
||||
tmdbTest = 0;
|
||||
const tmdb = new TheMovieDb();
|
||||
await tmdb.getTvShow({ tvId: 1054 });
|
||||
tmdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (body.tvdb) {
|
||||
tvdbTest = 0;
|
||||
const tvdb = await Tvdb.getInstance();
|
||||
await tvdb.test();
|
||||
tvdbTest = 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to test metadata provider', {
|
||||
label: 'MetadataProvider',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
|
||||
const success = !(tvdbTest === 0 || tmdbTest === 0);
|
||||
const statusCode = success ? 200 : 500;
|
||||
|
||||
return res.status(statusCode).json({
|
||||
success: success,
|
||||
tests: {
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
tests: {
|
||||
tmdb: getTestResultString(tmdbTest),
|
||||
tvdb: getTestResultString(tvdbTest),
|
||||
},
|
||||
error: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default metadataRoutes;
|
||||
@@ -4,6 +4,7 @@ import type { NotificationAgent } from '@server/lib/notifications/agents/agent';
|
||||
import DiscordAgent from '@server/lib/notifications/agents/discord';
|
||||
import EmailAgent from '@server/lib/notifications/agents/email';
|
||||
import GotifyAgent from '@server/lib/notifications/agents/gotify';
|
||||
import LunaSeaAgent from '@server/lib/notifications/agents/lunasea';
|
||||
import NtfyAgent from '@server/lib/notifications/agents/ntfy';
|
||||
import PushbulletAgent from '@server/lib/notifications/agents/pushbullet';
|
||||
import PushoverAgent from '@server/lib/notifications/agents/pushover';
|
||||
@@ -270,7 +271,6 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
||||
|
||||
const response: typeof webhookSettings = {
|
||||
enabled: webhookSettings.enabled,
|
||||
embedPoster: webhookSettings.embedPoster,
|
||||
types: webhookSettings.types,
|
||||
options: {
|
||||
...webhookSettings.options,
|
||||
@@ -292,7 +292,6 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
||||
|
||||
settings.notifications.agents.webhook = {
|
||||
enabled: req.body.enabled,
|
||||
embedPoster: req.body.embedPoster,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
@@ -323,7 +322,6 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
|
||||
const testBody = {
|
||||
enabled: req.body.enabled,
|
||||
embedPoster: req.body.embedPoster,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
@@ -348,6 +346,40 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
notificationRoutes.get('/lunasea', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.lunasea);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/lunasea', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.lunasea = req.body;
|
||||
await settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.lunasea);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/lunasea/test', async (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information is missing from the request.',
|
||||
});
|
||||
}
|
||||
|
||||
const lunaseaAgent = new LunaSeaAgent(req.body);
|
||||
if (await sendTestNotification(lunaseaAgent, req.user)) {
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to send web push notification.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
notificationRoutes.get('/gotify', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
@@ -16,20 +13,12 @@ const tvRoutes = Router();
|
||||
|
||||
tvRoutes.get('/:id', async (req, res, next) => {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
try {
|
||||
const tmdbTv = await tmdb.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
const metadataProvider = tmdbTv.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
const tv = await metadataProvider.getTvShow({
|
||||
const tv = await tmdb.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
});
|
||||
|
||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||
|
||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||
@@ -45,9 +34,7 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
|
||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||
if (!data.overview) {
|
||||
const tvEnglish = await metadataProvider.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
||||
data.overview = tvEnglish.overview;
|
||||
}
|
||||
|
||||
@@ -66,18 +53,10 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
||||
});
|
||||
|
||||
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
||||
try {
|
||||
const tmdb = new TheMovieDb();
|
||||
const tmdbTv = await tmdb.getTvShow({
|
||||
tvId: Number(req.params.id),
|
||||
});
|
||||
const metadataProvider = tmdbTv.keywords.results.some(
|
||||
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||
)
|
||||
? await getMetadataProvider('anime')
|
||||
: await getMetadataProvider('tv');
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const season = await metadataProvider.getTvSeason({
|
||||
try {
|
||||
const season = await tmdb.getTvSeason({
|
||||
tvId: Number(req.params.id),
|
||||
seasonNumber: Number(req.params.seasonNumber),
|
||||
language: (req.query.language as string) ?? req.locale,
|
||||
|
||||
@@ -42,6 +42,9 @@ router.get('/', async (req, res, next) => {
|
||||
: Math.max(10, includeIds.length);
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||
const sortDirection =
|
||||
(req.query.sortDirection as string) === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
let query = getRepository(User).createQueryBuilder('user');
|
||||
|
||||
if (q) {
|
||||
@@ -56,28 +59,31 @@ router.get('/', async (req, res, next) => {
|
||||
}
|
||||
|
||||
switch (req.query.sort) {
|
||||
case 'created':
|
||||
query = query.orderBy('user.createdAt', sortDirection);
|
||||
break;
|
||||
case 'updated':
|
||||
query = query.orderBy('user.updatedAt', 'DESC');
|
||||
query = query.orderBy('user.updatedAt', sortDirection);
|
||||
break;
|
||||
case 'displayname':
|
||||
query = query
|
||||
.addSelect(
|
||||
`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"
|
||||
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)
|
||||
LOWER(user.plexUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.jellyfinUsername)
|
||||
END)
|
||||
ELSE
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
LOWER(user.username)
|
||||
END`,
|
||||
'displayname_sort_key'
|
||||
)
|
||||
.orderBy('displayname_sort_key', 'ASC');
|
||||
.orderBy('displayname_sort_key', sortDirection);
|
||||
break;
|
||||
case 'requests':
|
||||
query = query
|
||||
@@ -87,10 +93,16 @@ router.get('/', async (req, res, next) => {
|
||||
.from(MediaRequest, 'request')
|
||||
.where('request.requestedBy.id = user.id');
|
||||
}, 'request_count')
|
||||
.orderBy('request_count', 'DESC');
|
||||
.orderBy('request_count', sortDirection);
|
||||
break;
|
||||
case 'usertype':
|
||||
query = query.orderBy('user.userType', sortDirection);
|
||||
break;
|
||||
case 'role':
|
||||
query = query.orderBy('user.permissions', sortDirection);
|
||||
break;
|
||||
default:
|
||||
query = query.orderBy('user.id', 'ASC');
|
||||
query = query.orderBy('user.id', sortDirection);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,10 @@ import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { LinkedAccount } from '@server/entity/LinkedAccount';
|
||||
import { User } from '@server/entity/User';
|
||||
import { UserSettings } from '@server/entity/UserSettings';
|
||||
import type {
|
||||
UserSettingsGeneralResponse,
|
||||
UserSettingsLinkedAccount,
|
||||
UserSettingsLinkedAccountResponse,
|
||||
UserSettingsNotificationsResponse,
|
||||
} from '@server/interfaces/api/userSettingsInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
@@ -21,7 +18,7 @@ import { ApiError } from '@server/types/error';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import net from 'net';
|
||||
import { In, Not, type FindOptionsWhere } from 'typeorm';
|
||||
import { Not } from 'typeorm';
|
||||
import { canMakePermissionsChange } from '.';
|
||||
|
||||
const isOwnProfile = (): Middleware => {
|
||||
@@ -549,73 +546,6 @@ userSettingsRoutes.delete<{ id: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsLinkedAccountResponse>(
|
||||
'/linked-accounts',
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
if (!settings.main.oidcLogin) {
|
||||
// don't show any linked accounts if OIDC login is disabled
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
|
||||
const activeProviders = settings.oidc.providers.map((p) => p.slug);
|
||||
const linkedAccountsRepository = getRepository(LinkedAccount);
|
||||
|
||||
const linkedAccounts = await linkedAccountsRepository.find({
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
where: {
|
||||
provider: In(activeProviders),
|
||||
user: {
|
||||
id: Number(req.params.id),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const linkedAccountInfo = linkedAccounts.map((acc) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const provider = settings.oidc.providers.find(
|
||||
(p) => p.slug === acc.provider
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: acc.id,
|
||||
username: acc.username,
|
||||
provider: {
|
||||
slug: provider.slug,
|
||||
name: provider.name,
|
||||
logo: provider.logo,
|
||||
},
|
||||
} satisfies UserSettingsLinkedAccount;
|
||||
});
|
||||
|
||||
return res.status(200).json(linkedAccountInfo);
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.delete<{ id: string; acctId: string }>(
|
||||
'/linked-accounts/:acctId',
|
||||
isOwnProfileOrAdmin(),
|
||||
async (req, res) => {
|
||||
const linkedAccountsRepository = getRepository(LinkedAccount);
|
||||
const condition: FindOptionsWhere<LinkedAccount> = {
|
||||
id: Number(req.params.acctId),
|
||||
user: {
|
||||
id: Number(req.params.id),
|
||||
},
|
||||
};
|
||||
|
||||
if (await linkedAccountsRepository.exist({ where: condition })) {
|
||||
await linkedAccountsRepository.delete(condition);
|
||||
return res.status(204).send();
|
||||
} else {
|
||||
return res.status(404).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
@@ -33,93 +33,52 @@ import { EventSubscriber } from 'typeorm';
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
{
|
||||
private async notifyAvailableMovie(
|
||||
entity: MediaRequest,
|
||||
event?: UpdateEvent<MediaRequest>
|
||||
) {
|
||||
// Get fresh media state using event manager
|
||||
let latestMedia: Media | null = null;
|
||||
if (event?.manager) {
|
||||
latestMedia = await event.manager.findOne(Media, {
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
}
|
||||
if (!latestMedia) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
latestMedia = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Check availability using fresh media state
|
||||
private async notifyAvailableMovie(entity: MediaRequest) {
|
||||
if (
|
||||
!latestMedia ||
|
||||
latestMedia[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE
|
||||
entity.media[entity.is4k ? 'status4k' : 'status'] ===
|
||||
MediaStatus.AVAILABLE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: entity.media.tmdbId,
|
||||
});
|
||||
|
||||
try {
|
||||
const movie = await tmdb.getMovie({
|
||||
movieId: entity.media.tmdbId,
|
||||
});
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: latestMedia,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Movie Request Now Available`,
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
subject: `${movie.title}${
|
||||
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(movie.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
media: entity.media,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyAvailableSeries(
|
||||
entity: MediaRequest,
|
||||
event?: UpdateEvent<MediaRequest>
|
||||
) {
|
||||
// Get fresh media state with seasons using event manager
|
||||
let latestMedia: Media | null = null;
|
||||
if (event?.manager) {
|
||||
latestMedia = await event.manager.findOne(Media, {
|
||||
where: { id: entity.media.id },
|
||||
relations: { seasons: true },
|
||||
});
|
||||
}
|
||||
if (!latestMedia) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
latestMedia = await mediaRepository.findOne({
|
||||
where: { id: entity.media.id },
|
||||
relations: { seasons: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!latestMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check availability using fresh media state
|
||||
private async notifyAvailableSeries(entity: MediaRequest) {
|
||||
// Find all seasons in the related media entity
|
||||
// and see if they are available, then we can check
|
||||
// if the request contains the same seasons
|
||||
const requestedSeasons =
|
||||
entity.seasons?.map((entitySeason) => entitySeason.seasonNumber) ?? [];
|
||||
const availableSeasons = latestMedia.seasons.filter(
|
||||
const availableSeasons = entity.media.seasons.filter(
|
||||
(season) =>
|
||||
season[entity.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE &&
|
||||
requestedSeasons.includes(season.seasonNumber)
|
||||
@@ -128,46 +87,44 @@ export class MediaRequestSubscriber
|
||||
availableSeasons.length > 0 &&
|
||||
availableSeasons.length === requestedSeasons.length;
|
||||
|
||||
if (!isMediaAvailable) {
|
||||
return;
|
||||
}
|
||||
if (isMediaAvailable) {
|
||||
const tmdb = new TheMovieDb();
|
||||
|
||||
const tmdb = new TheMovieDb();
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
try {
|
||||
const tv = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
|
||||
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: latestMedia,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: entity.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||
event: `${entity.is4k ? '4K ' : ''}Series Request Now Available`,
|
||||
subject: `${tv.name}${
|
||||
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
|
||||
}`,
|
||||
message: truncate(tv.overview, {
|
||||
length: 500,
|
||||
separator: /\s/,
|
||||
omission: '…',
|
||||
}),
|
||||
notifyAdmin: false,
|
||||
notifySystem: true,
|
||||
notifyUser: entity.requestedBy,
|
||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||
media: entity.media,
|
||||
extra: [
|
||||
{
|
||||
name: 'Requested Seasons',
|
||||
value: entity.seasons
|
||||
.map((season) => season.seasonNumber)
|
||||
.join(', '),
|
||||
},
|
||||
],
|
||||
request: entity,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong sending media notification(s)', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
mediaId: entity.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,10 +782,10 @@ export class MediaRequestSubscriber
|
||||
|
||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||
this.notifyAvailableMovie(event.entity as MediaRequest);
|
||||
}
|
||||
if (event.entity.media.mediaType === MediaType.TV) {
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||
this.notifyAvailableSeries(event.entity as MediaRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,11 +53,10 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
||||
b(style='color: #9ca3af; font-weight: 700;')
|
||||
| #{extra.name}
|
||||
| #{extra.value}
|
||||
if imageUrl
|
||||
td(rowspan='2' style='width: 7rem;')
|
||||
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
||||
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
||||
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
||||
td(rowspan='2' style='width: 7rem;')
|
||||
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
||||
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
||||
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
||||
tr
|
||||
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
|
||||
span
|
||||
|
||||
35
server/types/languages.d.ts
vendored
@@ -1,35 +0,0 @@
|
||||
export type AvailableLocale =
|
||||
| 'ar'
|
||||
| 'bg'
|
||||
| 'ca'
|
||||
| 'cs'
|
||||
| 'da'
|
||||
| 'de'
|
||||
| 'en'
|
||||
| 'el'
|
||||
| 'es'
|
||||
| 'es-MX'
|
||||
| 'fi'
|
||||
| 'fr'
|
||||
| 'hr'
|
||||
| 'he'
|
||||
| 'hi'
|
||||
| 'hu'
|
||||
| 'it'
|
||||
| 'ja'
|
||||
| 'ko'
|
||||
| 'lt'
|
||||
| 'nb-NO'
|
||||
| 'nl'
|
||||
| 'pl'
|
||||
| 'pt-BR'
|
||||
| 'pt-PT'
|
||||
| 'ro'
|
||||
| 'ru'
|
||||
| 'sq'
|
||||
| 'sr'
|
||||
| 'sv'
|
||||
| 'tr'
|
||||
| 'uk'
|
||||
| 'zh-CN'
|
||||
| 'zh-TW';
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { ProxySettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios';
|
||||
import axios from 'axios';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
|
||||
export let requestInterceptorFunction: (
|
||||
config: InternalAxiosRequestConfig
|
||||
) => InternalAxiosRequestConfig;
|
||||
|
||||
export default async function createCustomProxyAgent(
|
||||
proxySettings: ProxySettings
|
||||
) {
|
||||
@@ -60,9 +56,12 @@ export default async function createCustomProxyAgent(
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const proxyUrl = `${proxySettings.useSsl ? 'https' : 'http'}://${
|
||||
proxySettings.hostname
|
||||
}:${proxySettings.port}`;
|
||||
const proxyUrl =
|
||||
(proxySettings.useSsl ? 'https://' : 'http://') +
|
||||
proxySettings.hostname +
|
||||
':' +
|
||||
proxySettings.port;
|
||||
|
||||
const proxyAgent = new ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
token,
|
||||
@@ -71,24 +70,15 @@ export default async function createCustomProxyAgent(
|
||||
|
||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
|
||||
requestInterceptorFunction = (config) => {
|
||||
const url = config.baseURL
|
||||
? new URL(config.baseURL + (config.url || ''))
|
||||
: config.url;
|
||||
if (url && skipUrl(url)) {
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl);
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl);
|
||||
axios.interceptors.request.use((config) => {
|
||||
if (config.url && skipUrl(config.url)) {
|
||||
config.httpAgent = false;
|
||||
config.httpsAgent = false;
|
||||
}
|
||||
return config;
|
||||
};
|
||||
axios.interceptors.request.use(requestInterceptorFunction);
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Failed to connect to the proxy: ' + e.message, {
|
||||
label: 'Proxy',
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import logger from '@server/logger';
|
||||
import { DnsCacheManager } from 'dns-caching';
|
||||
|
||||
export let dnsCache: DnsCacheManager | undefined;
|
||||
|
||||
export function initializeDnsCache({
|
||||
forceMinTtl,
|
||||
forceMaxTtl,
|
||||
}: {
|
||||
forceMinTtl?: number;
|
||||
forceMaxTtl?: number;
|
||||
}) {
|
||||
if (dnsCache) {
|
||||
logger.warn('DNS Cache is already initialized', { label: 'DNS Cache' });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Initializing DNS Cache', { label: 'DNS Cache' });
|
||||
|
||||
dnsCache = new DnsCacheManager({
|
||||
logger,
|
||||
forceMinTtl: typeof forceMinTtl === 'number' ? forceMinTtl * 1000 : 0,
|
||||
forceMaxTtl: typeof forceMaxTtl === 'number' ? forceMaxTtl * 1000 : -1,
|
||||
});
|
||||
dnsCache.initialize();
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import type {
|
||||
IdTokenClaims,
|
||||
OidcProviderMetadata,
|
||||
OidcStandardClaims,
|
||||
OidcTokenResponse,
|
||||
} from '@server/interfaces/api/oidcInterfaces';
|
||||
import type { OidcProvider } from '@server/lib/settings';
|
||||
import type { Request } from 'express';
|
||||
import * as yup from 'yup';
|
||||
|
||||
/** Fetch the issuer configuration from the OpenID Connect Discovery endpoint */
|
||||
export async function getOpenIdConfiguration(domain: string) {
|
||||
// remove trailing slash from url if it exists and add /.well-known/openid-configuration path
|
||||
const wellKnownUrl = new URL(
|
||||
domain.replace(/\/$/, '') + '/.well-known/openid-configuration'
|
||||
).toString();
|
||||
|
||||
const wellKnownInfo: OidcProviderMetadata = await fetch(wellKnownUrl, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
|
||||
return wellKnownInfo;
|
||||
}
|
||||
|
||||
function getOpenIdCallbackUrl(req: Request, provider: OidcProvider) {
|
||||
const callbackUrl = new URL(
|
||||
`/login`,
|
||||
`${req.protocol}://${req.headers.host}`
|
||||
);
|
||||
callbackUrl.searchParams.set('provider', provider.slug);
|
||||
callbackUrl.searchParams.set('callback', 'true');
|
||||
return callbackUrl.toString();
|
||||
}
|
||||
|
||||
/** Generate authentication request url */
|
||||
export async function getOpenIdRedirectUrl(
|
||||
req: Request,
|
||||
provider: OidcProvider,
|
||||
state: string
|
||||
) {
|
||||
const wellKnownInfo = await getOpenIdConfiguration(provider.issuerUrl);
|
||||
const url = new URL(wellKnownInfo.authorization_endpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', provider.clientId);
|
||||
|
||||
url.searchParams.set('redirect_uri', getOpenIdCallbackUrl(req, provider));
|
||||
url.searchParams.set('scope', provider.scopes ?? 'openid profile email');
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** Exchange authorization code for token data */
|
||||
export async function fetchOpenIdTokenData(
|
||||
req: Request,
|
||||
provider: OidcProvider,
|
||||
wellKnownInfo: OidcProviderMetadata,
|
||||
code: string
|
||||
): Promise<OidcTokenResponse> {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('client_secret', provider.clientSecret);
|
||||
formData.append('grant_type', 'authorization_code');
|
||||
formData.append('redirect_uri', getOpenIdCallbackUrl(req, provider));
|
||||
formData.append('client_id', provider.clientId);
|
||||
formData.append('code', code);
|
||||
|
||||
return await fetch(wellKnownInfo.token_endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
export async function getOpenIdUserInfo(
|
||||
wellKnownInfo: OidcProviderMetadata,
|
||||
authToken: string
|
||||
) {
|
||||
return fetch(wellKnownInfo.userinfo_endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}).then((r) => r.json());
|
||||
}
|
||||
|
||||
class OidcAuthorizationError extends Error {}
|
||||
|
||||
class OidcMissingKeyError extends OidcAuthorizationError {
|
||||
constructor(public userInfo: FullUserInfo, public key: string) {
|
||||
super(`Key ${key} was missing on OIDC userinfo but was expected.`);
|
||||
}
|
||||
}
|
||||
|
||||
type PrimitiveString = 'string' | 'boolean';
|
||||
type TypeFromName<T extends PrimitiveString> = T extends 'string'
|
||||
? string
|
||||
: T extends 'boolean'
|
||||
? boolean
|
||||
: unknown;
|
||||
|
||||
export function tryGetUserInfoKey<T extends PrimitiveString>(
|
||||
userInfo: FullUserInfo,
|
||||
key: string,
|
||||
expectedType: T
|
||||
): TypeFromName<T> {
|
||||
if (!Object.hasOwn(userInfo, key) || typeof userInfo[key] !== expectedType) {
|
||||
throw new OidcMissingKeyError(userInfo, key);
|
||||
}
|
||||
|
||||
return userInfo[key] as TypeFromName<T>;
|
||||
}
|
||||
|
||||
export function validateUserClaims(
|
||||
userInfo: FullUserInfo,
|
||||
requiredClaims: string[]
|
||||
) {
|
||||
requiredClaims.some((claim) => {
|
||||
const value = tryGetUserInfoKey(userInfo, claim, 'boolean');
|
||||
if (!value)
|
||||
throw new OidcAuthorizationError('User was missing a required claim.');
|
||||
});
|
||||
}
|
||||
|
||||
/** Generates a schema to validate ID token JWT and userinfo claims */
|
||||
export const createIdTokenSchema = ({
|
||||
oidcDomain,
|
||||
oidcClientId,
|
||||
requiredClaims,
|
||||
}: {
|
||||
oidcDomain: string;
|
||||
oidcClientId: string;
|
||||
requiredClaims: string[];
|
||||
}) => {
|
||||
return yup.object().shape({
|
||||
iss: yup
|
||||
.string()
|
||||
.oneOf(
|
||||
[oidcDomain, `${oidcDomain}/`],
|
||||
`The token iss value doesn't match the oidc_DOMAIN (${oidcDomain})`
|
||||
)
|
||||
.required("The token didn't come with an iss value."),
|
||||
aud: yup.lazy((val) => {
|
||||
// single audience
|
||||
if (typeof val === 'string')
|
||||
return yup
|
||||
.string()
|
||||
.oneOf(
|
||||
[oidcClientId],
|
||||
`The token aud value doesn't match the oidc_CLIENT_ID (${oidcClientId})`
|
||||
)
|
||||
.required("The token didn't come with an aud value.");
|
||||
// several audiences
|
||||
if (typeof val === 'object' && Array.isArray(val))
|
||||
return yup
|
||||
.array()
|
||||
.of(yup.string())
|
||||
.test(
|
||||
'contains-client-id',
|
||||
`The token aud value doesn't contain the oidc_CLIENT_ID (${oidcClientId})`,
|
||||
(value) => !!(value && value.includes(oidcClientId))
|
||||
);
|
||||
// invalid type
|
||||
return yup
|
||||
.mixed()
|
||||
.typeError('The token aud value is not a string or array.');
|
||||
}),
|
||||
exp: yup
|
||||
.number()
|
||||
.required()
|
||||
.test(
|
||||
'is_before_date',
|
||||
'Token exp value is before current time.',
|
||||
(value) => {
|
||||
if (!value) return false;
|
||||
if (value < Math.ceil(Date.now() / 1000)) return false;
|
||||
return true;
|
||||
}
|
||||
),
|
||||
iat: yup
|
||||
.number()
|
||||
.required()
|
||||
.test(
|
||||
'is_before_one_day',
|
||||
'Token was issued before one day ago and is now invalid.',
|
||||
(value) => {
|
||||
if (!value) return false;
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 1);
|
||||
if (value < Math.ceil(Number(date) / 1000)) return false;
|
||||
return true;
|
||||
}
|
||||
),
|
||||
// TODO: only require this for new user login
|
||||
email: yup.string().required(),
|
||||
// ensure all required claims are present and are booleans
|
||||
...requiredClaims.reduce(
|
||||
(a, v) => ({ ...a, [v]: yup.boolean().required() }),
|
||||
{}
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export type FullUserInfo = IdTokenClaims & OidcStandardClaims;
|
||||
1
src/assets/extlogos/lunasea.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 750 750" xmlns="http://www.w3.org/2000/svg"><g fill="currentColor"><path d="m554.69 180.46c-333.63 0-452.75 389.23-556.05 389.23 185.37 0 237.85-247.18 419.12-247.18l47.24-102.05z"/><path d="m749.31 375.08c0 107.48-87.14 194.61-194.62 194.61s-194.62-87.13-194.62-194.61 87.13-194.62 194.62-194.62c7.391-2e-3 14.776 0.412 22.12 1.24-78.731 10.172-136.59 78.893-133.2 158.2 3.393 79.313 66.907 142.84 146.22 146.25 79.311 3.411 148.05-54.43 158.24-133.16 0.826 7.331 1.24 14.703 1.24 22.08z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 519 B |
@@ -29,10 +29,14 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||
Promise.all(
|
||||
keywordIds.map(async (keywordId) => {
|
||||
const { data } = await axios.get<Keyword | null>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data?.name || `[Invalid: ${keywordId}]`;
|
||||
try {
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data.name;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
})
|
||||
).then((keywords) => {
|
||||
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||
|
||||
@@ -5,10 +5,7 @@ import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
||||
import type {
|
||||
TmdbKeyword,
|
||||
TmdbKeywordSearchResponse,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
||||
import type { Keyword } from '@server/models/common';
|
||||
import axios from 'axios';
|
||||
import { useFormikContext } from 'formik';
|
||||
@@ -127,19 +124,15 @@ const ControlledKeywordSelector = ({
|
||||
|
||||
const keywords = await Promise.all(
|
||||
defaultValue.split(',').map(async (keywordId) => {
|
||||
const { data } = await axios.get<Keyword | null>(
|
||||
const { data } = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
return data;
|
||||
})
|
||||
);
|
||||
|
||||
const validKeywords: TmdbKeyword[] = keywords.filter(
|
||||
(keyword): keyword is TmdbKeyword => keyword !== null
|
||||
);
|
||||
|
||||
onChange(
|
||||
validKeywords.map((keyword) => ({
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
|
||||
@@ -31,7 +31,7 @@ type BaseProps<P> = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type ButtonProps<P extends React.ElementType> = {
|
||||
type ButtonProps<P extends React.ElementType> = {
|
||||
as?: P;
|
||||
} & MergeElementProps<P, BaseProps<P>>;
|
||||
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
import { Field } from 'formik';
|
||||
import { useId } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface LabeledCheckboxProps {
|
||||
name: string;
|
||||
id: string;
|
||||
className?: string;
|
||||
label: string;
|
||||
description: string;
|
||||
onChange?: () => void;
|
||||
onChange: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const LabeledCheckbox: React.FC<LabeledCheckboxProps> = ({
|
||||
name,
|
||||
id,
|
||||
className,
|
||||
label,
|
||||
description,
|
||||
onChange,
|
||||
children,
|
||||
}) => {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={twMerge('relative flex items-start', className)}>
|
||||
<div className="flex h-6 items-center">
|
||||
<Field type="checkbox" id={id} name={name} onChange={onChange} />
|
||||
<Field type="checkbox" id={id} name={id} onChange={onChange} />
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<label htmlFor={id} className="block">
|
||||
<label htmlFor="localLogin" className="block">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-white">{label}</span>
|
||||
<span className="font-normal text-gray-400">{description}</span>
|
||||
|
||||
@@ -84,7 +84,6 @@ const SettingsTabs = ({
|
||||
Select a Tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
onChange={(e) => {
|
||||
router.push(e.target.value);
|
||||
}}
|
||||
|
||||
@@ -77,19 +77,16 @@ const CreateSlider = ({ onCreate, slider }: CreateSliderProps) => {
|
||||
|
||||
const keywords = await Promise.all(
|
||||
slider.data.split(',').map(async (keywordId) => {
|
||||
const keyword = await axios.get<Keyword | null>(
|
||||
const keyword = await axios.get<Keyword>(
|
||||
`/api/v1/keyword/${keywordId}`
|
||||
);
|
||||
|
||||
return keyword.data;
|
||||
})
|
||||
);
|
||||
|
||||
const validKeywords: Keyword[] = keywords.filter(
|
||||
(keyword): keyword is Keyword => keyword !== null
|
||||
);
|
||||
|
||||
setDefaultDataValue(
|
||||
validKeywords.map((keyword) => ({
|
||||
keywords.map((keyword) => ({
|
||||
label: keyword.name,
|
||||
value: keyword.id,
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Badge from '@app/components/Common/Badge';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import Tooltip from '@app/components/Common/Tooltip';
|
||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -27,7 +26,6 @@ const messages = defineMessages('components.IssueList.IssueItem', {
|
||||
opened: 'Opened',
|
||||
viewissue: 'View Issue',
|
||||
unknownissuetype: 'Unknown',
|
||||
descriptionpreview: 'Issue Description',
|
||||
});
|
||||
|
||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||
@@ -109,15 +107,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const description = issue.comments?.[0]?.message || '';
|
||||
const maxDescriptionLength = 120;
|
||||
const shouldTruncate = description.length > maxDescriptionLength;
|
||||
const truncatedDescription = shouldTruncate
|
||||
? description.substring(0, maxDescriptionLength) + '...'
|
||||
: description;
|
||||
|
||||
return (
|
||||
<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:flex-row">
|
||||
<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">
|
||||
{title.backdropPath && (
|
||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||
<CachedImage
|
||||
@@ -177,38 +168,8 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
||||
>
|
||||
{isMovie(title) ? title.title : title.name}
|
||||
</Link>
|
||||
{description && (
|
||||
<div className="mt-1 max-w-full">
|
||||
<div className="overflow-hidden text-sm text-gray-300">
|
||||
{shouldTruncate ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="max-w-sm p-3">
|
||||
<div className="mb-1 text-sm font-medium text-gray-200">
|
||||
Issue Description
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
tooltipConfig={{
|
||||
placement: 'top',
|
||||
offset: [0, 8],
|
||||
}}
|
||||
>
|
||||
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
|
||||
{truncatedDescription}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="block break-words">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{problemSeasonEpisodeLine.length > 0 && (
|
||||
<div className="card-field mt-1">
|
||||
<div className="card-field">
|
||||
{problemSeasonEpisodeLine.map((t, k) => (
|
||||
<span key={k}>{t}</span>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import { availableLanguages } from '@app/context/LanguageContext';
|
||||
import useClickOutside from '@app/hooks/useClickOutside';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CachedImage from '@app/components/Common/CachedImage';
|
||||
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
@@ -36,7 +36,7 @@ ForwardedLink.displayName = 'ForwardedLink';
|
||||
|
||||
const UserDropdown = () => {
|
||||
const intl = useIntl();
|
||||
const { user, revalidate, hasPermission } = useUser();
|
||||
const { user, revalidate } = useUser();
|
||||
|
||||
const logout = async () => {
|
||||
const response = await axios.post('/api/v1/auth/logout');
|
||||
@@ -118,14 +118,7 @@ const UserDropdown = () => {
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<ForwardedLink
|
||||
href={
|
||||
hasPermission(
|
||||
[Permission.MANAGE_REQUESTS, Permission.REQUEST_VIEW],
|
||||
{ type: 'or' }
|
||||
)
|
||||
? `/users/${user?.id}/requests?filter=all`
|
||||
: '/requests'
|
||||
}
|
||||
href={`/users/${user?.id}/requests?filter=all`}
|
||||
className={`flex items-center rounded px-4 py-2 text-sm font-medium text-gray-200 transition duration-150 ease-in-out ${
|
||||
active
|
||||
? 'bg-gradient-to-br from-indigo-600 to-purple-600 text-white'
|
||||
|
||||
@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
||||
import SearchInput from '@app/components/Layout/SearchInput';
|
||||
import Sidebar from '@app/components/Layout/Sidebar';
|
||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
||||
import useLocale from '@app/hooks/useLocale';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||
import type { AvailableLocale } from '@server/types/languages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import Button, { type ButtonProps } from '@app/components/Common/Button';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type LoginButtonProps = ButtonProps<'button'> &
|
||||
PropsWithChildren<{
|
||||
loading?: boolean;
|
||||
}>;
|
||||
|
||||
export default function LoginButton({
|
||||
loading,
|
||||
className,
|
||||
children,
|
||||
...buttonProps
|
||||
}: LoginButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
className={twMerge(
|
||||
'relative flex-grow bg-transparent disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
disabled={loading}
|
||||
{...buttonProps}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute right-0 mr-4 h-4 w-4">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import type { PublicOidcProvider } from '@server/lib/settings';
|
||||
import axios, { isAxiosError } from 'axios';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import LoginButton from './LoginButton';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
oidcLoginError: 'An error occurred while logging in with {provider}.',
|
||||
});
|
||||
|
||||
async function processCallback(params: URLSearchParams, provider: string) {
|
||||
const url = new URL(
|
||||
`/api/v1/auth/oidc/callback/${encodeURIComponent(provider)}`,
|
||||
window.location.origin
|
||||
);
|
||||
url.search = params.toString();
|
||||
|
||||
try {
|
||||
const res = await axios.get(url.toString());
|
||||
|
||||
return {
|
||||
type: 'success',
|
||||
message: res.data,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isAxiosError(e) && e.response?.data?.message) {
|
||||
return { type: 'error', message: e.response.data.message };
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
message: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type OidcLoginButtonProps = {
|
||||
provider: PublicOidcProvider;
|
||||
onError?: (message: string) => void;
|
||||
};
|
||||
|
||||
export default function OidcLoginButton({
|
||||
provider,
|
||||
onError,
|
||||
}: OidcLoginButtonProps) {
|
||||
const intl = useIntl();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const redirectToLogin = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get<{ redirectUrl: string }>(
|
||||
`/api/v1/auth/oidc/login/${provider.slug}`
|
||||
);
|
||||
window.location.href = res.data.redirectUrl;
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
onError?.(
|
||||
intl.formatMessage(messages.oidcLoginError, {
|
||||
provider: provider.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [provider, intl, onError]);
|
||||
|
||||
const handleCallback = useCallback(async () => {
|
||||
const result = await processCallback(searchParams, provider.slug);
|
||||
if (result.type === 'success') {
|
||||
// redirect to homepage
|
||||
router.push(result.message?.to ?? '/');
|
||||
} else {
|
||||
setLoading(false);
|
||||
onError?.(
|
||||
intl.formatMessage(messages.oidcLoginError, {
|
||||
provider: provider.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [provider, searchParams, intl, onError, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const isCallback = searchParams.get('callback') === 'true';
|
||||
const providerSlug = searchParams.get('provider');
|
||||
|
||||
if (providerSlug === provider.slug) {
|
||||
setLoading(true);
|
||||
if (isCallback) handleCallback();
|
||||
else redirectToLogin();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoginButton loading={loading} onClick={() => redirectToLogin()}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={provider.logo || '/images/openid.svg'}
|
||||
alt={provider.name}
|
||||
className="mr-2 max-h-5 w-5"
|
||||
/>
|
||||
<span>{provider.name}</span>
|
||||
</LoginButton>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import PlexIcon from '@app/assets/services/plex.svg';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
|
||||
import usePlexLogin from '@app/hooks/usePlexLogin';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import LoginButton from './LoginButton';
|
||||
|
||||
const messages = defineMessages('components.Login', {
|
||||
loginwithapp: 'Login with {appName}',
|
||||
@@ -24,12 +25,18 @@ const PlexLoginButton = ({
|
||||
const { loading, login } = usePlexLogin({ onAuthToken, onError });
|
||||
|
||||
return (
|
||||
<LoginButton
|
||||
className="border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)]"
|
||||
<Button
|
||||
className="relative flex-1 border-[#cc7b19] bg-[rgba(204,123,25,0.3)] hover:border-[#cc7b19] hover:bg-[rgba(204,123,25,0.7)] disabled:opacity-50"
|
||||
onClick={login}
|
||||
loading={loading || isProcessing}
|
||||
disabled={loading || isProcessing}
|
||||
data-testid="plex-login-button"
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute right-0 mr-4 h-4 w-4">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{large ? (
|
||||
<FormattedMessage
|
||||
{...messages.loginwithapp}
|
||||
@@ -48,7 +55,7 @@ const PlexLoginButton = ({
|
||||
) : (
|
||||
<PlexIcon className="w-8" />
|
||||
)}
|
||||
</LoginButton>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||