Compare commits

..

1 Commits

Author SHA1 Message Date
fallenbagel
ac33fe75b6 fix(jellyfin): library toggle not toggling off 2024-07-24 22:07:48 +05:00
71 changed files with 730 additions and 2857 deletions

View File

@@ -403,42 +403,6 @@
"contributions": [
"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
* @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="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 -->
<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 -->
**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://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="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>
</tbody>
</table>

View File

@@ -1,5 +1,5 @@
const testUser = {
username: 'Test User',
displayName: 'Test User',
emailAddress: 'test@seeerr.dev',
password: 'test1234',
};
@@ -32,7 +32,7 @@ describe('User List', () => {
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('#password').type(testUser.password);

View File

@@ -248,7 +248,6 @@ git checkout main
```
3. Install the dependencies:
```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && yarn install --frozen-lockfile --network-timeout 1000000
```
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:
```powershell
npm install -g win-node-env
set CYPRESS_INSTALL_BINARY=0 && pnpm install --frozen-lockfile
```
4. Build the project:

View File

@@ -14,6 +14,7 @@ module.exports = {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: '*', protocol: 'https' },
],
},
webpack(config) {

View File

@@ -2775,15 +2775,6 @@ paths:
imageCount:
type: number
example: 123
avatar:
type: object
properties:
size:
type: number
example: 123456
imageCount:
type: number
example: 123
apiCaches:
type: array
items:

View File

@@ -62,7 +62,6 @@
"formik": "^2.4.6",
"gravatar-url": "3.1.0",
"lodash": "4.17.21",
"mime": "3",
"next": "^14.2.4",
"node-cache": "5.1.2",
"node-gyp": "9.3.1",
@@ -120,7 +119,6 @@
"@types/express": "4.17.17",
"@types/express-session": "1.17.6",
"@types/lodash": "4.14.191",
"@types/mime": "3",
"@types/node": "20.14.8",
"@types/node-schedule": "2.1.0",
"@types/nodemailer": "6.4.7",

25
pnpm-lock.yaml generated
View File

@@ -98,9 +98,6 @@ importers:
lodash:
specifier: 4.17.21
version: 4.17.21
mime:
specifier: '3'
version: 3.0.0
next:
specifier: ^14.2.4
version: 14.2.4(@babel/core@7.24.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -267,9 +264,6 @@ importers:
'@types/lodash':
specifier: 4.14.191
version: 4.14.191
'@types/mime':
specifier: '3'
version: 3.0.4
'@types/node':
specifier: 20.14.8
version: 20.14.8
@@ -2854,9 +2848,6 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/mime@3.0.4':
resolution: {integrity: sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==}
'@types/minimatch@3.0.5':
resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==}
@@ -10845,7 +10836,7 @@ snapshots:
nopt: 5.0.0
npmlog: 5.0.1
rimraf: 3.0.2
semver: 7.3.8
semver: 7.6.2
tar: 6.2.1
transitivePeerDependencies:
- encoding
@@ -10920,13 +10911,13 @@ snapshots:
'@npmcli/fs@1.1.1':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.3.8
semver: 7.6.2
optional: true
'@npmcli/fs@2.1.2':
dependencies:
'@gar/promisify': 1.1.3
semver: 7.3.8
semver: 7.6.2
'@npmcli/move-file@1.1.2':
dependencies:
@@ -12335,7 +12326,7 @@ snapshots:
read-pkg: 5.2.0
registry-auth-token: 5.0.2
semantic-release: 19.0.5(encoding@0.1.13)
semver: 7.3.8
semver: 7.6.2
tempy: 1.0.1
'@semantic-release/release-notes-generator@10.0.3(semantic-release@19.0.5(encoding@0.1.13))':
@@ -12679,8 +12670,6 @@ snapshots:
'@types/mime@1.3.5': {}
'@types/mime@3.0.4': {}
'@types/minimatch@3.0.5': {}
'@types/minimist@1.2.5': {}
@@ -12898,7 +12887,7 @@ snapshots:
debug: 4.3.5(supports-color@8.1.1)
globby: 11.1.0
is-glob: 4.0.3
semver: 7.3.8
semver: 7.6.2
tsutils: 3.21.0(typescript@4.9.5)
optionalDependencies:
typescript: 4.9.5
@@ -17280,7 +17269,7 @@ snapshots:
nopt: 5.0.0
npmlog: 6.0.2
rimraf: 3.0.2
semver: 7.3.8
semver: 7.6.2
tar: 6.2.1
which: 2.0.2
transitivePeerDependencies:
@@ -17359,7 +17348,7 @@ snapshots:
dependencies:
hosted-git-info: 4.1.0
is-core-module: 2.14.0
semver: 7.3.8
semver: 7.6.2
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}

View File

@@ -68,10 +68,7 @@ class ExternalAPI {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`
);
}
const data = await this.getDataFromResponse(response);
@@ -85,7 +82,7 @@ class ExternalAPI {
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
data: Record<string, unknown>,
params?: Record<string, string>,
ttl?: number,
config?: RequestInit
@@ -107,17 +104,8 @@ class ExternalAPI {
...this.defaultHeaders,
...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);
if (this.cache) {
@@ -153,15 +141,6 @@ class ExternalAPI {
},
body: JSON.stringify(data),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const resData = await this.getDataFromResponse(response);
if (this.cache) {
@@ -178,22 +157,12 @@ class ExternalAPI {
): Promise<T> {
const url = this.formatUrl(endpoint, params);
const response = await this.fetch(url, {
method: 'DELETE',
...config,
headers: {
...this.defaultHeaders,
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
return data;
@@ -228,17 +197,6 @@ class ExternalAPI {
...config?.headers,
},
}).then(async (response) => {
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${
text ? ': ' + text : ''
}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL);
});
@@ -254,15 +212,6 @@ class ExternalAPI {
...config?.headers,
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(
`${response.status} ${response.statusText}${text ? ': ' + text : ''}`,
{
cause: response,
}
);
}
const data = await this.getDataFromResponse(response);
if (this.cache) {
@@ -286,12 +235,7 @@ class ExternalAPI {
...this.params,
...params,
});
return (
href +
(searchParams.toString().length
? '?' + searchParams.toString()
: searchParams.toString())
);
return `${href}?${searchParams.toString()}`;
}
private serializeCacheKey(
@@ -306,24 +250,20 @@ class ExternalAPI {
}
private async getDataFromResponse(response: Response) {
const contentType = response.headers.get('Content-Type');
if (contentType?.includes('application/json')) {
const contentType = response.headers.get('Content-Type')?.split(';')[0];
if (contentType === 'application/json') {
return await response.json();
} else if (
contentType?.includes('application/xml') ||
contentType?.includes('text/html') ||
contentType?.includes('text/plain')
contentType === 'application/xml' ||
contentType === 'text/html' ||
contentType === 'text/plain'
) {
return await response.text();
} else {
try {
return await response.json();
} catch {
try {
return await response.blob();
} catch {
return null;
}
return await response.blob();
}
}
}

View File

@@ -93,7 +93,9 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
}
class JellyfinAPI extends ExternalAPI {
private authToken?: string;
private userId?: string;
private jellyfinHost: string;
constructor(jellyfinHost: string, authToken?: string, deviceId?: string) {
let authHeaderVal: string;
@@ -112,6 +114,9 @@ class JellyfinAPI extends ExternalAPI {
},
}
);
this.jellyfinHost = jellyfinHost;
this.authToken = authToken;
}
public async login(
@@ -147,7 +152,7 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(false);
} catch (e) {
const status = e.cause?.status;
const status = e.response?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
@@ -185,7 +190,7 @@ class JellyfinAPI extends ExternalAPI {
return systemInfoResponse;
} 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' }
);
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' }
);
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' }
);
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' }
);
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' }
);
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;
} catch (e) {
if (availabilitySync.running) {
if (e.cause?.status === 500) {
if (e.response && e.response.status === 500) {
return undefined;
}
}
@@ -357,7 +362,7 @@ class JellyfinAPI extends ExternalAPI {
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ 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' }
);
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' }
);
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);
}
}

View File

