Merge branch 'develop' of https://github.com/Fallenbagel/jellyseerr into v1.7.0/postgresql

This commit is contained in:
dr-carrot
2024-11-25 12:54:42 -05:00
49 changed files with 641 additions and 255 deletions

View File

@@ -448,6 +448,69 @@
"contributions": [
"security"
]
},
{
"login": "j0srisk",
"name": "Joseph Risk",
"avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4",
"profile": "http://josephrisk.com",
"contributions": [
"code"
]
},
{
"login": "Loetwiek",
"name": "Loetwiek",
"avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4",
"profile": "https://github.com/Loetwiek",
"contributions": [
"code"
]
},
{
"login": "Fuochi",
"name": "Fuochi",
"avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4",
"profile": "https://github.com/Fuochi",
"contributions": [
"doc"
]
},
{
"login": "demrich",
"name": "David Emrich",
"avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4",
"profile": "https://github.com/demrich",
"contributions": [
"code"
]
},
{
"login": "maxnatamo",
"name": "Max T. Kristiansen",
"avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4",
"profile": "https://maxtrier.dk",
"contributions": [
"code"
]
},
{
"login": "DamsDev1",
"name": "Damien Fajole",
"avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4",
"profile": "https://damsdev.me",
"contributions": [
"code"
]
},
{
"login": "AhmedNSidd",
"name": "Ahmed Siddiqui",
"avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4",
"profile": "https://github.com/AhmedNSidd",
"contributions": [
"code"
]
}
]
}

View File

