Compare commits

..

5 Commits

Author SHA1 Message Date
Gauthier
23e959acc1 fix: remove logs 2024-10-31 10:42:39 +01:00
Gauthier
2000c2ddf6 fix: add error handling for proxy creating 2024-10-31 02:08:06 +01:00
Gauthier
4a3fb5e6c8 feat: add bypass list, bypass local addresses and username/password to proxy setting
This PR adds more options to the proxy setting, like username/password authentication, bypass list
of domains and bypass local addresses. The UX is taken from *arrs.
2024-10-31 02:01:55 +01:00
Gauthier
4f14e057c7 fix: add missing merge function of default and current config 2024-10-30 22:00:16 +01:00
Gauthier
d16e399011 fix: use fs/promises for settings
This PR switches from synchronous operations with the 'fs' module to asynchronous operations with
the 'fs/promises' module. It also corrects a small error with hostname migration.
2024-10-29 16:50:24 +01:00
21 changed files with 526 additions and 570 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json config/*.json
dist dist
Dockerfile* Dockerfile*
compose.yaml docker-compose.yml
docs docs
LICENSE LICENSE
node_modules node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore .all-contributorsrc export-ignore
.editorconfig export-ignore .editorconfig export-ignore
Dockerfile.local export-ignore Dockerfile.local export-ignore
compose.yaml export-ignore docker-compose.yml export-ignore
stylelint.config.js export-ignore stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore public/os_logo_filled.png export-ignore

View File

@@ -52,7 +52,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
pnpm dev pnpm dev
``` ```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly. - Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes. 5. Create your patch and test your changes.

View File

@@ -1,3 +1,4 @@
version: '3'
services: services:
jellyseerr: jellyseerr:
build: build:

View File

@@ -190,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2) ## Traefik (v2)
Add the following labels to the Jellyseerr service in your `compose.yaml` file: Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
```yaml ```yaml
labels: labels:

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/). For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation: #### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows: Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml ```yaml
--- ---
services: services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file: Then, start all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
#### Updating: #### Updating:
Pull the latest image: Pull the latest image:
```bash ```bash
docker compose pull jellyseerr docker-compose pull jellyseerr
``` ```
Then, restart all services defined in the Compose file: Then, restart all services defined in the Compose file:
```bash ```bash
docker compose up -d docker-compose up -d
``` ```
:::tip :::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files. You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

View File

@@ -43,7 +43,6 @@
"@svgr/webpack": "6.5.1", "@svgr/webpack": "6.5.1",
"@tanem/react-nprogress": "5.0.30", "@tanem/react-nprogress": "5.0.30",
"ace-builds": "1.15.2", "ace-builds": "1.15.2",
"axios": "^1.7.7",
"bcrypt": "5.1.0", "bcrypt": "5.1.0",
"bowser": "2.11.0", "bowser": "2.11.0",
"connect-typeorm": "1.1.4", "connect-typeorm": "1.1.4",

40
pnpm-lock.yaml generated
View File

@@ -41,9 +41,6 @@ importers:
ace-builds: ace-builds:
specifier: 1.15.2 specifier: 1.15.2
version: 1.15.2 version: 1.15.2
axios:
specifier: ^1.7.7
version: 1.7.7
bcrypt: bcrypt:
specifier: 5.1.0 specifier: 5.1.0
version: 5.1.0(encoding@0.1.13) version: 5.1.0(encoding@0.1.13)
@@ -3405,9 +3402,6 @@ packages:
resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==} resolution: {integrity: sha512-QbUdXJVTpvUTHU7871ppZkdOLBeGUKBQWHkHrvN2V9IQWGMt61zf3B45BtzjxEJzYuj0JBjBZP/hmYS/R9pmAw==}
engines: {node: '>=4'} engines: {node: '>=4'}
axios@1.7.7:
resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==}
axobject-query@3.1.1: axobject-query@3.1.1:
resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==}
@@ -5028,15 +5022,6 @@ packages:
fn.name@1.1.0: fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
for-each@0.3.3: for-each@0.3.3:
resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==}
@@ -5051,10 +5036,6 @@ packages:
resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==}
engines: {node: '>= 0.12'} engines: {node: '>= 0.12'}
form-data@4.0.1:
resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
engines: {node: '>= 6'}
formik@2.4.6: formik@2.4.6:
resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==} resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==}
peerDependencies: peerDependencies:
@@ -7403,9 +7384,6 @@ packages:
proxy-from-env@1.0.0: proxy-from-env@1.0.0:
resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
psl@1.9.0: psl@1.9.0:
resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==}
@@ -13285,14 +13263,6 @@ snapshots:
axe-core@4.9.1: {} axe-core@4.9.1: {}
axios@1.7.7:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.1
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axobject-query@3.1.1: axobject-query@3.1.1:
dependencies: dependencies:
deep-equal: 2.2.3 deep-equal: 2.2.3
@@ -15308,8 +15278,6 @@ snapshots:
fn.name@1.1.0: {} fn.name@1.1.0: {}
follow-redirects@1.15.9: {}
for-each@0.3.3: for-each@0.3.3:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@@ -15327,12 +15295,6 @@ snapshots:
combined-stream: 1.0.8 combined-stream: 1.0.8
mime-types: 2.1.35 mime-types: 2.1.35
form-data@4.0.1:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
formik@2.4.6(react@18.3.1): formik@2.4.6(react@18.3.1):
dependencies: dependencies:
'@types/hoist-non-react-statics': 3.3.5 '@types/hoist-non-react-statics': 3.3.5
@@ -17880,8 +17842,6 @@ snapshots:
proxy-from-env@1.0.0: {} proxy-from-env@1.0.0: {}
proxy-from-env@1.1.0: {}
psl@1.9.0: {} psl@1.9.0: {}
pstree.remy@1.1.8: {} pstree.remy@1.1.8: {}

View File

@@ -1,6 +1,5 @@
import type { AxiosInstance, AxiosRequestConfig } from 'axios'; import type { RateLimitOptions } from '@server/utils/rateLimit';
import axios from 'axios'; import rateLimit from '@server/utils/rateLimit';
// import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache'; import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds) // 5 minute default TTL (in seconds)
@@ -12,71 +11,87 @@ const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions { interface ExternalAPIOptions {
nodeCache?: NodeCache; nodeCache?: NodeCache;
headers?: Record<string, unknown>; headers?: Record<string, unknown>;
rateLimit?: { rateLimit?: RateLimitOptions;
maxRPS: number;
maxRequests: number;
};
} }
class ExternalAPI { class ExternalAPI {
protected axios: AxiosInstance; protected fetch: typeof fetch;
protected params: Record<string, string>;
protected defaultHeaders: { [key: string]: string };
private baseUrl: string; private baseUrl: string;
private cache?: NodeCache; private cache?: NodeCache;
constructor( constructor(
baseUrl: string, baseUrl: string,
params: Record<string, unknown>, params: Record<string, string> = {},
options: ExternalAPIOptions = {} options: ExternalAPIOptions = {}
) { ) {
this.axios = axios.create({ if (options.rateLimit) {
baseURL: baseUrl, this.fetch = rateLimit(fetch, options.rateLimit);
params, } else {
headers: { this.fetch = fetch;
'Content-Type': 'application/json', }
Accept: 'application/json',
...options.headers,
},
});
// if (options.rateLimit) {
// this.axios = rateLimit(this.axios, {
// maxRequests: options.rateLimit.maxRequests,
// maxRPS: options.rateLimit.maxRPS,
// });
// }
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.params = params;
this.defaultHeaders = {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
};
this.cache = options.nodeCache; this.cache = options.nodeCache;
} }
protected async get<T>( protected async get<T>(
endpoint: string, endpoint: string,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params); const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) { if (cachedItem) {
return cachedItem; return cachedItem;
} }
const response = await this.axios.get<T>(endpoint, config); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
return response.data; return data;
} }
protected async post<T>( protected async post<T>(
endpoint: string, endpoint: string,
data?: Record<string, unknown>, data?: Record<string, unknown>,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, { const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params, config: { ...this.params, ...params },
data, data,
}); });
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
@@ -84,23 +99,43 @@ class ExternalAPI {
return cachedItem; return cachedItem;
} }
const response = await this.axios.post<T>(endpoint, data, config); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'POST',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
return response.data; return resData;
} }
protected async put<T>( protected async put<T>(
endpoint: string, endpoint: string,
data: Record<string, unknown>, data: Record<string, unknown>,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, { const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params, config: { ...this.params, ...params },
data, data,
}); });
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
@@ -108,41 +143,73 @@ class ExternalAPI {
return cachedItem; return cachedItem;
} }
const response = await this.axios.put<T>(endpoint, data, config); const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'PUT',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, resData, ttl ?? DEFAULT_TTL);
} }
return response.data; return resData;
} }
protected async delete<T>( protected async delete<T>(
endpoint: string, endpoint: string,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number config?: RequestInit
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params); const url = this.formatUrl(endpoint, params);
const cachedItem = this.cache?.get<T>(cacheKey); const response = await this.fetch(url, {
if (cachedItem) { method: 'DELETE',
return cachedItem; ...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
} }
const data = await this.getDataFromResponse(response);
const response = await this.axios.delete<T>(endpoint, config); return data;
if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
} }
protected async getRolling<T>( protected async getRolling<T>(
endpoint: string, endpoint: string,
config?: AxiosRequestConfig, params?: Record<string, string>,
ttl?: number ttl?: number,
config?: RequestInit,
overwriteBaseUrl?: string
): Promise<T> { ): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, config?.params); const cacheKey = this.serializeCacheKey(endpoint, {
...this.params,
...params,
});
const cachedItem = this.cache?.get<T>(cacheKey); const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) { if (cachedItem) {
@@ -153,20 +220,78 @@ class ExternalAPI {
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 < keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER Date.now() - DEFAULT_ROLLING_BUFFER
) { ) {
this.axios.get<T>(endpoint, config).then((response) => { const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
}); });
} }
return cachedItem; return cachedItem;
} }
const response = await this.axios.get<T>(endpoint, config); const url = this.formatUrl(endpoint, params, overwriteBaseUrl);
const response = await this.fetch(url, {
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) { if (this.cache) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); this.cache.set(cacheKey, data, ttl ?? DEFAULT_TTL);
} }
return response.data; return data;
}
private formatUrl(
endpoint: string,
params?: Record<string, string>,
overwriteBaseUrl?: string
): string {
const baseUrl = overwriteBaseUrl || this.baseUrl;
const href =
baseUrl +
(baseUrl.endsWith('/') ? '' : '/') +
(endpoint.startsWith('/') ? endpoint.slice(1) : endpoint);
const searchParams = new URLSearchParams({
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
} }
private serializeCacheKey( private serializeCacheKey(
@@ -179,6 +304,29 @@ class ExternalAPI {
return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`; return `${this.baseUrl}${endpoint}${JSON.stringify(params)}`;
} }
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
}
}
}
} }
export default ExternalAPI; export default ExternalAPI;

View File

@@ -1,6 +1,6 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import logger from '@server/logger'; import logger from '@server/logger';
import ExternalAPI from './externalapi';
interface GitHubRelease { interface GitHubRelease {
url: string; url: string;
@@ -67,10 +67,6 @@ class GithubAPI extends ExternalAPI {
'https://api.github.com', 'https://api.github.com',
{}, {},
{ {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('github').data, nodeCache: cacheManager.getCache('github').data,
} }
); );
@@ -85,9 +81,7 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GitHubRelease[]>( const data = await this.get<GitHubRelease[]>(
'/repos/fallenbagel/jellyseerr/releases', '/repos/fallenbagel/jellyseerr/releases',
{ {
params: { per_page: take.toString(),
per_page: take,
},
} }
); );
@@ -112,10 +106,8 @@ class GithubAPI extends ExternalAPI {
const data = await this.get<GithubCommit[]>( const data = await this.get<GithubCommit[]>(
'/repos/fallenbagel/jellyseerr/commits', '/repos/fallenbagel/jellyseerr/commits',
{ {
params: { per_page: take.toString(),
per_page: take, branch,
branch,
},
} }
); );

View File

@@ -129,6 +129,8 @@ class JellyfinAPI extends ExternalAPI {
Username, Username,
Pw: Password, Pw: Password,
}, },
{},
undefined,
{ headers } { headers }
); );
}; };
@@ -291,15 +293,13 @@ class JellyfinAPI extends ExternalAPI {
const libraryItemsResponse = await this.get<any>( const libraryItemsResponse = await this.get<any>(
`/Users/${this.userId}/Items`, `/Users/${this.userId}/Items`,
{ {
params: { SortBy: 'SortName',
SortBy: 'SortName', SortOrder: 'Ascending',
SortOrder: 'Ascending', IncludeItemTypes: 'Series,Movie,Others',
IncludeItemTypes: 'Series,Movie,Others', Recursive: 'true',
Recursive: 'true', StartIndex: '0',
StartIndex: '0', ParentId: id,
ParentId: id, collapseBoxSetItems: 'false',
collapseBoxSetItems: 'false',
},
} }
); );
@@ -321,10 +321,8 @@ class JellyfinAPI extends ExternalAPI {
const itemResponse = await this.get<any>( const itemResponse = await this.get<any>(
`/Users/${this.userId}/Items/Latest`, `/Users/${this.userId}/Items/Latest`,
{ {
params: { Limit: '12',
Limit: '12', ParentId: id,
ParentId: id,
},
} }
); );
@@ -386,9 +384,7 @@ class JellyfinAPI extends ExternalAPI {
const episodeResponse = await this.get<any>( const episodeResponse = await this.get<any>(
`/Shows/${seriesID}/Episodes`, `/Shows/${seriesID}/Episodes`,
{ {
params: { seasonId: seasonID,
seasonId: seasonID,
},
} }
); );
@@ -414,7 +410,7 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken; ).AccessToken;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`, `Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' } { label: 'Jellyfin API' }
); );

View File

@@ -1,9 +1,9 @@
import ExternalAPI from '@server/api/externalapi';
import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache'; import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import xml2js from 'xml2js'; import xml2js from 'xml2js';
import ExternalAPI from './externalapi';
interface PlexAccountResponse { interface PlexAccountResponse {
user: PlexUser; user: PlexUser;
@@ -137,8 +137,6 @@ class PlexTvAPI extends ExternalAPI {
{ {
headers: { headers: {
'X-Plex-Token': authToken, 'X-Plex-Token': authToken,
'Content-Type': 'application/json',
Accept: 'application/json',
}, },
nodeCache: cacheManager.getCache('plextv').data, nodeCache: cacheManager.getCache('plextv').data,
} }
@@ -149,15 +147,11 @@ class PlexTvAPI extends ExternalAPI {
public async getDevices(): Promise<PlexDevice[]> { public async getDevices(): Promise<PlexDevice[]> {
try { try {
const devicesResp = await this.axios.get( const devicesResp = await this.get('/api/resources', {
'/api/resources?includeHttps=1', includeHttps: '1',
{ });
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise( const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse devicesResp as DeviceResponse
); );
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name, name: pxml.$.name,
@@ -205,11 +199,11 @@ class PlexTvAPI extends ExternalAPI {
public async getUser(): Promise<PlexUser> { public async getUser(): Promise<PlexUser> {
try { try {
const account = await this.axios.get<PlexAccountResponse>( const account = await this.get<PlexAccountResponse>(
'/users/account.json' '/users/account.json'
); );
return account.data.user; return account.user;
} catch (e) { } catch (e) {
logger.error( logger.error(
`Something went wrong while getting the account from plex.tv: ${e.message}`, `Something went wrong while getting the account from plex.tv: ${e.message}`,
@@ -249,13 +243,10 @@ class PlexTvAPI extends ExternalAPI {
} }
public async getUsers(): Promise<UsersResponse> { public async getUsers(): Promise<UsersResponse> {
const response = await this.axios.get('/api/users', { const data = await this.get('/api/users');
transformResponse: [],
responseType: 'text',
});
const parsedXml = (await xml2js.parseStringPromise( const parsedXml = (await xml2js.parseStringPromise(
response.data data as string
)) as UsersResponse; )) as UsersResponse;
return parsedXml; return parsedXml;
} }
@@ -270,49 +261,49 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[]; items: PlexWatchlistItem[];
}> { }> {
try { try {
const response = await this.axios.get<WatchlistResponse>( const params = new URLSearchParams({
'/library/sections/watchlist/all', 'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
});
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{ {
params: { headers: this.defaultHeaders,
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
baseURL: 'https://metadata.provider.plex.tv',
} }
); );
const data = (await response.json()) as WatchlistResponse;
const watchlistDetails = await Promise.all( const watchlistDetails = await Promise.all(
(response.data.MediaContainer.Metadata ?? []).map( (data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
async (watchlistItem) => { const detailedResponse = await this.getRolling<MetadataResponse>(
const detailedResponse = await this.getRolling<MetadataResponse>( `/library/metadata/${watchlistItem.ratingKey}`,
`/library/metadata/${watchlistItem.ratingKey}`, {},
{ undefined,
baseURL: 'https://metadata.provider.plex.tv', {},
} 'https://metadata.provider.plex.tv'
); );
const metadata = detailedResponse.MediaContainer.Metadata[0]; const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) => const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb') guid.id.startsWith('tmdb')
); );
const tvdbString = metadata.Guid.find((guid) => const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb') guid.id.startsWith('tvdb')
); );
return { return {
ratingKey: metadata.ratingKey, ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be? // This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards // We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1]) ? Number(tvdbString.id.split('//')[1])
: undefined, : undefined,
title: metadata.title, title: metadata.title,
type: metadata.type, type: metadata.type,
}; };
} })
)
); );
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -320,7 +311,7 @@ class PlexTvAPI extends ExternalAPI {
return { return {
offset, offset,
size, size,
totalSize: response.data.MediaContainer.totalSize, totalSize: data.MediaContainer.totalSize,
items: filteredList, items: filteredList,
}; };
} catch (e) { } catch (e) {

View File

@@ -1,4 +1,4 @@
import ExternalAPI from './externalapi'; import ExternalAPI from '@server/api/externalapi';
interface PushoverSoundsResponse { interface PushoverSoundsResponse {
sounds: { sounds: {
@@ -26,24 +26,13 @@ export const mapSounds = (sounds: {
class PushoverAPI extends ExternalAPI { class PushoverAPI extends ExternalAPI {
constructor() { constructor() {
super( super('https://api.pushover.net/1');
'https://api.pushover.net/1',
{},
{
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
}
);
} }
public async getSounds(appToken: string): Promise<PushoverSound[]> { public async getSounds(appToken: string): Promise<PushoverSound[]> {
try { try {
const data = await this.get<PushoverSoundsResponse>('/sounds.json', { const data = await this.get<PushoverSoundsResponse>('/sounds.json', {
params: { token: appToken,
token: appToken,
},
}); });
return mapSounds(data.sounds); return mapSounds(data.sounds);

View File

@@ -160,9 +160,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
const data = await this.get<QueueResponse<QueueItemAppendT>>( const data = await this.get<QueueResponse<QueueItemAppendT>>(
`/queue`, `/queue`,
{ {
params: { includeEpisode: 'true',
includeEpisode: 'true',
},
}, },
0 0
); );

View File

@@ -58,9 +58,7 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> { public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try { try {
const data = await this.get<RadarrMovie[]>('/movie/lookup', { const data = await this.get<RadarrMovie[]>('/movie/lookup', {
params: { term: `tmdb:${id}`,
term: `tmdb:${id}`,
},
}); });
if (!data[0]) { if (!data[0]) {
@@ -224,10 +222,8 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
try { try {
const { id, title } = await this.getMovieByTmdbId(movieId); const { id, title } = await this.getMovieByTmdbId(movieId);
await this.delete(`/movie/${id}`, { await this.delete(`/movie/${id}`, {
params: { deleteFiles: 'true',
deleteFiles: 'true', addImportExclusion: 'false',
addImportExclusion: 'false',
},
}); });
logger.info(`[Radarr] Removed movie ${title}`); logger.info(`[Radarr] Removed movie ${title}`);
} catch (e) { } catch (e) {

View File

@@ -138,9 +138,7 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> { public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try { try {
const data = await this.get<SonarrSeries[]>('/series/lookup', { const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: { term: title,
term: title,
},
}); });
if (!data[0]) { if (!data[0]) {
@@ -161,9 +159,7 @@ class SonarrAPI extends ServarrBase<{
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> { public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try { try {
const data = await this.get<SonarrSeries[]>('/series/lookup', { const data = await this.get<SonarrSeries[]>('/series/lookup', {
params: { term: `tvdb:${id}`,
term: `tvdb:${id}`,
},
}); });
if (!data[0]) { if (!data[0]) {
@@ -349,10 +345,8 @@ class SonarrAPI extends ServarrBase<{
try { try {
const { id, title } = await this.getSeriesByTvdbId(serieId); const { id, title } = await this.getSeriesByTvdbId(serieId);
await this.delete(`/series/${id}`, { await this.delete(`/series/${id}`, {
params: { deleteFiles: 'true',
deleteFiles: 'true', addImportExclusion: 'false',
addImportExclusion: 'false',
},
}); });
logger.info(`[Radarr] Removed serie ${title}`); logger.info(`[Radarr] Removed serie ${title}`);
} catch (e) { } catch (e) {

View File

@@ -1,8 +1,7 @@
import ExternalAPI from '@server/api/externalapi';
import type { User } from '@server/entity/User'; import type { User } from '@server/entity/User';
import type { TautulliSettings } from '@server/lib/settings'; import type { TautulliSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import type { AxiosInstance } from 'axios';
import axios from 'axios';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
export interface TautulliHistoryRecord { export interface TautulliHistoryRecord {
@@ -113,25 +112,25 @@ interface TautulliInfoResponse {
}; };
} }
class TautulliAPI { class TautulliAPI extends ExternalAPI {
private axios: AxiosInstance;
constructor(settings: TautulliSettings) { constructor(settings: TautulliSettings) {
this.axios = axios.create({ super(
baseURL: `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${ `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port settings.port
}${settings.urlBase ?? ''}`, }${settings.urlBase ?? ''}`,
params: { apikey: settings.apiKey }, {
}); apikey: settings.apiKey || '',
}
);
} }
public async getInfo(): Promise<TautulliInfo> { public async getInfo(): Promise<TautulliInfo> {
try { try {
return ( return (
await this.axios.get<TautulliInfoResponse>('/api/v2', { await this.get<TautulliInfoResponse>('/api/v2', {
params: { cmd: 'get_tautulli_info' }, cmd: 'get_tautulli_info',
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error('Something went wrong fetching Tautulli server info', { logger.error('Something went wrong fetching Tautulli server info', {
label: 'Tautulli API', label: 'Tautulli API',
@@ -148,14 +147,12 @@ class TautulliAPI {
): Promise<TautulliWatchStats[]> { ): Promise<TautulliWatchStats[]> {
try { try {
return ( return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', { await this.get<TautulliWatchStatsResponse>('/api/v2', {
params: { cmd: 'get_item_watch_time_stats',
cmd: 'get_item_watch_time_stats', rating_key: ratingKey,
rating_key: ratingKey, grouping: '1',
grouping: 1,
},
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching media watch stats from Tautulli', 'Something went wrong fetching media watch stats from Tautulli',
@@ -176,14 +173,12 @@ class TautulliAPI {
): Promise<TautulliWatchUser[]> { ): Promise<TautulliWatchUser[]> {
try { try {
return ( return (
await this.axios.get<TautulliWatchUsersResponse>('/api/v2', { await this.get<TautulliWatchUsersResponse>('/api/v2', {
params: { cmd: 'get_item_user_stats',
cmd: 'get_item_user_stats', rating_key: ratingKey,
rating_key: ratingKey, grouping: '1',
grouping: 1,
},
}) })
).data.response.data; ).response.data;
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching media watch users from Tautulli', 'Something went wrong fetching media watch users from Tautulli',
@@ -206,15 +201,13 @@ class TautulliAPI {
} }
return ( return (
await this.axios.get<TautulliWatchStatsResponse>('/api/v2', { await this.get<TautulliWatchStatsResponse>('/api/v2', {
params: { cmd: 'get_user_watch_time_stats',
cmd: 'get_user_watch_time_stats', user_id: user.plexId.toString(),
user_id: user.plexId, query_days: '0',
query_days: 0, grouping: '1',
grouping: 1,
},
}) })
).data.response.data[0]; ).response.data[0];
} catch (e) { } catch (e) {
logger.error( logger.error(
'Something went wrong fetching user watch stats from Tautulli', 'Something went wrong fetching user watch stats from Tautulli',
@@ -245,19 +238,17 @@ class TautulliAPI {
while (results.length < 20) { while (results.length < 20) {
const tautulliData = ( const tautulliData = (
await this.axios.get<TautulliHistoryResponse>('/api/v2', { await this.get<TautulliHistoryResponse>('/api/v2', {
params: { cmd: 'get_history',
cmd: 'get_history', grouping: '1',
grouping: 1, order_column: 'date',
order_column: 'date', order_dir: 'desc',
order_dir: 'desc', user_id: user.plexId.toString(),
user_id: user.plexId, media_type: 'movie,episode',
media_type: 'movie,episode', length: take.toString(),
length: take, start: start.toString(),
start,
},
}) })
).data.response.data.data; ).response.data.data;
if (!tautulliData.length) { if (!tautulliData.length) {
return results; return results;

View File

@@ -112,10 +112,10 @@ class TheMovieDb extends ExternalAPI {
}, },
{ {
nodeCache: cacheManager.getCache('tmdb').data, nodeCache: cacheManager.getCache('tmdb').data,
// rateLimit: { rateLimit: {
// maxRPS: 50, maxRPS: 50,
// id: 'tmdb', id: 'tmdb',
// }, },
} }
); );
this.region = region; this.region = region;
@@ -130,12 +130,10 @@ class TheMovieDb extends ExternalAPI {
}: SearchOptions): Promise<TmdbSearchMultiResponse> => { }: SearchOptions): Promise<TmdbSearchMultiResponse> => {
try { try {
const data = await this.get<TmdbSearchMultiResponse>('/search/multi', { const data = await this.get<TmdbSearchMultiResponse>('/search/multi', {
params: { query,
query, page: page.toString(),
page: page.toString(), include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult ? 'true' : 'false', language,
language,
},
}); });
return data; return data;
@@ -158,13 +156,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => { }: SingleSearchOptions): Promise<TmdbSearchMovieResponse> => {
try { try {
const data = await this.get<TmdbSearchMovieResponse>('/search/movie', { const data = await this.get<TmdbSearchMovieResponse>('/search/movie', {
params: { query,
query, page: page.toString(),
page: page.toString(), include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult ? 'true' : 'false', language,
language, primary_release_year: year?.toString() || '',
primary_release_year: year?.toString() || '',
},
}); });
return data; return data;
@@ -187,13 +183,11 @@ class TheMovieDb extends ExternalAPI {
}: SingleSearchOptions): Promise<TmdbSearchTvResponse> => { }: SingleSearchOptions): Promise<TmdbSearchTvResponse> => {
try { try {
const data = await this.get<TmdbSearchTvResponse>('/search/tv', { const data = await this.get<TmdbSearchTvResponse>('/search/tv', {
params: { query,
query, page: page.toString(),
page: page.toString(), include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult ? 'true' : 'false', language,
language, first_air_date_year: year?.toString() || '',
first_air_date_year: year?.toString() || '',
},
}); });
return data; return data;
@@ -216,9 +210,7 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbPersonDetails> => { }): Promise<TmdbPersonDetails> => {
try { try {
const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, { const data = await this.get<TmdbPersonDetails>(`/person/${personId}`, {
params: { language,
language,
},
}); });
return data; return data;
@@ -238,9 +230,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbPersonCombinedCredits>( const data = await this.get<TmdbPersonCombinedCredits>(
`/person/${personId}/combined_credits`, `/person/${personId}/combined_credits`,
{ {
params: { language,
language,
},
} }
); );
@@ -263,11 +253,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbMovieDetails>( const data = await this.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/movie/${movieId}`,
{ {
params: { language,
language, append_to_response:
append_to_response: 'credits,external_ids,videos,keywords,release_dates,watch/providers',
'credits,external_ids,videos,keywords,release_dates,watch/providers',
},
}, },
43200 43200
); );
@@ -289,11 +277,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbTvDetails>( const data = await this.get<TmdbTvDetails>(
`/tv/${tvId}`, `/tv/${tvId}`,
{ {
params: { language,
language, append_to_response:
append_to_response: 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers',
},
}, },
43200 43200
); );
@@ -317,10 +303,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSeasonWithEpisodes>( const data = await this.get<TmdbSeasonWithEpisodes>(
`/tv/${tvId}/season/${seasonNumber}`, `/tv/${tvId}/season/${seasonNumber}`,
{ {
params: { language: language || '',
language: language || '', append_to_response: 'external_ids',
append_to_response: 'external_ids',
},
} }
); );
@@ -343,10 +327,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/recommendations`, `/movie/${movieId}/recommendations`,
{ {
params: { page: page.toString(),
page: page.toString(), language,
language,
},
} }
); );
@@ -369,10 +351,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/movie/${movieId}/similar`, `/movie/${movieId}/similar`,
{ {
params: { page: page.toString(),
page: page.toString(), language,
language,
},
} }
); );
@@ -395,10 +375,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/keyword/${keywordId}/movies`, `/keyword/${keywordId}/movies`,
{ {
params: { page: page.toString(),
page: page.toString(), language,
language,
},
} }
); );
@@ -421,10 +399,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/tv/${tvId}/recommendations`, `/tv/${tvId}/recommendations`,
{ {
params: { page: page.toString(),
page: page.toString(), language,
language,
},
} }
); );
@@ -447,10 +423,8 @@ class TheMovieDb extends ExternalAPI {
}): Promise<TmdbSearchTvResponse> { }): Promise<TmdbSearchTvResponse> {
try { try {
const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, { const data = await this.get<TmdbSearchTvResponse>(`/tv/${tvId}/similar`, {
params: { page: page.toString(),
page: page.toString(), language,
language,
},
}); });
return data; return data;
@@ -491,40 +465,38 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', { const data = await this.get<TmdbSearchMovieResponse>('/discover/movie', {
params: { sort_by: sortBy,
sort_by: sortBy, page: page.toString(),
page: page.toString(), include_adult: includeAdult ? 'true' : 'false',
include_adult: includeAdult ? 'true' : 'false', language,
language, region: this.region || '',
region: this.region || '', with_original_language:
with_original_language: originalLanguage && originalLanguage !== 'all'
originalLanguage && originalLanguage !== 'all' ? originalLanguage
? originalLanguage : originalLanguage === 'all'
: originalLanguage === 'all' ? ''
? '' : this.originalLanguage || '',
: this.originalLanguage || '', // Set our release date values, but check if one is set and not the other,
// Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set!
// so we can force a past date or a future date. TMDB Requires both values if one is set! 'primary_release_date.gte':
'primary_release_date.gte': !primaryReleaseDateGte && primaryReleaseDateLte
!primaryReleaseDateGte && primaryReleaseDateLte ? defaultPastDate
? defaultPastDate : primaryReleaseDateGte || '',
: primaryReleaseDateGte || '', 'primary_release_date.lte':
'primary_release_date.lte': !primaryReleaseDateLte && primaryReleaseDateGte
!primaryReleaseDateLte && primaryReleaseDateGte ? defaultFutureDate
? defaultFutureDate : primaryReleaseDateLte || '',
: primaryReleaseDateLte || '', with_genres: genre || '',
with_genres: genre || '', with_companies: studio || '',
with_companies: studio || '', with_keywords: keywords || '',
with_keywords: keywords || '', 'with_runtime.gte': withRuntimeGte || '',
'with_runtime.gte': withRuntimeGte || '', 'with_runtime.lte': withRuntimeLte || '',
'with_runtime.lte': withRuntimeLte || '', 'vote_average.gte': voteAverageGte || '',
'vote_average.gte': voteAverageGte || '', 'vote_average.lte': voteAverageLte || '',
'vote_average.lte': voteAverageLte || '', 'vote_count.gte': voteCountGte || '',
'vote_count.gte': voteCountGte || '', 'vote_count.lte': voteCountLte || '',
'vote_count.lte': voteCountLte || '', watch_region: watchRegion || '',
watch_region: watchRegion || '', with_watch_providers: watchProviders || '',
with_watch_providers: watchProviders || '',
},
}); });
return data; return data;
@@ -566,43 +538,41 @@ class TheMovieDb extends ExternalAPI {
.split('T')[0]; .split('T')[0];
const data = await this.get<TmdbSearchTvResponse>('/discover/tv', { const data = await this.get<TmdbSearchTvResponse>('/discover/tv', {
params: { sort_by: sortBy,
sort_by: sortBy, page: page.toString(),
page: page.toString(), language,
language, region: this.region || '',
region: this.region || '', // Set our release date values, but check if one is set and not the other,
// Set our release date values, but check if one is set and not the other, // so we can force a past date or a future date. TMDB Requires both values if one is set!
// so we can force a past date or a future date. TMDB Requires both values if one is set! 'first_air_date.gte':
'first_air_date.gte': !firstAirDateGte && firstAirDateLte
!firstAirDateGte && firstAirDateLte ? defaultPastDate
? defaultPastDate : firstAirDateGte || '',
: firstAirDateGte || '', 'first_air_date.lte':
'first_air_date.lte': !firstAirDateLte && firstAirDateGte
!firstAirDateLte && firstAirDateGte ? defaultFutureDate
? defaultFutureDate : firstAirDateLte || '',
: firstAirDateLte || '', with_original_language:
with_original_language: originalLanguage && originalLanguage !== 'all'
originalLanguage && originalLanguage !== 'all' ? originalLanguage
? originalLanguage : originalLanguage === 'all'
: originalLanguage === 'all' ? ''
? '' : this.originalLanguage || '',
: this.originalLanguage || '', include_null_first_air_dates: includeEmptyReleaseDate
include_null_first_air_dates: includeEmptyReleaseDate ? 'true'
? 'true' : 'false',
: 'false', with_genres: genre || '',
with_genres: genre || '', with_networks: network?.toString() || '',
with_networks: network?.toString() || '', with_keywords: keywords || '',
with_keywords: keywords || '', 'with_runtime.gte': withRuntimeGte || '',
'with_runtime.gte': withRuntimeGte || '', 'with_runtime.lte': withRuntimeLte || '',
'with_runtime.lte': withRuntimeLte || '', 'vote_average.gte': voteAverageGte || '',
'vote_average.gte': voteAverageGte || '', 'vote_average.lte': voteAverageLte || '',
'vote_average.lte': voteAverageLte || '', 'vote_count.gte': voteCountGte || '',
'vote_count.gte': voteCountGte || '', 'vote_count.lte': voteCountLte || '',
'vote_count.lte': voteCountLte || '', with_watch_providers: watchProviders || '',
with_watch_providers: watchProviders || '', watch_region: watchRegion || '',
watch_region: watchRegion || '', with_status: withStatus || '',
with_status: withStatus || '',
},
}); });
return data; return data;
@@ -622,12 +592,10 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbUpcomingMoviesResponse>( const data = await this.get<TmdbUpcomingMoviesResponse>(
'/movie/upcoming', '/movie/upcoming',
{ {
params: { page: page.toString(),
page: page.toString(), language,
language, region: this.region || '',
region: this.region || '', originalLanguage: this.originalLanguage || '',
originalLanguage: this.originalLanguage || '',
},
} }
); );
@@ -650,11 +618,9 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMultiResponse>( const data = await this.get<TmdbSearchMultiResponse>(
`/trending/all/${timeWindow}`, `/trending/all/${timeWindow}`,
{ {
params: { page: page.toString(),
page: page.toString(), language,
language, region: this.region || '',
region: this.region || '',
},
} }
); );
@@ -675,9 +641,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchMovieResponse>( const data = await this.get<TmdbSearchMovieResponse>(
`/trending/movie/${timeWindow}`, `/trending/movie/${timeWindow}`,
{ {
params: { page: page.toString(),
page: page.toString(),
},
} }
); );
@@ -698,9 +662,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbSearchTvResponse>( const data = await this.get<TmdbSearchTvResponse>(
`/trending/tv/${timeWindow}`, `/trending/tv/${timeWindow}`,
{ {
params: { page: page.toString(),
page: page.toString(),
},
} }
); );
@@ -729,10 +691,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbExternalIdResponse>( const data = await this.get<TmdbExternalIdResponse>(
`/find/${externalId}`, `/find/${externalId}`,
{ {
params: { external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id',
external_source: type === 'imdb' ? 'imdb_id' : 'tvdb_id', language,
language,
},
} }
); );
@@ -822,9 +782,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCollection>( const data = await this.get<TmdbCollection>(
`/collection/${collectionId}`, `/collection/${collectionId}`,
{ {
params: { language,
language,
},
} }
); );
@@ -897,9 +855,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
params: { language,
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -911,9 +867,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/movie/list', '/genre/movie/list',
{ {
params: { language: 'en',
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -948,9 +902,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbGenresResult>( const data = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
params: { language,
language,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -962,9 +914,7 @@ class TheMovieDb extends ExternalAPI {
const englishData = await this.get<TmdbGenresResult>( const englishData = await this.get<TmdbGenresResult>(
'/genre/tv/list', '/genre/tv/list',
{ {
params: { language: 'en',
language: 'en',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1019,10 +969,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbKeywordSearchResponse>( const data = await this.get<TmdbKeywordSearchResponse>(
'/search/keyword', '/search/keyword',
{ {
params: { query,
query, page: page.toString(),
page: page.toString(),
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1044,10 +992,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<TmdbCompanySearchResponse>( const data = await this.get<TmdbCompanySearchResponse>(
'/search/company', '/search/company',
{ {
params: { query,
query, page: page.toString(),
page: page.toString(),
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1067,9 +1013,7 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderRegion[] }>( const data = await this.get<{ results: TmdbWatchProviderRegion[] }>(
'/watch/providers/regions', '/watch/providers/regions',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ? this.originalLanguage || '' : '',
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1093,10 +1037,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/movie', '/watch/providers/movie',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ? this.originalLanguage || '' : '', watch_region: watchRegion,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );
@@ -1120,10 +1062,8 @@ class TheMovieDb extends ExternalAPI {
const data = await this.get<{ results: TmdbWatchProviderDetails[] }>( const data = await this.get<{ results: TmdbWatchProviderDetails[] }>(
'/watch/providers/tv', '/watch/providers/tv',
{ {
params: { language: language ? this.originalLanguage || '' : '',
language: language ? this.originalLanguage || '' : '', watch_region: watchRegion,
watch_region: watchRegion,
},
}, },
86400 // 24 hours 86400 // 24 hours
); );

View File

@@ -27,14 +27,8 @@ const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
admin.jellyfinDeviceId admin.jellyfinDeviceId
); );
jellyfinClient.setUserId(admin.jellyfinUserId ?? ''); jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
try { const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
const apiKey = await jellyfinClient.createApiToken('Jellyseerr'); settings.jellyfin.apiKey = apiKey;
settings.jellyfin.apiKey = apiKey;
} catch {
throw new Error(
"Failed to create Jellyfin API token from admin account. Please check your network configuration or edit your settings.json by adding an 'apiKey' field inside of the 'jellyfin' section to fix this issue."
);
}
} }
return settings; return settings;
}; };

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { AllSettings } from '@server/lib/settings'; import type { AllSettings } from '@server/lib/settings';
import logger from '@server/logger'; import logger from '@server/logger';
import fs from 'fs/promises'; import fs from 'fs/promises';
@@ -44,20 +45,10 @@ export const runMigrations = async (
} }
migrated = newSettings; migrated = newSettings;
} catch (e) { } catch (e) {
// we stop jellyseerr if the migration failed logger.error(`Error while running migration '${migration}'`, {
logger.error( label: 'Settings Migrator',
`Error while running migration '${migration}': ${e.message}`, });
{ throw e;
label: 'Settings Migrator',
}
);
logger.error(
'A common cause for this error is a permission issue with your configuration folder, a network issue or a corrupted database.',
{
label: 'Settings Migrator',
}
);
process.exit();
} }
} }
@@ -81,18 +72,22 @@ export const runMigrations = async (
await fs.writeFile(BACKUP_PATH, oldBackup.toString()); await fs.writeFile(BACKUP_PATH, oldBackup.toString());
} }
} catch (e) { } catch (e) {
// we stop jellyseerr if the migration failed
logger.error( logger.error(
`Something went wrong while running settings migrations: ${e.message}`, `Something went wrong while running settings migrations: ${e.message}`,
{ { label: 'Settings Migrator' }
label: 'Settings Migrator',
}
); );
logger.error( // we stop jellyseerr if the migration failed
'A common cause for this issue is a permission error of your configuration folder.', console.log(
{ '===================================================================='
label: 'Settings Migrator', );
} console.log(
' SOMETHING WENT WRONG WHILE RUNNING SETTINGS MIGRATIONS '
);
console.log(
' Please check that your configuration folder is properly set up '
);
console.log(
'===================================================================='
); );
process.exit(); process.exit();
} }

View File

@@ -299,84 +299,54 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id }, where: { jellyfinUserId: account.User.Id },
}); });
const missingAdminUser = !user && !(await userRepository.count()); if (!user && !(await userRepository.count())) {
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
// Check if user is admin on jellyfin // Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) { if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin); throw new ApiError(403, ApiErrorCode.NotAdmin);
} }
if ( logger.info(
body.serverType !== MediaServerType.JELLYFIN && 'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
body.serverType !== MediaServerType.EMBY {
) { label: 'API',
throw new Error('select_server_type'); ip: req.ip,
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
user = new User({
id: 1,
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name, jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(user);
} else {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User alread exist but settings.json is not configured, we'll edit the admin user
user = await userRepository.findOne({
where: { id: 1 },
});
if (!user) {
throw new Error('Unable to find admin user to edit');
} }
user.email = body.email || account.User.Name; );
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
await userRepository.save(user); // User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
} }
// Create an API key on Jellyfin from this admin user // Create an API key on Jellyfin from this admin user
@@ -398,6 +368,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.apiKey = apiKey; settings.jellyfin.apiKey = apiKey;
await settings.save(); await settings.save();
startJobs(); startJobs();
await userRepository.save(user);
} }
// User already exists, let's update their information // User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) { else if (account.User.Id === user?.jellyfinUserId) {