feat(blacklist): Automatically add media with blacklisted tags to the blacklist (#1306)
* feat(blacklist): add blacktag settings to main settings page * feat(blacklist): create blacktag logic and infrastructure * feat(blacklist): add scheduling for blacktags job * feat(blacklist): create blacktag ui badge for blacklist * docs(blacklist): document blacktags in using-jellyseerr * fix(blacklist): batch blacklist and media db removes to avoid expression tree too large error * feat(blacklist): allow easy import and export of blacktag configuration * fix(settings): don't copy the API key every time you press enter on the main settings * fix(blacklist): move filter inline with page title to match all the other pages * feat(blacklist): allow filtering between manually blacklisted and automatically blacklisted entries * docs(blacklist): reword blacktag documentation a little * refactor(blacklist): remove blacktag settings from public settings interfaces There's no reason for it to be there * refactor(blacklist): remove unused variable from processResults in blacktagsProcessor * refactor(blacklist): change all instances of blacktag to blacklistedTag and update doc to match * docs(blacklist): update general documentation for blacklisted tag settings * fix(blacklist): update setting use of "blacklisted tag" to match between modals * perf(blacklist): remove media type constraint from existing blacklist entry query Doesn't make sense to keep it because tmdbid has a unique constraint on it * fix(blacklist): remove whitespace line causing prettier to fail in CI * refactor(blacklist): swap out some != and == for !s and _s * fix(blacklist): merge back CopyButton changes, disable button when there's nothing to copy * refactor(blacklist): use axios instead of fetch for blacklisted tag queries * style(blacklist): use templated axios types and remove redundant try-catches
This commit is contained in:
@@ -19,6 +19,8 @@
|
|||||||
"discoverRegion": "",
|
"discoverRegion": "",
|
||||||
"streamingRegion": "",
|
"streamingRegion": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
|
"blacklistedTags": "",
|
||||||
|
"blacklistedTagsLimit": 50,
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
"mediaServerType": 1,
|
"mediaServerType": 1,
|
||||||
"partialRequestsEnabled": true,
|
"partialRequestsEnabled": true,
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting
|
|||||||
|
|
||||||
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
|
||||||
|
|
||||||
|
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||||
|
|
||||||
|
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
|
||||||
|
|
||||||
|
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
|
||||||
|
|
||||||
|
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||||
|
|
||||||
## Hide Available Media
|
## Hide Available Media
|
||||||
|
|
||||||
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
|
||||||
|
|||||||
@@ -4239,6 +4239,12 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
example: dune
|
example: dune
|
||||||
|
- in: query
|
||||||
|
name: filter
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [all, manual, blacklistedTags]
|
||||||
|
default: manual
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Blacklisted items returned
|
description: Blacklisted items returned
|
||||||
|
|||||||
@@ -38,23 +38,26 @@ interface SingleSearchOptions extends SearchOptions {
|
|||||||
year?: number;
|
year?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOptions =
|
export const SortOptionsIterable = [
|
||||||
| 'popularity.asc'
|
'popularity.desc',
|
||||||
| 'popularity.desc'
|
'popularity.asc',
|
||||||
| 'release_date.asc'
|
'release_date.desc',
|
||||||
| 'release_date.desc'
|
'release_date.asc',
|
||||||
| 'revenue.asc'
|
'revenue.desc',
|
||||||
| 'revenue.desc'
|
'revenue.asc',
|
||||||
| 'primary_release_date.asc'
|
'primary_release_date.desc',
|
||||||
| 'primary_release_date.desc'
|
'primary_release_date.asc',
|
||||||
| 'original_title.asc'
|
'original_title.asc',
|
||||||
| 'original_title.desc'
|
'original_title.desc',
|
||||||
| 'vote_average.asc'
|
'vote_average.desc',
|
||||||
| 'vote_average.desc'
|
'vote_average.asc',
|
||||||
| 'vote_count.asc'
|
'vote_count.desc',
|
||||||
| 'vote_count.desc'
|
'vote_count.asc',
|
||||||
| 'first_air_date.asc'
|
'first_air_date.desc',
|
||||||
| 'first_air_date.desc';
|
'first_air_date.asc',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SortOptions = (typeof SortOptionsIterable)[number];
|
||||||
|
|
||||||
interface DiscoverMovieOptions {
|
interface DiscoverMovieOptions {
|
||||||
page?: number;
|
page?: number;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { MediaStatus, type MediaType } from '@server/constants/media';
|
import { MediaStatus, type MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import dataSource from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
Column,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
@@ -35,7 +36,7 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@ManyToOne(() => User, (user) => user.id, {
|
@ManyToOne(() => User, (user) => user.id, {
|
||||||
eager: true,
|
eager: true,
|
||||||
})
|
})
|
||||||
user: User;
|
user?: User;
|
||||||
|
|
||||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
@@ -43,6 +44,9 @@ export class Blacklist implements BlacklistItem {
|
|||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
|
public blacklistedTags?: string;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -50,27 +54,32 @@ export class Blacklist implements BlacklistItem {
|
|||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async addToBlacklist({
|
public static async addToBlacklist(
|
||||||
blacklistRequest,
|
{
|
||||||
}: {
|
blacklistRequest,
|
||||||
blacklistRequest: {
|
}: {
|
||||||
mediaType: MediaType;
|
blacklistRequest: {
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
mediaType: MediaType;
|
||||||
tmdbId: ZodNumber['_output'];
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
};
|
tmdbId: ZodNumber['_output'];
|
||||||
}): Promise<void> {
|
blacklistedTags?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
entityManager?: EntityManager
|
||||||
|
): Promise<void> {
|
||||||
|
const em = entityManager ?? dataSource;
|
||||||
const blacklist = new this({
|
const blacklist = new this({
|
||||||
...blacklistRequest,
|
...blacklistRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = em.getRepository(Media);
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
tmdbId: blacklistRequest.tmdbId,
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blacklistRepository = getRepository(this);
|
const blacklistRepository = em.getRepository(this);
|
||||||
|
|
||||||
await blacklistRepository.save(blacklist);
|
await blacklistRepository.save(blacklist);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export interface BlacklistItem {
|
|||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
title?: string;
|
title?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
user: User;
|
user?: User;
|
||||||
|
blacklistedTags?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||||
|
|||||||
184
server/job/blacklistedTagsProcessor.ts
Normal file
184
server/job/blacklistedTagsProcessor.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import type { SortOptions } from '@server/api/themoviedb';
|
||||||
|
import { SortOptionsIterable } from '@server/api/themoviedb';
|
||||||
|
import type {
|
||||||
|
TmdbSearchMovieResponse,
|
||||||
|
TmdbSearchTvResponse,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import { MediaType } from '@server/constants/media';
|
||||||
|
import dataSource from '@server/datasource';
|
||||||
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
|
import Media from '@server/entity/Media';
|
||||||
|
import type {
|
||||||
|
RunnableScanner,
|
||||||
|
StatusBase,
|
||||||
|
} from '@server/lib/scanners/baseScanner';
|
||||||
|
import { getSettings } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
|
||||||
|
import type { EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
const TMDB_API_DELAY_MS = 250;
|
||||||
|
class AbortTransaction extends Error {}
|
||||||
|
|
||||||
|
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||||
|
private running = false;
|
||||||
|
private progress = 0;
|
||||||
|
private total = 0;
|
||||||
|
|
||||||
|
public async run() {
|
||||||
|
this.running = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dataSource.transaction(async (em) => {
|
||||||
|
await this.cleanBlacklist(em);
|
||||||
|
await this.createBlacklistEntries(em);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AbortTransaction) {
|
||||||
|
logger.info('Aborting job: Process Blacklisted Tags', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public status(): StatusBase {
|
||||||
|
return {
|
||||||
|
running: this.running,
|
||||||
|
progress: this.progress,
|
||||||
|
total: this.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public cancel() {
|
||||||
|
this.running = false;
|
||||||
|
this.progress = 0;
|
||||||
|
this.total = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBlacklistEntries(em: EntityManager) {
|
||||||
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
const blacklistedTags = settings.main.blacklistedTags;
|
||||||
|
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||||
|
|
||||||
|
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||||
|
|
||||||
|
if (blacklistedTags.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The maximum number of queries we're expected to execute
|
||||||
|
this.total =
|
||||||
|
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
|
||||||
|
|
||||||
|
for (const type of [MediaType.MOVIE, MediaType.TV]) {
|
||||||
|
const getDiscover =
|
||||||
|
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
|
||||||
|
|
||||||
|
// Iterate for each tag
|
||||||
|
for (const tag of blacklistedTagsArr) {
|
||||||
|
let queryMax = pageLimit * SortOptionsIterable.length;
|
||||||
|
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag
|
||||||
|
|
||||||
|
for (let query = 0; query < queryMax; query++) {
|
||||||
|
const page: number = fixedSortMode
|
||||||
|
? query + 1
|
||||||
|
: (query % pageLimit) + 1;
|
||||||
|
const sortBy: SortOptions | undefined = fixedSortMode
|
||||||
|
? undefined
|
||||||
|
: SortOptionsIterable[query % SortOptionsIterable.length];
|
||||||
|
|
||||||
|
if (!this.running) {
|
||||||
|
throw new AbortTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getDiscover({
|
||||||
|
page,
|
||||||
|
sortBy,
|
||||||
|
keywords: tag,
|
||||||
|
});
|
||||||
|
await this.processResults(response, tag, type, em);
|
||||||
|
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));
|
||||||
|
|
||||||
|
this.progress++;
|
||||||
|
if (page === 1 && response.total_pages <= queryMax) {
|
||||||
|
// We will finish the tag with less queries than expected, move progress accordingly
|
||||||
|
this.progress += queryMax - response.total_pages;
|
||||||
|
fixedSortMode = true;
|
||||||
|
queryMax = response.total_pages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processResults(
|
||||||
|
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
|
||||||
|
keywordId: string,
|
||||||
|
mediaType: MediaType,
|
||||||
|
em: EntityManager
|
||||||
|
) {
|
||||||
|
const blacklistRepository = em.getRepository(Blacklist);
|
||||||
|
|
||||||
|
for (const entry of response.results) {
|
||||||
|
const blacklistEntry = await blacklistRepository.findOne({
|
||||||
|
where: { tmdbId: entry.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blacklistEntry) {
|
||||||
|
// Don't mark manual blacklists with tags
|
||||||
|
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
|
||||||
|
if (
|
||||||
|
blacklistEntry.blacklistedTags &&
|
||||||
|
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
|
||||||
|
) {
|
||||||
|
await blacklistRepository.update(blacklistEntry.id, {
|
||||||
|
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Media wasn't previously blacklisted, add it to the blacklist
|
||||||
|
await Blacklist.addToBlacklist(
|
||||||
|
{
|
||||||
|
blacklistRequest: {
|
||||||
|
mediaType,
|
||||||
|
title: 'title' in entry ? entry.title : entry.name,
|
||||||
|
tmdbId: entry.id,
|
||||||
|
blacklistedTags: `,${keywordId},`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
em
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanBlacklist(em: EntityManager) {
|
||||||
|
// Remove blacklist and media entries blacklisted by tags
|
||||||
|
const mediaRepository = em.getRepository(Media);
|
||||||
|
const mediaToRemove = await mediaRepository
|
||||||
|
.createQueryBuilder('media')
|
||||||
|
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||||
|
.where(`blist.blacklistedTags IS NOT NULL`)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Batch removes so the query doesn't get too large
|
||||||
|
for (let i = 0; i < mediaToRemove.length; i += 500) {
|
||||||
|
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
|
||||||
|
|
||||||
|
export default blacklistedTagsProcessor;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
|
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
|
||||||
import availabilitySync from '@server/lib/availabilitySync';
|
import availabilitySync from '@server/lib/availabilitySync';
|
||||||
import downloadTracker from '@server/lib/downloadtracker';
|
import downloadTracker from '@server/lib/downloadtracker';
|
||||||
import ImageProxy from '@server/lib/imageproxy';
|
import ImageProxy from '@server/lib/imageproxy';
|
||||||
@@ -21,7 +22,7 @@ interface ScheduledJob {
|
|||||||
job: schedule.Job;
|
job: schedule.Job;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
running?: () => boolean;
|
running?: () => boolean;
|
||||||
cancelFn?: () => void;
|
cancelFn?: () => void;
|
||||||
@@ -237,5 +238,21 @@ export const startJobs = (): void => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
scheduledJobs.push({
|
||||||
|
id: 'process-blacklisted-tags',
|
||||||
|
name: 'Process Blacklisted Tags',
|
||||||
|
type: 'process',
|
||||||
|
interval: 'days',
|
||||||
|
cronSchedule: jobs['process-blacklisted-tags'].schedule,
|
||||||
|
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
|
||||||
|
logger.info('Starting scheduled job: Process Blacklisted Tags', {
|
||||||
|
label: 'Jobs',
|
||||||
|
});
|
||||||
|
blacklistedTagsProcessor.run();
|
||||||
|
}),
|
||||||
|
running: () => blacklistedTagsProcessor.status().running,
|
||||||
|
cancelFn: () => blacklistedTagsProcessor.cancel(),
|
||||||
|
});
|
||||||
|
|
||||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ export interface MainSettings {
|
|||||||
discoverRegion: string;
|
discoverRegion: string;
|
||||||
streamingRegion: string;
|
streamingRegion: string;
|
||||||
originalLanguage: string;
|
originalLanguage: string;
|
||||||
|
blacklistedTags: string;
|
||||||
|
blacklistedTagsLimit: number;
|
||||||
mediaServerType: number;
|
mediaServerType: number;
|
||||||
partialRequestsEnabled: boolean;
|
partialRequestsEnabled: boolean;
|
||||||
enableSpecialEpisodes: boolean;
|
enableSpecialEpisodes: boolean;
|
||||||
@@ -302,7 +304,8 @@ export type JobId =
|
|||||||
| 'jellyfin-recently-added-scan'
|
| 'jellyfin-recently-added-scan'
|
||||||
| 'jellyfin-full-scan'
|
| 'jellyfin-full-scan'
|
||||||
| 'image-cache-cleanup'
|
| 'image-cache-cleanup'
|
||||||
| 'availability-sync';
|
| 'availability-sync'
|
||||||
|
| 'process-blacklisted-tags';
|
||||||
|
|
||||||
export interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -349,6 +352,8 @@ class Settings {
|
|||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
originalLanguage: '',
|
originalLanguage: '',
|
||||||
|
blacklistedTags: '',
|
||||||
|
blacklistedTagsLimit: 50,
|
||||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||||
partialRequestsEnabled: true,
|
partialRequestsEnabled: true,
|
||||||
enableSpecialEpisodes: false,
|
enableSpecialEpisodes: false,
|
||||||
@@ -505,6 +510,9 @@ class Settings {
|
|||||||
'image-cache-cleanup': {
|
'image-cache-cleanup': {
|
||||||
schedule: '0 0 5 * * *',
|
schedule: '0 0 5 * * *',
|
||||||
},
|
},
|
||||||
|
'process-blacklisted-tags': {
|
||||||
|
schedule: '0 30 1 */7 * *',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
network: {
|
network: {
|
||||||
csrfProtection: false,
|
csrfProtection: false,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||||
|
name = 'AddBlacklistTagsColumn1737320080282';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" ADD blacklistedTags character varying`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" DROP COLUMN blacklistedTags`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface {
|
||||||
|
name = 'AddBlacklistTagsColumn1737320080282';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_blacklist"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,39 +19,54 @@ export const blacklistAdd = z.object({
|
|||||||
user: z.coerce.number(),
|
user: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const blacklistGet = z.object({
|
||||||
|
take: z.coerce.number().int().positive().default(25),
|
||||||
|
skip: z.coerce.number().int().nonnegative().default(0),
|
||||||
|
search: z.string().optional(),
|
||||||
|
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
blacklistRoutes.get(
|
blacklistRoutes.get(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
const { take, skip, search, filter } = blacklistGet.parse(req.query);
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
|
||||||
const search = (req.query.search as string) ?? '';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = getRepository(Blacklist)
|
let query = getRepository(Blacklist)
|
||||||
.createQueryBuilder('blacklist')
|
.createQueryBuilder('blacklist')
|
||||||
.leftJoinAndSelect('blacklist.user', 'user');
|
.leftJoinAndSelect('blacklist.user', 'user')
|
||||||
|
.where('1 = 1'); // Allow use of andWhere later
|
||||||
|
|
||||||
if (search.length > 0) {
|
switch (filter) {
|
||||||
query = query.where('blacklist.title like :title', {
|
case 'manual':
|
||||||
|
query = query.andWhere('blacklist.blacklistedTags IS NULL');
|
||||||
|
break;
|
||||||
|
case 'blacklistedTags':
|
||||||
|
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query = query.andWhere('blacklist.title like :title', {
|
||||||
title: `%${search}%`,
|
title: `%${search}%`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [blacklistedItems, itemsCount] = await query
|
const [blacklistedItems, itemsCount] = await query
|
||||||
.orderBy('blacklist.createdAt', 'DESC')
|
.orderBy('blacklist.createdAt', 'DESC')
|
||||||
.take(pageSize)
|
.take(take)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
|
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
pageInfo: {
|
pageInfo: {
|
||||||
pages: Math.ceil(itemsCount / pageSize),
|
pages: Math.ceil(itemsCount / take),
|
||||||
pageSize,
|
pageSize: take,
|
||||||
results: itemsCount,
|
results: itemsCount,
|
||||||
page: Math.ceil(skip / pageSize) + 1,
|
page: Math.ceil(skip / take) + 1,
|
||||||
},
|
},
|
||||||
results: blacklistedItems,
|
results: blacklistedItems,
|
||||||
} as BlacklistResultsResponse);
|
} as BlacklistResultsResponse);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
@@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import {
|
import {
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
FunnelIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
@@ -42,8 +44,17 @@ const messages = defineMessages('components.Blacklist', {
|
|||||||
blacklistdate: 'date',
|
blacklistdate: 'date',
|
||||||
blacklistedby: '{date} by {user}',
|
blacklistedby: '{date} by {user}',
|
||||||
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
||||||
|
filterManual: 'Manual',
|
||||||
|
filterBlacklistedTags: 'Blacklisted Tags',
|
||||||
|
showAllBlacklisted: 'Show All Blacklisted Media',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
enum Filter {
|
||||||
|
ALL = 'all',
|
||||||
|
MANUAL = 'manual',
|
||||||
|
BLACKLISTEDTAGS = 'blacklistedTags',
|
||||||
|
}
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
@@ -52,6 +63,7 @@ const Blacklist = () => {
|
|||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||||
useDebouncedState('');
|
useDebouncedState('');
|
||||||
|
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
@@ -64,9 +76,11 @@ const Blacklist = () => {
|
|||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<BlacklistResultsResponse>(
|
} = useSWR<BlacklistResultsResponse>(
|
||||||
`/api/v1/blacklist/?take=${currentPageSize}
|
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
|
||||||
&skip=${pageIndex * currentPageSize}
|
pageIndex * currentPageSize
|
||||||
${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`,
|
}&filter=${currentFilter}${
|
||||||
|
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
||||||
|
}`,
|
||||||
{
|
{
|
||||||
refreshInterval: 0,
|
refreshInterval: 0,
|
||||||
revalidateOnFocus: false,
|
revalidateOnFocus: false,
|
||||||
@@ -94,19 +108,52 @@ const Blacklist = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
||||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
|
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
<MagnifyingGlassIcon className="h-6 w-6" />
|
<FunnelIcon className="h-6 w-6" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<select
|
||||||
type="text"
|
id="filter"
|
||||||
className="rounded-r-only"
|
name="filter"
|
||||||
value={searchFilter}
|
onChange={(e) => {
|
||||||
onChange={(e) => searchItem(e)}
|
setCurrentFilter(e.target.value as Filter);
|
||||||
/>
|
router.push({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: router.query.userId
|
||||||
|
? { userId: router.query.userId }
|
||||||
|
: {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
value={currentFilter}
|
||||||
|
className="rounded-r-only"
|
||||||
|
>
|
||||||
|
<option value="all">
|
||||||
|
{intl.formatMessage(globalMessages.all)}
|
||||||
|
</option>
|
||||||
|
<option value="manual">
|
||||||
|
{intl.formatMessage(messages.filterManual)}
|
||||||
|
</option>
|
||||||
|
<option value="blacklistedTags">
|
||||||
|
{intl.formatMessage(messages.filterBlacklistedTags)}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
|
||||||
|
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
|
||||||
|
<MagnifyingGlassIcon className="h-6 w-6" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="rounded-r-only"
|
||||||
|
value={searchFilter}
|
||||||
|
onChange={(e) => searchItem(e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,6 +164,16 @@ const Blacklist = () => {
|
|||||||
<span className="text-2xl text-gray-400">
|
<span className="text-2xl text-gray-400">
|
||||||
{intl.formatMessage(globalMessages.noresults)}
|
{intl.formatMessage(globalMessages.noresults)}
|
||||||
</span>
|
</span>
|
||||||
|
{currentFilter !== Filter.ALL && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||||
|
>
|
||||||
|
{intl.formatMessage(messages.showAllBlacklisted)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.results.map((item: BlacklistItem) => {
|
data.results.map((item: BlacklistItem) => {
|
||||||
@@ -352,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
numeric="auto"
|
numeric="auto"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
user: (
|
user: item.user ? (
|
||||||
<Link href={`/users/${item.user.id}`}>
|
<Link href={`/users/${item.user.id}`}>
|
||||||
<span className="group flex items-center truncate">
|
<span className="group flex items-center truncate">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -369,6 +426,14 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
) : item.blacklistedTags ? (
|
||||||
|
<span className="ml-1">
|
||||||
|
<BlacklistedTagsBadge data={item} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1 truncate text-sm font-semibold">
|
||||||
|
???
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
@@ -77,22 +78,33 @@ const BlacklistBlock = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
|
||||||
<div className="white mb-1 flex flex-nowrap">
|
<div className="white mb-1 flex flex-nowrap">
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
{data.user ? (
|
||||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
<>
|
||||||
</Tooltip>
|
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
||||||
<span className="w-40 truncate md:w-auto">
|
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||||
<Link
|
</Tooltip>
|
||||||
href={
|
<span className="w-40 truncate md:w-auto">
|
||||||
data.user.id === user?.id
|
<Link
|
||||||
? '/profile'
|
href={
|
||||||
: `/users/${data.user.id}`
|
data.user.id === user?.id
|
||||||
}
|
? '/profile'
|
||||||
>
|
: `/users/${data.user.id}`
|
||||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
}
|
||||||
{data.user.displayName}
|
>
|
||||||
|
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||||
|
{data.user.displayName}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</>
|
||||||
</span>
|
) : data.blacklistedTags ? (
|
||||||
|
<>
|
||||||
|
<span className="w-40 truncate md:w-auto">
|
||||||
|
{intl.formatMessage(messages.blacklistedby)}:
|
||||||
|
</span>
|
||||||
|
<BlacklistedTagsBadge data={data} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
||||||
|
|||||||
62
src/components/BlacklistedTagsBadge/index.tsx
Normal file
62
src/components/BlacklistedTagsBadge/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { TagIcon } from '@heroicons/react/20/solid';
|
||||||
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings', {
|
||||||
|
blacklistedTagsText: 'Blacklisted Tags',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BlacklistedTagsBadgeProps {
|
||||||
|
data: BlacklistItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||||
|
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
|
||||||
|
useState<string>('Loading...');
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data.blacklistedTags) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
||||||
|
Promise.all(
|
||||||
|
keywordIds.map(async (keywordId) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return data.name;
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).then((keywords) => {
|
||||||
|
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||||
|
});
|
||||||
|
}, [data.blacklistedTags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={tagNamesBlacklistedFor}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
badgeType="dark"
|
||||||
|
className="items-center border border-red-500 !text-red-400"
|
||||||
|
>
|
||||||
|
<TagIcon className="mr-1 h-4" />
|
||||||
|
{intl.formatMessage(messages.blacklistedTagsText)}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlacklistedTagsBadge;
|
||||||
402
src/components/BlacklistedTagsSelector/index.tsx
Normal file
402
src/components/BlacklistedTagsSelector/index.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import Modal from '@app/components/Common/Modal';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
|
import CopyButton from '@app/components/Settings/CopyButton';
|
||||||
|
import { encodeURIExtraParams } from '@app/hooks/useDiscover';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { ArrowDownIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select';
|
||||||
|
import { components } from 'react-select';
|
||||||
|
import AsyncSelect from 'react-select/async';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings', {
|
||||||
|
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
|
||||||
|
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
|
||||||
|
copyBlacklistedTagsEmpty: 'Nothing to copy',
|
||||||
|
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
|
||||||
|
clearBlacklistedTagsConfirm:
|
||||||
|
'Are you sure you want to clear the blacklisted tags?',
|
||||||
|
yes: 'Yes',
|
||||||
|
no: 'No',
|
||||||
|
searchKeywords: 'Search keywords…',
|
||||||
|
starttyping: 'Starting typing to search.',
|
||||||
|
nooptions: 'No results.',
|
||||||
|
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
|
||||||
|
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
|
||||||
|
valueRequired: 'You must provide a value.',
|
||||||
|
noSpecialCharacters:
|
||||||
|
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
|
||||||
|
invalidKeyword: '{keywordId} is not a TMDB keyword.',
|
||||||
|
});
|
||||||
|
|
||||||
|
type SingleVal = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagsSelectorProps = {
|
||||||
|
defaultValue?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlacklistedTagsSelector = ({
|
||||||
|
defaultValue,
|
||||||
|
}: BlacklistedTagsSelectorProps) => {
|
||||||
|
const { setFieldValue } = useFormikContext();
|
||||||
|
const [value, setValue] = useState<string | undefined>(defaultValue);
|
||||||
|
const intl = useIntl();
|
||||||
|
const [selectorValue, setSelectorValue] =
|
||||||
|
useState<MultiValue<SingleVal> | null>(null);
|
||||||
|
|
||||||
|
const update = useCallback(
|
||||||
|
(value: MultiValue<SingleVal> | null) => {
|
||||||
|
const strVal = value?.map((v) => v.value).join(',');
|
||||||
|
setSelectorValue(value);
|
||||||
|
setValue(strVal);
|
||||||
|
setFieldValue('blacklistedTags', strVal);
|
||||||
|
},
|
||||||
|
[setSelectorValue, setValue, setFieldValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyDisabled = value === null || value?.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ControlledKeywordSelector
|
||||||
|
value={selectorValue}
|
||||||
|
onChange={update}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
components={{
|
||||||
|
DropdownIndicator: undefined,
|
||||||
|
IndicatorSeparator: undefined,
|
||||||
|
ClearIndicator: VerifyClearIndicator,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyButton
|
||||||
|
textToCopy={value ?? ''}
|
||||||
|
disabled={copyDisabled}
|
||||||
|
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
|
||||||
|
tooltipContent={intl.formatMessage(
|
||||||
|
copyDisabled
|
||||||
|
? messages.copyBlacklistedTagsEmpty
|
||||||
|
: messages.copyBlacklistedTagsTip
|
||||||
|
)}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
/>
|
||||||
|
<BlacklistedTagsImportButton setSelector={update} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BaseSelectorMultiProps = {
|
||||||
|
defaultValue?: string;
|
||||||
|
value: MultiValue<SingleVal> | null;
|
||||||
|
onChange: (value: MultiValue<SingleVal> | null) => void;
|
||||||
|
components?: Partial<typeof components>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ControlledKeywordSelector = ({
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
components,
|
||||||
|
value,
|
||||||
|
}: BaseSelectorMultiProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDefaultKeywords = async (): Promise<void> => {
|
||||||
|
if (!defaultValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = await Promise.all(
|
||||||
|
defaultValue.split(',').map(async (keywordId) => {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onChange(
|
||||||
|
keywords.map((keyword) => ({
|
||||||
|
label: keyword.name,
|
||||||
|
value: keyword.id,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultKeywords();
|
||||||
|
}, [defaultValue, onChange]);
|
||||||
|
|
||||||
|
const loadKeywordOptions = async (inputValue: string) => {
|
||||||
|
const { data } = await axios.get<TmdbKeywordSearchResponse>(
|
||||||
|
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.results.map((result) => ({
|
||||||
|
label: result.name,
|
||||||
|
value: result.id,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`keyword-select-blacklistedTags`}
|
||||||
|
inputId="data"
|
||||||
|
isMulti
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
noOptionsMessage={({ inputValue }) =>
|
||||||
|
inputValue === ''
|
||||||
|
? intl.formatMessage(messages.starttyping)
|
||||||
|
: intl.formatMessage(messages.nooptions)
|
||||||
|
}
|
||||||
|
value={value}
|
||||||
|
loadOptions={loadKeywordOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchKeywords)}
|
||||||
|
onChange={onChange}
|
||||||
|
components={components}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagsImportButtonProps = {
|
||||||
|
setSelector: (value: MultiValue<SingleVal>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlacklistedTagsImportButton = ({
|
||||||
|
setSelector,
|
||||||
|
}: BlacklistedTagsImportButtonProps) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const onConfirm = useCallback(async () => {
|
||||||
|
if (formRef.current) {
|
||||||
|
if (await formRef.current.submitForm()) {
|
||||||
|
setShow(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClick = useCallback((event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setShow(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
enter="transition-opacity duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
|
||||||
|
okText="Confirm"
|
||||||
|
onOk={onConfirm}
|
||||||
|
onCancel={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
||||||
|
</Modal>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
|
||||||
|
tooltipConfig={{ followCursor: false }}
|
||||||
|
>
|
||||||
|
<button className="input-action" onClick={onClick} type="button">
|
||||||
|
<ArrowDownIcon />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
|
||||||
|
|
||||||
|
const BlacklistedTagImportForm = forwardRef<
|
||||||
|
Partial<HTMLFormElement>,
|
||||||
|
BlacklistedTagImportFormProps
|
||||||
|
>((props, ref) => {
|
||||||
|
const { setSelector } = props;
|
||||||
|
const intl = useIntl();
|
||||||
|
const [formValue, setFormValue] = useState('');
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
submitForm: handleSubmit,
|
||||||
|
formValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validate = async () => {
|
||||||
|
if (formValue.length === 0) {
|
||||||
|
setErrors([intl.formatMessage(messages.valueRequired)]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^(?:\d+,)*\d+$/.test(formValue)) {
|
||||||
|
setErrors([intl.formatMessage(messages.noSpecialCharacters)]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = await Promise.allSettled(
|
||||||
|
formValue.split(',').map(async (keywordId) => {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get<Keyword>(
|
||||||
|
`/api/v1/keyword/${keywordId}`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
label: data.name,
|
||||||
|
value: data.id,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw intl.formatMessage(messages.invalidKeyword, { keywordId });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const failures = keywords.filter(
|
||||||
|
(res) => res.status === 'rejected'
|
||||||
|
) as PromiseRejectedResult[];
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setErrors(failures.map((failure) => `${failure.reason}`));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelector(
|
||||||
|
(keywords as PromiseFulfilledResult<SingleVal>[]).map(
|
||||||
|
(keyword) => keyword.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setErrors([]);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = validate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="value">
|
||||||
|
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="value"
|
||||||
|
value={formValue}
|
||||||
|
onChange={(e) => setFormValue(e.target.value)}
|
||||||
|
className="h-20"
|
||||||
|
/>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<div key={error}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const VerifyClearIndicator = <
|
||||||
|
Option,
|
||||||
|
IsMuti extends boolean,
|
||||||
|
Group extends GroupBase<Option>
|
||||||
|
>(
|
||||||
|
props: ClearIndicatorProps<Option, IsMuti, Group>
|
||||||
|
) => {
|
||||||
|
const { clearValue } = props;
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const openForm = useCallback(() => {
|
||||||
|
setShow(true);
|
||||||
|
}, [setShow]);
|
||||||
|
|
||||||
|
const openFormKey = useCallback(
|
||||||
|
(event: React.KeyboardEvent) => {
|
||||||
|
if (show) return;
|
||||||
|
|
||||||
|
if (event.key === 'Enter' || event.key === 'Space') {
|
||||||
|
setShow(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setShow, show]
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptForm = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
clearValue();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
window.addEventListener('keydown', acceptForm);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('keydown', acceptForm);
|
||||||
|
}, [show, acceptForm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openForm}
|
||||||
|
onKeyDown={openFormKey}
|
||||||
|
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
|
||||||
|
>
|
||||||
|
<components.CrossIcon />
|
||||||
|
</button>
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
enter="transition-opacity duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
show={show}
|
||||||
|
>
|
||||||
|
<Modal
|
||||||
|
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
|
||||||
|
okText={intl.formatMessage(messages.yes)}
|
||||||
|
cancelText={intl.formatMessage(messages.no)}
|
||||||
|
onOk={clearValue}
|
||||||
|
onCancel={() => setShow(false)}
|
||||||
|
>
|
||||||
|
<form />{' '}
|
||||||
|
{/* Form prevents accidentally saving settings when pressing enter */}
|
||||||
|
</Modal>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlacklistedTagsSelector;
|
||||||
@@ -1,40 +1,54 @@
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
|
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
|
||||||
import { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import type { Config } from 'react-popper-tooltip';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useClipboard from 'react-use-clipboard';
|
import useClipboard from 'react-use-clipboard';
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings', {
|
type CopyButtonProps = {
|
||||||
copied: 'Copied API key to clipboard.',
|
textToCopy: string;
|
||||||
});
|
disabled?: boolean;
|
||||||
|
toastMessage?: string;
|
||||||
|
|
||||||
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
|
tooltipContent?: React.ReactNode;
|
||||||
const intl = useIntl();
|
tooltipConfig?: Partial<Config>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CopyButton = ({
|
||||||
|
textToCopy,
|
||||||
|
disabled,
|
||||||
|
toastMessage,
|
||||||
|
tooltipContent,
|
||||||
|
tooltipConfig,
|
||||||
|
}: CopyButtonProps) => {
|
||||||
const [isCopied, setCopied] = useClipboard(textToCopy, {
|
const [isCopied, setCopied] = useClipboard(textToCopy, {
|
||||||
successDuration: 1000,
|
successDuration: 1000,
|
||||||
});
|
});
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCopied) {
|
if (isCopied && toastMessage) {
|
||||||
addToast(intl.formatMessage(messages.copied), {
|
addToast(toastMessage, {
|
||||||
appearance: 'info',
|
appearance: 'info',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isCopied, addToast, intl]);
|
}, [isCopied, addToast, toastMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Tooltip content={tooltipContent} tooltipConfig={tooltipConfig}>
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.preventDefault();
|
onClick={(e) => {
|
||||||
setCopied();
|
e.preventDefault();
|
||||||
}}
|
setCopied();
|
||||||
className="input-action"
|
}}
|
||||||
>
|
className="input-action"
|
||||||
<ClipboardDocumentIcon />
|
type="button"
|
||||||
</button>
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<ClipboardDocumentIcon />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,14 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
|
|||||||
'download-sync': 'Download Sync',
|
'download-sync': 'Download Sync',
|
||||||
'download-sync-reset': 'Download Sync Reset',
|
'download-sync-reset': 'Download Sync Reset',
|
||||||
'image-cache-cleanup': 'Image Cache Cleanup',
|
'image-cache-cleanup': 'Image Cache Cleanup',
|
||||||
|
'process-blacklisted-tags': 'Process Blacklisted Tags',
|
||||||
editJobSchedule: 'Modify Job',
|
editJobSchedule: 'Modify Job',
|
||||||
jobScheduleEditSaved: 'Job edited successfully!',
|
jobScheduleEditSaved: 'Job edited successfully!',
|
||||||
jobScheduleEditFailed: 'Something went wrong while saving the job.',
|
jobScheduleEditFailed: 'Something went wrong while saving the job.',
|
||||||
editJobScheduleCurrent: 'Current Frequency',
|
editJobScheduleCurrent: 'Current Frequency',
|
||||||
editJobSchedulePrompt: 'New Frequency',
|
editJobSchedulePrompt: 'New Frequency',
|
||||||
|
editJobScheduleSelectorDays:
|
||||||
|
'Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}',
|
||||||
editJobScheduleSelectorHours:
|
editJobScheduleSelectorHours:
|
||||||
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
|
||||||
editJobScheduleSelectorMinutes:
|
editJobScheduleSelectorMinutes:
|
||||||
@@ -92,7 +95,7 @@ interface Job {
|
|||||||
id: JobId;
|
id: JobId;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'process' | 'command';
|
type: 'process' | 'command';
|
||||||
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
|
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
nextExecutionTime: string;
|
nextExecutionTime: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -101,13 +104,20 @@ interface Job {
|
|||||||
type JobModalState = {
|
type JobModalState = {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
job?: Job;
|
job?: Job;
|
||||||
|
scheduleDays: number;
|
||||||
scheduleHours: number;
|
scheduleHours: number;
|
||||||
scheduleMinutes: number;
|
scheduleMinutes: number;
|
||||||
scheduleSeconds: number;
|
scheduleSeconds: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JobModalAction =
|
type JobModalAction =
|
||||||
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
|
| {
|
||||||
|
type: 'set';
|
||||||
|
days?: number;
|
||||||
|
hours?: number;
|
||||||
|
minutes?: number;
|
||||||
|
seconds?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'close';
|
type: 'close';
|
||||||
}
|
}
|
||||||
@@ -128,6 +138,7 @@ const jobModalReducer = (
|
|||||||
return {
|
return {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
job: action.job,
|
job: action.job,
|
||||||
|
scheduleDays: 1,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
scheduleSeconds: 30,
|
scheduleSeconds: 30,
|
||||||
@@ -136,6 +147,7 @@ const jobModalReducer = (
|
|||||||
case 'set':
|
case 'set':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
scheduleDays: action.days ?? state.scheduleDays,
|
||||||
scheduleHours: action.hours ?? state.scheduleHours,
|
scheduleHours: action.hours ?? state.scheduleHours,
|
||||||
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
|
||||||
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
|
||||||
@@ -164,6 +176,7 @@ const SettingsJobs = () => {
|
|||||||
|
|
||||||
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
|
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
scheduleDays: 1,
|
||||||
scheduleHours: 1,
|
scheduleHours: 1,
|
||||||
scheduleMinutes: 5,
|
scheduleMinutes: 5,
|
||||||
scheduleSeconds: 30,
|
scheduleSeconds: 30,
|
||||||
@@ -239,6 +252,9 @@ const SettingsJobs = () => {
|
|||||||
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
|
||||||
} else if (jobModalState.job?.interval === 'hours') {
|
} else if (jobModalState.job?.interval === 'hours') {
|
||||||
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
|
||||||
|
} else if (jobModalState.job?.interval === 'days') {
|
||||||
|
jobScheduleCron[2] = '1';
|
||||||
|
jobScheduleCron[3] = `*/${jobModalState.scheduleDays}`;
|
||||||
} else {
|
} else {
|
||||||
// jobs with interval: fixed should not be editable
|
// jobs with interval: fixed should not be editable
|
||||||
throw new Error();
|
throw new Error();
|
||||||
@@ -367,6 +383,29 @@ const SettingsJobs = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
) : jobModalState.job?.interval === 'days' ? (
|
||||||
|
<select
|
||||||
|
name="jobScheduleDays"
|
||||||
|
className="inline"
|
||||||
|
value={jobModalState.scheduleDays}
|
||||||
|
onChange={(e) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'set',
|
||||||
|
days: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7, 10, 14, 21].map((v) => (
|
||||||
|
<option value={v} key={`jobScheduleDays-${v}`}>
|
||||||
|
{intl.formatMessage(
|
||||||
|
messages.editJobScheduleSelectorDays,
|
||||||
|
{
|
||||||
|
jobScheduleDays: v,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
name="jobScheduleHours"
|
name="jobScheduleHours"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BlacklistedTagsSelector from '@app/components/BlacklistedTagsSelector';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
@@ -29,12 +30,19 @@ const messages = defineMessages('components.Settings.SettingsMain', {
|
|||||||
generalsettingsDescription:
|
generalsettingsDescription:
|
||||||
'Configure global and default settings for Jellyseerr.',
|
'Configure global and default settings for Jellyseerr.',
|
||||||
apikey: 'API Key',
|
apikey: 'API Key',
|
||||||
|
apikeyCopied: 'Copied API key to clipboard.',
|
||||||
applicationTitle: 'Application Title',
|
applicationTitle: 'Application Title',
|
||||||
applicationurl: 'Application URL',
|
applicationurl: 'Application URL',
|
||||||
discoverRegion: 'Discover Region',
|
discoverRegion: 'Discover Region',
|
||||||
discoverRegionTip: 'Filter content by regional availability',
|
discoverRegionTip: 'Filter content by regional availability',
|
||||||
originallanguage: 'Discover Language',
|
originallanguage: 'Discover Language',
|
||||||
originallanguageTip: 'Filter content by original language',
|
originallanguageTip: 'Filter content by original language',
|
||||||
|
blacklistedTags: 'Blacklist Content with Tags',
|
||||||
|
blacklistedTagsTip:
|
||||||
|
'Automatically add content with tags to the blacklist using the "Process Blacklisted Tags" job',
|
||||||
|
blacklistedTagsLimit: 'Limit Content Blacklisted per Tag',
|
||||||
|
blacklistedTagsLimitTip:
|
||||||
|
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
|
||||||
streamingRegion: 'Streaming Region',
|
streamingRegion: 'Streaming Region',
|
||||||
streamingRegionTip: 'Show streaming sites by regional availability',
|
streamingRegionTip: 'Show streaming sites by regional availability',
|
||||||
toastApiKeySuccess: 'New API key generated successfully!',
|
toastApiKeySuccess: 'New API key generated successfully!',
|
||||||
@@ -81,6 +89,17 @@ const SettingsMain = () => {
|
|||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
(value) => !value || !value.endsWith('/')
|
(value) => !value || !value.endsWith('/')
|
||||||
),
|
),
|
||||||
|
blacklistedTagsLimit: Yup.number()
|
||||||
|
.test(
|
||||||
|
'positive',
|
||||||
|
'Number must be greater than 0.',
|
||||||
|
(value) => (value ?? 0) >= 0
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'lte-250',
|
||||||
|
'Number must be less than or equal to 250.',
|
||||||
|
(value) => (value ?? 0) <= 250
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const regenerate = async () => {
|
const regenerate = async () => {
|
||||||
@@ -130,6 +149,8 @@ const SettingsMain = () => {
|
|||||||
discoverRegion: data?.discoverRegion,
|
discoverRegion: data?.discoverRegion,
|
||||||
originalLanguage: data?.originalLanguage,
|
originalLanguage: data?.originalLanguage,
|
||||||
streamingRegion: data?.streamingRegion || 'US',
|
streamingRegion: data?.streamingRegion || 'US',
|
||||||
|
blacklistedTags: data?.blacklistedTags,
|
||||||
|
blacklistedTagsLimit: data?.blacklistedTagsLimit || 50,
|
||||||
partialRequestsEnabled: data?.partialRequestsEnabled,
|
partialRequestsEnabled: data?.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
enableSpecialEpisodes: data?.enableSpecialEpisodes,
|
||||||
cacheImages: data?.cacheImages,
|
cacheImages: data?.cacheImages,
|
||||||
@@ -146,6 +167,8 @@ const SettingsMain = () => {
|
|||||||
discoverRegion: values.discoverRegion,
|
discoverRegion: values.discoverRegion,
|
||||||
streamingRegion: values.streamingRegion,
|
streamingRegion: values.streamingRegion,
|
||||||
originalLanguage: values.originalLanguage,
|
originalLanguage: values.originalLanguage,
|
||||||
|
blacklistedTags: values.blacklistedTags,
|
||||||
|
blacklistedTagsLimit: values.blacklistedTagsLimit,
|
||||||
partialRequestsEnabled: values.partialRequestsEnabled,
|
partialRequestsEnabled: values.partialRequestsEnabled,
|
||||||
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
enableSpecialEpisodes: values.enableSpecialEpisodes,
|
||||||
cacheImages: values.cacheImages,
|
cacheImages: values.cacheImages,
|
||||||
@@ -201,6 +224,9 @@ const SettingsMain = () => {
|
|||||||
/>
|
/>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
textToCopy={data?.apiKey ?? ''}
|
textToCopy={data?.apiKey ?? ''}
|
||||||
|
toastMessage={intl.formatMessage(
|
||||||
|
messages.apikeyCopied
|
||||||
|
)}
|
||||||
key={data?.apiKey}
|
key={data?.apiKey}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -209,6 +235,7 @@ const SettingsMain = () => {
|
|||||||
regenerate();
|
regenerate();
|
||||||
}}
|
}}
|
||||||
className="input-action"
|
className="input-action"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<ArrowPathIcon />
|
<ArrowPathIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -352,6 +379,49 @@ const SettingsMain = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="blacklistedTags" className="text-label">
|
||||||
|
<span>{intl.formatMessage(messages.blacklistedTags)}</span>
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.blacklistedTagsTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field relative z-10">
|
||||||
|
<BlacklistedTagsSelector
|
||||||
|
defaultValue={values.blacklistedTags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="blacklistedTagsLimit" className="text-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.blacklistedTagsLimit)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="advanced" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.blacklistedTagsLimitTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
id="blacklistedTagsLimit"
|
||||||
|
name="blacklistedTagsLimit"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="short"
|
||||||
|
placeholder={50}
|
||||||
|
/>
|
||||||
|
{errors.blacklistedTagsLimit &&
|
||||||
|
touched.blacklistedTagsLimit &&
|
||||||
|
typeof errors.blacklistedTagsLimit === 'string' && (
|
||||||
|
<div className="error">
|
||||||
|
{errors.blacklistedTagsLimit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="hideAvailable" className="checkbox-label">
|
<label htmlFor="hideAvailable" className="checkbox-label">
|
||||||
<span className="mr-2">
|
<span className="mr-2">
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
"components.Blacklist.blacklistdate": "date",
|
"components.Blacklist.blacklistdate": "date",
|
||||||
"components.Blacklist.blacklistedby": "{date} by {user}",
|
"components.Blacklist.blacklistedby": "{date} by {user}",
|
||||||
"components.Blacklist.blacklistsettings": "Blacklist Settings",
|
"components.Blacklist.blacklistsettings": "Blacklist Settings",
|
||||||
|
"components.Blacklist.filterBlacklistedTags": "Blacklisted Tags",
|
||||||
|
"components.Blacklist.filterManual": "Manual",
|
||||||
"components.Blacklist.mediaName": "Name",
|
"components.Blacklist.mediaName": "Name",
|
||||||
"components.Blacklist.mediaTmdbId": "tmdb Id",
|
"components.Blacklist.mediaTmdbId": "tmdb Id",
|
||||||
"components.Blacklist.mediaType": "Type",
|
"components.Blacklist.mediaType": "Type",
|
||||||
|
"components.Blacklist.showAllBlacklisted": "Show All Blacklisted Media",
|
||||||
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
||||||
"components.CollectionDetails.overview": "Overview",
|
"components.CollectionDetails.overview": "Overview",
|
||||||
"components.CollectionDetails.requestcollection": "Request Collection",
|
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||||
@@ -100,7 +103,6 @@
|
|||||||
"components.Discover.StudioSlider.studios": "Studios",
|
"components.Discover.StudioSlider.studios": "Studios",
|
||||||
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
||||||
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||||
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
|
||||||
"components.Discover.createnewslider": "Create New Slider",
|
"components.Discover.createnewslider": "Create New Slider",
|
||||||
"components.Discover.customizediscover": "Customize Discover",
|
"components.Discover.customizediscover": "Customize Discover",
|
||||||
"components.Discover.discover": "Discover",
|
"components.Discover.discover": "Discover",
|
||||||
@@ -134,6 +136,7 @@
|
|||||||
"components.Discover.upcomingtv": "Upcoming Series",
|
"components.Discover.upcomingtv": "Upcoming Series",
|
||||||
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
|
"components.Discover.updatefailed": "Something went wrong updating the discover customization settings.",
|
||||||
"components.Discover.updatesuccess": "Updated discover customization settings.",
|
"components.Discover.updatesuccess": "Updated discover customization settings.",
|
||||||
|
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||||
"components.DownloadBlock.estimatedtime": "Estimated {time}",
|
"components.DownloadBlock.estimatedtime": "Estimated {time}",
|
||||||
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
|
"components.DownloadBlock.formattedTitle": "{title}: Season {seasonNumber} Episode {episodeNumber}",
|
||||||
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
|
"components.IssueDetails.IssueComment.areyousuredelete": "Are you sure you want to delete this comment?",
|
||||||
@@ -864,6 +867,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
|
"components.Settings.SettingsJobsCache.editJobSchedule": "Modify Job",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
|
"components.Settings.SettingsJobsCache.editJobScheduleCurrent": "Current Frequency",
|
||||||
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
"components.Settings.SettingsJobsCache.editJobSchedulePrompt": "New Frequency",
|
||||||
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorDays": "Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorHours": "Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorMinutes": "Every {jobScheduleMinutes, plural, one {minute} other {{jobScheduleMinutes} minutes}}",
|
||||||
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
"components.Settings.SettingsJobsCache.editJobScheduleSelectorSeconds": "Every {jobScheduleSeconds, plural, one {second} other {{jobScheduleSeconds} seconds}}",
|
||||||
@@ -890,6 +894,7 @@
|
|||||||
"components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
|
"components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
|
||||||
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
|
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
|
||||||
"components.Settings.SettingsJobsCache.process": "Process",
|
"components.Settings.SettingsJobsCache.process": "Process",
|
||||||
|
"components.Settings.SettingsJobsCache.process-blacklisted-tags": "Process Blacklisted Tags",
|
||||||
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
|
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
|
||||||
"components.Settings.SettingsJobsCache.runnow": "Run Now",
|
"components.Settings.SettingsJobsCache.runnow": "Run Now",
|
||||||
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
|
"components.Settings.SettingsJobsCache.sonarr-scan": "Sonarr Scan",
|
||||||
@@ -914,8 +919,13 @@
|
|||||||
"components.Settings.SettingsLogs.time": "Timestamp",
|
"components.Settings.SettingsLogs.time": "Timestamp",
|
||||||
"components.Settings.SettingsLogs.viewdetails": "View Details",
|
"components.Settings.SettingsLogs.viewdetails": "View Details",
|
||||||
"components.Settings.SettingsMain.apikey": "API Key",
|
"components.Settings.SettingsMain.apikey": "API Key",
|
||||||
|
"components.Settings.SettingsMain.apikeyCopied": "Copied API key to clipboard.",
|
||||||
"components.Settings.SettingsMain.applicationTitle": "Application Title",
|
"components.Settings.SettingsMain.applicationTitle": "Application Title",
|
||||||
"components.Settings.SettingsMain.applicationurl": "Application URL",
|
"components.Settings.SettingsMain.applicationurl": "Application URL",
|
||||||
|
"components.Settings.SettingsMain.blacklistedTags": "Blacklist Content with Tags",
|
||||||
|
"components.Settings.SettingsMain.blacklistedTagsLimit": "Limit Content Blacklisted per Tag",
|
||||||
|
"components.Settings.SettingsMain.blacklistedTagsLimitTip": "The \"Process Blacklisted Tags\" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.",
|
||||||
|
"components.Settings.SettingsMain.blacklistedTagsTip": "Automatically add content with tags to the blacklist using the \"Process Blacklisted Tags\" job",
|
||||||
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
|
"components.Settings.SettingsMain.cacheImages": "Enable Image Caching",
|
||||||
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
|
"components.Settings.SettingsMain.cacheImagesTip": "Cache externally sourced images (requires a significant amount of disk space)",
|
||||||
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
|
"components.Settings.SettingsMain.discoverRegion": "Discover Region",
|
||||||
@@ -1042,8 +1052,14 @@
|
|||||||
"components.Settings.addsonarr": "Add Sonarr Server",
|
"components.Settings.addsonarr": "Add Sonarr Server",
|
||||||
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
"components.Settings.advancedTooltip": "Incorrectly configuring this setting may result in broken functionality",
|
||||||
"components.Settings.apiKey": "API key",
|
"components.Settings.apiKey": "API key",
|
||||||
|
"components.Settings.blacklistedTagImportInstructions": "Paste blacklist tag configuration below.",
|
||||||
|
"components.Settings.blacklistedTagImportTitle": "Import Blacklisted Tag Configuration",
|
||||||
|
"components.Settings.blacklistedTagsText": "Blacklisted Tags",
|
||||||
"components.Settings.cancelscan": "Cancel Scan",
|
"components.Settings.cancelscan": "Cancel Scan",
|
||||||
"components.Settings.copied": "Copied API key to clipboard.",
|
"components.Settings.clearBlacklistedTagsConfirm": "Are you sure you want to clear the blacklisted tags?",
|
||||||
|
"components.Settings.copyBlacklistedTags": "Copied blacklisted tags to clipboard.",
|
||||||
|
"components.Settings.copyBlacklistedTagsEmpty": "Nothing to copy",
|
||||||
|
"components.Settings.copyBlacklistedTagsTip": "Copy blacklisted tag configuration",
|
||||||
"components.Settings.currentlibrary": "Current Library: {name}",
|
"components.Settings.currentlibrary": "Current Library: {name}",
|
||||||
"components.Settings.default": "Default",
|
"components.Settings.default": "Default",
|
||||||
"components.Settings.default4k": "Default 4K",
|
"components.Settings.default4k": "Default 4K",
|
||||||
@@ -1054,6 +1070,8 @@
|
|||||||
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
"components.Settings.experimentalTooltip": "Enabling this setting may result in unexpected application behavior",
|
||||||
"components.Settings.externalUrl": "External URL",
|
"components.Settings.externalUrl": "External URL",
|
||||||
"components.Settings.hostname": "Hostname or IP Address",
|
"components.Settings.hostname": "Hostname or IP Address",
|
||||||
|
"components.Settings.importBlacklistedTagsTip": "Import blacklisted tag configuration",
|
||||||
|
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
|
||||||
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
"components.Settings.invalidurlerror": "Unable to connect to {mediaServerName} server.",
|
||||||
"components.Settings.is4k": "4K",
|
"components.Settings.is4k": "4K",
|
||||||
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
"components.Settings.jellyfinForgotPasswordUrl": "Forgot Password URL",
|
||||||
@@ -1085,9 +1103,12 @@
|
|||||||
"components.Settings.menuPlexSettings": "Plex",
|
"components.Settings.menuPlexSettings": "Plex",
|
||||||
"components.Settings.menuServices": "Services",
|
"components.Settings.menuServices": "Services",
|
||||||
"components.Settings.menuUsers": "Users",
|
"components.Settings.menuUsers": "Users",
|
||||||
|
"components.Settings.no": "No",
|
||||||
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
|
"components.Settings.noDefault4kServer": "A 4K {serverType} server must be marked as default in order to enable users to submit 4K {mediaType} requests.",
|
||||||
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
|
"components.Settings.noDefaultNon4kServer": "If you only have a single {serverType} server for both non-4K and 4K content (or if you only download 4K content), your {serverType} server should <strong>NOT</strong> be designated as a 4K server.",
|
||||||
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
|
"components.Settings.noDefaultServer": "At least one {serverType} server must be marked as default in order for {mediaType} requests to be processed.",
|
||||||
|
"components.Settings.noSpecialCharacters": "Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.",
|
||||||
|
"components.Settings.nooptions": "No results.",
|
||||||
"components.Settings.notificationAgentSettingsDescription": "Configure and enable notification agents.",
|
"components.Settings.notificationAgentSettingsDescription": "Configure and enable notification agents.",
|
||||||
"components.Settings.notifications": "Notifications",
|
"components.Settings.notifications": "Notifications",
|
||||||
"components.Settings.notificationsettings": "Notification Settings",
|
"components.Settings.notificationsettings": "Notification Settings",
|
||||||
@@ -1107,6 +1128,7 @@
|
|||||||
"components.Settings.scan": "Sync Libraries",
|
"components.Settings.scan": "Sync Libraries",
|
||||||
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
"components.Settings.scanbackground": "Scanning will run in the background. You can continue the setup process in the meantime.",
|
||||||
"components.Settings.scanning": "Syncing…",
|
"components.Settings.scanning": "Syncing…",
|
||||||
|
"components.Settings.searchKeywords": "Search keywords…",
|
||||||
"components.Settings.serverLocal": "local",
|
"components.Settings.serverLocal": "local",
|
||||||
"components.Settings.serverRemote": "remote",
|
"components.Settings.serverRemote": "remote",
|
||||||
"components.Settings.serverSecure": "secure",
|
"components.Settings.serverSecure": "secure",
|
||||||
@@ -1120,6 +1142,7 @@
|
|||||||
"components.Settings.sonarrsettings": "Sonarr Settings",
|
"components.Settings.sonarrsettings": "Sonarr Settings",
|
||||||
"components.Settings.ssl": "SSL",
|
"components.Settings.ssl": "SSL",
|
||||||
"components.Settings.startscan": "Start Scan",
|
"components.Settings.startscan": "Start Scan",
|
||||||
|
"components.Settings.starttyping": "Starting typing to search.",
|
||||||
"components.Settings.syncJellyfin": "Sync Libraries",
|
"components.Settings.syncJellyfin": "Sync Libraries",
|
||||||
"components.Settings.syncing": "Syncing",
|
"components.Settings.syncing": "Syncing",
|
||||||
"components.Settings.tautulliApiKey": "API Key",
|
"components.Settings.tautulliApiKey": "API Key",
|
||||||
@@ -1143,10 +1166,12 @@
|
|||||||
"components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
|
"components.Settings.validationUrlBaseLeadingSlash": "URL base must have a leading slash",
|
||||||
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
|
"components.Settings.validationUrlBaseTrailingSlash": "URL base must not end in a trailing slash",
|
||||||
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
"components.Settings.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
|
"components.Settings.valueRequired": "You must provide a value.",
|
||||||
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
|
"components.Settings.webAppUrl": "<WebAppLink>Web App</WebAppLink> URL",
|
||||||
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
|
"components.Settings.webAppUrlTip": "Optionally direct users to the web app on your server instead of the \"hosted\" web app",
|
||||||
"components.Settings.webhook": "Webhook",
|
"components.Settings.webhook": "Webhook",
|
||||||
"components.Settings.webpush": "Web Push",
|
"components.Settings.webpush": "Web Push",
|
||||||
|
"components.Settings.yes": "Yes",
|
||||||
"components.Setup.back": "Go back",
|
"components.Setup.back": "Go back",
|
||||||
"components.Setup.configemby": "Configure Emby",
|
"components.Setup.configemby": "Configure Emby",
|
||||||
"components.Setup.configjellyfin": "Configure Jellyfin",
|
"components.Setup.configjellyfin": "Configure Jellyfin",
|
||||||
@@ -1159,7 +1184,7 @@
|
|||||||
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
"components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.",
|
||||||
"components.Setup.servertype": "Choose Server Type",
|
"components.Setup.servertype": "Choose Server Type",
|
||||||
"components.Setup.setup": "Setup",
|
"components.Setup.setup": "Setup",
|
||||||
"components.Setup.signin": "Sign In",
|
"components.Setup.signin": "Sign in to your account",
|
||||||
"components.Setup.signinMessage": "Get started by signing in",
|
"components.Setup.signinMessage": "Get started by signing in",
|
||||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||||
|
|||||||
@@ -342,7 +342,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.input-action {
|
button.input-action {
|
||||||
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out last:rounded-r-md hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700 sm:px-3.5;
|
@apply relative -ml-px inline-flex items-center border border-gray-500 bg-indigo-600 bg-opacity-80 px-3 py-2 text-sm font-medium leading-5 text-white last:rounded-r-md sm:px-3.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.input-action[disabled] {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.input-action:not([disabled]) {
|
||||||
|
@apply transition duration-150 ease-in-out hover:bg-opacity-100 active:bg-gray-100 active:text-gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-md :where(svg),
|
.button-md :where(svg),
|
||||||
@@ -382,6 +390,10 @@
|
|||||||
@apply w-full;
|
@apply w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-select-container:has(+ .input-action) .react-select__control {
|
||||||
|
@apply rounded-r-none border border-gray-500 bg-gray-700 text-white hover:border-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
.react-select-container .react-select__control {
|
.react-select-container .react-select__control {
|
||||||
@apply rounded-md border border-gray-500 bg-gray-700 text-white hover:border-gray-500;
|
@apply rounded-md border border-gray-500 bg-gray-700 text-white hover:border-gray-500;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user