@@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm
pnpm install
pnpm dev
```

View File

@@ -291,6 +291,12 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon
<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/sct/overseerr/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/sct/overseerr/commits?author=Loetwiek" title="Code">💻</a></td>
<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/sct/overseerr/commits?author=Fuochi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/demrich"><img src="https://avatars.githubusercontent.com/u/30092389?v=4?s=100" width="100px;" alt="David Emrich"/><br /><sub><b>David Emrich</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=demrich" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://maxtrier.dk"><img src="https://avatars.githubusercontent.com/u/5898152?v=4?s=100" width="100px;" alt="Max T. Kristiansen"/><br /><sub><b>Max T. Kristiansen</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=maxnatamo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://damsdev.me"><img src="https://avatars.githubusercontent.com/u/60252259?v=4?s=100" width="100px;" alt="Damien Fajole"/><br /><sub><b>Damien Fajole</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=DamsDev1" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/AhmedNSidd"><img src="https://avatars.githubusercontent.com/u/36286128?v=4?s=100" width="100px;" alt="Ahmed Siddiqui"/><br /><sub><b>Ahmed Siddiqui</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=AhmedNSidd" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -75,6 +75,7 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},

View File

@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';

View File

@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
You can find the webhook URL in the Discord application, at **Server Settings &rarr; Integrations &rarr; Webhooks**.
### Notification Role ID (optional)
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
### Bot Username (optional)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -10,6 +10,7 @@ module.exports = {
remotePatterns: [
{ hostname: 'gravatar.com' },
{ hostname: 'image.tmdb.org' },
{ hostname: 'artworks.thetvdb.com' },
],
},
webpack(config) {

View File

@@ -1273,6 +1273,8 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -4142,6 +4144,21 @@ paths:
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
get:
summary: Get media from blacklist
tags:
- blacklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blacklist details in JSON
delete:
summary: Remove media from blacklist
tags:
@@ -5469,7 +5486,7 @@ paths:
- type: array
items:
type: number
minimum: 1
minimum: 0
- type: string
enum: [all]
is4k:
@@ -5575,7 +5592,7 @@ paths:
type: array
items:
type: number
minimum: 1
minimum: 0
is4k:
type: boolean
example: false

View File

@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
logger.debug('Failed to authenticate with headers', {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
});
if (!e.cause.status) {
throw new ApiError(404, ApiErrorCode.InvalidUrl);
}
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
}
try {
return await authenticate(false);
} catch (e) {
const status = e.cause?.status;
const networkErrorCodes = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENOTFOUND',
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ENETDOWN',
'ENETUNREACH',
'EPIPE',
'ECONNABORTED',
'EPROTO',
'EHOSTDOWN',
'EAI_AGAIN',
'ERR_INVALID_URL',
]);
if (networkErrorCodes.has(e.code) || status === 404) {
throw new ApiError(status, ApiErrorCode.InvalidUrl);
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
}
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
logger.error(
'Something went wrong while authenticating with the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
ip: ClientIP,
}
);
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
}
}
@@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting libraries from the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
);
return [];
@@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse;
} catch (e) {
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
}
logger.error(
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
@@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -3,6 +3,7 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces';
import cacheManager from '@server/lib/cache';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { randomUUID } from 'node:crypto';
import xml2js from 'xml2js';
interface PlexAccountResponse {
@@ -127,6 +128,11 @@ export interface PlexWatchlistItem {
title: string;
}
export interface PlexWatchlistCache {
etag: string;
response: WatchlistResponse;
}
class PlexTvAPI extends ExternalAPI {
private authToken: string;
@@ -261,6 +267,11 @@ class PlexTvAPI extends ExternalAPI {
items: PlexWatchlistItem[];
}> {
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
this.authToken
);
const params = new URLSearchParams({
'X-Plex-Container-Start': offset.toString(),
'X-Plex-Container-Size': size.toString(),
@@ -268,42 +279,62 @@ class PlexTvAPI extends ExternalAPI {
const response = await this.fetch(
`https://metadata.provider.plex.tv/library/sections/watchlist/all?${params.toString()}`,
{
headers: this.defaultHeaders,
headers: {
...this.defaultHeaders,
...(cachedWatchlist?.etag
? { 'If-None-Match': cachedWatchlist.etag }
: {}),
},
}
);
const data = (await response.json()) as WatchlistResponse;
// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
if (response.status >= 200 && response.status <= 299) {
cachedWatchlist = {
etag: response.headers.get('etag') ?? '',
response: data,
};
watchlistCache.data.set<PlexWatchlistCache>(
this.authToken,
cachedWatchlist
);
}
const watchlistDetails = await Promise.all(
(data.MediaContainer.Metadata ?? []).map(async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
async (watchlistItem) => {
const detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
{},
undefined,
{},
'https://metadata.provider.plex.tv'
);
const metadata = detailedResponse.MediaContainer.Metadata[0];
const metadata = detailedResponse.MediaContainer.Metadata[0];
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
const tmdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tmdb')
);
const tvdbString = metadata.Guid.find((guid) =>
guid.id.startsWith('tvdb')
);
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
})
return {
ratingKey: metadata.ratingKey,
// This should always be set? But I guess it also cannot be?
// We will filter out the 0's afterwards
tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0,
tvdbId: tvdbString
? Number(tvdbString.id.split('//')[1])
: undefined,
title: metadata.title,
type: metadata.type,
};
}
)
);
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
@@ -311,7 +342,7 @@ class PlexTvAPI extends ExternalAPI {
return {
offset,
size,
totalSize: data.MediaContainer.totalSize,
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
items: filteredList,
};
} catch (e) {
@@ -327,6 +358,29 @@ class PlexTvAPI extends ExternalAPI {
};
}
}
public async pingToken() {
try {
const data: { pong: unknown } = await this.get(
'/api/v2/ping',
{},
undefined,
{
headers: {
'X-Plex-Client-Identifier': randomUUID(),
},
}
);
if (!data?.pong) {
throw new Error('No pong response');
}
} catch (e) {
logger.error('Failed to ping token', {
label: 'Plex Refresh Token',
errorMessage: e.message,
});
}
}
}
export default PlexTvAPI;

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: blacklist,
blacklist: Promise.resolve(blacklist),
});
await mediaRepository.save(media);
} else {
media.blacklist = blacklist;
media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -119,10 +119,8 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@CreateDateColumn()
public createdAt: Date;

View File

@@ -257,9 +257,7 @@ export class MediaRequest {
>;
const requestedSeasons =
requestBody.seasons === 'all'
? tmdbMediaShow.seasons
.map((season) => season.season_number)
.filter((sn) => sn > 0)
? tmdbMediaShow.seasons.map((season) => season.season_number)
: (requestBody.seasons as number[]);
let existingSeasons: number[] = [];

View File

@@ -2,6 +2,7 @@ import { MediaServerType } from '@server/constants/server';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
import refreshToken from '@server/lib/refreshToken';
import {
jellyfinFullScanner,
jellyfinRecentScanner,
@@ -13,7 +14,6 @@ import type { JobId } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import watchlistSync from '@server/lib/watchlistsync';
import logger from '@server/logger';
import random from 'lodash/random';
import schedule from 'node-schedule';
interface ScheduledJob {
@@ -113,30 +113,20 @@ export const startJobs = (): void => {
}
// Watchlist Sync
const watchlistSyncJob: ScheduledJob = {
scheduledJobs.push({
id: 'plex-watchlist-sync',
name: 'Plex Watchlist Sync',
type: 'process',
interval: 'fixed',
interval: 'seconds',
cronSchedule: jobs['plex-watchlist-sync'].schedule,
job: schedule.scheduleJob(new Date(Date.now() + 1000 * 60 * 20), () => {
job: schedule.scheduleJob(jobs['plex-watchlist-sync'].schedule, () => {
logger.info('Starting scheduled job: Plex Watchlist Sync', {
label: 'Jobs',
});
watchlistSync.syncWatchlist();
}),
};
// To help alleviate load on Plex's servers, we will add some fuzziness to the next schedule
// after each run
watchlistSyncJob.job.on('run', () => {
watchlistSyncJob.job.schedule(
new Date(Math.floor(Date.now() + 1000 * 60 * random(14, 24, true)))
);
});
scheduledJobs.push(watchlistSyncJob);
// Run full radarr scan every 24 hours
scheduledJobs.push({
id: 'radarr-scan',
@@ -233,5 +223,19 @@ export const startJobs = (): void => {
}),
});
scheduledJobs.push({
id: 'plex-refresh-token',
name: 'Plex Refresh Token',
type: 'process',
interval: 'fixed',
cronSchedule: jobs['plex-refresh-token'].schedule,
job: schedule.scheduleJob(jobs['plex-refresh-token'].schedule, () => {
logger.info('Starting scheduled job: Plex Refresh Token', {
label: 'Jobs',
});
refreshToken.run();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};

View File

@@ -8,7 +8,8 @@ export type AvailableCacheIds =
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
| 'plextv'
| 'plexwatchlist';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -68,6 +69,7 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -291,6 +291,10 @@ class DiscordAgent
}
}
if (settings.options.webhookRoleId) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {

View File

@@ -0,0 +1,37 @@
import PlexTvAPI from '@server/api/plextv';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import logger from '@server/logger';
class RefreshToken {
public async run() {
const userRepository = getRepository(User);
const users = await userRepository
.createQueryBuilder('user')
.addSelect('user.plexToken')
.where("user.plexToken != ''")
.getMany();
for (const user of users) {
await this.refreshUserToken(user);
}
}
private async refreshUserToken(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user refresh token for user without plex token', {
label: 'Plex Refresh Token',
user: user.displayName,
});
return;
}
const plexTvApi = new PlexTvAPI(user.plexToken);
plexTvApi.pingToken();
}
}
const refreshToken = new RefreshToken();
export default refreshToken;

View File

@@ -278,9 +278,7 @@ class PlexScanner
const seasons = tvShow.seasons;
const processableSeasons: ProcessableSeason[] = [];
const filteredSeasons = seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
for (const season of seasons) {
const matchedPlexSeason = metadata.Children?.Metadata.find(
(md) => Number(md.index) === season.season_number
);

View File

@@ -103,10 +103,8 @@ class SonarrScanner
const tmdbId = tvShow.id;
const filteredSeasons = sonarrSeries.seasons.filter(
(sn) =>
sn.seasonNumber !== 0 &&
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
const filteredSeasons = sonarrSeries.seasons.filter((sn) =>
tvShow.seasons.find((s) => s.season_number === sn.seasonNumber)
);
for (const season of filteredSeasons) {

View File

@@ -170,6 +170,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
};
}
@@ -281,6 +282,7 @@ export type JobId =
| 'plex-recently-added-scan'
| 'plex-full-scan'
| 'plex-watchlist-sync'
| 'plex-refresh-token'
| 'radarr-scan'
| 'sonarr-scan'
| 'download-sync'
@@ -394,6 +396,7 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},
@@ -467,7 +470,10 @@ class Settings {
schedule: '0 0 3 * * *',
},
'plex-watchlist-sync': {
schedule: '0 */10 * * * *',
schedule: '0 */3 * * * *',
},
'plex-refresh-token': {
schedule: '0 0 5 * * *',
},
'radarr-scan': {
schedule: '0 0 4 * * *',

View File

@@ -62,7 +62,7 @@ class WatchlistSync {
const plexTvApi = new PlexTvAPI(user.plexToken);
const response = await plexTvApi.getWatchlist({ size: 200 });
const response = await plexTvApi.getWatchlist({ size: 20 });
const mediaItems = await Media.getRelatedMedia(
user,

View File

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

View File

@@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
@@ -26,7 +24,6 @@ blacklistRoutes.get(
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
@@ -71,6 +68,32 @@ blacklistRoutes.get(
}
);
blacklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,
});
}
return next({ status: 500, message: e.message });
}
}
);
blacklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -134,7 +157,7 @@ blacklistRoutes.delete(
return res.status(204).send();
} catch (e) {
if (e instanceof NotFoundError) {
if (e instanceof EntityNotFoundError) {
return next({
status: 401,
message: e.message,

View File

@@ -1,4 +1,5 @@
import { ApiErrorCode } from '@server/constants/error';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
@@ -99,11 +100,29 @@ userSettingsRoutes.post<
});
}
user.username = req.body.username;
const oldEmail = user.email;
const oldUsername = user.username;
user.username = req.body.username;
if (user.jellyfinUsername) {
user.email = req.body.email || user.jellyfinUsername || user.email;
}
// Edge case for local users, because they have no Jellyfin username to fall back on
// if the email is not provided
if (user.userType === UserType.LOCAL) {
if (req.body.email) {
user.email = req.body.email;
if (
!user.username &&
user.email !== oldEmail &&
!oldEmail.includes('@')
) {
user.username = oldEmail;
}
} else if (req.body.username) {
user.email = oldUsername || user.email;
user.username = req.body.username;
}
}
const existingUser = await userRepository.findOne({
where: { email: user.email },

View File

@@ -1,5 +1,6 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -10,6 +11,7 @@ import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
@@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', {
});
interface BlacklistBlockProps {
blacklistItem: Blacklist;
tmdbId: number;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
blacklistItem,
tmdbId,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
@@ -31,6 +33,7 @@ const BlacklistBlock = ({
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
@@ -62,6 +65,14 @@ const BlacklistBlock = ({
setIsUpdating(false);
};
if (!data) {
return (
<>
<LoadingSpinner />
</>
);
}
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
@@ -73,13 +84,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto">
<Link
href={
blacklistItem.user.id === user?.id
data.user.id === user?.id
? '/profile'
: `/users/${blacklistItem.user.id}`
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{blacklistItem.user.displayName}
{data.user.displayName}
</span>
</Link>
</span>
@@ -91,9 +102,7 @@ const BlacklistBlock = ({
>
<Button
buttonType="danger"
onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
@@ -114,7 +123,7 @@ const BlacklistBlock = ({
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(blacklistItem.createdAt, {
{intl.formatDate(data.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@@ -38,7 +38,7 @@ const BlacklistModal = ({
const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>(
`/api/v1/${type}/${tmdbId}`
show ? `/api/v1/${type}/${tmdbId}` : null
);
return (

View File

@@ -74,6 +74,12 @@ const studios: Studio[] = [
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/2Tc1P3Ac8M479naPp1kYT3izLS5.png',
url: '/discover/movies/studio/9993',
},
{
name: 'A24',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1ZXsGaFPgrgS6ZZGS37AqD5uU12.png',
url: '/discover/movies/studio/41077',
},
];
const StudioSlider = () => {

View File

@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
const PullToRefresh = () => {
const router = useRouter();
const [pullStartPoint, setPullStartPoint] = useState(0);
const [pullChange, setPullChange] = useState(0);
const refreshDiv = useRef<HTMLDivElement>(null);
@@ -19,6 +18,7 @@ const PullToRefresh = () => {
// Reload function that is called when reload threshold has been hit
// Add loading class to determine when to add spin animation
const forceReload = () => {
setPullStartPoint(0);
refreshDiv.current?.classList.add('loading');
setTimeout(() => {
router.reload();
@@ -32,6 +32,8 @@ const PullToRefresh = () => {
const pullStart = (e: TouchEvent) => {
setPullStartPoint(e.targetTouches[0].screenY);
const html = document.querySelector('html');
if (window.scrollY === 0 && window.scrollX === 0) {
refreshDiv.current?.classList.add('block');
refreshDiv.current?.classList.remove('hidden');
@@ -41,6 +43,7 @@ const PullToRefresh = () => {
html.style.overscrollBehaviorY = 'none';
}
} else {
setPullStartPoint(0);
refreshDiv.current?.classList.remove('block');
refreshDiv.current?.classList.add('hidden');
}
@@ -49,7 +52,6 @@ const PullToRefresh = () => {
// Tracks how far we have pulled down the refresh icon
const pullDown = async (e: TouchEvent) => {
const screenY = e.targetTouches[0].screenY;
const pullLength =
pullStartPoint < screenY ? Math.abs(screenY - pullStartPoint) : 0;
@@ -59,12 +61,11 @@ const PullToRefresh = () => {
// Will reload the page if we are past the threshold
// Otherwise, we reset the pull
const pullFinish = () => {
setPullStartPoint(0);
if (pullDownReloadThreshold) {
if (pullDownReloadThreshold && pullStartPoint !== 0) {
forceReload();
} else {
setPullChange(0);
setTimeout(() => setPullStartPoint(0), 200);
}
document.body.style.touchAction = 'auto';
@@ -83,7 +84,21 @@ const PullToRefresh = () => {
window.removeEventListener('touchmove', pullDown);
window.removeEventListener('touchend', pullFinish);
};
}, [pullDownInitThreshold, pullDownReloadThreshold, pullStartPoint, router]);
}, [
pullDownInitThreshold,
pullDownReloadThreshold,
pullStartPoint,
refreshDiv,
router,
setPullStartPoint,
]);
if (
pullStartPoint === 0 &&
!refreshDiv.current?.classList.contains('loading')
) {
return null;
}
return (
<div
@@ -102,7 +117,7 @@ const PullToRefresh = () => {
<div
className={`${
refreshDiv.current?.classList.contains('loading') && 'animate-spin'
} relative -top-24 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
} relative -top-28 h-9 w-9 rounded-full border-4 border-gray-800 bg-gray-800 shadow-md shadow-black ring-1 ring-gray-700`}
style={{ animationDirection: 'reverse' }}
>
<ArrowPathIcon

View File

@@ -82,10 +82,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string().matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
urlBase: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),

View File

@@ -292,7 +292,7 @@ const ManageSlideOver = ({
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock
blacklistItem={data.mediaInfo.blacklist}
tmdbId={data.mediaInfo.tmdbId}
onUpdate={() => revalidate()}
onDelete={() => onClose()}
/>

View File

@@ -247,7 +247,11 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => {
key={`season-${season.id}`}
className="mb-1 mr-2 inline-block"
>
<Badge>{season.seasonNumber}</Badge>
<Badge>
{season.seasonNumber === 0
? intl.formatMessage(globalMessages.specials)
: season.seasonNumber}
</Badge>
</span>
))}
</div>

View File

@@ -411,8 +411,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter((season) => season.seasonNumber !== 0)
.length === request.seasons.length
title.seasons.length === request.seasons.length
? 0
: request.seasons.length,
})}
@@ -420,7 +419,11 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<div className="hide-scrollbar overflow-x-scroll">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
<Badge>
{season.seasonNumber === 0
? intl.formatMessage(globalMessages.specials)
: season.seasonNumber}
</Badge>
</span>
))}
</div>

View File

@@ -481,9 +481,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount:
title.seasons.filter(
(season) => season.seasonNumber !== 0
).length === request.seasons.length
title.seasons.length === request.seasons.length
? 0
: request.seasons.length,
})}
@@ -491,7 +489,11 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">
{request.seasons.map((season) => (
<span key={`season-${season.id}`} className="mr-2">
<Badge>{season.seasonNumber}</Badge>
<Badge>
{season.seasonNumber === 0
? intl.formatMessage(globalMessages.specials)
: season.seasonNumber}
</Badge>
</span>
))}
</div>

View File

@@ -88,14 +88,14 @@ const SearchByNameModal = ({
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `}
>
<div className="flex w-24 flex-none items-center space-x-4">
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
<Image
src={
item.remotePoster ??
'/images/overseerr_poster_not_found.png'
}
alt={item.title}
className="h-100 w-auto rounded-md"
className="w-100 h-auto rounded-md"
fill
/>
</div>

View File

@@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestModal', {
season: 'Season',
numberofepisodes: '# of Episodes',
seasonnumber: 'Season {number}',
extras: 'Extras',
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for <strong>{title}</strong> edited successfully!',
requestApproved: 'Request for <strong>{title}</strong> approved!',
@@ -255,9 +254,7 @@ const TvRequestModal = ({
const getAllSeasons = (): number[] => {
return (data?.seasons ?? [])
.filter(
(season) => season.seasonNumber !== 0 && season.episodeCount !== 0
)
.filter((season) => season.episodeCount !== 0)
.map((season) => season.seasonNumber);
};
@@ -580,10 +577,7 @@ const TvRequestModal = ({
</thead>
<tbody className="divide-y divide-gray-700">
{data?.seasons
.filter(
(season) =>
season.seasonNumber !== 0 && season.episodeCount !== 0
)
.filter((season) => season.episodeCount !== 0)
.map((season) => {
const seasonRequest = getSeasonRequest(
season.seasonNumber
@@ -660,7 +654,7 @@ const TvRequestModal = ({
</td>
<td className="whitespace-nowrap px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6">
{season.seasonNumber === 0
? intl.formatMessage(messages.extras)
? intl.formatMessage(globalMessages.specials)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}

View File

@@ -437,7 +437,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
className={`provider-container w-full cursor-pointer rounded-lg ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -451,18 +451,15 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
fill
className="rounded-lg"
/>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />
@@ -483,7 +480,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
className={`provider-container w-full cursor-pointer rounded-lg ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -497,18 +494,15 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
fill
className="rounded-lg"
/>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
{isActive && (
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
<CheckCircleIcon className="h-6 w-6" />

View File

@@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookUrl: 'Webhook URL',
webhookUrlTip:
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
webhookRoleId: 'Notification Role ID',
webhookRoleIdTip:
'The role ID to mention in the webhook message. Leave empty to disable mentions',
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…',
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
validationWebhookRoleId: 'You must provide a valid Discord Role ID',
validationTypes: 'You must select at least one notification type',
enableMentions: 'Enable Mentions',
});
@@ -53,6 +57,12 @@ const NotificationsDiscord = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
webhookRoleId: Yup.string()
.nullable()
.matches(
/^\d{17,19}$/,
intl.formatMessage(messages.validationWebhookRoleId)
),
});
if (!data && !error) {
@@ -67,6 +77,7 @@ const NotificationsDiscord = () => {
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl,
webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions,
}}
validationSchema={NotificationsDiscordSchema}
@@ -84,6 +95,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -141,6 +153,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -254,6 +267,21 @@ const NotificationsDiscord = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="webhookRoleId" className="text-label">
{intl.formatMessage(messages.webhookRoleId)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="webhookRoleId" name="webhookRoleId" type="text" />
</div>
{errors.webhookRoleId &&
touched.webhookRoleId &&
typeof errors.webhookRoleId === 'string' && (
<div className="error">{errors.webhookRoleId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="enableMentions" className="checkbox-label">
{intl.formatMessage(messages.enableMentions)}

View File

@@ -130,7 +130,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),

View File

@@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinExternalUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinForgotPasswordUrl: Yup.string()
.nullable()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -58,6 +58,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
'plex-refresh-token': 'Plex Refresh Token',
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
'availability-sync': 'Media Availability Sync',

View File

@@ -87,7 +87,10 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationTitle)
),
applicationUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),

View File

@@ -190,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
otherwise: Yup.string().nullable(),
}),
tautulliExternalUrl: Yup.string()
.url(intl.formatMessage(messages.validationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -145,7 +145,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)
: Yup.number(),
externalUrl: Yup.string()
.url(intl.formatMessage(messages.validationApplicationUrl))
.matches(
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
intl.formatMessage(messages.validationApplicationUrl)
)
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),

View File

@@ -238,6 +238,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
);
}
// Does NOT include "Specials"
const seasonCount = data.seasons.filter(
(season) => season.seasonNumber !== 0 && season.episodeCount !== 0
).length;
@@ -299,9 +300,17 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
return [...requestedSeasons, ...availableSeasons];
};
const isComplete = seasonCount <= getAllRequestedSeasons(false).length;
const showHasSpecials = data.seasons.some(
(season) => season.seasonNumber === 0
);
const is4kComplete = seasonCount <= getAllRequestedSeasons(true).length;
const isComplete =
(showHasSpecials ? seasonCount + 1 : seasonCount) <=
getAllRequestedSeasons(false).length;
const is4kComplete =
(showHasSpecials ? seasonCount + 1 : seasonCount) <=
getAllRequestedSeasons(true).length;
const streamingProviders =
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
@@ -784,7 +793,6 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
{data.seasons
.slice()
.reverse()
.filter((season) => season.seasonNumber !== 0)
.map((season) => {
const show4k =
settings.currentSettings.series4kEnabled &&
@@ -838,9 +846,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
>
<div className="flex flex-1 items-center space-x-2 text-lg">
<span>
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: season.seasonNumber,
})}
{season.seasonNumber === 0
? intl.formatMessage(globalMessages.specials)
: intl.formatMessage(messages.seasonnumber, {
seasonNumber: season.seasonNumber,
})}
</span>
<Badge badgeType="dark">
{intl.formatMessage(messages.episodeCount, {

View File

@@ -44,6 +44,8 @@ const messages = defineMessages(
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
toastSettingsFailureEmail: 'This email is already taken!',
toastSettingsFailureEmailEmpty:
'Another user already has this username. You must set an email',
region: 'Discover Region',
regionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
@@ -138,7 +140,7 @@ const UserGeneralSettings = () => {
</div>
<Formik
initialValues={{
displayName: data?.username ?? '',
displayName: data?.username !== user?.email ? data?.username : '',
email: data?.email?.includes('@') ? data.email : '',
discordId: data?.discordId ?? '',
locale: data?.locale,
@@ -203,10 +205,23 @@ const UserGeneralSettings = () => {
/* empty */
}
if (errorData?.message === ApiErrorCode.InvalidEmail) {
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
autoDismiss: true,
appearance: 'error',
});
if (values.email) {
addToast(
intl.formatMessage(messages.toastSettingsFailureEmail),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} else {
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
@@ -284,9 +299,9 @@ const UserGeneralSettings = () => {
name="displayName"
type="text"
placeholder={
user?.username ||
user?.jellyfinUsername ||
user?.plexUsername
user?.plexUsername ||
user?.email
}
/>
</div>

View File

@@ -23,7 +23,7 @@ const useDeepLinks = ({
if (
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
(navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1))
) {
setReturnedMediaUrl(iOSPlexUrl);
setReturnedMediaUrl4k(iOSPlexUrl4k);

View File

@@ -25,15 +25,14 @@ async function extractMessages(
try {
const formattedMessages = messages
.trim()
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":')
.replace(
/'.*'/g,
(match) =>
`"${match
.match(/'(.*)'/)?.[1]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}"`
)
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":')
.replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => {
const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match);
if (!parts) return match;
return `${parts[1]}"${parts[2]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}",`;
})
.replace(/,$/, '');
const messagesJson = JSON.parse(`{${formattedMessages}}`);
return { namespace: namespace.trim(), messages: messagesJson };

View File

@@ -65,6 +65,7 @@ const globalMessages = defineMessages('i18n', {
'<strong>{title}</strong> was successfully removed from the Blacklist.',
addToBlacklist: 'Add to Blacklist',
removefromBlacklist: 'Remove from Blacklist',
specials: 'Specials',
});
export default globalMessages;

View File

@@ -298,7 +298,6 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -494,6 +493,7 @@
"components.RequestList.RequestItem.modified": "Modified",
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
"components.RequestList.RequestItem.profileName": "Profile",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.RequestList.RequestItem.requested": "Requested",
"components.RequestList.RequestItem.requesteddate": "Requested",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
@@ -536,7 +536,6 @@
"components.RequestModal.cancel": "Cancel Request",
"components.RequestModal.edit": "Edit Request",
"components.RequestModal.errorediting": "Something went wrong while editing the request.",
"components.RequestModal.extras": "Extras",
"components.RequestModal.numberofepisodes": "# of Episodes",
"components.RequestModal.pending4krequest": "Pending 4K Request",
"components.RequestModal.pendingapproval": "Your request is pending approval.",
@@ -727,6 +726,9 @@
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
"components.Settings.Notifications.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.validationUrl": "You must provide a valid URL",
"components.Settings.Notifications.validationWebhookRoleId": "You must provide a valid Discord Role ID",
"components.Settings.Notifications.webhookRoleId": "Notification Role ID",
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
"components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
"components.Settings.RadarrModal.add": "Add Server",
@@ -844,6 +846,7 @@
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
"components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
"components.Settings.SettingsJobsCache.process": "Process",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
@@ -885,6 +888,15 @@
"components.Settings.SettingsMain.originallanguage": "Discover Language",
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
"components.Settings.SettingsMain.proxyBypassFilter": "Proxy Ignored Addresses",
"components.Settings.SettingsMain.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
"components.Settings.SettingsMain.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
"components.Settings.SettingsMain.proxyEnabled": "HTTP(S) Proxy",
"components.Settings.SettingsMain.proxyHostname": "Proxy Hostname",
"components.Settings.SettingsMain.proxyPassword": "Proxy Password",
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
"components.Settings.SettingsMain.region": "Discover Region",
"components.Settings.SettingsMain.regionTip": "Filter content by regional availability",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
@@ -896,6 +908,7 @@
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
@@ -1084,7 +1097,7 @@
"components.Setup.finishing": "Finishing…",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
@@ -1235,6 +1248,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
@@ -1367,6 +1381,7 @@
"i18n.saving": "Saving…",
"i18n.settings": "Settings",
"i18n.showingresults": "Showing <strong>{from}</strong> to <strong>{to}</strong> of <strong>{total}</strong> results",
"i18n.specials": "Specials",
"i18n.status": "Status",
"i18n.test": "Test",
"i18n.testing": "Testing…",