Compare commits
21 Commits
pr-934
...
preview-fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7528a7c266 | ||
|
|
8be79fcad8 | ||
|
|
e9d6046997 | ||
|
|
eddbf2d0ec | ||
|
|
6d9a1c596e | ||
|
|
c4054531cc | ||
|
|
98c5500967 | ||
|
|
943d84e4d5 | ||
|
|
faec3260f6 | ||
|
|
6bcaa672ad | ||
|
|
15568b1f6b | ||
|
|
0f6ba544f8 | ||
|
|
27cd125532 | ||
|
|
8f6cbefbb3 | ||
|
|
1b2b137760 | ||
|
|
543341d9f9 | ||
|
|
5865bfac96 | ||
|
|
c5e35aad05 | ||
|
|
a46cdb3a4f | ||
|
|
bebbd690b4 | ||
|
|
99443d2796 |
@@ -403,42 +403,6 @@
|
|||||||
"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 @gauthier-th
|
* @Fallenbagel
|
||||||
|
|||||||
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-47-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-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,15 +137,6 @@ 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 = {
|
||||||
username: 'Test User',
|
displayName: '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('#username').type(testUser.username);
|
cy.get('#displayName').type(testUser.displayName);
|
||||||
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,7 +248,6 @@ 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:
|
||||||
@@ -273,7 +272,6 @@ 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,10 +68,7 @@ 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);
|
||||||
@@ -85,7 +82,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
|
||||||
@@ -107,17 +104,8 @@ class ExternalAPI {
|
|||||||
...this.defaultHeaders,
|
...this.defaultHeaders,
|
||||||
...config?.headers,
|
...config?.headers,
|
||||||
},
|
},
|
||||||
body: data ? JSON.stringify(data) : undefined,
|
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) {
|
||||||
@@ -153,15 +141,6 @@ 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) {
|
||||||
@@ -178,22 +157,12 @@ 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;
|
||||||
@@ -228,17 +197,6 @@ 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);
|
||||||
});
|
});
|
||||||
@@ -254,15 +212,6 @@ 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) {
|
||||||
@@ -286,12 +235,7 @@ class ExternalAPI {
|
|||||||
...this.params,
|
...this.params,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
return (
|
return `${href}?${searchParams.toString()}`;
|
||||||
href +
|
|
||||||
(searchParams.toString().length
|
|
||||||
? '?' + searchParams.toString()
|
|
||||||
: searchParams.toString())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeCacheKey(
|
private serializeCacheKey(
|
||||||
@@ -306,24 +250,20 @@ class ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getDataFromResponse(response: Response) {
|
private async getDataFromResponse(response: Response) {
|
||||||
const contentType = response.headers.get('Content-Type');
|
const contentType = response.headers.get('Content-Type')?.split(';')[0];
|
||||||
if (contentType?.includes('application/json')) {
|
if (contentType === 'application/json') {
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} else if (
|
} else if (
|
||||||
contentType?.includes('application/xml') ||
|
contentType === 'application/xml' ||
|
||||||
contentType?.includes('text/html') ||
|
contentType === 'text/html' ||
|
||||||
contentType?.includes('text/plain')
|
contentType === '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,7 +93,9 @@ 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;
|
||||||
@@ -112,6 +114,9 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.jellyfinHost = jellyfinHost;
|
||||||
|
this.authToken = authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async login(
|
public async login(
|
||||||
@@ -147,7 +152,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.cause?.status;
|
const status = e.response?.status;
|
||||||
|
|
||||||
const networkErrorCodes = new Set([
|
const networkErrorCodes = new Set([
|
||||||
'ECONNREFUSED',
|
'ECONNREFUSED',
|
||||||
@@ -185,7 +190,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
|
|
||||||
return systemInfoResponse;
|
return systemInfoResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +207,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +222,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +238,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +317,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +338,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +353,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (availabilitySync.running) {
|
if (availabilitySync.running) {
|
||||||
if (e.cause?.status === 500) {
|
if (e.response && e.response.status === 500) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,7 +362,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.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,7 +377,7 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API' }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,23 +402,6 @@ 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,11 +175,7 @@ class IMDBRadarrProxy extends ExternalAPI {
|
|||||||
`/movie/imdb/${IMDBid}`
|
`/movie/imdb/${IMDBid}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!data?.length || data[0].ImdbId !== IMDBid) {
|
||||||
!data?.length ||
|
|
||||||
data[0].ImdbId !== IMDBid ||
|
|
||||||
!data[0].MovieRatings.Imdb
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,20 +179,13 @@ 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: errorData,
|
response: e?.response?.data,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
throw new Error('Failed to add movie to Radarr');
|
throw new Error('Failed to add movie to Radarr');
|
||||||
|
|||||||
@@ -257,18 +257,11 @@ 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: errorData,
|
response: e?.response?.data,
|
||||||
});
|
});
|
||||||
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 = await getSettings().load();
|
const settings = getSettings();
|
||||||
restartFlag.initializeSettings(settings.main);
|
restartFlag.initializeSettings(settings.main);
|
||||||
|
|
||||||
// Migrate library types
|
// Migrate library types
|
||||||
|
|||||||
@@ -8,16 +8,3 @@ 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 { NonFunctionProperties, PaginatedResponse } from './common';
|
import type { PaginatedResponse } from './common';
|
||||||
|
|
||||||
export interface RequestResultsResponse extends PaginatedResponse {
|
export interface RequestResultsResponse extends PaginatedResponse {
|
||||||
results: NonFunctionProperties<MediaRequest>[];
|
results: MediaRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaRequestBody = {
|
export type MediaRequestBody = {
|
||||||
@@ -14,7 +14,6 @@ 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,7 +63,12 @@ class AvailabilitySync {
|
|||||||
) {
|
) {
|
||||||
admin = await userRepository.findOne({
|
admin = await userRepository.findOne({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,7 +86,7 @@ class AvailabilitySync {
|
|||||||
if (admin) {
|
if (admin) {
|
||||||
this.jellyfinClient = new JellyfinAPI(
|
this.jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -305,25 +305,15 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -132,32 +132,22 @@ class GotifyAgent
|
|||||||
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
await 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class LunaSeaAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: settings.options.profileName
|
headers: settings.options.profileName
|
||||||
? {
|
? {
|
||||||
@@ -114,25 +114,15 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -133,23 +133,13 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -174,7 +164,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -182,24 +172,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -235,7 +215,7 @@ class PushbulletAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -243,24 +223,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ 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 = (
|
||||||
@@ -67,17 +64,10 @@ class PushoverAgent
|
|||||||
attachment_type: contentType,
|
attachment_type: contentType,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
logger.error('Error getting image payload', {
|
logger.error('Error getting image payload', {
|
||||||
label: 'Notifications',
|
label: 'Notifications',
|
||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
response: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -210,7 +200,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -222,23 +212,13 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -266,7 +246,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -278,24 +258,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -332,7 +302,7 @@ class PushoverAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -343,24 +313,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -237,32 +237,22 @@ class SlackAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -185,23 +185,13 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -225,7 +215,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -237,24 +227,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -288,7 +268,7 @@ class TelegramAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -299,24 +279,14 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class WebhookAgent
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -187,25 +187,15 @@ 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: errorData,
|
response: e.response?.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -582,7 +582,12 @@ 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: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinUserId',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -592,7 +597,7 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
this.jfClient = new JellyfinAPI(
|
this.jfClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken,
|
||||||
admin.jellyfinDeviceId
|
admin.jellyfinDeviceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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;
|
||||||
@@ -343,7 +342,6 @@ class Settings {
|
|||||||
jellyfinForgotPasswordUrl: '',
|
jellyfinForgotPasswordUrl: '',
|
||||||
libraries: [],
|
libraries: [],
|
||||||
serverId: '',
|
serverId: '',
|
||||||
apiKey: '',
|
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
@@ -631,7 +629,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 async load(overrideSettings?: AllSettings): Promise<Settings> {
|
public load(overrideSettings?: AllSettings): Settings {
|
||||||
if (overrideSettings) {
|
if (overrideSettings) {
|
||||||
this.data = overrideSettings;
|
this.data = overrideSettings;
|
||||||
return this;
|
return this;
|
||||||
@@ -644,7 +642,7 @@ class Settings {
|
|||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const parsedJson = JSON.parse(data);
|
const parsedJson = JSON.parse(data);
|
||||||
this.data = await runMigrations(parsedJson);
|
this.data = runMigrations(parsedJson);
|
||||||
|
|
||||||
this.data = merge(this.data, parsedJson);
|
this.data = merge(this.data, parsedJson);
|
||||||
|
|
||||||
@@ -658,6 +656,7 @@ 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 => {
|
||||||
@@ -665,6 +664,11 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
|
|||||||
settings = new Settings(initialSettings);
|
settings = new Settings(initialSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
settings.load();
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
return settings;
|
return settings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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,13 +1,10 @@
|
|||||||
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 = async (
|
export const runMigrations = (settings: AllSettings): AllSettings => {
|
||||||
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'))
|
||||||
@@ -16,15 +13,8 @@ export const runMigrations = async (
|
|||||||
|
|
||||||
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,7 +85,6 @@ export interface MovieDetails {
|
|||||||
mediaUrl?: string;
|
mediaUrl?: string;
|
||||||
watchProviders?: WatchProviders[];
|
watchProviders?: WatchProviders[];
|
||||||
keywords: Keyword[];
|
keywords: Keyword[];
|
||||||
onUserWatchlist?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mapProductionCompany = (
|
export const mapProductionCompany = (
|
||||||
@@ -102,8 +101,7 @@ 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,
|
||||||
@@ -150,5 +148,4 @@ export const mapMovieDetails = (
|
|||||||
id: keyword.id,
|
id: keyword.id,
|
||||||
name: keyword.name,
|
name: keyword.name,
|
||||||
})),
|
})),
|
||||||
onUserWatchlist: userWatchlist,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ 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 => ({
|
||||||
@@ -162,8 +161,7 @@ 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,
|
||||||
@@ -225,5 +223,4 @@ export const mapTvDetails = (
|
|||||||
})),
|
})),
|
||||||
mediaInfo: media,
|
mediaInfo: media,
|
||||||
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
|
||||||
onUserWatchlist: userWatchlist,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -320,28 +320,18 @@ 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 || account.User.Name,
|
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: 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 || account.User.Name, {
|
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
|
||||||
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;
|
||||||
@@ -350,7 +340,6 @@ 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();
|
||||||
|
|
||||||
@@ -374,11 +363,15 @@ 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 || account.User.Name, {
|
user.avatar = gravatarUrl(user.email, {
|
||||||
default: 'mm',
|
default: 'mm',
|
||||||
size: 200,
|
size: 200,
|
||||||
});
|
});
|
||||||
@@ -420,18 +413,20 @@ 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 || account.User.Name, {
|
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
|
||||||
default: 'mm',
|
|
||||||
size: 200,
|
|
||||||
}),
|
|
||||||
userType: UserType.JELLYFIN,
|
userType: UserType.JELLYFIN,
|
||||||
});
|
});
|
||||||
//initialize Jellyfin/Emby users with local login
|
//initialize Jellyfin/Emby users with local login
|
||||||
@@ -735,7 +730,6 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
user.recoveryLinkExpirationDate = null;
|
user.recoveryLinkExpirationDate = null;
|
||||||
await user.setPassword(req.body.password);
|
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
logger.info('Successfully reset password', {
|
logger.info('Successfully reset password', {
|
||||||
label: 'API',
|
label: 'API',
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ 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';
|
||||||
@@ -24,24 +22,7 @@ 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);
|
||||||
|
|
||||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
|
||||||
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,5 +1,3 @@
|
|||||||
import RadarrAPI from '@server/api/servarr/radarr';
|
|
||||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
|
||||||
import {
|
import {
|
||||||
MediaRequestStatus,
|
MediaRequestStatus,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
@@ -21,7 +19,6 @@ 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';
|
||||||
@@ -146,62 +143,6 @@ 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),
|
||||||
@@ -209,7 +150,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
|
|||||||
results: requestCount,
|
results: requestCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / pageSize) + 1,
|
||||||
},
|
},
|
||||||
results: requestsWithProfileNames,
|
results: requests,
|
||||||
});
|
});
|
||||||
} 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', 'jellyfinUserId', 'jellyfinDeviceId'],
|
select: ['id', 'jellyfinAuthToken', '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),
|
||||||
tempJellyfinSettings.apiKey,
|
admin.jellyfinAuthToken ?? '',
|
||||||
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', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,8 +376,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
settingsRoutes.get('/jellyfin/users', async (req, res) => {
|
||||||
const settings = getSettings();
|
const { externalHostname } = getSettings().jellyfin;
|
||||||
const { externalHostname } = settings.jellyfin;
|
|
||||||
const jellyfinHost =
|
const jellyfinHost =
|
||||||
externalHostname && externalHostname.length > 0
|
externalHostname && externalHostname.length > 0
|
||||||
? externalHostname
|
? externalHostname
|
||||||
@@ -385,13 +384,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', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
getHostname(),
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
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';
|
||||||
@@ -21,24 +19,7 @@ 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);
|
||||||
|
|
||||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
return res.status(200).json(mapTvDetails(tv, media));
|
||||||
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,19 +41,7 @@ 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.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.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;
|
||||||
@@ -102,13 +90,12 @@ 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: email.toLowerCase(),
|
email: body.email.toLowerCase(),
|
||||||
})
|
})
|
||||||
.getOne();
|
.getOne();
|
||||||
|
|
||||||
@@ -121,7 +108,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passedExplicitPassword = body.password && body.password.length > 0;
|
const passedExplicitPassword = body.password && body.password.length > 0;
|
||||||
const avatar = gravatarUrl(email, { default: 'mm', size: 200 });
|
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!passedExplicitPassword &&
|
!passedExplicitPassword &&
|
||||||
@@ -131,9 +118,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: '',
|
||||||
@@ -501,14 +488,17 @@ 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: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
|
select: [
|
||||||
|
'id',
|
||||||
|
'jellyfinAuthToken',
|
||||||
|
'jellyfinDeviceId',
|
||||||
|
'jellyfinUserId',
|
||||||
|
],
|
||||||
order: { id: 'ASC' },
|
order: { id: 'ASC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const hostname = getHostname();
|
|
||||||
const jellyfinClient = new JellyfinAPI(
|
const jellyfinClient = new JellyfinAPI(
|
||||||
hostname,
|
getHostname(),
|
||||||
settings.jellyfin.apiKey,
|
admin.jellyfinAuthToken ?? '',
|
||||||
admin.jellyfinDeviceId ?? ''
|
admin.jellyfinDeviceId ?? ''
|
||||||
);
|
);
|
||||||
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
|
||||||
@@ -516,6 +506,7 @@ 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,9 +98,7 @@ userSettingsRoutes.post<
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
user.username = req.body.username;
|
||||||
if (user.jellyfinUsername) {
|
user.email = req.body.email ?? user.email;
|
||||||
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,10 +7,9 @@ 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;
|
|
||||||
}[];
|
}[];
|
||||||
lastTimestamps: number[];
|
activeRequests: number;
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
timer: NodeJS.Timeout | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const rateLimitById: Record<string, unknown> = {};
|
const rateLimitById: Record<string, unknown> = {};
|
||||||
@@ -28,40 +27,46 @@ 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: [], lastTimestamps: [] };
|
] as RateLimiteState<T, U>) || { queue: [], activeRequests: 0, timer: null };
|
||||||
if (options.id) {
|
if (options.id) {
|
||||||
rateLimitById[options.id] = state;
|
rateLimitById[options.id] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processQueue = () => {
|
const processQueue = () => {
|
||||||
// remove old timestamps
|
if (state.queue.length === 0) {
|
||||||
state.lastTimestamps = state.lastTimestamps.filter(
|
if (state.timer) {
|
||||||
(timestamp) => Date.now() - timestamp < 1000
|
clearInterval(state.timer);
|
||||||
);
|
state.timer = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.lastTimestamps.length < options.maxRPS) {
|
while (state.activeRequests < options.maxRPS) {
|
||||||
// process requests if RPS not exceeded
|
state.activeRequests++;
|
||||||
const item = state.queue.shift();
|
const item = state.queue.shift();
|
||||||
if (!item) return;
|
if (!item) break;
|
||||||
state.lastTimestamps.push(Date.now());
|
const { args, resolve } = item;
|
||||||
const { args, resolve, reject } = item;
|
|
||||||
fn(...args)
|
fn(...args)
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch(reject);
|
.finally(() => {
|
||||||
processQueue();
|
state.activeRequests--;
|
||||||
} else {
|
if (state.queue.length > 0) {
|
||||||
// rerun once the oldest item in queue is older than 1s
|
if (!state.timer) {
|
||||||
if (state.timeout) clearTimeout(state.timeout);
|
state.timer = setInterval(processQueue, 1000);
|
||||||
state.timeout = setTimeout(
|
}
|
||||||
processQueue,
|
} else {
|
||||||
1000 - (Date.now() - state.lastTimestamps[0])
|
if (state.timer) {
|
||||||
);
|
clearInterval(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (...args: Parameters<T>): Promise<U> => {
|
return (...args: Parameters<T>): Promise<U> => {
|
||||||
return new Promise<U>((resolve, reject) => {
|
return new Promise<U>((resolve) => {
|
||||||
state.queue.push({ args, resolve, reject });
|
state.queue.push({ args, resolve });
|
||||||
processQueue();
|
processQueue();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,85 +1 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#a)}.cls-3{fill:#e5a00d}</style><radialGradient id="a" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><polygon points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79" class="cls-1"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76" class="cls-3"/><path d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>
|
||||||
<!-- Generator: Adobe Illustrator 26.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
|
||||||
xmlns:cc="http://creativecommons.org/ns#"
|
|
||||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
version="1.1"
|
|
||||||
id="plex-logo"
|
|
||||||
x="0px"
|
|
||||||
y="0px"
|
|
||||||
viewBox="0 0 1000 460.89727"
|
|
||||||
xml:space="preserve"
|
|
||||||
sodipodi:docname="plex-logo.svg"
|
|
||||||
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
|
|
||||||
id="metadata25"><rdf:RDF><cc:Work
|
|
||||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
|
||||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
|
|
||||||
id="defs23">
|
|
||||||
</defs><sodipodi:namedview
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#111111"
|
|
||||||
borderopacity="1"
|
|
||||||
objecttolerance="10"
|
|
||||||
gridtolerance="10"
|
|
||||||
guidetolerance="10"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pageshadow="2"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1017"
|
|
||||||
id="namedview21"
|
|
||||||
showgrid="false"
|
|
||||||
fit-margin-top="0"
|
|
||||||
fit-margin-left="0"
|
|
||||||
fit-margin-right="0"
|
|
||||||
fit-margin-bottom="0"
|
|
||||||
inkscape:zoom="0.27956081"
|
|
||||||
inkscape:cx="783.06912"
|
|
||||||
inkscape:cy="-132.85701"
|
|
||||||
inkscape:window-x="1912"
|
|
||||||
inkscape:window-y="-8"
|
|
||||||
inkscape:window-maximized="1"
|
|
||||||
inkscape:current-layer="plex-logo" />
|
|
||||||
<style
|
|
||||||
type="text/css"
|
|
||||||
id="style2">
|
|
||||||
.st0{fill:#FFFFFF;}
|
|
||||||
.st1{fill:#EBAF00;}
|
|
||||||
</style>
|
|
||||||
<path
|
|
||||||
class="st0"
|
|
||||||
d="m 164.18919,82.43243 c -39.86487,0 -65.540543,11.48648 -87.162163,38.51351 V 91.21621 H 0 v 366.21621 c 0,0 1.3513514,0.67567 5.4054053,1.35135 5.4054057,1.35135 33.7837827,7.43243 54.7297287,-10.13514 18.243244,-15.54054 22.297295,-33.78378 22.297295,-54.05405 v -52.7027 c 22.297301,23.64864 47.297301,33.78378 82.432431,33.78378 75.67567,0 133.78378,-61.48648 133.78378,-143.24323 0,-88.51352 -56.08108,-150 -134.45945,-150 z m -14.86487,223.64864 c -42.56756,0 -76.351351,-35.13513 -76.351351,-77.7027 0,-41.89189 39.864871,-75.67567 76.351351,-75.67567 43.24324,0 76.35135,33.1081 76.35135,76.35135 0,43.24324 -33.78378,77.02702 -76.35135,77.02702 z"
|
|
||||||
id="path4"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 408.1081,223.64864 c 0,31.75676 3.37838,70.27027 34.45946,112.16216 0.67567,0.67567 2.02702,2.7027 2.02702,2.7027 -12.83783,21.62162 -28.37837,36.48648 -49.32432,36.48648 -16.21622,0 -32.43243,-8.78378 -45.94595,-23.64864 -14.18918,-16.21622 -20.94594,-37.16216 -20.94594,-59.45946 V 0 h 79.05405 z"
|
|
||||||
id="path6"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><polygon
|
|
||||||
class="st1"
|
|
||||||
points="117.9,33.9 104.1,13.5 118.3,13.5 132,33.9 118.3,54.2 104.1,54.2 "
|
|
||||||
id="polygon8"
|
|
||||||
style="fill:#ebaf00"
|
|
||||||
transform="scale(6.7567568)" /><polygon
|
|
||||||
class="st0"
|
|
||||||
points="135.7,31.6 148,13.5 133.8,13.5 128.7,21 "
|
|
||||||
id="polygon10"
|
|
||||||
style="fill:#ffffff"
|
|
||||||
transform="scale(6.7567568)" /><path
|
|
||||||
class="st0"
|
|
||||||
d="m 869.59458,316.2162 c 0,0 16.2162,22.2973 16.2162,22.2973 15.54058,24.32432 35.8108,36.48648 59.45949,36.48648 25,-0.67567 42.56752,-22.29729 49.3243,-30.4054 0,0 -12.16218,-10.81081 -27.7027,-29.05405 -20.94598,-24.32432 -48.64868,-68.91892 -49.3243,-70.94594 z"
|
|
||||||
id="path12"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678" /><path
|
|
||||||
style="fill:#ffffff;stroke-width:6.75675678"
|
|
||||||
inkscape:connector-curvature="0"
|
|
||||||
id="path16"
|
|
||||||
d="m 632.43242,287.16215 c -16.21622,14.86486 -27.02703,22.97297 -49.32432,22.97297 -39.86487,0 -62.83784,-28.37837 -66.21622,-59.45945 h 211.4865 c 1.35131,-4.05406 2.027,-9.45946 2.027,-18.24324 0,-85.81082 -62.83783,-150 -145.27026,-150 -78.37837,0 -142.56756,65.54054 -142.56756,147.29729 0,81.08108 64.18919,145.27026 144.59459,145.27026 56.08108,0 104.72973,-31.75675 131.08105,-87.83783 z M 585.8108,147.29729 c 35.13513,0 61.48648,22.97297 67.56756,53.37838 H 519.59458 c 6.75676,-31.75676 31.75676,-53.37838 66.21622,-53.37838 z"
|
|
||||||
class="st0" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -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="relative mb-6 flex h-24 justify-center sm:h-32">
|
<div className="mb-6 flex justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.network.logoPath}`}
|
||||||
alt={firstResultData.network.name}
|
alt={firstResultData.network.name}
|
||||||
className="object-contain"
|
className="max-h-24 sm:max-h-32"
|
||||||
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="relative mb-6 flex h-24 justify-center sm:h-32">
|
<div className="mb-6 flex justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
src={`//image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)${firstResultData.studio.logoPath}`}
|
||||||
alt={firstResultData.studio.name}
|
alt={firstResultData.studio.name}
|
||||||
className="object-contain"
|
className="max-h-24 sm:max-h-32"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,9 +181,6 @@ 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,9 +126,6 @@ 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();
|
||||||
@@ -504,9 +501,6 @@ 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 }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -102,9 +102,6 @@ const CreateIssueModal = ({
|
|||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/issue', {
|
const res = await fetch('/api/v1/issue', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
issueType: values.selectedIssue.issueType,
|
issueType: values.selectedIssue.issueType,
|
||||||
message: values.message,
|
message: values.message,
|
||||||
|
|||||||
@@ -90,11 +90,9 @@ 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} />}
|
||||||
|
|||||||
@@ -17,45 +17,47 @@ interface UserWarningsProps {
|
|||||||
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
const UserWarnings: React.FC<UserWarningsProps> = ({ onClick }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
//check if a user has warnings
|
if (!user) {
|
||||||
if (!user || !user.warnings || user.warnings.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = null;
|
let res = null;
|
||||||
|
|
||||||
user.warnings.forEach((warning) => {
|
//check if a user has warnings
|
||||||
let link = '';
|
if (user.warnings.length > 0) {
|
||||||
let warningText = '';
|
user.warnings.forEach((warning) => {
|
||||||
let warningTitle = '';
|
let link = '';
|
||||||
switch (warning) {
|
let warningText = '';
|
||||||
case 'userEmailRequired':
|
let warningTitle = '';
|
||||||
link = '/profile/settings/';
|
switch (warning) {
|
||||||
warningTitle = 'Profile is incomplete';
|
case 'userEmailRequired':
|
||||||
warningText = intl.formatMessage(messages.emailRequired);
|
link = '/profile/settings/';
|
||||||
}
|
warningTitle = 'Profile is incomplete';
|
||||||
|
warningText = intl.formatMessage(messages.emailRequired);
|
||||||
|
}
|
||||||
|
|
||||||
res = (
|
res = (
|
||||||
<Link
|
<Link
|
||||||
href={link}
|
href={link}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && onClick) {
|
if (e.key === 'Enter' && onClick) {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
className="mx-2 mb-2 flex items-center rounded-lg bg-yellow-500 p-2 text-xs text-white ring-1 ring-gray-700 transition duration-300 hover:bg-yellow-400"
|
||||||
>
|
>
|
||||||
<ExclamationTriangleIcon className="h-6 w-6" />
|
<ExclamationTriangleIcon className="h-6 w-6" />
|
||||||
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
<div className="flex min-w-0 flex-1 flex-col truncate px-2 last:pr-0">
|
||||||
<span className="font-bold">{warningTitle}</span>
|
<span className="font-bold">{warningTitle}</span>
|
||||||
<span className="truncate">{warningText}</span>
|
<span className="truncate">{warningText}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ 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,17 +119,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error();
|
||||||
} 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 (errorData?.message) {
|
switch (e.response?.data?.message) {
|
||||||
case ApiErrorCode.InvalidUrl:
|
case ApiErrorCode.InvalidUrl:
|
||||||
errorMessage = messages.invalidurlerror;
|
errorMessage = messages.invalidurlerror;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -45,26 +45,16 @@ 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(res.statusText, { cause: res });
|
if (!res.ok) throw new Error();
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data?.id) {
|
if (data?.id) {
|
||||||
revalidate();
|
revalidate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
setError(e.response.data.message);
|
||||||
try {
|
|
||||||
errorData = await e.cause?.text();
|
|
||||||
errorData = JSON.parse(errorData);
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
setError(errorData?.message);
|
|
||||||
setAuthToken(undefined);
|
setAuthToken(undefined);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ const ManageSlideOver = ({
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
revalidate();
|
revalidate();
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +133,6 @@ const ManageSlideOver = ({
|
|||||||
if (!res2.ok) throw new Error();
|
if (!res2.ok) throw new Error();
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,48 +57,44 @@ 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 grid h-full w-full grid-cols-2 items-center justify-center gap-2 opacity-30">
|
<div className="relative z-10 flex h-full flex-wrap items-center justify-center opacity-30">
|
||||||
{posters[0] && (
|
{posters[0] && (
|
||||||
<div className="">
|
<div className="w-1/2 p-1">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="w-full rounded-md"
|
||||||
width={300}
|
fill
|
||||||
height={450}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[1] && (
|
{posters[1] && (
|
||||||
<div className="">
|
<div className="w-1/2 p-1">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="w-full rounded-md"
|
||||||
width={300}
|
fill
|
||||||
height={450}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[2] && (
|
{posters[2] && (
|
||||||
<div className="">
|
<div className="w-1/2 p-1">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="w-full rounded-md"
|
||||||
width={300}
|
fill
|
||||||
height={450}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{posters[3] && (
|
{posters[3] && (
|
||||||
<div className="">
|
<div className="w-1/2 p-1">
|
||||||
<Image
|
<Image
|
||||||
src={`https://image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||||
alt=""
|
alt=""
|
||||||
className="rounded-md"
|
className="w-full rounded-md"
|
||||||
width={300}
|
fill
|
||||||
height={450}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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';
|
||||||
@@ -26,7 +25,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 ErrorPage from '@app/pages/_error';
|
import Error 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';
|
||||||
@@ -42,12 +41,10 @@ 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, MediaType } from '@server/constants/media';
|
import { MediaStatus } 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';
|
||||||
@@ -58,7 +55,6 @@ 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', {
|
||||||
@@ -98,12 +94,6 @@ 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 {
|
||||||
@@ -122,12 +112,7 @@ 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,
|
||||||
@@ -169,7 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <ErrorPage statusCode={404} />;
|
return <Error statusCode={404} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
|
||||||
@@ -302,80 +287,6 @@ 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"
|
||||||
@@ -497,40 +408,6 @@ 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,7 +19,6 @@ 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';
|
||||||
@@ -59,7 +58,7 @@ const RequestCardPlaceholder = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardErrorProps {
|
interface RequestCardErrorProps {
|
||||||
requestData?: NonFunctionProperties<MediaRequest>;
|
requestData?: MediaRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
||||||
@@ -214,7 +213,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestCardProps {
|
interface RequestCardProps {
|
||||||
request: NonFunctionProperties<MediaRequest>;
|
request: MediaRequest;
|
||||||
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,19 +238,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
|||||||
data: requestData,
|
data: requestData,
|
||||||
error: requestError,
|
error: requestError,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<NonFunctionProperties<MediaRequest>>(
|
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
|
||||||
`/api/v1/request/${request.id}`,
|
fallbackData: request,
|
||||||
{
|
refreshInterval: refreshIntervalHelper(
|
||||||
fallbackData: request,
|
{
|
||||||
refreshInterval: refreshIntervalHelper(
|
downloadStatus: request.media.downloadStatus,
|
||||||
{
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
downloadStatus: request.media.downloadStatus,
|
},
|
||||||
downloadStatus4k: request.media.downloadStatus4k,
|
15000
|
||||||
},
|
),
|
||||||
15000
|
});
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
|
||||||
mediaUrl: requestData?.media?.mediaUrl,
|
mediaUrl: requestData?.media?.mediaUrl,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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';
|
||||||
@@ -43,7 +42,6 @@ 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 => {
|
||||||
@@ -51,7 +49,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemErrorProps {
|
interface RequestItemErrorProps {
|
||||||
requestData?: NonFunctionProperties<MediaRequest>;
|
requestData?: MediaRequest;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +285,7 @@ const RequestItemError = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RequestItemProps {
|
interface RequestItemProps {
|
||||||
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
|
request: MediaRequest;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,18 +304,19 @@ 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<
|
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
|
||||||
NonFunctionProperties<MediaRequest>
|
`/api/v1/request/${request.id}`,
|
||||||
>(`/api/v1/request/${request.id}`, {
|
{
|
||||||
fallbackData: request,
|
fallbackData: request,
|
||||||
refreshInterval: refreshIntervalHelper(
|
refreshInterval: refreshIntervalHelper(
|
||||||
{
|
{
|
||||||
downloadStatus: request.media.downloadStatus,
|
downloadStatus: request.media.downloadStatus,
|
||||||
downloadStatus4k: request.media.downloadStatus4k,
|
downloadStatus4k: request.media.downloadStatus4k,
|
||||||
},
|
},
|
||||||
15000
|
15000
|
||||||
),
|
),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const [isRetrying, setRetrying] = useState(false);
|
const [isRetrying, setRetrying] = useState(false);
|
||||||
|
|
||||||
@@ -402,7 +401,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-2 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-4 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
|
||||||
@@ -483,7 +482,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 gap-1 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 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)}
|
||||||
@@ -633,16 +632,6 @@ 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,7 +8,6 @@ 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';
|
||||||
@@ -39,7 +38,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?: NonFunctionProperties<MediaRequest>;
|
editRequest?: MediaRequest;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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';
|
||||||
@@ -58,7 +57,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?: NonFunctionProperties<MediaRequest>;
|
editRequest?: MediaRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TvRequestModal = ({
|
const TvRequestModal = ({
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ 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?: NonFunctionProperties<MediaRequest>;
|
editRequest?: MediaRequest;
|
||||||
onComplete?: (newStatus: MediaStatus) => void;
|
onComplete?: (newStatus: MediaStatus) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onUpdating?: (isUpdating: boolean) => void;
|
onUpdating?: (isUpdating: boolean) => void;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@@ -31,14 +30,13 @@ 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. You can also change the Jellyfin API key, which was automatically generated previously.',
|
'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.',
|
||||||
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',
|
||||||
@@ -175,18 +173,11 @@ 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(res.statusText, { cause: res });
|
if (!res.ok) throw new Error();
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
revalidate();
|
revalidate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
let errorData;
|
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
|
||||||
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
|
||||||
@@ -196,7 +187,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
appearance: 'warning',
|
appearance: 'warning',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
|
||||||
toasts.addToast(
|
toasts.addToast(
|
||||||
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
|
||||||
{
|
{
|
||||||
@@ -261,7 +252,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 {
|
||||||
@@ -446,121 +437,112 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 mb-6">
|
{showAdvancedSettings && (
|
||||||
<h3 className="heading">
|
<>
|
||||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
<div className="mt-10 mb-6">
|
||||||
? intl.formatMessage(messages.jellyfinSettings, {
|
<h3 className="heading">
|
||||||
mediaServerName: 'Emby',
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
})
|
? intl.formatMessage(messages.jellyfinSettings, {
|
||||||
: intl.formatMessage(messages.jellyfinSettings, {
|
mediaServerName: 'Emby',
|
||||||
mediaServerName: 'Jellyfin',
|
})
|
||||||
})}
|
: intl.formatMessage(messages.jellyfinSettings, {
|
||||||
</h3>
|
mediaServerName: 'Jellyfin',
|
||||||
<p className="description">
|
})}
|
||||||
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
</h3>
|
||||||
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
<p className="description">
|
||||||
mediaServerName: 'Emby',
|
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
})
|
? intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
mediaServerName: 'Emby',
|
||||||
mediaServerName: 'Jellyfin',
|
})
|
||||||
})}
|
: intl.formatMessage(messages.jellyfinSettingsDescription, {
|
||||||
</p>
|
mediaServerName: 'Jellyfin',
|
||||||
</div>
|
})}
|
||||||
<Formik
|
</p>
|
||||||
initialValues={{
|
</div>
|
||||||
hostname: data?.ip,
|
<Formik
|
||||||
port: data?.port ?? 8096,
|
initialValues={{
|
||||||
useSsl: data?.useSsl,
|
hostname: data?.ip,
|
||||||
urlBase: data?.urlBase || '',
|
port: data?.port ?? 8096,
|
||||||
jellyfinExternalUrl: data?.externalHostname || '',
|
useSsl: data?.useSsl,
|
||||||
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
urlBase: data?.urlBase || '',
|
||||||
apiKey: data?.apiKey,
|
jellyfinExternalUrl: data?.externalHostname || '',
|
||||||
}}
|
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
|
||||||
validationSchema={JellyfinSettingsSchema}
|
}}
|
||||||
onSubmit={async (values) => {
|
validationSchema={JellyfinSettingsSchema}
|
||||||
try {
|
onSubmit={async (values) => {
|
||||||
const res = await fetch('/api/v1/settings/jellyfin', {
|
try {
|
||||||
method: 'POST',
|
const res = await fetch('/api/v1/settings/jellyfin', {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
body: JSON.stringify({
|
},
|
||||||
ip: values.hostname,
|
body: JSON.stringify({
|
||||||
port: Number(values.port),
|
ip: values.hostname,
|
||||||
useSsl: values.useSsl,
|
port: Number(values.port),
|
||||||
urlBase: values.urlBase,
|
useSsl: values.useSsl,
|
||||||
externalHostname: values.jellyfinExternalUrl,
|
urlBase: values.urlBase,
|
||||||
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
externalHostname: values.jellyfinExternalUrl,
|
||||||
apiKey: values.apiKey,
|
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
|
||||||
} as JellyfinSettings),
|
} as JellyfinSettings),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error();
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
intl.formatMessage(messages.jellyfinSettingsSuccess, {
|
||||||
mediaServerName:
|
mediaServerName:
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
? 'Emby'
|
? 'Emby'
|
||||||
: 'Jellyfin',
|
: 'Jellyfin',
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
appearance: 'success',
|
appearance: 'success',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.data?.message === ApiErrorCode.InvalidUrl) {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.invalidurlerror, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
||||||
|
mediaServerName:
|
||||||
|
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
||||||
|
? 'Emby'
|
||||||
|
: 'Jellyfin',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
revalidate();
|
||||||
}
|
}
|
||||||
);
|
}}
|
||||||
} catch (e) {
|
>
|
||||||
let errorData;
|
{({
|
||||||
try {
|
errors,
|
||||||
errorData = await e.cause?.text();
|
touched,
|
||||||
errorData = JSON.parse(errorData);
|
values,
|
||||||
} catch {
|
setFieldValue,
|
||||||
/* empty */
|
handleSubmit,
|
||||||
}
|
isSubmitting,
|
||||||
if (errorData?.message === ApiErrorCode.InvalidUrl) {
|
isValid,
|
||||||
addToast(
|
}) => {
|
||||||
intl.formatMessage(messages.invalidurlerror, {
|
return (
|
||||||
mediaServerName:
|
<form className="section" onSubmit={handleSubmit}>
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
||||||
? 'Emby'
|
|
||||||
: 'Jellyfin',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
autoDismiss: true,
|
|
||||||
appearance: 'error',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
addToast(
|
|
||||||
intl.formatMessage(messages.jellyfinSettingsFailure, {
|
|
||||||
mediaServerName:
|
|
||||||
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
|
|
||||||
? 'Emby'
|
|
||||||
: 'Jellyfin',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
autoDismiss: true,
|
|
||||||
appearance: 'error',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
revalidate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({
|
|
||||||
errors,
|
|
||||||
touched,
|
|
||||||
values,
|
|
||||||
setFieldValue,
|
|
||||||
handleSubmit,
|
|
||||||
isSubmitting,
|
|
||||||
isValid,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<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)}
|
||||||
@@ -622,29 +604,6 @@ 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)}
|
||||||
@@ -665,73 +624,75 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="form-row">
|
||||||
)}
|
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
||||||
<div className="form-row">
|
{intl.formatMessage(messages.externalUrl)}
|
||||||
<label htmlFor="jellyfinExternalUrl" className="text-label">
|
</label>
|
||||||
{intl.formatMessage(messages.externalUrl)}
|
<div className="form-input-area">
|
||||||
</label>
|
<div className="form-input-field">
|
||||||
<div className="form-input-area">
|
<Field
|
||||||
<div className="form-input-field">
|
type="text"
|
||||||
<Field
|
inputMode="url"
|
||||||
type="text"
|
id="jellyfinExternalUrl"
|
||||||
inputMode="url"
|
name="jellyfinExternalUrl"
|
||||||
id="jellyfinExternalUrl"
|
/>
|
||||||
name="jellyfinExternalUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.jellyfinExternalUrl &&
|
|
||||||
touched.jellyfinExternalUrl && (
|
|
||||||
<div className="error">{errors.jellyfinExternalUrl}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="form-row">
|
|
||||||
<label
|
|
||||||
htmlFor="jellyfinForgotPasswordUrl"
|
|
||||||
className="text-label"
|
|
||||||
>
|
|
||||||
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
|
||||||
</label>
|
|
||||||
<div className="form-input-area">
|
|
||||||
<div className="form-input-field">
|
|
||||||
<Field
|
|
||||||
type="text"
|
|
||||||
inputMode="url"
|
|
||||||
id="jellyfinForgotPasswordUrl"
|
|
||||||
name="jellyfinForgotPasswordUrl"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errors.jellyfinForgotPasswordUrl &&
|
|
||||||
touched.jellyfinForgotPasswordUrl && (
|
|
||||||
<div className="error">
|
|
||||||
{errors.jellyfinForgotPasswordUrl}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{errors.jellyfinExternalUrl &&
|
||||||
</div>
|
touched.jellyfinExternalUrl && (
|
||||||
</div>
|
<div className="error">
|
||||||
<div className="actions">
|
{errors.jellyfinExternalUrl}
|
||||||
<div className="flex justify-end">
|
</div>
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
)}
|
||||||
<Button
|
</div>
|
||||||
buttonType="primary"
|
</div>
|
||||||
type="submit"
|
<div className="form-row">
|
||||||
disabled={isSubmitting || !isValid}
|
<label
|
||||||
|
htmlFor="jellyfinForgotPasswordUrl"
|
||||||
|
className="text-label"
|
||||||
>
|
>
|
||||||
<ArrowDownOnSquareIcon />
|
{intl.formatMessage(messages.jellyfinForgotPasswordUrl)}
|
||||||
<span>
|
</label>
|
||||||
{isSubmitting
|
<div className="form-input-area">
|
||||||
? intl.formatMessage(globalMessages.saving)
|
<div className="form-input-field">
|
||||||
: intl.formatMessage(globalMessages.save)}
|
<Field
|
||||||
|
type="text"
|
||||||
|
inputMode="url"
|
||||||
|
id="jellyfinForgotPasswordUrl"
|
||||||
|
name="jellyfinForgotPasswordUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.jellyfinForgotPasswordUrl &&
|
||||||
|
touched.jellyfinForgotPasswordUrl && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.jellyfinForgotPasswordUrl}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon />
|
||||||
|
<span>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: intl.formatMessage(globalMessages.save)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
);
|
||||||
</form>
|
}}
|
||||||
);
|
</Formik>
|
||||||
}}
|
</>
|
||||||
</Formik>
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -41,19 +40,11 @@ import {
|
|||||||
FilmIcon,
|
FilmIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import {
|
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
||||||
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 {
|
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
|
||||||
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';
|
||||||
@@ -64,7 +55,6 @@ 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', {
|
||||||
@@ -99,12 +89,6 @@ 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 {
|
||||||
@@ -122,12 +106,7 @@ 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,
|
||||||
@@ -323,82 +302,6 @@ 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"
|
||||||
@@ -530,40 +433,6 @@ 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,6 +56,14 @@ 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);
|
||||||
|
|
||||||
@@ -70,7 +78,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const createdUsers = await res.json();
|
const { data: 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.');
|
||||||
@@ -201,71 +209,64 @@ 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
|
{data?.map((user) => (
|
||||||
?.filter(
|
<tr key={`user-${user.id}`}>
|
||||||
(user) =>
|
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
||||||
!existingUsers?.results.some(
|
<span
|
||||||
(u) => u.jellyfinUserId === user.id
|
role="checkbox"
|
||||||
)
|
tabIndex={0}
|
||||||
)
|
aria-checked={isSelectedUser(user.id)}
|
||||||
.map((user) => (
|
onClick={() => toggleUser(user.id)}
|
||||||
<tr key={`user-${user.id}`}>
|
onKeyDown={(e) => {
|
||||||
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
|
if (e.key === 'Enter' || e.key === 'Space') {
|
||||||
|
toggleUser(user.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
role="checkbox"
|
aria-hidden="true"
|
||||||
tabIndex={0}
|
className={`${
|
||||||
aria-checked={isSelectedUser(user.id)}
|
isSelectedUser(user.id)
|
||||||
onClick={() => toggleUser(user.id)}
|
? 'bg-indigo-500'
|
||||||
onKeyDown={(e) => {
|
: 'bg-gray-800'
|
||||||
if (e.key === 'Enter' || e.key === 'Space') {
|
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
||||||
toggleUser(user.id);
|
></span>
|
||||||
}
|
<span
|
||||||
}}
|
aria-hidden="true"
|
||||||
className="relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none"
|
className={`${
|
||||||
>
|
isSelectedUser(user.id)
|
||||||
<span
|
? 'translate-x-5'
|
||||||
aria-hidden="true"
|
: 'translate-x-0'
|
||||||
className={`${
|
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
||||||
isSelectedUser(user.id)
|
></span>
|
||||||
? 'bg-indigo-500'
|
</span>
|
||||||
: 'bg-gray-800'
|
</td>
|
||||||
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
|
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
||||||
></span>
|
<div className="flex items-center">
|
||||||
<span
|
<Image
|
||||||
aria-hidden="true"
|
className="h-10 w-10 flex-shrink-0 rounded-full"
|
||||||
className={`${
|
src={user.thumb}
|
||||||
isSelectedUser(user.id)
|
alt=""
|
||||||
? 'translate-x-5'
|
width={40}
|
||||||
: 'translate-x-0'
|
height={40}
|
||||||
} absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`}
|
/>
|
||||||
></span>
|
<div className="ml-4">
|
||||||
</span>
|
<div className="text-base font-bold leading-5">
|
||||||
</td>
|
{user.username}
|
||||||
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
|
</div>
|
||||||
<div className="flex items-center">
|
{/* {user.username &&
|
||||||
<Image
|
|
||||||
className="h-10 w-10 flex-shrink-0 rounded-full"
|
|
||||||
src={user.thumb}
|
|
||||||
alt=""
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
/>
|
|
||||||
<div className="ml-4">
|
|
||||||
<div className="text-base font-bold leading-5">
|
|
||||||
{user.username}
|
|
||||||
</div>
|
|
||||||
{/* {user.username &&
|
|
||||||
user.username.toLowerCase() !==
|
user.username.toLowerCase() !==
|
||||||
user.email && (
|
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>
|
||||||
)} */}
|
)} */}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</td>
|
||||||
))}
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -68,15 +68,14 @@ 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!',
|
||||||
username: 'Username',
|
displayName: 'Display Name',
|
||||||
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',
|
||||||
validationUsername: 'You must provide an username',
|
validationEmail: 'You must provide a valid email address',
|
||||||
validationEmail: 'Email required',
|
|
||||||
sortCreated: 'Join Date',
|
sortCreated: 'Join Date',
|
||||||
sortDisplayName: 'Display Name',
|
sortDisplayName: 'Display Name',
|
||||||
sortRequests: 'Request Count',
|
sortRequests: 'Request Count',
|
||||||
@@ -209,10 +208,9 @@ const UserList = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CreateUserSchema = Yup.object().shape({
|
const CreateUserSchema = Yup.object().shape({
|
||||||
username: Yup.string().required(
|
email: Yup.string()
|
||||||
intl.formatMessage(messages.validationUsername)
|
.required(intl.formatMessage(messages.validationEmail))
|
||||||
),
|
.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()
|
||||||
@@ -260,7 +258,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?.username}
|
subTitle={deleteModal.user?.displayName}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.deleteconfirm)}
|
{intl.formatMessage(messages.deleteconfirm)}
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -278,7 +276,7 @@ const UserList = () => {
|
|||||||
>
|
>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
username: '',
|
displayName: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
genpassword: false,
|
genpassword: false,
|
||||||
@@ -292,28 +290,21 @@ const UserList = () => {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: values.username,
|
username: values.displayName,
|
||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.genpassword ? null : values.password,
|
password: values.genpassword ? null : values.password,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(res.statusText, { cause: res });
|
if (!res.ok) throw new Error();
|
||||||
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(
|
||||||
errorData.errors?.includes('USER_EXISTS')
|
e.response.data.errors?.includes('USER_EXISTS')
|
||||||
? messages.usercreatedfailedexisting
|
? messages.usercreatedfailedexisting
|
||||||
: messages.usercreatedfailed
|
: messages.usercreatedfailed
|
||||||
),
|
),
|
||||||
@@ -372,24 +363,23 @@ const UserList = () => {
|
|||||||
)}
|
)}
|
||||||
<Form className="section">
|
<Form className="section">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="username" className="text-label">
|
<label htmlFor="displayName" className="text-label">
|
||||||
{intl.formatMessage(messages.username)}
|
{intl.formatMessage(messages.displayName)}
|
||||||
<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 id="username" name="username" type="text" />
|
<Field
|
||||||
|
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">
|
||||||
@@ -648,16 +638,9 @@ 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.username ||
|
{user.displayName}
|
||||||
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,14 +93,9 @@ const UserGeneralSettings = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const UserGeneralSettingsSchema = Yup.object().shape({
|
const UserGeneralSettingsSchema = Yup.object().shape({
|
||||||
email:
|
email: Yup.string()
|
||||||
user?.id === 1
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
? Yup.string()
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
|
||||||
.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)),
|
||||||
@@ -139,7 +134,7 @@ const UserGeneralSettings = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
displayName: data?.username ?? '',
|
displayName: data?.username ?? '',
|
||||||
email: data?.email?.includes('@') ? data.email : '',
|
email: data?.email ?? '',
|
||||||
discordId: data?.discordId ?? '',
|
discordId: data?.discordId ?? '',
|
||||||
locale: data?.locale,
|
locale: data?.locale,
|
||||||
region: data?.region,
|
region: data?.region,
|
||||||
@@ -162,8 +157,7 @@ const UserGeneralSettings = () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: values.displayName,
|
username: values.displayName,
|
||||||
email:
|
email: values.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,
|
||||||
@@ -270,9 +264,7 @@ const UserGeneralSettings = () => {
|
|||||||
name="displayName"
|
name="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={
|
placeholder={
|
||||||
user?.username ||
|
user?.plexUsername ? user.plexUsername : user?.email
|
||||||
user?.jellyfinUsername ||
|
|
||||||
user?.plexUsername
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,7 +289,6 @@ 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,7 +12,6 @@ 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,7 +286,6 @@
|
|||||||
"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",
|
||||||
@@ -307,7 +306,6 @@
|
|||||||
"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",
|
||||||
@@ -321,9 +319,6 @@
|
|||||||
"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.",
|
||||||
@@ -470,7 +465,6 @@
|
|||||||
"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}}",
|
||||||
@@ -955,7 +949,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. You can also change the Jellyfin API key, which was automatically generated previously.",
|
"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.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",
|
||||||
@@ -1076,7 +1070,6 @@
|
|||||||
"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}}",
|
||||||
@@ -1094,7 +1087,6 @@
|
|||||||
"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",
|
||||||
@@ -1107,9 +1099,6 @@
|
|||||||
"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",
|
||||||
@@ -1122,6 +1111,7 @@
|
|||||||
"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!",
|
||||||
@@ -1155,11 +1145,9 @@
|
|||||||
"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": "Email required",
|
"components.UserList.validationEmail": "You must provide a valid email address",
|
||||||
"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
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,29 +7,6 @@
|
|||||||
min-height: calc(100% + env(safe-area-inset-top));
|
min-height: calc(100% + env(safe-area-inset-top));
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||||
calc(4rem + env(safe-area-inset-bottom)) env(safe-area-inset-left);
|
calc(4rem + env(safe-area-inset-bottom)) env(safe-area-inset-left);
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #4b5563 #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
html:hover {
|
|
||||||
scrollbar-color: #6b7280 #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WebKit scrollbar styles */
|
|
||||||
html::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html::-webkit-scrollbar-track {
|
|
||||||
background: #1f2937;
|
|
||||||
}
|
|
||||||
|
|
||||||
html::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
html:hover::-webkit-scrollbar-thumb {
|
|
||||||
background-color: #6b7280;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user