Compare commits

..

21 Commits

Author SHA1 Message Date
gauthier-th
7528a7c266 refactor: clean the rate limit utility 2024-07-13 20:31:31 +02:00
gauthier-th
8be79fcad8 fix(jellyfinapi): add missing argument in JellyfinAPI constructor 2024-07-13 02:03:46 +02:00
Gauthier
e9d6046997 feat: set IPv4 first as an option 2024-07-11 16:33:33 +02:00
Gauthier
eddbf2d0ec revert: remove retry to external API requests 2024-07-11 15:48:45 +02:00
gauthier-th
6d9a1c596e fix: imageProxy rate limit 2024-07-11 15:48:43 +02:00
Gauthier
c4054531cc fix: try to fix network errors with IPv6 2024-07-11 15:48:43 +02:00
gauthier-th
98c5500967 feat: add retry to external API requests 2024-07-11 15:48:42 +02:00
gauthier-th
943d84e4d5 fix: better log for ExternalAPI errors 2024-07-11 15:48:42 +02:00
gauthier-th
faec3260f6 fix: use the right search params for ExternalAPI 2024-07-11 15:48:41 +02:00
gauthier-th
6bcaa672ad fix: switch from URL objects to strings 2024-07-11 15:48:41 +02:00
gauthier-th
15568b1f6b refactor: switch frontend from axios to fetch api 2024-07-11 15:48:40 +02:00
gauthier-th
0f6ba544f8 refactor: switch server from axios to fetch api 2024-07-11 15:48:39 +02:00
gauthier-th
27cd125532 refactor: add rate limit to fetch api 2024-07-11 15:48:39 +02:00
gauthier-th
8f6cbefbb3 refactor: add rate limit to fetch api 2024-07-11 15:48:38 +02:00
gauthier-th
1b2b137760 refactor: make tautulli use the ExternalAPI class 2024-07-11 15:48:38 +02:00
gauthier-th
543341d9f9 ci: try to fix format checker 2024-07-11 15:48:37 +02:00
gauthier-th
5865bfac96 ci: ci: try to fix format checker 2024-07-11 15:48:37 +02:00
gauthier-th
c5e35aad05 ci: try to fix format checker 2024-07-11 15:48:36 +02:00
gauthier-th
a46cdb3a4f fix: send proper URL params 2024-07-11 15:48:36 +02:00
gauthier-th
bebbd690b4 fix: add missing auth token in Plex request 2024-07-11 15:48:35 +02:00
gauthier-th
99443d2796 refactor: switch ExternalAPI to Fetch API 2024-07-11 15:48:34 +02:00
66 changed files with 678 additions and 4137 deletions

View File

@@ -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
View File

@@ -1,2 +1,2 @@
# Global code ownership # Global code ownership
* @Fallenbagel @gauthier-th * @Fallenbagel

View File

@@ -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>

View File

@@ -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);

View File

@@ -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:

View File

@@ -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;
}
} }
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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;
} }

View File

@@ -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');

View File

@@ -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');
} }

View File

@@ -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

View File

@@ -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>>;

View File

@@ -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;

View File

@@ -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
); );

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
); );

View File

@@ -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;
}; };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
}); });

View File

@@ -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,
}); });

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 });

View File

@@ -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 ?? ''
); );

View File

@@ -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',

View File

@@ -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

View File

@@ -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 (

View File

@@ -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();
}); });
}; };

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }),
} }
); );

View File

@@ -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 }),
} }
); );

View File

@@ -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,

View File

@@ -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} />}

View File

@@ -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;
}; };

View File

@@ -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,

View File

@@ -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;

View File

@@ -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);
} }

View File

@@ -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();
} }
}; };

View File

@@ -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>
)} )}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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">

View File

@@ -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;

View File

@@ -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 = ({

View File

@@ -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;

View File

@@ -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> )}
</> </>
); );
}; };

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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) {