@@ -175,11 +175,7 @@ class IMDBRadarrProxy extends ExternalAPI {
`/movie/imdb/${IMDBid}`
);
if (
!data?.length ||
data[0].ImdbId !== IMDBid ||
!data[0].MovieRatings.Imdb
) {
if (!data?.length || data[0].ImdbId !== IMDBid) {
return null;
}

View File

@@ -179,20 +179,13 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> {
}
return data;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
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.',
{
label: 'Radarr',
errorMessage: e.message,
options,
response: errorData,
response: e?.response?.data,
}
);
throw new Error('Failed to add movie to Radarr');

View File

@@ -257,18 +257,11 @@ class SonarrAPI extends ServarrBase<{
return createdSeriesData;
} 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.', {
label: 'Sonarr API',
errorMessage: e.message,
options,
response: errorData,
response: e?.response?.data,
});
throw new Error('Failed to add series');
}

View File

@@ -19,7 +19,6 @@ import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import clearCookies from '@server/middleware/clearcookies';
import routes from '@server/routes';
import avatarproxy from '@server/routes/avatarproxy';
import imageproxy from '@server/routes/imageproxy';
import { getAppVersion } from '@server/utils/appVersion';
import restartFlag from '@server/utils/restartFlag';
@@ -64,7 +63,7 @@ app
}
// Load Settings
const settings = await getSettings().load();
const settings = getSettings();
restartFlag.initializeSettings(settings.main);
// Migrate library types
@@ -203,7 +202,6 @@ app
// Do not set cookies so CDNs can cache them
server.use('/imageproxy', clearCookies, imageproxy);
server.use('/avatarproxy', clearCookies, avatarproxy);
server.get('*', (req, res) => handle(req, res));
server.use(

View File

@@ -8,16 +8,3 @@ interface PageInfo {
export interface PaginatedResponse {
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 { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties, PaginatedResponse } from './common';
import type { PaginatedResponse } from './common';
export interface RequestResultsResponse extends PaginatedResponse {
results: NonFunctionProperties<MediaRequest>[];
results: MediaRequest[];
}
export type MediaRequestBody = {
@@ -14,7 +14,6 @@ export type MediaRequestBody = {
is4k?: boolean;
serverId?: number;
profileId?: number;
profileName?: string;
rootFolder?: string;
languageProfileId?: number;
userId?: number;

View File

@@ -58,7 +58,7 @@ export interface CacheItem {
export interface CacheResponse {
apiCaches: CacheItem[];
imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>;
imageCache: Record<'tmdb', { size: number; imageCount: number }>;
}
export interface StatusResponse {

View File

@@ -227,9 +227,6 @@ export const startJobs = (): void => {
});
// Clean TMDB image cache
ImageProxy.clearCache('tmdb');
// Clean users avatar image cache
ImageProxy.clearCache('avatar');
}),
});

View File

@@ -63,7 +63,12 @@ class AvailabilitySync {
) {
admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' },
});
}
@@ -81,7 +86,7 @@ class AvailabilitySync {
if (admin) {
this.jellyfinClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.apiKey,
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -3,7 +3,6 @@ import type { RateLimitOptions } from '@server/utils/rateLimit';
import rateLimit from '@server/utils/rateLimit';
import { createHash } from 'crypto';
import { promises } from 'fs';
import mime from 'mime/lite';
import path, { join } from 'path';
type ImageResponse = {
@@ -12,7 +11,7 @@ type ImageResponse = {
curRevalidate: number;
isStale: boolean;
etag: string;
extension: string | null;
extension: string;
cacheKey: string;
cacheMiss: boolean;
};
@@ -28,45 +27,29 @@ class ImageProxy {
let deletedImages = 0;
const cacheDirectory = path.join(baseCacheDirectory, key);
try {
const files = await promises.readdir(cacheDirectory);
const files = await promises.readdir(cacheDirectory);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
for (const file of files) {
const filePath = path.join(cacheDirectory, file);
const stat = await promises.lstat(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
if (stat.isDirectory()) {
const imageFiles = await promises.readdir(filePath);
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
for (const imageFile of imageFiles) {
const [, expireAtSt] = imageFile.split('.');
const expireAt = Number(expireAtSt);
const now = Date.now();
if (now > expireAt) {
await promises.rm(path.join(filePath), {
recursive: true,
});
deletedImages += 1;
}
if (now > expireAt) {
await promises.rm(path.join(filePath, imageFile));
deletedImages += 1;
}
}
}
} catch (e) {
if (e.code === 'ENOENT') {
logger.error('Directory not found', {
label: 'Image Cache',
message: e.message,
});
} else {
logger.error('Failed to read directory', {
label: 'Image Cache',
message: e.message,
});
}
}
logger.info(`Cleared ${deletedImages} stale image(s) from cache '${key}'`, {
logger.info(`Cleared ${deletedImages} stale image(s) from cache`, {
label: 'Image Cache',
});
}
@@ -86,49 +69,33 @@ class ImageProxy {
}
private static async getDirectorySize(dir: string): Promise<number> {
try {
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const files = await promises.readdir(dir, {
withFileTypes: true,
});
const paths = files.map(async (file) => {
const path = join(dir, file.name);
const paths = files.map(async (file) => {
const path = join(dir, file.name);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isDirectory()) return await ImageProxy.getDirectorySize(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
if (file.isFile()) {
const { size } = await promises.stat(path);
return size;
}
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
return size;
}
}
return 0;
return 0;
});
return (await Promise.all(paths))
.flat(Infinity)
.reduce((i, size) => i + size, 0);
}
private static async getImageCount(dir: string) {
try {
const files = await promises.readdir(dir);
const files = await promises.readdir(dir);
return files.length;
} catch (e) {
if (e.code === 'ENOENT') {
return 0;
}
}
return 0;
return files.length;
}
private fetch: typeof fetch;
@@ -180,27 +147,6 @@ class ImageProxy {
return imageResponse;
}
public async clearCachedImage(path: string) {
// find cacheKey
const cacheKey = this.getCacheKey(path);
try {
const directory = join(this.getCacheDirectory(), cacheKey);
const files = await promises.readdir(directory);
await promises.rm(directory, { recursive: true });
logger.info(`Cleared ${files[0]} image from cache 'avatar'`, {
label: 'Image Cache',
});
} catch (e) {
logger.error('Failed to clear cached image', {
label: 'Image Cache',
message: e.message,
});
}
}
private async get(cacheKey: string): Promise<ImageResponse | null> {
try {
const directory = join(this.getCacheDirectory(), cacheKey);
@@ -241,25 +187,16 @@ class ImageProxy {
const directory = join(this.getCacheDirectory(), cacheKey);
const href =
this.baseUrl +
(this.baseUrl.length > 0
? this.baseUrl.endsWith('/')
? ''
: '/'
: '') +
(this.baseUrl.endsWith('/') ? '' : '/') +
(path.startsWith('/') ? path.slice(1) : path);
const response = await this.fetch(href);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = mime.getExtension(
response.headers.get('content-type') ?? ''
);
let maxAge = Number(
const extension = path.split('.').pop() ?? '';
const maxAge = Number(
(response.headers.get('cache-control') ?? '0').split('=')[1]
);
if (!maxAge) maxAge = 86400;
const expireAt = Date.now() + maxAge * 1000;
const etag = (response.headers.get('etag') ?? '').replace(/"/g, '');
@@ -295,7 +232,7 @@ class ImageProxy {
private async writeToCacheDir(
dir: string,
extension: string | null,
extension: string,
maxAge: number,
expireAt: number,
buffer: Buffer,

View File

@@ -291,7 +291,7 @@ class DiscordAgent
}
}
const response = await fetch(settings.options.webhookUrl, {
await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -305,25 +305,15 @@ class DiscordAgent
content: userMentions.join(' '),
} as DiscordWebhookPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Discord notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -132,32 +132,22 @@ class GotifyAgent
const endpoint = `${settings.options.url}/message?token=${settings.options.token}`;
const notificationPayload = this.getNotificationPayload(type, payload);
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Gotify notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -100,7 +100,7 @@ class LunaSeaAgent
});
try {
const response = await fetch(settings.options.webhookUrl, {
await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: settings.options.profileName
? {
@@ -114,25 +114,15 @@ class LunaSeaAgent
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending LunaSea notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -122,7 +122,7 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -133,23 +133,13 @@ class PushbulletAgent
channel_tag: settings.options.channelTag,
}),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -174,7 +164,7 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -182,24 +172,14 @@ class PushbulletAgent
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -235,7 +215,7 @@ class PushbulletAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -243,24 +223,14 @@ class PushbulletAgent
},
body: JSON.stringify(notificationPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -52,9 +52,6 @@ class PushoverAgent
): Promise<Partial<PushoverImagePayload>> {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
const arrayBuffer = await response.arrayBuffer();
const base64 = Buffer.from(arrayBuffer).toString('base64');
const contentType = (
@@ -67,17 +64,10 @@ class PushoverAgent
attachment_type: contentType,
};
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error getting image payload', {
label: 'Notifications',
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return {};
}
@@ -210,7 +200,7 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -222,23 +212,13 @@ class PushoverAgent
sound: settings.options.sound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -266,7 +246,7 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -278,24 +258,14 @@ class PushoverAgent
sound: payload.notifyUser.settings.pushoverSound,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -332,7 +302,7 @@ class PushoverAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -343,24 +313,14 @@ class PushoverAgent
user: user.settings.pushoverUserKey,
} as PushoverPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Pushover notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -237,32 +237,22 @@ class SlackAgent
subject: payload.subject,
});
try {
const response = await fetch(settings.options.webhookUrl, {
await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.buildEmbed(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -174,7 +174,7 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -185,23 +185,13 @@ class TelegramAgent
disable_notification: !!settings.options.sendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -225,7 +215,7 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -237,24 +227,14 @@ class TelegramAgent
!!payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;
@@ -288,7 +268,7 @@ class TelegramAgent
});
try {
const response = await fetch(endpoint, {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -299,24 +279,14 @@ class TelegramAgent
disable_notification: !!user.settings?.telegramSendSilently,
} as TelegramMessagePayload | TelegramPhotoPayload),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -177,7 +177,7 @@ class WebhookAgent
});
try {
const response = await fetch(settings.options.webhookUrl, {
await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -187,25 +187,15 @@ class WebhookAgent
},
body: JSON.stringify(this.buildPayload(type, payload)),
});
if (!response.ok) {
throw new Error(response.statusText, { cause: response });
}
return true;
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
logger.error('Error sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: errorData,
response: e.response?.data,
});
return false;

View File

@@ -582,7 +582,12 @@ class JellyfinScanner {
const userRepository = getRepository(User);
const admin = await userRepository.findOne({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
select: [
'id',
'jellyfinAuthToken',
'jellyfinUserId',
'jellyfinDeviceId',
],
order: { id: 'ASC' },
});
@@ -592,7 +597,7 @@ class JellyfinScanner {
this.jfClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.apiKey,
admin.jellyfinAuthToken,
admin.jellyfinDeviceId
);

View File

@@ -47,7 +47,6 @@ export interface JellyfinSettings {
jellyfinForgotPasswordUrl?: string;
libraries: Library[];
serverId: string;
apiKey: string;
}
export interface TautulliSettings {
hostname?: string;
@@ -343,7 +342,6 @@ class Settings {
jellyfinForgotPasswordUrl: '',
libraries: [],
serverId: '',
apiKey: '',
},
tautulli: {},
radarr: [],
@@ -631,7 +629,7 @@ class Settings {
* @param overrideSettings If passed in, will override all existing settings with these
* values
*/
public async load(overrideSettings?: AllSettings): Promise<Settings> {
public load(overrideSettings?: AllSettings): Settings {
if (overrideSettings) {
this.data = overrideSettings;
return this;
@@ -644,7 +642,7 @@ class Settings {
if (data) {
const parsedJson = JSON.parse(data);
this.data = await runMigrations(parsedJson);
this.data = runMigrations(parsedJson);
this.data = merge(this.data, parsedJson);
@@ -658,6 +656,7 @@ class Settings {
}
}
let loaded = false;
let settings: Settings | undefined;
export const getSettings = (initialSettings?: AllSettings): Settings => {
@@ -665,6 +664,11 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
settings = new Settings(initialSettings);
}
if (!loaded) {
settings.load();
loaded = true;
}
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 logger from '@server/logger';
import fs from 'fs';
import path from 'path';
const migrationsDir = path.join(__dirname, 'migrations');
export const runMigrations = async (
settings: AllSettings
): Promise<AllSettings> => {
export const runMigrations = (settings: AllSettings): AllSettings => {
const migrations = fs
.readdirSync(migrationsDir)
.filter((file) => file.endsWith('.js') || file.endsWith('.ts'))
@@ -16,15 +13,8 @@ export const runMigrations = async (
let migrated = settings;
try {
for (const migration of migrations) {
migrated = await migration(migrated);
}
} catch (e) {
logger.error(
`Something went wrong while running settings migrations: ${e.message}`,
{ label: 'Settings Migrator' }
);
for (const migration of migrations) {
migrated = migration(migrated);
}
return migrated;

View File

@@ -85,7 +85,6 @@ export interface MovieDetails {
mediaUrl?: string;
watchProviders?: WatchProviders[];
keywords: Keyword[];
onUserWatchlist?: boolean;
}
export const mapProductionCompany = (
@@ -102,8 +101,7 @@ export const mapProductionCompany = (
export const mapMovieDetails = (
movie: TmdbMovieDetails,
media?: Media,
userWatchlist?: boolean
media?: Media
): MovieDetails => ({
id: movie.id,
adult: movie.adult,
@@ -150,5 +148,4 @@ export const mapMovieDetails = (
id: keyword.id,
name: keyword.name,
})),
onUserWatchlist: userWatchlist,
});

View File

@@ -111,7 +111,6 @@ export interface TvDetails {
keywords: Keyword[];
mediaInfo?: Media;
watchProviders?: WatchProviders[];
onUserWatchlist?: boolean;
}
const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
@@ -162,8 +161,7 @@ export const mapNetwork = (network: TmdbNetwork): TvNetwork => ({
export const mapTvDetails = (
show: TmdbTvDetails,
media?: Media,
userWatchlist?: boolean
media?: Media
): TvDetails => ({
createdBy: show.created_by,
episodeRunTime: show.episode_run_time,
@@ -225,5 +223,4 @@ export const mapTvDetails = (
})),
mediaInfo: media,
watchProviders: mapWatchProviders(show['watch/providers']?.results ?? {}),
onUserWatchlist: userWatchlist,
});

View File

@@ -6,7 +6,6 @@ import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { startJobs } from '@server/job/schedule';
import ImageProxy from '@server/lib/imageproxy';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -321,28 +320,18 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
// with admin permission
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
: gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }),
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();
settings.jellyfin.name = serverName;
@@ -351,7 +340,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.port = body.port ?? 8096;
settings.jellyfin.urlBase = body.urlBase ?? '';
settings.jellyfin.useSsl = body.useSsl ?? false;
settings.jellyfin.apiKey = apiKey;
settings.save();
startJobs();
@@ -375,25 +363,18 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
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)
if (account.User.PrimaryImageTag) {
const avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`;
} else {
const avatar = gravatarUrl(user.email || account.User.Name, {
user.avatar = gravatarUrl(user.email, {
default: 'mm',
size: 200,
});
if (avatar !== user.avatar) {
const avatarProxy = new ImageProxy('avatar', '');
avatarProxy.clearCachedImage(user.avatar);
}
user.avatar = avatar;
}
user.jellyfinUsername = account.User.Name;
@@ -432,18 +413,20 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
);
if (!body.email) {
throw new Error('add_email');
}
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: settings.main.defaultPermissions,
avatar: account.User.PrimaryImageTag
? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`
: gravatarUrl(body.email || account.User.Name, {
default: 'mm',
size: 200,
}),
: gravatarUrl(body.email, { default: 'mm', size: 200 }),
userType: UserType.JELLYFIN,
});
//initialize Jellyfin/Emby users with local login

View File

@@ -1,31 +0,0 @@
import ImageProxy from '@server/lib/imageproxy';
import logger from '@server/logger';
import { Router } from 'express';
const router = Router();
const avatarImageProxy = new ImageProxy('avatar', '');
// Proxy avatar images
router.get('/*', async (req, res) => {
const imagePath = req.url.startsWith('/') ? req.url.slice(1) : req.url;
try {
const imageData = await avatarImageProxy.getImage(imagePath);
res.writeHead(200, {
'Content-Type': `image/${imageData.meta.extension}`,
'Content-Length': imageData.imageBuffer.length,
'Cache-Control': `public, max-age=${imageData.meta.curRevalidate}`,
'OS-Cache-Key': imageData.meta.cacheKey,
'OS-Cache-Status': imageData.meta.cacheMiss ? 'MISS' : 'HIT',
});
res.end(imageData.imageBuffer);
} catch (e) {
logger.error('Failed to proxy avatar image', {
imagePath,
errorMessage: e.message,
});
}
});
export default router;

View File

@@ -3,9 +3,7 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger';
import { mapMovieDetails } from '@server/models/Movie';
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 onUserWatchlist = await getRepository(Watchlist).exist({
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapMovieDetails(tmdbMovie, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getMovie({ movieId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
} catch (e) {
logger.debug('Something went wrong retrieving movie', {
label: 'API',

View File

@@ -1,5 +1,3 @@
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import {
MediaRequestStatus,
MediaStatus,
@@ -21,7 +19,6 @@ import type {
RequestResultsResponse,
} from '@server/interfaces/api/requestInterfaces';
import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
@@ -146,62 +143,6 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
.skip(skip)
.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({
pageInfo: {
pages: Math.ceil(requestCount / pageSize),
@@ -209,7 +150,7 @@ requestRoutes.get<Record<string, unknown>, RequestResultsResponse>(
results: requestCount,
page: Math.ceil(skip / pageSize) + 1,
},
results: requestsWithProfileNames,
results: requests,
});
} catch (e) {
next({ status: 500, message: e.message });

View File

@@ -262,7 +262,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
try {
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
select: ['id', 'jellyfinAuthToken', 'jellyfinUserId', 'jellyfinDeviceId'],
order: { id: 'ASC' },
});
@@ -270,7 +270,7 @@ settingsRoutes.post('/jellyfin', async (req, res, next) => {
const jellyfinClient = new JellyfinAPI(
getHostname(tempJellyfinSettings),
tempJellyfinSettings.apiKey,
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -318,13 +318,13 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
if (req.query.sync) {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 },
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.apiKey,
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -376,8 +376,7 @@ settingsRoutes.get('/jellyfin/library', async (req, res, next) => {
});
settingsRoutes.get('/jellyfin/users', async (req, res) => {
const settings = getSettings();
const { externalHostname } = settings.jellyfin;
const { externalHostname } = getSettings().jellyfin;
const jellyfinHost =
externalHostname && externalHostname.length > 0
? externalHostname
@@ -385,13 +384,13 @@ settingsRoutes.get('/jellyfin/users', async (req, res) => {
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
select: ['id', 'jellyfinAuthToken', 'jellyfinDeviceId', 'jellyfinUserId'],
where: { id: 1 },
order: { id: 'ASC' },
});
const jellyfinClient = new JellyfinAPI(
getHostname(),
settings.jellyfin.apiKey,
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
@@ -746,13 +745,11 @@ settingsRoutes.get('/cache', async (_req, res) => {
}));
const tmdbImageCache = await ImageProxy.getImageStats('tmdb');
const avatarImageCache = await ImageProxy.getImageStats('avatar');
return res.status(200).json({
apiCaches,
imageCache: {
tmdb: tmdbImageCache,
avatar: avatarImageCache,
},
});
});

View File

@@ -1,9 +1,7 @@
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
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 onUserWatchlist = await getRepository(Watchlist).exist({
where: {
tmdbId: Number(req.params.id),
requestedBy: {
id: req.user?.id,
},
},
});
const data = mapTvDetails(tv, media, onUserWatchlist);
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
data.overview = tvEnglish.overview;
}
return res.status(200).json(data);
return res.status(200).json(mapTvDetails(tv, media));
} catch (e) {
logger.debug('Something went wrong retrieving series', {
label: 'API',

View File

@@ -41,19 +41,7 @@ router.get('/', async (req, res, next) => {
break;
case 'displayname':
query = query.orderBy(
`CASE WHEN (user.username IS NULL OR user.username = '') THEN (
CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN (
CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN
user.email
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.jellyfinUsername)
END)
ELSE
LOWER(user.username)
END`,
"(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)",
'ASC'
);
break;
@@ -102,13 +90,12 @@ router.post(
const settings = getSettings();
const body = req.body;
const email = body.email || body.username;
const userRepository = getRepository(User);
const existingUser = await userRepository
.createQueryBuilder('user')
.where('user.email = :email', {
email: email.toLowerCase(),
email: body.email.toLowerCase(),
})
.getOne();
@@ -121,7 +108,7 @@ router.post(
}
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 (
!passedExplicitPassword &&
@@ -131,9 +118,9 @@ router.post(
}
const user = new User({
email,
avatar: body.avatar ?? avatar,
username: body.username,
email: body.email,
password: body.password,
permissions: settings.main.defaultPermissions,
plexToken: '',
@@ -501,14 +488,17 @@ router.post(
// taken from auth.ts
const admin = await userRepository.findOneOrFail({
where: { id: 1 },
select: ['id', 'jellyfinDeviceId', 'jellyfinUserId'],
select: [
'id',
'jellyfinAuthToken',
'jellyfinDeviceId',
'jellyfinUserId',
],
order: { id: 'ASC' },
});
const hostname = getHostname();
const jellyfinClient = new JellyfinAPI(
hostname,
settings.jellyfin.apiKey,
getHostname(),
admin.jellyfinAuthToken ?? '',
admin.jellyfinDeviceId ?? ''
);
jellyfinClient.setUserId(admin.jellyfinUserId ?? '');
@@ -516,6 +506,7 @@ router.post(
//const jellyfinUsersResponse = await jellyfinClient.getUsers();
const createdUsers: User[] = [];
const { externalHostname } = getSettings().jellyfin;
const hostname = getHostname();
const jellyfinHost =
externalHostname && externalHostname.length > 0

View File

@@ -98,9 +98,7 @@ userSettingsRoutes.post<
}
user.username = req.body.username;
if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email;
}
user.email = req.body.email ?? user.email;
// Update quota values only if the user has the correct permissions
if (

View File

@@ -7,10 +7,9 @@ type RateLimiteState<T extends (...args: Parameters<T>) => Promise<U>, U> = {
queue: {
args: Parameters<T>;
resolve: (value: U) => void;
reject: (reason?: unknown) => void;
}[];
lastTimestamps: number[];
timeout: ReturnType<typeof setTimeout>;
activeRequests: number;
timer: NodeJS.Timeout | null;
};
const rateLimitById: Record<string, unknown> = {};
@@ -28,40 +27,46 @@ export default function rateLimit<
>(fn: T, options: RateLimitOptions): (...args: Parameters<T>) => Promise<U> {
const state: RateLimiteState<T, U> = (rateLimitById[
options.id || ''
] as RateLimiteState<T, U>) || { queue: [], lastTimestamps: [] };
] as RateLimiteState<T, U>) || { queue: [], activeRequests: 0, timer: null };
if (options.id) {
rateLimitById[options.id] = state;
}
const processQueue = () => {
// remove old timestamps
state.lastTimestamps = state.lastTimestamps.filter(
(timestamp) => Date.now() - timestamp < 1000
);
if (state.queue.length === 0) {
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
return;
}
if (state.lastTimestamps.length < options.maxRPS) {
// process requests if RPS not exceeded
while (state.activeRequests < options.maxRPS) {
state.activeRequests++;
const item = state.queue.shift();
if (!item) return;
state.lastTimestamps.push(Date.now());
const { args, resolve, reject } = item;
if (!item) break;
const { args, resolve } = item;
fn(...args)
.then(resolve)
.catch(reject);
processQueue();
} else {
// rerun once the oldest item in queue is older than 1s
if (state.timeout) clearTimeout(state.timeout);
state.timeout = setTimeout(
processQueue,
1000 - (Date.now() - state.lastTimestamps[0])
);
.finally(() => {
state.activeRequests--;
if (state.queue.length > 0) {
if (!state.timer) {
state.timer = setInterval(processQueue, 1000);
}
} else {
if (state.timer) {
clearInterval(state.timer);
state.timer = null;
}
}
});
}
};
return (...args: Parameters<T>): Promise<U> => {
return new Promise<U>((resolve, reject) => {
state.queue.push({ args, resolve, reject });
return new Promise<U>((resolve) => {
state.queue.push({ args, resolve });
processQueue();
});
};

View File

@@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
<div className="mt-1 mb-5">
<Header>
{firstResultData?.network.logoPath ? (
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
<div className="mb-6 flex justify-center">
<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}
className="object-contain"
className="max-h-24 sm:max-h-32"
fill
/>
</div>

View File

@@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
<div className="mt-1 mb-5">
<Header>
{firstResultData?.studio.logoPath ? (
<div className="relative mb-6 flex h-24 justify-center sm:h-32">
<div className="mb-6 flex justify-center">
<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}
className="object-contain"
className="max-h-24 sm:max-h-32"
fill
/>
</div>

View File

@@ -1,5 +1,4 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -7,6 +6,7 @@ import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/24/solid';
import type { default as IssueCommentType } from '@server/entity/IssueComment';
import { Field, Form, Formik } from 'formik';
import Image from 'next/image';
import Link from 'next/link';
import { Fragment, useState } from 'react';
import { FormattedRelativeTime, useIntl } from 'react-intl';
@@ -88,8 +88,8 @@ const IssueComment = ({
</Modal>
</Transition>
<Link href={isActiveUser ? '/profile' : `/users/${comment.user.id}`}>
<CachedImage
src={`/avatarproxy/${comment.user.avatar}`}
<Image
src={comment.user.avatar}
alt=""
className="h-10 w-10 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={40}
@@ -181,9 +181,6 @@ const IssueComment = ({
`/api/v1/issueComment/${comment.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.newMessage }),
}
);

View File

@@ -29,6 +29,7 @@ import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
@@ -125,9 +126,6 @@ const IssueDetails = () => {
try {
const res = await fetch(`/api/v1/issueComment/${firstComment.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: newMessage }),
});
if (!res.ok) throw new Error();
@@ -288,10 +286,10 @@ const IssueDetails = () => {
}
className="group ml-1 inline-flex h-full items-center xl:ml-1.5"
>
<CachedImage
src={`/avatarproxy/${issueData.createdBy.avatar}`}
alt=""
<Image
className="mr-0.5 h-5 w-5 scale-100 transform-gpu rounded-full object-cover transition duration-300 group-hover:scale-105 xl:mr-1 xl:h-6 xl:w-6"
src={issueData.createdBy.avatar}
alt=""
width={20}
height={20}
/>
@@ -503,9 +501,6 @@ const IssueDetails = () => {
`/api/v1/issue/${issueData?.id}/comment`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: values.message }),
}
);

View File

@@ -1,4 +1,3 @@
import CachedImage from '@app/components/Common/CachedImage';
import MiniQuotaDisplay from '@app/components/Layout/UserDropdown/MiniQuotaDisplay';
import { useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
@@ -8,6 +7,7 @@ import {
ClockIcon,
} from '@heroicons/react/24/outline';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { forwardRef, Fragment } from 'react';
@@ -56,9 +56,9 @@ const UserDropdown = () => {
className="flex max-w-xs items-center rounded-full text-sm ring-1 ring-gray-700 hover:ring-gray-500 focus:outline-none focus:ring-gray-500"
data-testid="user-menu"
>
<CachedImage
<Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
src={user?.avatar || ''}
alt=""
width={40}
height={40}
@@ -79,9 +79,9 @@ const UserDropdown = () => {
<div className="divide-y divide-gray-700 rounded-md bg-gray-800 bg-opacity-80 ring-1 ring-gray-700 backdrop-blur">
<div className="flex flex-col space-y-4 px-4 py-4">
<div className="flex items-center space-x-2">
<CachedImage
<Image
className="h-8 w-8 rounded-full object-cover sm:h-10 sm:w-10"
src={user && user.avatar ? `/avatarproxy/${user.avatar}` : ''}
src={user?.avatar || ''}
alt=""
width={40}
height={40}
@@ -90,11 +90,9 @@ const UserDropdown = () => {
<span className="truncate text-xl font-semibold text-gray-200">
{user?.displayName}
</span>
{user?.displayName?.toLowerCase() !== user?.email && (
<span className="truncate text-sm text-gray-400">
{user?.email}
</span>
)}
<span className="truncate text-sm text-gray-400">
{user?.email}
</span>
</div>
</div>
{user && <MiniQuotaDisplay userId={user?.id} />}

View File

@@ -59,9 +59,6 @@ const AddEmailModal: React.FC<AddEmailModalProps> = ({
try {
const res = await fetch('/api/v1/auth/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password,

View File

@@ -119,17 +119,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
email: values.email,
}),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
if (!res.ok) throw new Error();
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
let errorMessage = null;
switch (errorData?.message) {
switch (e.response?.data?.message) {
case ApiErrorCode.InvalidUrl:
errorMessage = messages.invalidurlerror;
break;

View File

@@ -45,26 +45,16 @@ const Login = () => {
try {
const res = await fetch('/api/v1/auth/plex', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
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();
if (data?.id) {
revalidate();
}
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
setError(errorData?.message);
setError(e.response.data.message);
setAuthToken(undefined);
setProcessing(false);
}

View File

@@ -1,5 +1,4 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import ConfirmButton from '@app/components/Common/ConfirmButton';
import SlideOver from '@app/components/Common/SlideOver';
import Tooltip from '@app/components/Common/Tooltip';
@@ -28,6 +27,7 @@ import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
@@ -355,8 +355,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<CachedImage
src={`/avatarproxy/${user.avatar}`}
<Image
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32}
@@ -516,8 +516,8 @@ const ManageSlideOver = ({
key={`watch-user-${user.id}`}
content={user.displayName}
>
<CachedImage
src={`/avatarproxy/${user.avatar}`}
<Image
src={user.avatar}
alt={user.displayName}
className="h-8 w-8 scale-100 transform-gpu rounded-full object-cover ring-1 ring-gray-500 transition duration-300 hover:scale-105"
width={32}

View File

@@ -57,48 +57,44 @@ const ShowMoreCard = ({ url, posters }: ShowMoreCardProps) => {
>
<div style={{ paddingBottom: '150%' }}>
<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] && (
<div className="">
<div className="w-1/2 p-1">
<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=""
className="rounded-md"
width={300}
height={450}
className="w-full rounded-md"
fill
/>
</div>
)}
{posters[1] && (
<div className="">
<div className="w-1/2 p-1">
<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=""
className="rounded-md"
width={300}
height={450}
className="w-full rounded-md"
fill
/>
</div>
)}
{posters[2] && (
<div className="">
<div className="w-1/2 p-1">
<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=""
className="rounded-md"
width={300}
height={450}
className="w-full rounded-md"
fill
/>
</div>
)}
{posters[3] && (
<div className="">
<div className="w-1/2 p-1">
<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=""
className="rounded-md"
width={300}
height={450}
className="w-full rounded-md"
fill
/>
</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 RTRotten from '@app/assets/rt_rotten.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 Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -26,7 +25,7 @@ import useLocale from '@app/hooks/useLocale';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
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 defineMessages from '@app/utils/defineMessages';
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
@@ -42,12 +41,10 @@ import {
import {
ChevronDoubleDownIcon,
ChevronDoubleUpIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/solid';
import { type RatingResponse } from '@server/api/ratings';
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 type { MovieDetails as MovieDetailsType } from '@server/models/Movie';
import { countries } from 'country-flag-icons';
@@ -58,7 +55,6 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.MovieDetails', {
@@ -98,12 +94,6 @@ const messages = defineMessages('components.MovieDetails', {
rtaudiencescore: 'Rotten Tomatoes Audience Score',
tmdbuserscore: 'TMDB 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 {
@@ -122,12 +112,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
const minStudios = 3;
const [showMoreStudios, setShowMoreStudios] = useState(false);
const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!movie?.onUserWatchlist
);
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const {
data,
@@ -169,7 +154,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
}
if (!data) {
return <ErrorPage statusCode={404} />;
return <Error statusCode={404} />;
}
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
@@ -302,80 +287,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
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 (
<div
className="media-page"
@@ -497,40 +408,6 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
</span>
</div>
<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} />
<RequestButton
mediaType="movie"

View File

@@ -19,9 +19,9 @@ import {
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -58,7 +58,7 @@ const RequestCardPlaceholder = () => {
};
interface RequestCardErrorProps {
requestData?: NonFunctionProperties<MediaRequest>;
requestData?: MediaRequest;
}
const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
@@ -115,8 +115,8 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
className="group flex items-center"
>
<span className="avatar-sm">
<CachedImage
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
<Image
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -213,7 +213,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => {
};
interface RequestCardProps {
request: NonFunctionProperties<MediaRequest>;
request: MediaRequest;
onTitleData?: (requestId: number, title: MovieDetails | TvDetails) => void;
}
@@ -238,19 +238,16 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
data: requestData,
error: requestError,
mutate: revalidate,
} = useSWR<NonFunctionProperties<MediaRequest>>(
`/api/v1/request/${request.id}`,
{
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}
);
} = useSWR<MediaRequest>(`/api/v1/request/${request.id}`, {
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
});
const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({
mediaUrl: requestData?.media?.mediaUrl,
@@ -389,8 +386,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
className="group flex items-center"
>
<span className="avatar-sm">
<CachedImage
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
<Image
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}

View File

@@ -18,9 +18,9 @@ import {
} from '@heroicons/react/24/solid';
import { MediaRequestStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useInView } from 'react-intersection-observer';
@@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestList.RequestItem', {
tmdbid: 'TMDB ID',
tvdbid: 'TheTVDB ID',
unknowntitle: 'Unknown Title',
profileName: 'Profile',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@@ -50,7 +49,7 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
};
interface RequestItemErrorProps {
requestData?: NonFunctionProperties<MediaRequest>;
requestData?: MediaRequest;
revalidateList: () => void;
}
@@ -189,8 +188,8 @@ const RequestItemError = ({
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<CachedImage
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
<Image
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -248,8 +247,8 @@ const RequestItemError = ({
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<CachedImage
src={`/avatarproxy/${requestData.modifiedBy.avatar}`}
<Image
src={requestData.modifiedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -286,7 +285,7 @@ const RequestItemError = ({
};
interface RequestItemProps {
request: NonFunctionProperties<MediaRequest> & { profileName?: string };
request: MediaRequest;
revalidateList: () => void;
}
@@ -305,18 +304,19 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? url : null
);
const { data: requestData, mutate: revalidate } = useSWR<
NonFunctionProperties<MediaRequest>
>(`/api/v1/request/${request.id}`, {
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
});
const { data: requestData, mutate: revalidate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
fallbackData: request,
refreshInterval: refreshIntervalHelper(
{
downloadStatus: request.media.downloadStatus,
downloadStatus4k: request.media.downloadStatus4k,
},
15000
),
}
);
const [isRetrying, setRetrying] = useState(false);
@@ -401,7 +401,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
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 && (
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
<CachedImage
@@ -482,7 +482,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)}
</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">
<span className="card-field-name">
{intl.formatMessage(globalMessages.status)}
@@ -556,8 +556,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<CachedImage
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
<Image
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -615,8 +615,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
className="group flex items-center truncate"
>
<span className="avatar-sm ml-1.5">
<CachedImage
src={`/avatarproxy/${requestData.requestedBy.avatar}`}
<Image
src={requestData.requestedBy.avatar}
alt=""
className="avatar-sm object-cover"
width={20}
@@ -632,16 +632,6 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
</span>
</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 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

@@ -1,5 +1,4 @@
/* eslint-disable react-hooks/exhaustive-deps */
import CachedImage from '@app/components/Common/CachedImage';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
@@ -15,6 +14,7 @@ import type {
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import { hasPermission } from '@server/lib/permissions';
import { isEqual } from 'lodash';
import Image from 'next/image';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
@@ -561,8 +561,8 @@ const AdvancedRequester = ({
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="focus:shadow-outline-blue relative w-full cursor-default rounded-md border border-gray-700 bg-gray-800 py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5">
<span className="flex items-center">
<CachedImage
src={`/avatarproxy/${selectedUser.avatar}`}
<Image
src={selectedUser.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24}
@@ -613,8 +613,8 @@ const AdvancedRequester = ({
selected ? 'font-semibold' : 'font-normal'
} flex items-center`}
>
<CachedImage
src={`/avatarproxy/${user.avatar}`}
<Image
src={user.avatar}
alt=""
className="h-6 w-6 flex-shrink-0 rounded-full object-cover"
width={24}

View File

@@ -8,7 +8,6 @@ import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { MovieDetails } from '@server/models/Movie';
@@ -39,7 +38,7 @@ const messages = defineMessages('components.RequestModal', {
interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
tmdbId: number;
is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>;
editRequest?: MediaRequest;
onCancel?: () => void;
onComplete?: (newStatus: MediaStatus) => 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 type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
import type { QuotaResponse } from '@server/interfaces/api/userInterfaces';
import { Permission } from '@server/lib/permissions';
import type { TvDetails } from '@server/models/Tv';
@@ -58,7 +57,7 @@ interface RequestModalProps extends React.HTMLAttributes<HTMLDivElement> {
onComplete?: (newStatus: MediaStatus) => void;
onUpdating?: (isUpdating: boolean) => void;
is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>;
editRequest?: MediaRequest;
}
const TvRequestModal = ({

View File

@@ -4,14 +4,13 @@ import TvRequestModal from '@app/components/RequestModal/TvRequestModal';
import { Transition } from '@headlessui/react';
import type { MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type { NonFunctionProperties } from '@server/interfaces/api/common';
interface RequestModalProps {
show: boolean;
type: 'movie' | 'tv' | 'collection';
tmdbId: number;
is4k?: boolean;
editRequest?: NonFunctionProperties<MediaRequest>;
editRequest?: MediaRequest;
onComplete?: (newStatus: MediaStatus) => void;
onCancel?: () => void;
onUpdating?: (isUpdating: boolean) => void;

View File

@@ -1,7 +1,6 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import SensitiveInput from '@app/components/Common/SensitiveInput';
import LibraryItem from '@app/components/Settings/LibraryItem';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -31,14 +30,13 @@ const messages = defineMessages('components.Settings', {
jellyfinSettingsSuccess: '{mediaServerName} settings saved successfully!',
jellyfinSettings: '{mediaServerName} 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.',
'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',
hostname: 'Hostname or IP Address',
port: 'Port',
enablessl: 'Use SSL',
urlBase: 'URL Base',
jellyfinForgotPasswordUrl: 'Forgot Password URL',
apiKey: 'API key',
jellyfinSyncFailedNoLibrariesFound: 'No libraries were found',
jellyfinSyncFailedAutomaticGroupedFolders:
'Custom authentication with Automatic Library Grouping not supported',
@@ -175,18 +173,11 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
const res = await fetch(
`/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);
revalidate();
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
if (errorData?.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') {
toasts.addToast(
intl.formatMessage(
messages.jellyfinSyncFailedAutomaticGroupedFolders
@@ -196,7 +187,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
appearance: 'warning',
}
);
} else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') {
} else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') {
toasts.addToast(
intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound),
{
@@ -446,121 +437,112 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
</div>
</div>
</div>
<div className="mt-10 mb-6">
<h3 className="heading">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Jellyfin',
})}
</h3>
<p className="description">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Jellyfin',
})}
</p>
</div>
<Formik
initialValues={{
hostname: data?.ip,
port: data?.port ?? 8096,
useSsl: data?.useSsl,
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
apiKey: data?.apiKey,
}}
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/settings/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
apiKey: values.apiKey,
} as JellyfinSettings),
});
if (!res.ok) throw new Error(res.statusText, { cause: res });
{showAdvancedSettings && (
<>
<div className="mt-10 mb-6">
<h3 className="heading">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettings, {
mediaServerName: 'Jellyfin',
})}
</h3>
<p className="description">
{publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Emby',
})
: intl.formatMessage(messages.jellyfinSettingsDescription, {
mediaServerName: 'Jellyfin',
})}
</p>
</div>
<Formik
initialValues={{
hostname: data?.ip,
port: data?.port ?? 8096,
useSsl: data?.useSsl,
urlBase: data?.urlBase || '',
jellyfinExternalUrl: data?.externalHostname || '',
jellyfinForgotPasswordUrl: data?.jellyfinForgotPasswordUrl || '',
}}
validationSchema={JellyfinSettingsSchema}
onSubmit={async (values) => {
try {
const res = await fetch('/api/v1/settings/jellyfin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
ip: values.hostname,
port: Number(values.port),
useSsl: values.useSsl,
urlBase: values.urlBase,
externalHostname: values.jellyfinExternalUrl,
jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl,
} as JellyfinSettings),
});
if (!res.ok) throw new Error();
addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
appearance: 'success',
addToast(
intl.formatMessage(messages.jellyfinSettingsSuccess, {
mediaServerName:
publicRuntimeConfig.JELLYFIN_TYPE == 'emby'
? 'Emby'
: 'Jellyfin',
}),
{
autoDismiss: true,
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 {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
if (errorData?.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();
}
}}
>
{({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
{showAdvancedSettings && (
<>
}}
>
{({
errors,
touched,
values,
setFieldValue,
handleSubmit,
isSubmitting,
isValid,
}) => {
return (
<form className="section" onSubmit={handleSubmit}>
<div className="form-row">
<label htmlFor="hostname" className="text-label">
{intl.formatMessage(messages.hostname)}
@@ -622,29 +604,6 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
/>
</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">
<label htmlFor="urlBase" className="text-label">
{intl.formatMessage(messages.urlBase)}
@@ -665,73 +624,75 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
)}
</div>
</div>
</>
)}
<div className="form-row">
<label htmlFor="jellyfinExternalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
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 className="form-row">
<label htmlFor="jellyfinExternalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field
type="text"
inputMode="url"
id="jellyfinExternalUrl"
name="jellyfinExternalUrl"
/>
</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}
{errors.jellyfinExternalUrl &&
touched.jellyfinExternalUrl && (
<div className="error">
{errors.jellyfinExternalUrl}
</div>
)}
</div>
</div>
<div className="form-row">
<label
htmlFor="jellyfinForgotPasswordUrl"
className="text-label"
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
{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>
</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>
</Button>
</span>
</div>
</div>
</form>
);
}}
</Formik>
</div>
</div>
</form>
);
}}
</Formik>
</>
)}
</>
);
};

View File

@@ -81,7 +81,6 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'When enabled in settings, Jellyseerr will proxy and cache images from pre-configured external sources. Cached images are saved into your config folder. You can find the files in <code>{appDataPath}/cache/images</code>.',
imagecachecount: 'Images Cached',
imagecachesize: 'Total Cache Size',
useravatars: 'User Avatars',
}
);
@@ -559,19 +558,6 @@ const SettingsJobs = () => {
{formatBytes(cacheData?.imageCache.tmdb.size ?? 0)}
</Table.TD>
</tr>
<tr>
<Table.TD>
{intl.formatMessage(messages.useravatars)} (avatar)
</Table.TD>
<Table.TD>
{intl.formatNumber(
cacheData?.imageCache.avatar.imageCount ?? 0
)}
</Table.TD>
<Table.TD>
{formatBytes(cacheData?.imageCache.avatar.size ?? 0)}
</Table.TD>
</tr>
</Table.TBody>
</Table>
</div>

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 RTFresh from '@app/assets/rt_fresh.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 Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
@@ -41,19 +40,11 @@ import {
FilmIcon,
PlayIcon,
} from '@heroicons/react/24/outline';
import {
ChevronDownIcon,
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/solid';
import { ChevronDownIcon } from '@heroicons/react/24/solid';
import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { Crew } from '@server/models/common';
import type { TvDetails as TvDetailsType } from '@server/models/Tv';
@@ -64,7 +55,6 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.TvDetails', {
@@ -99,12 +89,6 @@ const messages = defineMessages('components.TvDetails', {
rtcriticsscore: 'Rotten Tomatoes Tomatometer',
rtaudiencescore: 'Rotten Tomatoes Audience 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 {
@@ -122,12 +106,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
router.query.manage == '1' ? true : false
);
const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
!tv?.onUserWatchlist
);
const { publicRuntimeConfig } = getConfig();
const { addToast } = useToasts();
const {
data,
@@ -323,82 +302,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
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 (
<div
className="media-page"
@@ -530,40 +433,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
</span>
</div>
<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} />
<RequestButton
mediaType="tv"

View File

@@ -1,11 +1,11 @@
import Alert from '@app/components/Common/Alert';
import CachedImage from '@app/components/Common/CachedImage';
import Modal from '@app/components/Common/Modal';
import useSettings from '@app/hooks/useSettings';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import getConfig from 'next/config';
import Image from 'next/image';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
@@ -56,6 +56,14 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
`/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 () => {
setImporting(true);
@@ -70,7 +78,7 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
}),
});
if (!res.ok) throw new Error();
const createdUsers = await res.json();
const { data: createdUsers } = await res.json();
if (!createdUsers.length) {
throw new Error('No users were imported from Jellyfin.');
@@ -201,71 +209,64 @@ const JellyfinImportModal: React.FC<JellyfinImportProps> = ({
</tr>
</thead>
<tbody className="divide-y divide-gray-700 bg-gray-600">
{data
?.filter(
(user) =>
!existingUsers?.results.some(
(u) => u.jellyfinUserId === user.id
)
)
.map((user) => (
<tr key={`user-${user.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
{data?.map((user) => (
<tr key={`user-${user.id}`}>
<td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100">
<span
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
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
role="checkbox"
tabIndex={0}
aria-checked={isSelectedUser(user.id)}
onClick={() => toggleUser(user.id)}
onKeyDown={(e) => {
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
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} 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>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<CachedImage
className="h-10 w-10 flex-shrink-0 rounded-full"
src={`/avatarproxy/${user.thumb}`}
alt=""
width={40}
height={40}
/>
<div className="ml-4">
<div className="text-base font-bold leading-5">
{user.username}
</div>
{/* {user.username &&
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'bg-indigo-500'
: 'bg-gray-800'
} absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`}
></span>
<span
aria-hidden="true"
className={`${
isSelectedUser(user.id)
? 'translate-x-5'
: 'translate-x-0'
} 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>
</span>
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
<div className="flex items-center">
<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.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>
)} */}
</div>
</div>
</td>
</tr>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>

View File

@@ -1,7 +1,6 @@
import Alert from '@app/components/Common/Alert';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import Header from '@app/components/Common/Header';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
@@ -30,6 +29,7 @@ import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'
import { hasPermission } from '@server/lib/permissions';
import { Field, Form, Formik } from 'formik';
import getConfig from 'next/config';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
@@ -68,15 +68,14 @@ const messages = defineMessages('components.UserList', {
usercreatedfailedexisting:
'The provided email address is already in use by another user.',
usercreatedsuccess: 'User created successfully!',
username: 'Username',
displayName: 'Display Name',
email: 'Email Address',
password: 'Password',
passwordinfodescription:
'Configure an application URL and enable email notifications to allow automatic password generation.',
autogeneratepassword: 'Automatically Generate Password',
autogeneratepasswordTip: 'Email a server-generated password to the user',
validationUsername: 'You must provide an username',
validationEmail: 'Email required',
validationEmail: 'You must provide a valid email address',
sortCreated: 'Join Date',
sortDisplayName: 'Display Name',
sortRequests: 'Request Count',
@@ -209,10 +208,9 @@ const UserList = () => {
}
const CreateUserSchema = Yup.object().shape({
username: Yup.string().required(
intl.formatMessage(messages.validationUsername)
),
email: Yup.string().email(intl.formatMessage(messages.validationEmail)),
email: Yup.string()
.required(intl.formatMessage(messages.validationEmail))
.email(intl.formatMessage(messages.validationEmail)),
password: Yup.lazy((value) =>
!value
? Yup.string()
@@ -260,7 +258,7 @@ const UserList = () => {
setDeleteModal({ isOpen: false, user: deleteModal.user })
}
title={intl.formatMessage(messages.deleteuser)}
subTitle={deleteModal.user?.username}
subTitle={deleteModal.user?.displayName}
>
{intl.formatMessage(messages.deleteconfirm)}
</Modal>
@@ -278,7 +276,7 @@ const UserList = () => {
>
<Formik
initialValues={{
username: '',
displayName: '',
email: '',
password: '',
genpassword: false,
@@ -292,28 +290,21 @@ const UserList = () => {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: values.username,
username: values.displayName,
email: values.email,
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), {
appearance: 'success',
autoDismiss: true,
});
setCreateModal({ isOpen: false });
} catch (e) {
let errorData;
try {
errorData = await e.cause?.text();
errorData = JSON.parse(errorData);
} catch {
/* empty */
}
addToast(
intl.formatMessage(
errorData.errors?.includes('USER_EXISTS')
e.response.data.errors?.includes('USER_EXISTS')
? messages.usercreatedfailedexisting
: messages.usercreatedfailed
),
@@ -372,24 +363,23 @@ const UserList = () => {
)}
<Form className="section">
<div className="form-row">
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
<span className="label-required">*</span>
<label htmlFor="displayName" className="text-label">
{intl.formatMessage(messages.displayName)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="username" name="username" type="text" />
<Field
id="displayName"
name="displayName"
type="text"
/>
</div>
{errors.username &&
touched.username &&
typeof errors.username === 'string' && (
<div className="error">{errors.username}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
<span className="label-required">*</span>
</label>
<div className="form-input-area">
<div className="form-input-field">
@@ -634,9 +624,9 @@ const UserList = () => {
href={`/users/${user.id}`}
className="h-10 w-10 flex-shrink-0"
>
<CachedImage
<Image
className="h-10 w-10 rounded-full object-cover"
src={`/avatarproxy/${user.avatar}`}
src={user.avatar}
alt=""
width={40}
height={40}
@@ -648,16 +638,9 @@ const UserList = () => {
className="text-base font-bold leading-5 transition duration-300 hover:underline"
data-testid="user-list-username-link"
>
{user.username ||
user.jellyfinUsername ||
user.plexUsername ||
user.email}
{user.displayName}
</Link>
{(
user.username ||
user.jellyfinUsername ||
user.plexUsername
)?.toLowerCase() !== user.email && (
{user.displayName.toLowerCase() !== user.email && (
<div className="text-sm leading-5 text-gray-300">
{user.email}
</div>

View File

@@ -1,9 +1,9 @@
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
import type { User } from '@app/hooks/useUser';
import { Permission, useUser } from '@app/hooks/useUser';
import defineMessages from '@app/utils/defineMessages';
import { CogIcon, UserIcon } from '@heroicons/react/24/solid';
import Image from 'next/image';
import Link from 'next/link';
import { useIntl } from 'react-intl';
@@ -42,9 +42,9 @@ const ProfileHeader = ({ user, isSettingsPage }: ProfileHeaderProps) => {
<div className="flex items-end justify-items-end space-x-5">
<div className="flex-shrink-0">
<div className="relative">
<CachedImage
<Image
className="h-24 w-24 rounded-full bg-gray-600 object-cover ring-1 ring-gray-700"
src={`/avatarproxy/${user.avatar}`}
src={user.avatar}
alt=""
width={96}
height={96}

View File

@@ -93,14 +93,9 @@ const UserGeneralSettings = () => {
);
const UserGeneralSettingsSchema = Yup.object().shape({
email:
user?.id === 1
? Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired))
: Yup.string().email(
intl.formatMessage(messages.validationemailformat)
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
discordId: Yup.string()
.nullable()
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
@@ -139,7 +134,7 @@ const UserGeneralSettings = () => {
<Formik
initialValues={{
displayName: data?.username ?? '',
email: data?.email?.includes('@') ? data.email : '',
email: data?.email ?? '',
discordId: data?.discordId ?? '',
locale: data?.locale,
region: data?.region,
@@ -162,8 +157,7 @@ const UserGeneralSettings = () => {
},
body: JSON.stringify({
username: values.displayName,
email:
values.email || user?.jellyfinUsername || user?.plexUsername,
email: values.email,
discordId: values.discordId,
locale: values.locale,
region: values.region,
@@ -270,9 +264,7 @@ const UserGeneralSettings = () => {
name="displayName"
type="text"
placeholder={
user?.username ||
user?.jellyfinUsername ||
user?.plexUsername
user?.plexUsername ? user.plexUsername : user?.email
}
/>
</div>
@@ -297,7 +289,6 @@ const UserGeneralSettings = () => {
name="email"
type="text"
placeholder="example@domain.com"
disabled={user?.plexUsername}
className={
user?.warnings.find((w) => w === 'userEmailRequired')
? 'border-2 border-red-400 focus:border-blue-600'

View File

@@ -12,7 +12,6 @@ export interface User {
id: number;
warnings: string[];
plexUsername?: string;
jellyfinUsername?: string;
username?: string;
displayName: string;
email: string;

View File

@@ -286,7 +286,6 @@
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
"components.MovieDetails.addtowatchlist": "Add To Watchlist",
"components.MovieDetails.budget": "Budget",
"components.MovieDetails.cast": "Cast",
"components.MovieDetails.digitalrelease": "Digital Release",
@@ -307,7 +306,6 @@
"components.MovieDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
"components.MovieDetails.recommendations": "Recommendations",
"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.revenue": "Revenue",
"components.MovieDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
@@ -321,9 +319,6 @@
"components.MovieDetails.theatricalrelease": "Theatrical Release",
"components.MovieDetails.tmdbuserscore": "TMDB User Score",
"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.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.",
"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.modified": "Modified",
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
"components.RequestList.RequestItem.profileName": "Profile",
"components.RequestList.RequestItem.requested": "Requested",
"components.RequestList.RequestItem.requesteddate": "Requested",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
@@ -820,7 +814,6 @@
"components.Settings.SettingsJobsCache.runnow": "Run Now",
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
"components.Settings.SettingsJobsCache.unknownJob": "Unknown Job",
"components.Settings.SettingsJobsCache.useravatars": "User Avatars",
"components.Settings.SettingsLogs.copiedLogMessage": "Copied log message to clipboard.",
"components.Settings.SettingsLogs.copyToClipboard": "Copy to Clipboard",
"components.Settings.SettingsLogs.extraData": "Additional Data",
@@ -956,7 +949,7 @@
"components.Settings.is4k": "4K",
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
"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.jellyfinSettingsSuccess": "{mediaServerName} settings saved successfully!",
"components.Settings.jellyfinSyncFailedAutomaticGroupedFolders": "Custom authentication with Automatic Library Grouping not supported",
@@ -1077,7 +1070,6 @@
"components.TvDetails.Season.somethingwentwrong": "Something went wrong while retrieving season data.",
"components.TvDetails.TvCast.fullseriescast": "Full Series Cast",
"components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew",
"components.TvDetails.addtowatchlist": "Add To Watchlist",
"components.TvDetails.anime": "Anime",
"components.TvDetails.cast": "Cast",
"components.TvDetails.episodeCount": "{episodeCount, plural, one {# Episode} other {# Episodes}}",
@@ -1095,7 +1087,6 @@
"components.TvDetails.play4k": "Play 4K on {mediaServerName}",
"components.TvDetails.productioncountries": "Production {countryCount, plural, one {Country} other {Countries}}",
"components.TvDetails.recommendations": "Recommendations",
"components.TvDetails.removefromwatchlist": "Remove From Watchlist",
"components.TvDetails.reportissue": "Report an Issue",
"components.TvDetails.rtaudiencescore": "Rotten Tomatoes Audience Score",
"components.TvDetails.rtcriticsscore": "Rotten Tomatoes Tomatometer",
@@ -1108,9 +1099,6 @@
"components.TvDetails.streamingproviders": "Currently Streaming On",
"components.TvDetails.tmdbuserscore": "TMDB User Score",
"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.UserList.accounttype": "Type",
"components.UserList.admin": "Admin",
@@ -1123,6 +1111,7 @@
"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.deleteuser": "Delete User",
"components.UserList.displayName": "Display Name",
"components.UserList.edituser": "Edit User Permissions",
"components.UserList.email": "Email Address",
"components.UserList.importedfromJellyfin": "<strong>{userCount}</strong> {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!",
@@ -1156,11 +1145,9 @@
"components.UserList.userdeleteerror": "Something went wrong while deleting the user.",
"components.UserList.userfail": "Something went wrong while saving user permissions.",
"components.UserList.userlist": "User List",
"components.UserList.username": "Username",
"components.UserList.users": "Users",
"components.UserList.userssaved": "User permissions saved successfully!",
"components.UserList.validationEmail": "Email required",
"components.UserList.validationUsername": "You must provide an username",
"components.UserList.validationEmail": "You must provide a valid email address",
"components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters",
"components.UserProfile.ProfileHeader.joindate": "Joined {joindate}",
"components.UserProfile.ProfileHeader.profile": "View Profile",

File diff suppressed because it is too large Load Diff