Compare commits
68 Commits
fix-librar
...
pr-934
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd18a9fe9a | ||
|
|
70aab8b0a9 | ||
|
|
1c176dc71e | ||
|
|
bd4da6d5fc | ||
|
|
12f908de7f | ||
|
|
c7d2386799 | ||
|
|
61dcd8e487 | ||
|
|
9aee8887d3 | ||
|
|
2348f23f43 | ||
|
|
74a2d25f15 | ||
|
|
a2c2d261fc | ||
|
|
71acfb1b1f | ||
|
|
29a32d0391 | ||
|
|
f7be4789a2 | ||
|
|
181cb19048 | ||
|
|
32c77f9e94 | ||
|
|
b43c1e350e | ||
|
|
64453320d3 | ||
|
|
36d98a2681 | ||
|
|
d5f817e734 | ||
|
|
422085523e | ||
|
|
fccfca6ed0 | ||
|
|
b04f280fbd | ||
|
|
3fc14c9e22 | ||
|
|
6b0909264d | ||
|
|
62dbde448c | ||
|
|
0116c13e06 | ||
|
|
c96ca6742e | ||
|
|
c80d9a853a | ||
|
|
6cea8bba59 | ||
|
|
2be9c7dcc1 | ||
|
|
7923fc67e8 | ||
|
|
afed6e8879 | ||
|
|
341c7171cd | ||
|
|
3ab184be20 | ||
|
|
7be97a0e12 | ||
|
|
78e6fdb7bf | ||
|
|
fc95e9da4b | ||
|
|
e6b5707190 | ||
|
|
1218876fbf | ||
|
|
b9336fc589 | ||
|
|
20762143db | ||
|
|
9d10efd277 | ||
|
|
ba81ed25e2 | ||
|
|
05a4adf369 | ||
|
|
1449883629 | ||
|
|
9f4329e243 | ||
|
|
5743105db4 | ||
|
|
fce8231710 | ||
|
|
53be59ad51 | ||
|
|
7b8adf5bdf | ||
|
|
7ee45ef6e1 | ||
|
|
50488db05a | ||
|
|
a132105b45 | ||
|
|
cb172cc737 | ||
|
|
cee3be2c64 | ||
|
|
8ecb459808 | ||
|
|
372b3649b5 | ||
|
|
ee472e11af | ||
|
|
1fd3a00284 | ||
|
|
c97c40e04c | ||
|
|
12f4d78692 | ||
|
|
18e3f140eb | ||
|
|
226e910728 | ||
|
|
fb6140972a | ||
|
|
9499b364af | ||
|
|
a15f0ec029 | ||
|
|
5c269368aa |
@@ -403,6 +403,42 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "mobihen",
|
||||||
|
"name": "Nir Israel Hen",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/35529491?v=4",
|
||||||
|
"profile": "https://mobihen.com",
|
||||||
|
"contributions": [
|
||||||
|
"translation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "XDark187",
|
||||||
|
"name": "Baraa",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/39034192?v=4",
|
||||||
|
"profile": "https://github.com/XDark187",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "franciscofsales",
|
||||||
|
"name": "Francisco Sales",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/7977645?v=4",
|
||||||
|
"profile": "https://github.com/franciscofsales",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "myselfolli",
|
||||||
|
"name": "Oliver Laing",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/37535998?v=4",
|
||||||
|
"profile": "https://github.com/myselfolli",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1,2 +1,2 @@
|
|||||||
# Global code ownership
|
# Global code ownership
|
||||||
* @Fallenbagel
|
* @Fallenbagel @gauthier-th
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -11,7 +11,7 @@
|
|||||||
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
<a href="http://jellyseerr.borgcube.de/engage/jellyseerr/"><img src="http://jellyseerr.borgcube.de/widget/jellyseerr/jellyseerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||||
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
<a href="https://github.com/fallenbagel/jellyseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/fallenbagel/jellyseerr"></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-47-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
|
|
||||||
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
**Jellyseerr** is a free and open source software application for managing requests for your media library.
|
||||||
@@ -137,6 +137,15 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
|
|||||||
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://joaquinolivero.com"><img src="https://avatars.githubusercontent.com/u/66050823?v=4?s=100" width="100px;" alt="Joaquin Olivero"/><br /><sub><b>Joaquin Olivero</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=JoaquinOlivero" title="Code">💻</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Bretterteig"><img src="https://avatars.githubusercontent.com/u/47298401?v=4?s=100" width="100px;" alt="Julian Behr"/><br /><sub><b>Julian Behr</b></sub></a><br /><a href="#translation-Bretterteig" title="Translation">🌍</a></td>
|
||||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ThowZzy"><img src="https://avatars.githubusercontent.com/u/61882536?v=4?s=100" width="100px;" alt="ThowZzy"/><br /><sub><b>ThowZzy</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=ThowZzy" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://josephrisk.com"><img src="https://avatars.githubusercontent.com/u/18372584?v=4?s=100" width="100px;" alt="Joseph Risk"/><br /><sub><b>Joseph Risk</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=j0srisk" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Loetwiek"><img src="https://avatars.githubusercontent.com/u/79059734?v=4?s=100" width="100px;" alt="Loetwiek"/><br /><sub><b>Loetwiek</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Loetwiek" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Fuochi"><img src="https://avatars.githubusercontent.com/u/4720478?v=4?s=100" width="100px;" alt="Fuochi"/><br /><sub><b>Fuochi</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://mobihen.com"><img src="https://avatars.githubusercontent.com/u/35529491?v=4?s=100" width="100px;" alt="Nir Israel Hen"/><br /><sub><b>Nir Israel Hen</b></sub></a><br /><a href="#translation-mobihen" title="Translation">🌍</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/XDark187"><img src="https://avatars.githubusercontent.com/u/39034192?v=4?s=100" width="100px;" alt="Baraa"/><br /><sub><b>Baraa</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=XDark187" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/franciscofsales"><img src="https://avatars.githubusercontent.com/u/7977645?v=4?s=100" width="100px;" alt="Francisco Sales"/><br /><sub><b>Francisco Sales</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=franciscofsales" title="Code">💻</a></td>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="https://github.com/myselfolli"><img src="https://avatars.githubusercontent.com/u/37535998?v=4?s=100" width="100px;" alt="Oliver Laing"/><br /><sub><b>Oliver Laing</b></sub></a><br /><a href="https://github.com/Fallenbagel/jellyseerr/commits?author=myselfolli" title="Code">💻</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const testUser = {
|
const testUser = {
|
||||||
displayName: 'Test User',
|
username: 'Test User',
|
||||||
emailAddress: 'test@seeerr.dev',
|
emailAddress: 'test@seeerr.dev',
|
||||||
password: 'test1234',
|
password: 'test1234',
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,7 @@ describe('User List', () => {
|
|||||||
|
|
||||||
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
cy.get('[data-testid=modal-title]').should('contain', 'Create Local User');
|
||||||
|
|
||||||
cy.get('#displayName').type(testUser.displayName);
|
cy.get('#username').type(testUser.username);
|
||||||
cy.get('#email').type(testUser.emailAddress);
|
cy.get('#email').type(testUser.emailAddress);
|
||||||
cy.get('#password').type(testUser.password);
|
cy.get('#password').type(testUser.password);
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ git checkout main
|
|||||||
```
|
```
|
||||||
3. Install the dependencies:
|
3. Install the dependencies:
|
||||||
```powershell
|
```powershell
|
||||||
|
npm install -g win-node-env
|
||||||
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
|
||||||
```
|
```
|
||||||
4. Build the project:
|
4. Build the project:
|
||||||
@@ -272,6 +273,7 @@ git checkout develop # by default, you are on the develop branch so this step is
|
|||||||
```
|
```
|
||||||
3. Install the dependencies:
|
3. Install the dependencies:
|
||||||
```powershell
|
```powershell
|
||||||
|
npm install -g win-node-env
|
||||||
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
|
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
|
||||||
```
|
```
|
||||||
4. Build the project:
|
4. Build the project:
|
||||||
|
|||||||
@@ -68,7 +68,10 @@ class ExternalAPI {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`
|
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
|
||||||
|
{
|
||||||
|
cause: response,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await this.getDataFromResponse(response);
|
const data = await this.getDataFromResponse(response);
|
||||||
@@ -82,7 +85,7 @@ class ExternalAPI {
|
|||||||
|
|
||||||
protected async post<T>(
|
protected async post<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
data: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
ttl?: number,
|
ttl?: number,
|
||||||
config?: RequestInit
|
config?: RequestInit
|
||||||
@@ -104,8 +107,17 @@ class ExternalAPI {
|
|||||||
...this.defaultHeaders,
|
...this.defaultHeaders,
|
||||||
...config?.headers,
|
...config?.headers,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
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);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
@@ -141,6 +153,15 @@ class ExternalAPI {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
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);
|
const resData = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
@@ -157,12 +178,22 @@ class ExternalAPI {
|
|||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const url = this.formatUrl(endpoint, params);
|
const url = this.formatUrl(endpoint, params);
|
||||||
const response = await this.fetch(url, {
|
const response = await this.fetch(url, {
|
||||||
|
method: 'DELETE',
|
||||||
...config,
|
...config,
|
||||||
headers: {
|
headers: {
|
||||||
...this.defaultHeaders,
|
...this.defaultHeaders,
|
||||||
...config?.headers,
|
...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 data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -197,6 +228,17 @@ class ExternalAPI {
|
|||||||
...config?.headers,
|
...config?.headers,
|
||||||
},
|
},
|
||||||
}).then(async (response) => {
|
}).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);
|
const data = await this.getDataFromResponse(response);
|
||||||
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
|
||||||
});
|
});
|
||||||
@@ -212,6 +254,15 @@ class ExternalAPI {
|
|||||||
...config?.headers,
|
...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 data = await this.getDataFromResponse(response);
|
||||||
|
|
||||||
if (this.cache) {
|
if (this.cache) {
|
||||||
@@ -235,7 +286,12 @@ class ExternalAPI {
|
|||||||
...this.params,
|
...this.params,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
return `${href}?${searchParams.toString()}`;
|
return (
|
||||||
|
href +
|
||||||
|
(searchParams.toString().length
|
||||||
|
? '?' + searchParams.toString()
|
||||||
|
: searchParams.toString())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
@@ -250,20 +306,24 @@ class ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getDataFromResponse(response: Response) {
|
private async getDataFromResponse(response: Response) {
|
||||||
const contentType = response.headers.get('Content-Type')?.split(';')[0];
|
const contentType = response.headers.get('Content-Type');
|
||||||
if (contentType === 'application/json') {
|
if (contentType?.includes('application/json')) {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} else if (
|
} else if (
|
||||||
contentType === 'application/xml' ||
|
contentType?.includes('application/xml') ||
|
||||||
contentType === 'text/html' ||
|
contentType?.includes('text/html') ||
|
||||||
contentType === 'text/plain'
|
contentType?.includes('text/plain')
|
||||||
) {
|
) {
|
||||||
return await response.text();
|
return await response.text();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
try {
|
||||||
return await response.blob();
|
return await response.blob();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,9 +93,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class JellyfinAPI extends ExternalAPI {
|
class JellyfinAPI extends ExternalAPI {
|
||||||
private authToken?: string;
|
|
||||||
private userId?: string;
|
private userId?: string;
|
||||||
private jellyfinHost: string;
|
|
||||||
|
|
||||||
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
|
||||||
let authHeaderVal: string;
|
let authHeaderVal: string;
|
||||||
@@ -114,9 +112,6 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.jellyfinHost = jellyfinHost;
|
|
||||||
this.authToken = authToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -152,7 +147,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.response?.status;
|
const status = e.cause?.status;
|
||||||
|
|
||||||
const networkErrorCodes = new Set([
|
const networkErrorCodes = new Set([
|
||||||
'ECONNREFUSED',
|
'ECONNREFUSED',
|
||||||
@@ -190,7 +185,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +202,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +217,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +233,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +312,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +333,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +348,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.response && e.response.status === 500) {
|
if (e.cause?.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +357,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +372,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,6 +397,23 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createApiToken(appName: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
await this.post(`/Auth/Keys?App=${appName}`);
|
||||||
|
const apiKeys = await this.get<any>(`/Auth/Keys`);
|
||||||
|
return apiKeys.Items.reverse().find(
|
||||||
|
(item: any) => item.AppName === appName
|
||||||
|
).AccessToken;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong while creating an API key the Jellyfin server: ${e.message}`,
|
||||||
|
{ label: 'Jellyfin API' }
|
||||||
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,11 @@ class IMDBRadarrProxy extends ExternalAPI {
|
|||||||
`/movie/imdb/${IMDBid}`
|
`/movie/imdb/${IMDBid}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
if (
|
||||||
|
!data?.length ||
|
||||||
|
data[0].ImdbId !== IMDBid ||
|
||||||
|
!data[0].MovieRatings.Imdb
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,13 +179,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error(
|
logger.error(
|
||||||
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
|
||||||
{
|
{
|
||||||
label: 'Radarr',
|
label: 'Radarr',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: e?.response?.data,
|
response: errorData,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
|
|||||||
@@ -257,11 +257,18 @@ class SonarrAPI extends ServarrBase<{
|
|||||||
|
|
||||||
return createdSeriesData;
|
return createdSeriesData;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Something went wrong while adding a series to Sonarr.', {
|
logger.error('Something went wrong while adding a series to Sonarr.', {
|
||||||
label: 'Sonarr API',
|
label: 'Sonarr API',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
options,
|
options,
|
||||||
response: e?.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
throw new Error('Failed to add series');
|
throw new Error('Failed to add series');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ app
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load Settings
|
// Load Settings
|
||||||
const settings = getSettings();
|
const settings = await getSettings().load();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ interface PageInfo {
|
|||||||
export interface PaginatedResponse {
|
export interface PaginatedResponse {
|
||||||
pageInfo: PageInfo;
|
pageInfo: PageInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the keys of an object that are not functions
|
||||||
|
*/
|
||||||
|
type NonFunctionPropertyNames<T> = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
|
[K in keyof T]: T[K] extends Function ? never : K;
|
||||||
|
}[keyof T];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the properties of an object that are not functions
|
||||||
|
*/
|
||||||
|
export type NonFunctionProperties<T> = Pick<T, NonFunctionPropertyNames<T>>;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { MediaType } from '@server/constants/media';
|
import type { MediaType } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type { PaginatedResponse } from './common';
|
import type { NonFunctionProperties, PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: MediaRequest[];
|
results: NonFunctionProperties<MediaRequest>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
@@ -14,6 +14,7 @@ export type MediaRequestBody = {
|
|||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
serverId?: number;
|
serverId?: number;
|
||||||
profileId?: number;
|
profileId?: number;
|
||||||
|
profileName?: string;
|
||||||
rootFolder?: string;
|
rootFolder?: string;
|
||||||
languageProfileId?: number;
|
languageProfileId?: number;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
|
|||||||
@@ -63,12 +63,7 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
admin = await userRepository.findOne({
|
admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: [
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinUserId',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -86,7 +81,7 @@ class AvailabilitySync {
|
|||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -305,15 +305,25 @@ class DiscordAgent
|
|||||||
content: userMentions.join(' '),
|
content: userMentions.join(' '),
|
||||||
} as DiscordWebhookPayload),
|
} as DiscordWebhookPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Discord notification', {
|
logger.error('Error sending Discord notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -132,22 +132,32 @@ class GotifyAgent
|
|||||||
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Gotify notification', {
|
logger.error('Error sending Gotify notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class LunaSeaAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: settings.options.profileName
|
headers: settings.options.profileName
|
||||||
? {
|
? {
|
||||||
@@ -114,15 +114,25 @@ class LunaSeaAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
body: JSON.stringify(this.buildPayload(type, payload)),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending LunaSea notification', {
|
logger.error('Error sending LunaSea notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -133,13 +133,23 @@ class PushbulletAgent
|
|||||||
channel_tag: settings.options.channelTag,
|
channel_tag: settings.options.channelTag,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -164,7 +174,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -172,14 +182,24 @@ class PushbulletAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -215,7 +235,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -223,14 +243,24 @@ class PushbulletAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(notificationPayload),
|
body: JSON.stringify(notificationPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushbullet notification', {
|
logger.error('Error sending Pushbullet notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class PushoverAgent
|
|||||||
): Promise<Partial<PushoverImagePayload>> {
|
): Promise<Partial<PushoverImagePayload>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(imageUrl);
|
const response = await fetch(imageUrl);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
const base64 = Buffer.from(arrayBuffer).toString('base64');
|
||||||
const contentType = (
|
const contentType = (
|
||||||
@@ -64,10 +67,17 @@ class PushoverAgent
|
|||||||
attachment_type: contentType,
|
attachment_type: contentType,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error getting image payload', {
|
logger.error('Error getting image payload', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -200,7 +210,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -212,13 +222,23 @@ class PushoverAgent
|
|||||||
sound: settings.options.sound,
|
sound: settings.options.sound,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -246,7 +266,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -258,14 +278,24 @@ class PushoverAgent
|
|||||||
sound: payload.notifyUser.settings.pushoverSound,
|
sound: payload.notifyUser.settings.pushoverSound,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -302,7 +332,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -313,14 +343,24 @@ class PushoverAgent
|
|||||||
user: user.settings.pushoverUserKey,
|
user: user.settings.pushoverUserKey,
|
||||||
} as PushoverPayload),
|
} as PushoverPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Pushover notification', {
|
logger.error('Error sending Pushover notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -237,22 +237,32 @@ class SlackAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await fetch(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildEmbed(type, payload)),
|
body: JSON.stringify(this.buildEmbed(type, payload)),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Slack notification', {
|
logger.error('Error sending Slack notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -185,13 +185,23 @@ class TelegramAgent
|
|||||||
disable_notification: !!settings.options.sendSilently,
|
disable_notification: !!settings.options.sendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -215,7 +225,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -227,14 +237,24 @@ class TelegramAgent
|
|||||||
!!payload.notifyUser.settings.telegramSendSilently,
|
!!payload.notifyUser.settings.telegramSendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: payload.notifyUser.displayName,
|
recipient: payload.notifyUser.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -268,7 +288,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -279,14 +299,24 @@ class TelegramAgent
|
|||||||
disable_notification: !!user.settings?.telegramSendSilently,
|
disable_notification: !!user.settings?.telegramSendSilently,
|
||||||
} as TelegramMessagePayload | TelegramPhotoPayload),
|
} as TelegramMessagePayload | TelegramPhotoPayload),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending Telegram notification', {
|
logger.error('Error sending Telegram notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
recipient: user.displayName,
|
recipient: user.displayName,
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class WebhookAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -187,15 +187,25 @@ class WebhookAgent
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(this.buildPayload(type, payload)),
|
body: JSON.stringify(this.buildPayload(type, payload)),
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText, { cause: response });
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
logger.error('Error sending webhook notification', {
|
logger.error('Error sending webhook notification', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
type: Notification[type],
|
type: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: e.response?.data,
|
response: errorData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -582,12 +582,7 @@ class JellyfinScanner {
|
|||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: [
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinUserId',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -597,7 +592,7 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken,
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface JellyfinSettings {
|
|||||||
jellyfinForgotPasswordUrl?: string;
|
jellyfinForgotPasswordUrl?: string;
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
apiKey: string;
|
||||||
}
|
}
|
||||||
export interface TautulliSettings {
|
export interface TautulliSettings {
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
@@ -342,6 +343,7 @@ class Settings {
|
|||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
|
apiKey: '',
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
@@ -629,7 +631,7 @@ class Settings {
|
|||||||
* @param overrideSettings If passed in, will override all existing settings with these
|
* @param overrideSettings If passed in, will override all existing settings with these
|
||||||
* values
|
* values
|
||||||
*/
|
*/
|
||||||
public load(overrideSettings?: AllSettings): Settings {
|
public async load(overrideSettings?: AllSettings): Promise<Settings> {
|
||||||
if (overrideSettings) {
|
if (overrideSettings) {
|
||||||
this.data = overrideSettings;
|
this.data = overrideSettings;
|
||||||
return this;
|
return this;
|
||||||
@@ -642,7 +644,7 @@ class Settings {
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const parsedJson = JSON.parse(data);
|
const parsedJson = JSON.parse(data);
|
||||||
this.data = runMigrations(parsedJson);
|
this.data = await runMigrations(parsedJson);
|
||||||
|
|
||||||
this.data = merge(this.data, parsedJson);
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
@@ -656,7 +658,6 @@ class Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let loaded = false;
|
|
||||||
let settings: Settings | undefined;
|
let settings: Settings | undefined;
|
||||||
|
|
||||||
export const getSettings = (initialSettings?: AllSettings): Settings => {
|
export const getSettings = (initialSettings?: AllSettings): Settings => {
|
||||||
@@ -664,11 +665,6 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
|
|||||||
settings = new Settings(initialSettings);
|
settings = new Settings(initialSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
settings.load();
|
|
||||||
loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
36
server/lib/settings/migrations/0002_migrate_apitokens.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import { User } from '@server/entity/User';
|
||||||
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
|
|
||||||
|
const migrateApiTokens = async (settings: any): Promise<AllSettings> => {
|
||||||
|
const mediaServerType = settings.main.mediaServerType;
|
||||||
|
if (
|
||||||
|
!settings.jellyfin.apiKey &&
|
||||||
|
(mediaServerType === MediaServerType.JELLYFIN ||
|
||||||
|
mediaServerType === MediaServerType.EMBY)
|
||||||
|
) {
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const admin = await userRepository.findOne({
|
||||||
|
where: { id: 1 },
|
||||||
|
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
if (!admin) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
const jellyfinClient = new JellyfinAPI(
|
||||||
|
getHostname(settings.jellyfin),
|
||||||
|
admin.jellyfinAuthToken,
|
||||||
|
admin.jellyfinDeviceId
|
||||||
|
);
|
||||||
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
|
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||||
|
settings.jellyfin.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default migrateApiTokens;
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { AllSettings } from '@server/lib/settings';
|
import type { AllSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const migrationsDir = path.join(__dirname, 'migrations');
|
const migrationsDir = path.join(__dirname, 'migrations');
|
||||||
|
|
||||||
export const runMigrations = (settings: AllSettings): AllSettings => {
|
export const runMigrations = async (
|
||||||
|
settings: AllSettings
|
||||||
|
): Promise<AllSettings> => {
|
||||||
const migrations = fs
|
const migrations = fs
|
||||||
.readdirSync(migrationsDir)
|
.readdirSync(migrationsDir)
|
||||||
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
|
||||||
@@ -13,8 +16,15 @@ export const runMigrations = (settings: AllSettings): AllSettings => {
|
|||||||
|
|
||||||
let migrated = settings;
|
let migrated = settings;
|
||||||
|
|
||||||
|
try {
|
||||||
for (const migration of migrations) {
|
for (const migration of migrations) {
|
||||||
migrated = migration(migrated);
|
migrated = await migration(migrated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong while running settings migrations: ${e.message}`,
|
||||||
|
{ label: 'Settings Migrator' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrated;
|
return migrated;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export interface MovieDetails {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
watchProviders?: WatchProviders[];
|
watchProviders?: WatchProviders[];
|
||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
|
onUserWatchlist?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapProductionCompany = (
|
export const mapProductionCompany = (
|
||||||
@@ -101,7 +102,8 @@ export const mapProductionCompany = (
|
|||||||
|
|
||||||
export const mapMovieDetails = (
|
export const mapMovieDetails = (
|
||||||
movie: TmdbMovieDetails,
|
movie: TmdbMovieDetails,
|
||||||
media?: Media
|
media?: Media,
|
||||||
|
userWatchlist?: boolean
|
||||||
): MovieDetails => ({
|
): MovieDetails => ({
|
||||||
id: movie.id,
|
id: movie.id,
|
||||||
adult: movie.adult,
|
adult: movie.adult,
|
||||||
@@ -148,4 +150,5 @@ export const mapMovieDetails = (
|
|||||||
id: keyword.id,
|
id: keyword.id,
|
||||||
name: keyword.name,
|
name: keyword.name,
|
||||||
})),
|
})),
|
||||||
|
onUserWatchlist: userWatchlist,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export interface TvDetails {
|
|||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
mediaInfo?: Media;
|
mediaInfo?: Media;
|
||||||
watchProviders?: WatchProviders[];
|
watchProviders?: WatchProviders[];
|
||||||
|
onUserWatchlist?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
||||||
@@ -161,7 +162,8 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
|
|||||||
|
|
||||||
export const mapTvDetails = (
|
export const mapTvDetails = (
|
||||||
show: TmdbTvDetails,
|
show: TmdbTvDetails,
|
||||||
media?: Media
|
media?: Media,
|
||||||
|
userWatchlist?: boolean
|
||||||
): TvDetails => ({
|
): TvDetails => ({
|
||||||
createdBy: show.created_by,
|
createdBy: show.created_by,
|
||||||
episodeRunTime: show.episode_run_time,
|
episodeRunTime: show.episode_run_time,
|
||||||
@@ -223,4 +225,5 @@ export const mapTvDetails = (
|
|||||||
})),
|
})),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||||
|
onUserWatchlist: userWatchlist,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,18 +320,28 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
// with admin permission
|
// with admin permission
|
||||||
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
settings.main.mediaServerType = MediaServerType.JELLYFIN;
|
||||||
user = new User({
|
user = new User({
|
||||||
email: body.email,
|
email: body.email || account.User.Name,
|
||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
jellyfinUserId: account.User.Id,
|
jellyfinUserId: account.User.Id,
|
||||||
jellyfinDeviceId: deviceId,
|
jellyfinDeviceId: deviceId,
|
||||||
jellyfinAuthToken: account.AccessToken,
|
|
||||||
permissions: Permission.ADMIN,
|
permissions: Permission.ADMIN,
|
||||||
avatar: account.User.PrimaryImageTag
|
avatar: account.User.PrimaryImageTag
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create an API key on Jellyfin from this admin user
|
||||||
|
const jellyfinClient = new JellyfinAPI(
|
||||||
|
hostname,
|
||||||
|
account.AccessToken,
|
||||||
|
deviceId
|
||||||
|
);
|
||||||
|
const apiKey = await jellyfinClient.createApiToken('Jellyseerr');
|
||||||
|
|
||||||
const serverName = await jellyfinserver.getServerName();
|
const serverName = await jellyfinserver.getServerName();
|
||||||
|
|
||||||
settings.jellyfin.name = serverName;
|
settings.jellyfin.name = serverName;
|
||||||
@@ -340,6 +350,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
settings.jellyfin.port = body.port ?? 8096;
|
settings.jellyfin.port = body.port ?? 8096;
|
||||||
settings.jellyfin.urlBase = body.urlBase ?? '';
|
settings.jellyfin.urlBase = body.urlBase ?? '';
|
||||||
settings.jellyfin.useSsl = body.useSsl ?? false;
|
settings.jellyfin.useSsl = body.useSsl ?? false;
|
||||||
|
settings.jellyfin.apiKey = apiKey;
|
||||||
settings.save();
|
settings.save();
|
||||||
startJobs();
|
startJobs();
|
||||||
|
|
||||||
@@ -363,15 +374,11 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Let's check if their authtoken is up to date
|
|
||||||
if (user.jellyfinAuthToken !== account.AccessToken) {
|
|
||||||
user.jellyfinAuthToken = account.AccessToken;
|
|
||||||
}
|
|
||||||
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
// Update the users avatar with their jellyfin profile pic (incase it changed)
|
||||||
if (account.User.PrimaryImageTag) {
|
if (account.User.PrimaryImageTag) {
|
||||||
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
|
||||||
} else {
|
} else {
|
||||||
user.avatar = gravatarUrl(user.email, {
|
user.avatar = gravatarUrl(user.email || account.User.Name, {
|
||||||
default: 'mm',
|
default: 'mm',
|
||||||
size: 200,
|
size: 200,
|
||||||
});
|
});
|
||||||
@@ -413,20 +420,18 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!body.email) {
|
|
||||||
throw new Error('add_email');
|
|
||||||
}
|
|
||||||
|
|
||||||
user = new User({
|
user = new User({
|
||||||
email: body.email,
|
email: body.email,
|
||||||
jellyfinUsername: account.User.Name,
|
jellyfinUsername: account.User.Name,
|
||||||
jellyfinUserId: account.User.Id,
|
jellyfinUserId: account.User.Id,
|
||||||
jellyfinDeviceId: deviceId,
|
jellyfinDeviceId: deviceId,
|
||||||
jellyfinAuthToken: account.AccessToken,
|
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
avatar: account.User.PrimaryImageTag
|
avatar: account.User.PrimaryImageTag
|
||||||
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
|
||||||
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
|
: gravatarUrl(body.email || account.User.Name, {
|
||||||
|
default: 'mm',
|
||||||
|
size: 200,
|
||||||
|
}),
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
|||||||
import { type RatingResponse } from '@server/api/ratings';
|
import { type RatingResponse } from '@server/api/ratings';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapMovieDetails } from '@server/models/Movie';
|
import { mapMovieDetails } from '@server/models/Movie';
|
||||||
import { mapMovieResult } from '@server/models/Search';
|
import { mapMovieResult } from '@server/models/Search';
|
||||||
@@ -22,7 +24,24 @@ movieRoutes.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE);
|
||||||
|
|
||||||
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||||
|
where: {
|
||||||
|
tmdbId: Number(req.params.id),
|
||||||
|
requestedBy: {
|
||||||
|
id: req.user?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
|
||||||
|
|
||||||
|
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||||
|
if (!data.overview) {
|
||||||
|
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
|
||||||
|
data.overview = tvEnglish.overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving movie', {
|
logger.debug('Something went wrong retrieving movie', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import RadarrAPI from '@server/api/servarr/radarr';
|
||||||
|
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -19,6 +21,7 @@ import type {
|
|||||||
RequestResultsResponse,
|
RequestResultsResponse,
|
||||||
} from '@server/interfaces/api/requestInterfaces';
|
} from '@server/interfaces/api/requestInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
@@ -143,6 +146,62 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
// get all quality profiles for every configured sonarr server
|
||||||
|
const sonarrServers = await Promise.all(
|
||||||
|
settings.sonarr.map(async (sonarrSetting) => {
|
||||||
|
const sonarr = new SonarrAPI({
|
||||||
|
apiKey: sonarrSetting.apiKey,
|
||||||
|
url: SonarrAPI.buildUrl(sonarrSetting, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: sonarrSetting.id,
|
||||||
|
profiles: await sonarr.getProfiles(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// get all quality profiles for every configured radarr server
|
||||||
|
const radarrServers = await Promise.all(
|
||||||
|
settings.radarr.map(async (radarrSetting) => {
|
||||||
|
const radarr = new RadarrAPI({
|
||||||
|
apiKey: radarrSetting.apiKey,
|
||||||
|
url: RadarrAPI.buildUrl(radarrSetting, '/api/v3'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: radarrSetting.id,
|
||||||
|
profiles: await radarr.getProfiles(),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// add profile names to the media requests, with undefined if not found
|
||||||
|
const requestsWithProfileNames = requests.map((r) => {
|
||||||
|
switch (r.type) {
|
||||||
|
case MediaType.MOVIE: {
|
||||||
|
const profileName = radarrServers
|
||||||
|
.find((serverr) => serverr.id === r.serverId)
|
||||||
|
?.profiles.find((profile) => profile.id === r.profileId)?.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
profileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case MediaType.TV: {
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
profileName: sonarrServers
|
||||||
|
.find((serverr) => serverr.id === r.serverId)
|
||||||
|
?.profiles.find((profile) => profile.id === r.profileId)?.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(requestCount / pageSize),
|
pages: Math.ceil(requestCount / pageSize),
|
||||||
@@ -150,7 +209,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
results: requestCount,
|
results: requestCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: requests,
|
results: requestsWithProfileNames,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
next({ status: 500, message: e.message });
|
next({ status: 500, message: e.message });
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
|
|||||||
|
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(tempJellyfinSettings),
|
getHostname(tempJellyfinSettings),
|
||||||
admin.jellyfinAuthToken ?? '',
|
tempJellyfinSettings.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
if (req.query.sync) {
|
if (req.query.sync) {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,7 +376,8 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const settings = getSettings();
|
||||||
|
const { externalHostname } = settings.jellyfin;
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
@@ -384,13 +385,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
|||||||
|
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
admin.jellyfinAuthToken ?? '',
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
|
import { Watchlist } from '@server/entity/Watchlist';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { mapTvResult } from '@server/models/Search';
|
import { mapTvResult } from '@server/models/Search';
|
||||||
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
|
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
|
||||||
@@ -19,7 +21,24 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||||
|
|
||||||
return res.status(200).json(mapTvDetails(tv, media));
|
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||||
|
where: {
|
||||||
|
tmdbId: Number(req.params.id),
|
||||||
|
requestedBy: {
|
||||||
|
id: req.user?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = mapTvDetails(tv, media, onUserWatchlist);
|
||||||
|
|
||||||
|
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||||
|
if (!data.overview) {
|
||||||
|
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
||||||
|
data.overview = tvEnglish.overview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug('Something went wrong retrieving series', {
|
logger.debug('Something went wrong retrieving series', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
|
|||||||
@@ -41,7 +41,19 @@ router.get('/', async (req, res, next) => {
|
|||||||
break;
|
break;
|
||||||
case 'displayname':
|
case 'displayname':
|
||||||
query = query.orderBy(
|
query = query.orderBy(
|
||||||
"(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)",
|
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
|
||||||
|
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
|
||||||
|
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
|
||||||
|
user.email
|
||||||
|
ELSE
|
||||||
|
LOWER(user.jellyfinUsername)
|
||||||
|
END)
|
||||||
|
ELSE
|
||||||
|
LOWER(user.jellyfinUsername)
|
||||||
|
END)
|
||||||
|
ELSE
|
||||||
|
LOWER(user.username)
|
||||||
|
END`,
|
||||||
'ASC'
|
'ASC'
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
@@ -90,12 +102,13 @@ router.post(
|
|||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
|
|
||||||
const body = req.body;
|
const body = req.body;
|
||||||
|
const email = body.email || body.username;
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
|
|
||||||
const existingUser = await userRepository
|
const existingUser = await userRepository
|
||||||
.createQueryBuilder('user')
|
.createQueryBuilder('user')
|
||||||
.where('user.email = :email', {
|
.where('user.email = :email', {
|
||||||
email: body.email.toLowerCase(),
|
email: email.toLowerCase(),
|
||||||
})
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
@@ -108,7 +121,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
|
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!passedExplicitPassword &&
|
!passedExplicitPassword &&
|
||||||
@@ -118,9 +131,9 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = new User({
|
const user = new User({
|
||||||
|
email,
|
||||||
avatar: body.avatar ?? avatar,
|
avatar: body.avatar ?? avatar,
|
||||||
username: body.username,
|
username: body.username,
|
||||||
email: body.email,
|
|
||||||
password: body.password,
|
password: body.password,
|
||||||
permissions: settings.main.defaultPermissions,
|
permissions: settings.main.defaultPermissions,
|
||||||
plexToken: '',
|
plexToken: '',
|
||||||
@@ -488,17 +501,14 @@ router.post(
|
|||||||
// taken from auth.ts
|
// taken from auth.ts
|
||||||
const admin = await userRepository.findOneOrFail({
|
const admin = await userRepository.findOneOrFail({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: [
|
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
'id',
|
|
||||||
'jellyfinAuthToken',
|
|
||||||
'jellyfinDeviceId',
|
|
||||||
'jellyfinUserId',
|
|
||||||
],
|
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hostname = getHostname();
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
hostname,
|
||||||
admin.jellyfinAuthToken ?? '',
|
settings.jellyfin.apiKey,
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
@@ -506,7 +516,6 @@ router.post(
|
|||||||
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
|
||||||
const createdUsers: User[] = [];
|
const createdUsers: User[] = [];
|
||||||
const { externalHostname } = getSettings().jellyfin;
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const hostname = getHostname();
|
|
||||||
|
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
user.email = req.body.email ?? user.email;
|
if (user.jellyfinUsername) {
|
||||||
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
|
}
|
||||||
|
|
||||||
// Update quota values only if the user has the correct permissions
|
// Update quota values only if the user has the correct permissions
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
|
|||||||
queue: {
|
queue: {
|
||||||
args: Parameters<T>;
|
args: Parameters<T>;
|
||||||
resolve: (value: U) => void;
|
resolve: (value: U) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
}[];
|
}[];
|
||||||
activeRequests: number;
|
lastTimestamps: number[];
|
||||||
timer: NodeJS.Timeout | null;
|
timeout: ReturnType<typeof setTimeout>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rateLimitById: Record<string, unknown> = {};
|
const rateLimitById: Record<string, unknown> = {};
|
||||||
@@ -27,46 +28,40 @@ export default function rateLimit<
|
|||||||
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
|
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
|
||||||
const state: RateLimiteState<T, U> = (rateLimitById[
|
const state: RateLimiteState<T, U> = (rateLimitById[
|
||||||
options.id || ''
|
options.id || ''
|
||||||
] as RateLimiteState<T, U>) || { queue: [], activeRequests: 0, timer: null };
|
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
|
||||||
if (options.id) {
|
if (options.id) {
|
||||||
rateLimitById[options.id] = state;
|
rateLimitById[options.id] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processQueue = () => {
|
const processQueue = () => {
|
||||||
if (state.queue.length === 0) {
|
// remove old timestamps
|
||||||
if (state.timer) {
|
state.lastTimestamps = state.lastTimestamps.filter(
|
||||||
clearInterval(state.timer);
|
(timestamp) => Date.now() - timestamp < 1000
|
||||||
state.timer = null;
|
);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (state.activeRequests < options.maxRPS) {
|
if (state.lastTimestamps.length < options.maxRPS) {
|
||||||
state.activeRequests++;
|
// process requests if RPS not exceeded
|
||||||
const item = state.queue.shift();
|
const item = state.queue.shift();
|
||||||
if (!item) break;
|
if (!item) return;
|
||||||
const { args, resolve } = item;
|
state.lastTimestamps.push(Date.now());
|
||||||
|
const { args, resolve, reject } = item;
|
||||||
fn(...args)
|
fn(...args)
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.finally(() => {
|
.catch(reject);
|
||||||
state.activeRequests--;
|
processQueue();
|
||||||
if (state.queue.length > 0) {
|
|
||||||
if (!state.timer) {
|
|
||||||
state.timer = setInterval(processQueue, 1000);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (state.timer) {
|
// rerun once the oldest item in queue is older than 1s
|
||||||
clearInterval(state.timer);
|
if (state.timeout) clearTimeout(state.timeout);
|
||||||
state.timer = null;
|
state.timeout = setTimeout(
|
||||||
}
|
processQueue,
|
||||||
}
|
1000 - (Date.now() - state.lastTimestamps[0])
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (...args: Parameters<T>): Promise<U> => {
|
return (...args: Parameters<T>): Promise<U> => {
|
||||||
return new Promise<U>((resolve) => {
|
return new Promise<U>((resolve, reject) => {
|
||||||
state.queue.push({ args, resolve });
|
state.queue.push({ args, resolve, reject });
|
||||||
processQueue();
|
processQueue();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
|
|||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.network.logoPath ? (
|
{firstResultData?.network.logoPath ? (
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
alt={firstResultData.network.name}
|
alt={firstResultData.network.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="object-contain"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
|
|||||||
<div className="mt-1 mb-5">
|
<div className="mt-1 mb-5">
|
||||||
<Header>
|
<Header>
|
||||||
{firstResultData?.studio.logoPath ? (
|
{firstResultData?.studio.logoPath ? (
|
||||||
<div className="mb-6 flex justify-center">
|
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
alt={firstResultData.studio.name}
|
alt={firstResultData.studio.name}
|
||||||
className="max-h-24 sm:max-h-32"
|
className="object-contain"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ const IssueComment = ({
|
|||||||
`/api/v1/issueComment/${comment.id}`,
|
`/api/v1/issueComment/${comment.id}`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({ message: values.newMessage }),
|
body: JSON.stringify({ message: values.newMessage }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ const IssueDetails = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
|
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({ message: newMessage }),
|
body: JSON.stringify({ message: newMessage }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
@@ -501,6 +504,9 @@ const IssueDetails = () => {
|
|||||||
`/api/v1/issue/${issueData?.id}/comment`,
|
`/api/v1/issue/${issueData?.id}/comment`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({ message: values.message }),
|
body: JSON.stringify({ message: values.message }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -90,9 +90,11 @@ const UserDropdown = () => {
|
|||||||
<span className="truncate text-xl font-semibold text-gray-200">
|
<span className="truncate text-xl font-semibold text-gray-200">
|
||||||
{user?.displayName}
|
{user?.displayName}
|
||||||
</span>
|
</span>
|
||||||
|
{user?.displayName?.toLowerCase() !== user?.email && (
|
||||||
<span className="truncate text-sm text-gray-400">
|
<span className="truncate text-sm text-gray-400">
|
||||||
{user?.email}
|
{user?.email}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user && <MiniQuotaDisplay userId={user?.id} />}
|
{user && <MiniQuotaDisplay userId={user?.id} />}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/jellyfin', {
|
const res = await fetch('/api/v1/auth/jellyfin', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
|
|||||||
@@ -119,10 +119,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
let errorMessage = null;
|
let errorMessage = null;
|
||||||
switch (e.response?.data?.message) {
|
switch (errorData?.message) {
|
||||||
case ApiErrorCode.InvalidUrl:
|
case ApiErrorCode.InvalidUrl:
|
||||||
errorMessage = messages.invalidurlerror;
|
errorMessage = messages.invalidurlerror;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -45,16 +45,26 @@ const Login = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/plex', {
|
const res = await fetch('/api/v1/auth/plex', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({ authToken }),
|
body: JSON.stringify({ authToken }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data?.id) {
|
if (data?.id) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.response.data.message);
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
setError(errorData?.message);
|
||||||
setAuthToken(undefined);
|
setAuthToken(undefined);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,44 +57,48 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
|
|||||||
>
|
>
|
||||||
<div style={{ paddingBottom: '150%' }}>
|
<div style={{ paddingBottom: '150%' }}>
|
||||||
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
<div className="absolute inset-0 flex h-full w-full flex-col items-center p-2">
|
||||||
<div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
|
<div className="relative z-10 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
||||||
{posters[0] && (
|
{posters[0] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[1] && (
|
{posters[1] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[2] && (
|
{posters[2] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[3] && (
|
{posters[3] && (
|
||||||
<div className="w-1/2 p-1">
|
<div className="">
|
||||||
<Image
|
<Image
|
||||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="w-full rounded-md"
|
className="rounded-md"
|
||||||
fill
|
width={300}
|
||||||
|
height={450}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
|||||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
@@ -25,7 +26,7 @@ import useLocale from '@app/hooks/useLocale';
|
|||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import Error from '@app/pages/_error';
|
import ErrorPage from '@app/pages/_error';
|
||||||
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
import { sortCrewPriority } from '@app/utils/creditHelpers';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
@@ -41,10 +42,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
ChevronDoubleDownIcon,
|
ChevronDoubleDownIcon,
|
||||||
ChevronDoubleUpIcon,
|
ChevronDoubleUpIcon,
|
||||||
|
MinusCircleIcon,
|
||||||
|
StarIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { type RatingResponse } from '@server/api/ratings';
|
import { type RatingResponse } from '@server/api/ratings';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
import type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
|
||||||
import { countries } from 'country-flag-icons';
|
import { countries } from 'country-flag-icons';
|
||||||
@@ -55,6 +58,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.MovieDetails', {
|
const messages = defineMessages('components.MovieDetails', {
|
||||||
@@ -94,6 +98,12 @@ const messages = defineMessages('components.MovieDetails', {
|
|||||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||||
tmdbuserscore: 'TMDB User Score',
|
tmdbuserscore: 'TMDB User Score',
|
||||||
imdbuserscore: 'IMDB User Score',
|
imdbuserscore: 'IMDB User Score',
|
||||||
|
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
|
||||||
|
watchlistDeleted:
|
||||||
|
'<strong>{title}</strong> Removed from watchlist successfully!',
|
||||||
|
watchlistError: 'Something went wrong try again.',
|
||||||
|
removefromwatchlist: 'Remove From Watchlist',
|
||||||
|
addtowatchlist: 'Add To Watchlist',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface MovieDetailsProps {
|
interface MovieDetailsProps {
|
||||||
@@ -112,7 +122,12 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
const minStudios = 3;
|
const minStudios = 3;
|
||||||
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
const [showMoreStudios, setShowMoreStudios] = useState(false);
|
||||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
|
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||||
|
!movie?.onUserWatchlist
|
||||||
|
);
|
||||||
const { publicRuntimeConfig } = getConfig();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -154,7 +169,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <Error statusCode={404} />;
|
return <ErrorPage statusCode={404} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||||
@@ -287,6 +302,80 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
|
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClickWatchlistBtn = async (): Promise<void> => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tmdbId: movie?.id,
|
||||||
|
mediaType: MediaType.MOVIE,
|
||||||
|
title: movie?.title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
addToast(intl.formatMessage(messages.watchlistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.watchlistSuccess, {
|
||||||
|
title: movie?.title,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/watchlist/${movie?.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.watchlistDeleted, {
|
||||||
|
title: movie?.title,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'info', autoDismiss: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addToast(intl.formatMessage(messages.watchlistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -408,6 +497,40 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-actions">
|
<div className="media-actions">
|
||||||
|
<>
|
||||||
|
{toggleWatchlist ? (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
||||||
|
<Button
|
||||||
|
buttonType={'ghost'}
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize={'md'}
|
||||||
|
onClick={onClickWatchlistBtn}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<StarIcon className={'h-3 text-amber-300'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.removefromwatchlist)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize={'md'}
|
||||||
|
onClick={onClickDeleteWatchlistBtn}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<MinusCircleIcon className={'h-3'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
<PlayButton links={mediaLinks} />
|
<PlayButton links={mediaLinks} />
|
||||||
<RequestButton
|
<RequestButton
|
||||||
mediaType="movie"
|
mediaType="movie"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -58,7 +59,7 @@ const RequestCardPlaceholder = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardErrorProps {
|
interface RequestCardErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||||
@@ -213,7 +214,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest>;
|
||||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +239,9 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
data: requestData,
|
data: requestData,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
||||||
|
`/api/v1/request/${request.id}`,
|
||||||
|
{
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
refreshInterval: refreshIntervalHelper(
|
refreshInterval: refreshIntervalHelper(
|
||||||
{
|
{
|
||||||
@@ -247,7 +250,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
},
|
},
|
||||||
15000
|
15000
|
||||||
),
|
),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
mediaUrl: requestData?.media?.mediaUrl,
|
mediaUrl: requestData?.media?.mediaUrl,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import { MediaRequestStatus } from '@server/constants/media';
|
import { MediaRequestStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -42,6 +43,7 @@ const messages = defineMessages('components.RequestList.RequestItem', {
|
|||||||
tmdbid: 'TMDB ID',
|
tmdbid: 'TMDB ID',
|
||||||
tvdbid: 'TheTVDB ID',
|
tvdbid: 'TheTVDB ID',
|
||||||
unknowntitle: 'Unknown Title',
|
unknowntitle: 'Unknown Title',
|
||||||
|
profileName: 'Profile',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -49,7 +51,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemErrorProps {
|
interface RequestItemErrorProps {
|
||||||
requestData?: MediaRequest;
|
requestData?: NonFunctionProperties<MediaRequest>;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +287,7 @@ const RequestItemError = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemProps {
|
interface RequestItemProps {
|
||||||
request: MediaRequest;
|
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,9 +306,9 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
|
||||||
inView ? url : null
|
inView ? url : null
|
||||||
);
|
);
|
||||||
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
|
const { data: requestData, mutate: revalidate } = useSWR<
|
||||||
`/api/v1/request/${request.id}`,
|
NonFunctionProperties<MediaRequest>
|
||||||
{
|
>(`/api/v1/request/${request.id}`, {
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
refreshInterval: refreshIntervalHelper(
|
refreshInterval: refreshIntervalHelper(
|
||||||
{
|
{
|
||||||
@@ -315,8 +317,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
},
|
},
|
||||||
15000
|
15000
|
||||||
),
|
),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const [isRetrying, setRetrying] = useState(false);
|
const [isRetrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
@@ -401,7 +402,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-2 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -482,7 +483,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
<div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center gap-1 overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0">
|
||||||
<div className="card-field">
|
<div className="card-field">
|
||||||
<span className="card-field-name">
|
<span className="card-field-name">
|
||||||
{intl.formatMessage(globalMessages.status)}
|
{intl.formatMessage(globalMessages.status)}
|
||||||
@@ -632,6 +633,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{request.profileName && (
|
||||||
|
<div className="card-field">
|
||||||
|
<span className="card-field-name">
|
||||||
|
{intl.formatMessage(messages.profileName)}
|
||||||
|
</span>
|
||||||
|
<span className="flex truncate text-sm text-gray-300">
|
||||||
|
{request.profileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { MediaStatus } from '@server/constants/media';
|
import { MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
@@ -38,7 +39,7 @@ const messages = defineMessages('components.RequestModal', {
|
|||||||
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
|||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
import type SeasonRequest from '@server/entity/SeasonRequest';
|
import type SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
@@ -57,7 +58,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TvRequestModal = ({
|
const TvRequestModal = ({
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
|
|||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { MediaStatus } from '@server/constants/media';
|
import type { MediaStatus } from '@server/constants/media';
|
||||||
import type { MediaRequest } from '@server/entity/MediaRequest';
|
import type { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import type { NonFunctionProperties } from '@server/interfaces/api/common';
|
||||||
|
|
||||||
interface RequestModalProps {
|
interface RequestModalProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
type: 'movie' | 'tv' | 'collection';
|
type: 'movie' | 'tv' | 'collection';
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
is4k?: boolean;
|
is4k?: boolean;
|
||||||
editRequest?: MediaRequest;
|
editRequest?: NonFunctionProperties<MediaRequest>;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
import LibraryItem from '@app/components/Settings/LibraryItem';
|
import LibraryItem from '@app/components/Settings/LibraryItem';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
@@ -30,13 +31,14 @@ const messages = defineMessages('components.Settings', {
|
|||||||
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
|
||||||
jellyfinSettings: '{mediaServerName} Settings',
|
jellyfinSettings: '{mediaServerName} Settings',
|
||||||
jellyfinSettingsDescription:
|
jellyfinSettingsDescription:
|
||||||
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.',
|
'Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.',
|
||||||
externalUrl: 'External URL',
|
externalUrl: 'External URL',
|
||||||
hostname: 'Hostname or IP Address',
|
hostname: 'Hostname or IP Address',
|
||||||
port: 'Port',
|
port: 'Port',
|
||||||
enablessl: 'Use SSL',
|
enablessl: 'Use SSL',
|
||||||
urlBase: 'URL Base',
|
urlBase: 'URL Base',
|
||||||
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
jellyfinForgotPasswordUrl: 'Forgot Password URL',
|
||||||
|
apiKey: 'API key',
|
||||||
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
|
||||||
jellyfinSyncFailedAutomaticGroupedFolders:
|
jellyfinSyncFailedAutomaticGroupedFolders:
|
||||||
'Custom authentication with Automatic Library Grouping not supported',
|
'Custom authentication with Automatic Library Grouping not supported',
|
||||||
@@ -173,11 +175,18 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
revalidate();
|
revalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
if (errorData?.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
messages.jellyfinSyncFailedAutomaticGroupedFolders
|
messages.jellyfinSyncFailedAutomaticGroupedFolders
|
||||||
@@ -187,7 +196,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
appearance: 'warning',
|
appearance: 'warning',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
||||||
{
|
{
|
||||||
@@ -252,7 +261,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
|
|
||||||
const searchParams = new URLSearchParams(params.enable ? params : {});
|
const searchParams = new URLSearchParams(params.enable ? params : {});
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/v1/settings/jellyfin/library?${searchParams.toString}`
|
`/api/v1/settings/jellyfin/library?${searchParams.toString()}`
|
||||||
);
|
);
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
} else {
|
} else {
|
||||||
@@ -437,8 +446,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showAdvancedSettings && (
|
|
||||||
<>
|
|
||||||
<div className="mt-10 mb-6">
|
<div className="mt-10 mb-6">
|
||||||
<h3 className="heading">
|
<h3 className="heading">
|
||||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
@@ -467,6 +474,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
urlBase: data?.urlBase || '',
|
urlBase: data?.urlBase || '',
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
|
apiKey: data?.apiKey,
|
||||||
}}
|
}}
|
||||||
validationSchema={JellyfinSettingsSchema}
|
validationSchema={JellyfinSettingsSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -483,9 +491,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
urlBase: values.urlBase,
|
urlBase: values.urlBase,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
|
apiKey: values.apiKey,
|
||||||
} as JellyfinSettings),
|
} as JellyfinSettings),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
||||||
@@ -500,7 +509,14 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.invalidurlerror, {
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
mediaServerName:
|
mediaServerName:
|
||||||
@@ -543,6 +559,8 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<form className="section" onSubmit={handleSubmit}>
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
|
{showAdvancedSettings && (
|
||||||
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="hostname" className="text-label">
|
<label htmlFor="hostname" className="text-label">
|
||||||
{intl.formatMessage(messages.hostname)}
|
{intl.formatMessage(messages.hostname)}
|
||||||
@@ -604,6 +622,29 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="apiKey" className="text-label">
|
||||||
|
{intl.formatMessage(messages.apiKey)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<SensitiveInput
|
||||||
|
as="field"
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="apiKey"
|
||||||
|
name="apiKey"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.apiKey && touched.apiKey && (
|
||||||
|
<div className="error">{errors.apiKey}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showAdvancedSettings && (
|
||||||
|
<>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="urlBase" className="text-label">
|
<label htmlFor="urlBase" className="text-label">
|
||||||
{intl.formatMessage(messages.urlBase)}
|
{intl.formatMessage(messages.urlBase)}
|
||||||
@@ -624,6 +665,8 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
||||||
{intl.formatMessage(messages.externalUrl)}
|
{intl.formatMessage(messages.externalUrl)}
|
||||||
@@ -639,9 +682,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{errors.jellyfinExternalUrl &&
|
{errors.jellyfinExternalUrl &&
|
||||||
touched.jellyfinExternalUrl && (
|
touched.jellyfinExternalUrl && (
|
||||||
<div className="error">
|
<div className="error">{errors.jellyfinExternalUrl}</div>
|
||||||
{errors.jellyfinExternalUrl}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -692,8 +733,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
}}
|
}}
|
||||||
</Formik>
|
</Formik>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg';
|
|||||||
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
import RTAudRotten from '@app/assets/rt_aud_rotten.svg';
|
||||||
import RTFresh from '@app/assets/rt_fresh.svg';
|
import RTFresh from '@app/assets/rt_fresh.svg';
|
||||||
import RTRotten from '@app/assets/rt_rotten.svg';
|
import RTRotten from '@app/assets/rt_rotten.svg';
|
||||||
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
@@ -40,11 +41,19 @@ import {
|
|||||||
FilmIcon,
|
FilmIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
MinusCircleIcon,
|
||||||
|
StarIcon,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
import {
|
||||||
|
MediaRequestStatus,
|
||||||
|
MediaStatus,
|
||||||
|
MediaType,
|
||||||
|
} from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { Crew } from '@server/models/common';
|
import type { Crew } from '@server/models/common';
|
||||||
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
|
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
|
||||||
@@ -55,6 +64,7 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.TvDetails', {
|
const messages = defineMessages('components.TvDetails', {
|
||||||
@@ -89,6 +99,12 @@ const messages = defineMessages('components.TvDetails', {
|
|||||||
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
|
||||||
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
rtaudiencescore: 'Rotten Tomatoes Audience Score',
|
||||||
tmdbuserscore: 'TMDB User Score',
|
tmdbuserscore: 'TMDB User Score',
|
||||||
|
watchlistSuccess: '<strong>{title}</strong> added to watchlist successfully!',
|
||||||
|
watchlistDeleted:
|
||||||
|
'<strong>{title}</strong> Removed from watchlist successfully!',
|
||||||
|
watchlistError: 'Something went wrong try again.',
|
||||||
|
removefromwatchlist: 'Remove From Watchlist',
|
||||||
|
addtowatchlist: 'Add To Watchlist',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TvDetailsProps {
|
interface TvDetailsProps {
|
||||||
@@ -106,7 +122,12 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
router.query.manage == '1' ? true : false
|
router.query.manage == '1' ? true : false
|
||||||
);
|
);
|
||||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
|
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||||
|
!tv?.onUserWatchlist
|
||||||
|
);
|
||||||
const { publicRuntimeConfig } = getConfig();
|
const { publicRuntimeConfig } = getConfig();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@@ -302,6 +323,82 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
|
return intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onClickWatchlistBtn = async (): Promise<void> => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tmdbId: tv?.id,
|
||||||
|
mediaType: MediaType.TV,
|
||||||
|
title: tv?.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
addToast(intl.formatMessage(messages.watchlistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.watchlistSuccess, {
|
||||||
|
title: tv?.name,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickDeleteWatchlistBtn = async (): Promise<void> => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/watchlist/' + tv?.id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
addToast(intl.formatMessage(messages.watchlistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsUpdating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.watchlistDeleted, {
|
||||||
|
title: tv?.name,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'info', autoDismiss: true }
|
||||||
|
);
|
||||||
|
setIsUpdating(false);
|
||||||
|
setToggleWatchlist((prevState) => !prevState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -433,6 +530,40 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-actions">
|
<div className="media-actions">
|
||||||
|
<>
|
||||||
|
{toggleWatchlist ? (
|
||||||
|
<Tooltip content={intl.formatMessage(messages.addtowatchlist)}>
|
||||||
|
<Button
|
||||||
|
buttonType={'ghost'}
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize={'md'}
|
||||||
|
onClick={onClickWatchlistBtn}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<StarIcon className={'h-3 text-amber-300'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.removefromwatchlist)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize={'md'}
|
||||||
|
onClick={onClickDeleteWatchlistBtn}
|
||||||
|
>
|
||||||
|
{isUpdating ? (
|
||||||
|
<Spinner className="h-3" />
|
||||||
|
) : (
|
||||||
|
<MinusCircleIcon className={'h-3'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
<PlayButton links={mediaLinks} />
|
<PlayButton links={mediaLinks} />
|
||||||
<RequestButton
|
<RequestButton
|
||||||
mediaType="tv"
|
mediaType="tv"
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
|||||||
`/api/v1/user?take=${children}`
|
`/api/v1/user?take=${children}`
|
||||||
);
|
);
|
||||||
|
|
||||||
data?.forEach((user, pos) => {
|
|
||||||
if (
|
|
||||||
existingUsers?.results.some((data) => data.jellyfinUserId === user.id)
|
|
||||||
) {
|
|
||||||
data?.splice(pos, 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const importUsers = async () => {
|
const importUsers = async () => {
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
|
|
||||||
@@ -78,7 +70,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const { data: createdUsers } = await res.json();
|
const createdUsers = await res.json();
|
||||||
|
|
||||||
if (!createdUsers.length) {
|
if (!createdUsers.length) {
|
||||||
throw new Error('No users were imported from Jellyfin.');
|
throw new Error('No users were imported from Jellyfin.');
|
||||||
@@ -209,7 +201,14 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700 bg-gray-600">
|
<tbody className="divide-y divide-gray-700 bg-gray-600">
|
||||||
{data?.map((user) => (
|
{data
|
||||||
|
?.filter(
|
||||||
|
(user) =>
|
||||||
|
!existingUsers?.results.some(
|
||||||
|
(u) => u.jellyfinUserId === user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((user) => (
|
||||||
<tr key={`user-${user.id}`}>
|
<tr key={`user-${user.id}`}>
|
||||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -68,14 +68,15 @@ const messages = defineMessages('components.UserList', {
|
|||||||
usercreatedfailedexisting:
|
usercreatedfailedexisting:
|
||||||
'The provided email address is already in use by another user.',
|
'The provided email address is already in use by another user.',
|
||||||
usercreatedsuccess: 'User created successfully!',
|
usercreatedsuccess: 'User created successfully!',
|
||||||
displayName: 'Display Name',
|
username: 'Username',
|
||||||
email: 'Email Address',
|
email: 'Email Address',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
passwordinfodescription:
|
passwordinfodescription:
|
||||||
'Configure an application URL and enable email notifications to allow automatic password generation.',
|
'Configure an application URL and enable email notifications to allow automatic password generation.',
|
||||||
autogeneratepassword: 'Automatically Generate Password',
|
autogeneratepassword: 'Automatically Generate Password',
|
||||||
autogeneratepasswordTip: 'Email a server-generated password to the user',
|
autogeneratepasswordTip: 'Email a server-generated password to the user',
|
||||||
validationEmail: 'You must provide a valid email address',
|
validationUsername: 'You must provide an username',
|
||||||
|
validationEmail: 'Email required',
|
||||||
sortCreated: 'Join Date',
|
sortCreated: 'Join Date',
|
||||||
sortDisplayName: 'Display Name',
|
sortDisplayName: 'Display Name',
|
||||||
sortRequests: 'Request Count',
|
sortRequests: 'Request Count',
|
||||||
@@ -208,9 +209,10 @@ const UserList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreateUserSchema = Yup.object().shape({
|
const CreateUserSchema = Yup.object().shape({
|
||||||
email: Yup.string()
|
username: Yup.string().required(
|
||||||
.required(intl.formatMessage(messages.validationEmail))
|
intl.formatMessage(messages.validationUsername)
|
||||||
.email(intl.formatMessage(messages.validationEmail)),
|
),
|
||||||
|
email: Yup.string().email(intl.formatMessage(messages.validationEmail)),
|
||||||
password: Yup.lazy((value) =>
|
password: Yup.lazy((value) =>
|
||||||
!value
|
!value
|
||||||
? Yup.string()
|
? Yup.string()
|
||||||
@@ -258,7 +260,7 @@ const UserList = () => {
|
|||||||
setDeleteModal({ isOpen: false, user: deleteModal.user })
|
setDeleteModal({ isOpen: false, user: deleteModal.user })
|
||||||
}
|
}
|
||||||
title={intl.formatMessage(messages.deleteuser)}
|
title={intl.formatMessage(messages.deleteuser)}
|
||||||
subTitle={deleteModal.user?.displayName}
|
subTitle={deleteModal.user?.username}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.deleteconfirm)}
|
{intl.formatMessage(messages.deleteconfirm)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -276,7 +278,7 @@ const UserList = () => {
|
|||||||
>
|
>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
displayName: '',
|
username: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
genpassword: false,
|
genpassword: false,
|
||||||
@@ -290,21 +292,28 @@ const UserList = () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: values.displayName,
|
username: values.username,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.genpassword ? null : values.password,
|
password: values.genpassword ? null : values.password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
||||||
addToast(intl.formatMessage(messages.usercreatedsuccess), {
|
addToast(intl.formatMessage(messages.usercreatedsuccess), {
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
setCreateModal({ isOpen: false });
|
setCreateModal({ isOpen: false });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
let errorData;
|
||||||
|
try {
|
||||||
|
errorData = await e.cause?.text();
|
||||||
|
errorData = JSON.parse(errorData);
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(
|
intl.formatMessage(
|
||||||
e.response.data.errors?.includes('USER_EXISTS')
|
errorData.errors?.includes('USER_EXISTS')
|
||||||
? messages.usercreatedfailedexisting
|
? messages.usercreatedfailedexisting
|
||||||
: messages.usercreatedfailed
|
: messages.usercreatedfailed
|
||||||
),
|
),
|
||||||
@@ -363,23 +372,24 @@ const UserList = () => {
|
|||||||
)}
|
)}
|
||||||
<Form className="section">
|
<Form className="section">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="displayName" className="text-label">
|
<label htmlFor="username" className="text-label">
|
||||||
{intl.formatMessage(messages.displayName)}
|
{intl.formatMessage(messages.username)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
<Field
|
<Field id="username" name="username" type="text" />
|
||||||
id="displayName"
|
|
||||||
name="displayName"
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{errors.username &&
|
||||||
|
touched.username &&
|
||||||
|
typeof errors.username === 'string' && (
|
||||||
|
<div className="error">{errors.username}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="email" className="text-label">
|
<label htmlFor="email" className="text-label">
|
||||||
{intl.formatMessage(messages.email)}
|
{intl.formatMessage(messages.email)}
|
||||||
<span className="label-required">*</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
@@ -638,9 +648,16 @@ const UserList = () => {
|
|||||||
className="text-base font-bold leading-5 transition duration-300 hover:underline"
|
className="text-base font-bold leading-5 transition duration-300 hover:underline"
|
||||||
data-testid="user-list-username-link"
|
data-testid="user-list-username-link"
|
||||||
>
|
>
|
||||||
{user.displayName}
|
{user.username ||
|
||||||
|
user.jellyfinUsername ||
|
||||||
|
user.plexUsername ||
|
||||||
|
user.email}
|
||||||
</Link>
|
</Link>
|
||||||
{user.displayName.toLowerCase() !== user.email && (
|
{(
|
||||||
|
user.username ||
|
||||||
|
user.jellyfinUsername ||
|
||||||
|
user.plexUsername
|
||||||
|
)?.toLowerCase() !== user.email && (
|
||||||
<div className="text-sm leading-5 text-gray-300">
|
<div className="text-sm leading-5 text-gray-300">
|
||||||
{user.email}
|
{user.email}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,9 +93,14 @@ const UserGeneralSettings = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const UserGeneralSettingsSchema = Yup.object().shape({
|
const UserGeneralSettingsSchema = Yup.object().shape({
|
||||||
email: Yup.string()
|
email:
|
||||||
|
user?.id === 1
|
||||||
|
? Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
.required(intl.formatMessage(messages.validationemailrequired))
|
||||||
|
: Yup.string().email(
|
||||||
|
intl.formatMessage(messages.validationemailformat)
|
||||||
|
),
|
||||||
discordId: Yup.string()
|
discordId: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
|
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
|
||||||
@@ -134,7 +139,7 @@ const UserGeneralSettings = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
displayName: data?.username ?? '',
|
displayName: data?.username ?? '',
|
||||||
email: data?.email ?? '',
|
email: data?.email?.includes('@') ? data.email : '',
|
||||||
discordId: data?.discordId ?? '',
|
discordId: data?.discordId ?? '',
|
||||||
locale: data?.locale,
|
locale: data?.locale,
|
||||||
region: data?.region,
|
region: data?.region,
|
||||||
@@ -157,7 +162,8 @@ const UserGeneralSettings = () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: values.displayName,
|
username: values.displayName,
|
||||||
email: values.email,
|
email:
|
||||||
|
values.email || user?.jellyfinUsername || user?.plexUsername,
|
||||||
discordId: values.discordId,
|
discordId: values.discordId,
|
||||||
locale: values.locale,
|
locale: values.locale,
|
||||||
region: values.region,
|
region: values.region,
|
||||||
@@ -264,7 +270,9 @@ const UserGeneralSettings = () => {
|
|||||||
name="displayName"
|
name="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={
|
placeholder={
|
||||||
user?.plexUsername ? user.plexUsername : user?.email
|
user?.username ||
|
||||||
|
user?.jellyfinUsername ||
|
||||||
|
user?.plexUsername
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,6 +297,7 @@ const UserGeneralSettings = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="example@domain.com"
|
placeholder="example@domain.com"
|
||||||
|
disabled={user?.plexUsername}
|
||||||
className={
|
className={
|
||||||
user?.warnings.find((w) => w === 'userEmailRequired')
|
user?.warnings.find((w) => w === 'userEmailRequired')
|
||||||
? 'border-2 border-red-400 focus:border-blue-600'
|
? 'border-2 border-red-400 focus:border-blue-600'
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface User {
|
|||||||
id: number;
|
id: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
plexUsername?: string;
|
plexUsername?: string;
|
||||||
|
jellyfinUsername?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
@@ -286,6 +286,7 @@
|
|||||||
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
||||||
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
||||||
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
|
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
|
||||||
|
"components.MovieDetails.addtowatchlist": "Add To Watchlist",
|
||||||
"components.MovieDetails.budget": "Budget",
|
"components.MovieDetails.budget": "Budget",
|
||||||
"components.MovieDetails.cast": "Cast",
|
"components.MovieDetails.cast": "Cast",
|
||||||
"components.MovieDetails.digitalrelease": "Digital Release",
|
"components.MovieDetails.digitalrelease": "Digital Release",
|
||||||
@@ -306,6 +307,7 @@
|
|||||||
"components.MovieDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
|
"components.MovieDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
|
||||||
"components.MovieDetails.recommendations": "Recommendations",
|
"components.MovieDetails.recommendations": "Recommendations",
|
||||||
"components.MovieDetails.releasedate": "{releaseCount, plural, one {Release Date} other {Release Dates}}",
|
"components.MovieDetails.releasedate": "{releaseCount, plural, one {Release Date} other {Release Dates}}",
|
||||||
|
"components.MovieDetails.removefromwatchlist": "Remove From Watchlist",
|
||||||
"components.MovieDetails.reportissue": "Report an Issue",
|
"components.MovieDetails.reportissue": "Report an Issue",
|
||||||
"components.MovieDetails.revenue": "Revenue",
|
"components.MovieDetails.revenue": "Revenue",
|
||||||
"components.MovieDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
|
"components.MovieDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
|
||||||
@@ -319,6 +321,9 @@
|
|||||||
"components.MovieDetails.theatricalrelease": "Theatrical Release",
|
"components.MovieDetails.theatricalrelease": "Theatrical Release",
|
||||||
"components.MovieDetails.tmdbuserscore": "TMDB User Score",
|
"components.MovieDetails.tmdbuserscore": "TMDB User Score",
|
||||||
"components.MovieDetails.viewfullcrew": "View Full Crew",
|
"components.MovieDetails.viewfullcrew": "View Full Crew",
|
||||||
|
"components.MovieDetails.watchlistDeleted": "<strong>{title}</strong> Removed from watchlist successfully!",
|
||||||
|
"components.MovieDetails.watchlistError": "Something went wrong try again.",
|
||||||
|
"components.MovieDetails.watchlistSuccess": "<strong>{title}</strong> added to watchlist successfully!",
|
||||||
"components.MovieDetails.watchtrailer": "Watch Trailer",
|
"components.MovieDetails.watchtrailer": "Watch Trailer",
|
||||||
"components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.",
|
"components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.",
|
||||||
"components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.",
|
"components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.",
|
||||||
@@ -465,6 +470,7 @@
|
|||||||
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
|
"components.RequestList.RequestItem.mediaerror": "{mediaType} Not Found",
|
||||||
"components.RequestList.RequestItem.modified": "Modified",
|
"components.RequestList.RequestItem.modified": "Modified",
|
||||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||||
|
"components.RequestList.RequestItem.profileName": "Profile",
|
||||||
"components.RequestList.RequestItem.requested": "Requested",
|
"components.RequestList.RequestItem.requested": "Requested",
|
||||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
@@ -949,7 +955,7 @@
|
|||||||
"components.Settings.is4k": "4K",
|
"components.Settings.is4k": "4K",
|
||||||
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
||||||
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
"components.Settings.jellyfinSettings": "{mediaServerName} Settings",
|
||||||
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page.",
|
"components.Settings.jellyfinSettingsDescription": "Optionally configure the internal and external endpoints for your {mediaServerName} server. In most cases, the external URL is different to the internal URL. A custom password reset URL can also be set for {mediaServerName} login, in case you would like to redirect to a different password reset page. You can also change the Jellyfin API key, which was automatically generated previously.",
|
||||||
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
|
"components.Settings.jellyfinSettingsFailure": "Something went wrong while saving {mediaServerName} settings.",
|
||||||
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
|
"components.Settings.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
|
||||||
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
|
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
|
||||||
@@ -1070,6 +1076,7 @@
|
|||||||
"components.TvDetails.Season.somethingwentwrong": "Something went wrong while retrieving season data.",
|
"components.TvDetails.Season.somethingwentwrong": "Something went wrong while retrieving season data.",
|
||||||
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
|
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
|
||||||
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
|
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
|
||||||
|
"components.TvDetails.addtowatchlist": "Add To Watchlist",
|
||||||
"components.TvDetails.anime": "Anime",
|
"components.TvDetails.anime": "Anime",
|
||||||
"components.TvDetails.cast": "Cast",
|
"components.TvDetails.cast": "Cast",
|
||||||
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}",
|
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}",
|
||||||
@@ -1087,6 +1094,7 @@
|
|||||||
"components.TvDetails.play4k": "Play 4K on {mediaServerName}",
|
"components.TvDetails.play4k": "Play 4K on {mediaServerName}",
|
||||||
"components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
|
"components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
|
||||||
"components.TvDetails.recommendations": "Recommendations",
|
"components.TvDetails.recommendations": "Recommendations",
|
||||||
|
"components.TvDetails.removefromwatchlist": "Remove From Watchlist",
|
||||||
"components.TvDetails.reportissue": "Report an Issue",
|
"components.TvDetails.reportissue": "Report an Issue",
|
||||||
"components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
|
"components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
|
||||||
"components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer",
|
"components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer",
|
||||||
@@ -1099,6 +1107,9 @@
|
|||||||
"components.TvDetails.streamingproviders": "Currently Streaming On",
|
"components.TvDetails.streamingproviders": "Currently Streaming On",
|
||||||
"components.TvDetails.tmdbuserscore": "TMDB User Score",
|
"components.TvDetails.tmdbuserscore": "TMDB User Score",
|
||||||
"components.TvDetails.viewfullcrew": "View Full Crew",
|
"components.TvDetails.viewfullcrew": "View Full Crew",
|
||||||
|
"components.TvDetails.watchlistDeleted": "<strong>{title}</strong> Removed from watchlist successfully!",
|
||||||
|
"components.TvDetails.watchlistError": "Something went wrong try again.",
|
||||||
|
"components.TvDetails.watchlistSuccess": "<strong>{title}</strong> added to watchlist successfully!",
|
||||||
"components.TvDetails.watchtrailer": "Watch Trailer",
|
"components.TvDetails.watchtrailer": "Watch Trailer",
|
||||||
"components.UserList.accounttype": "Type",
|
"components.UserList.accounttype": "Type",
|
||||||
"components.UserList.admin": "Admin",
|
"components.UserList.admin": "Admin",
|
||||||
@@ -1111,7 +1122,6 @@
|
|||||||
"components.UserList.creating": "Creating…",
|
"components.UserList.creating": "Creating…",
|
||||||
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
|
"components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.",
|
||||||
"components.UserList.deleteuser": "Delete User",
|
"components.UserList.deleteuser": "Delete User",
|
||||||
"components.UserList.displayName": "Display Name",
|
|
||||||
"components.UserList.edituser": "Edit User Permissions",
|
"components.UserList.edituser": "Edit User Permissions",
|
||||||
"components.UserList.email": "Email Address",
|
"components.UserList.email": "Email Address",
|
||||||
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
|
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
|
||||||
@@ -1145,9 +1155,11 @@
|
|||||||
"components.UserList.userdeleteerror": "Something went wrong while deleting the user.",
|
"components.UserList.userdeleteerror": "Something went wrong while deleting the user.",
|
||||||
"components.UserList.userfail": "Something went wrong while saving user permissions.",
|
"components.UserList.userfail": "Something went wrong while saving user permissions.",
|
||||||
"components.UserList.userlist": "User List",
|
"components.UserList.userlist": "User List",
|
||||||
|
"components.UserList.username": "Username",
|
||||||
"components.UserList.users": "Users",
|
"components.UserList.users": "Users",
|
||||||
"components.UserList.userssaved": "User permissions saved successfully!",
|
"components.UserList.userssaved": "User permissions saved successfully!",
|
||||||
"components.UserList.validationEmail": "You must provide a valid email address",
|
"components.UserList.validationEmail": "Email required",
|
||||||
|
"components.UserList.validationUsername": "You must provide an username",
|
||||||
"components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
|
"components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
|
||||||
"components.UserProfile.ProfileHeader.joindate": "Joined {joindate}",
|
"components.UserProfile.ProfileHeader.joindate": "Joined {joindate}",
|
||||||
"components.UserProfile.ProfileHeader.profile": "View Profile",
|
"components.UserProfile.ProfileHeader.profile": "View Profile",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
59
src/i18n/locale/sl.json
Normal file
59
src/i18n/locale/sl.json
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"components.Discover.CreateSlider.editsuccess": "Urejen drsnik in shranjene nastavitve prilagajanja odkrivanja.",
|
||||||
|
"components.CollectionDetails.numberofmovies": "{count} film/ov",
|
||||||
|
"components.Discover.CreateSlider.slidernameplaceholder": "Ime drsnika",
|
||||||
|
"components.Discover.DiscoverTv.sortFirstAirDateAsc": "Premiera ↓",
|
||||||
|
"components.AppDataWarning.dockerVolumeMissingDescription": "Pripenjanje nosilca <code>{appDataPath}</code> ni bilo pravilno konfigurirano. Vsi podatki bodo izbrisani, ko se vsebnik zaustavi ali znova zažene.",
|
||||||
|
"components.Discover.DiscoverMovies.sortPopularityDesc": "Priljubljenost ↑",
|
||||||
|
"components.AirDateBadge.airsrelative": "Predvajanje {relativeTime}",
|
||||||
|
"components.CollectionDetails.overview": "Pregled",
|
||||||
|
"components.Discover.DiscoverMovies.activefilters": "{count, plural, one {# Active Filter} drugo {# Active Filters}}",
|
||||||
|
"components.Discover.DiscoverMovies.sortTmdbRatingAsc": "Ocena TMDB ↓",
|
||||||
|
"components.AirDateBadge.airedrelative": "Predvajano {relativeTime}",
|
||||||
|
"components.Discover.CreateSlider.searchStudios": "Iskanje studiev …",
|
||||||
|
"components.Discover.DiscoverMovies.sortReleaseDateDesc": "Datum izdaje ↑",
|
||||||
|
"components.Discover.CreateSlider.providetmdbnetwork": "Navedite ID omrežja TMDB",
|
||||||
|
"components.Discover.CreateSlider.addfail": "Novega drsnika ni bilo mogoče ustvariti.",
|
||||||
|
"components.CollectionDetails.requestcollection": "Zahtevaj zbirko",
|
||||||
|
"components.Discover.DiscoverMovieGenre.genreMovies": "Filmi: {genre}",
|
||||||
|
"components.Discover.DiscoverMovieLanguage.languageMovies": "Filmi: {language}",
|
||||||
|
"components.Discover.DiscoverTv.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}",
|
||||||
|
"components.Discover.DiscoverMovies.sortPopularityAsc": "Priljubljenost ↓",
|
||||||
|
"components.Discover.CreateSlider.needresults": "Imeti morate vsaj 1 rezultat.",
|
||||||
|
"components.Discover.CreateSlider.addcustomslider": "Ustvari drsnik po meri",
|
||||||
|
"components.Discover.DiscoverTv.sortPopularityAsc": "Priljubljenost ↓",
|
||||||
|
"components.Discover.CreateSlider.editSlider": "Uredi drsnik",
|
||||||
|
"components.Discover.DiscoverTv.sortTitleAsc": "Naslov (a-ž) ↓",
|
||||||
|
"components.Discover.CreateSlider.validationDatarequired": "Navesti morate vrednost podatkov.",
|
||||||
|
"components.Discover.DiscoverTv.sortFirstAirDateDesc": "Premiera ↑",
|
||||||
|
"components.Discover.DiscoverTv.discovertv": "Serije",
|
||||||
|
"components.Discover.DiscoverSliderEdit.deletefail": "Drsnika ni bilo mogoče izbrisati.",
|
||||||
|
"components.Discover.CreateSlider.providetmdbstudio": "Navedite ID studia v TMDB",
|
||||||
|
"components.Discover.DiscoverMovies.sortTitleDesc": "Naslov (a-ž) ↑",
|
||||||
|
"components.Discover.DiscoverStudio.studioMovies": "{studio} filmi",
|
||||||
|
"components.Discover.DiscoverTv.sortPopularityDesc": "Priljubljenost ↑",
|
||||||
|
"components.Discover.CreateSlider.searchGenres": "Išči žanre …",
|
||||||
|
"components.Discover.CreateSlider.editfail": "Drsnika ni bilo mogoče urediti.",
|
||||||
|
"components.Discover.CreateSlider.starttyping": "Tipkajte za iskanje.",
|
||||||
|
"components.Discover.DiscoverSliderEdit.enable": "Preklopi vidnost",
|
||||||
|
"components.Discover.CreateSlider.addSlider": "Dodaj drsnik",
|
||||||
|
"components.CollectionDetails.requestcollection4k": "Zahtevaj zbirko 4K",
|
||||||
|
"components.Discover.CreateSlider.providetmdbsearch": "Vnesite iskalno poizvedbo",
|
||||||
|
"components.Discover.DiscoverNetwork.networkSeries": "{network} serije",
|
||||||
|
"components.Discover.CreateSlider.providetmdbkeywordid": "Navedite ID ključne besede TMDB",
|
||||||
|
"components.Discover.DiscoverMovieKeyword.keywordMovies": "Filmi: {keywordTitle}",
|
||||||
|
"components.Discover.CreateSlider.validationTitlerequired": "Navesti morate naslov.",
|
||||||
|
"components.Discover.DiscoverMovies.sortReleaseDateAsc": "Datum izdaje ↓",
|
||||||
|
"components.Discover.CreateSlider.nooptions": "Ni zadetkov.",
|
||||||
|
"components.Discover.DiscoverMovies.sortTmdbRatingDesc": "Ocena TMDB ↑",
|
||||||
|
"components.Discover.CreateSlider.searchKeywords": "Iskanje po ključnih besedah …",
|
||||||
|
"components.Discover.CreateSlider.addsuccess": "Ustvarjen nov drsnik in shranjene nastavitve prilagajanja odkrivanja.",
|
||||||
|
"components.Discover.DiscoverSliderEdit.deletesuccess": "Drsnik je bil uspešno izbrisan.",
|
||||||
|
"components.Discover.DiscoverMovies.discovermovies": "Filmi",
|
||||||
|
"components.Discover.DiscoverMovies.sortTitleAsc": "Naslov (a-ž) ↓",
|
||||||
|
"components.Discover.CreateSlider.providetmdbgenreid": "Navedite ID žanra TMDB",
|
||||||
|
"components.Discover.DiscoverTv.sortTitleDesc": "Naslov (a-ž) ↑",
|
||||||
|
"components.Discover.DiscoverSliderEdit.remove": "Odstrani",
|
||||||
|
"components.Discover.DiscoverTv.sortTmdbRatingAsc": "Ocena TMDB ↓",
|
||||||
|
"components.Discover.DiscoverTvGenre.genreSeries": "{genre} Series"
|
||||||
|
}
|
||||||
1308
src/i18n/locale/tr.json
Normal file
1308
src/i18n/locale/tr.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user