Compare commits
8 Commits
preview-tv
...
preview-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8db770bf3 | ||
|
|
17172e93f9 | ||
|
|
4878722030 | ||
|
|
479be0daeb | ||
|
|
6245dae3b3 | ||
|
|
d82c6f6222 | ||
|
|
13fe4c890b | ||
|
|
22b2824441 |
148
cypress/e2e/providers/tvdb.cy.ts
Normal file
148
cypress/e2e/providers/tvdb.cy.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
describe('TVDB Integration', () => {
|
||||||
|
// Constants for routes and selectors
|
||||||
|
const ROUTES = {
|
||||||
|
home: '/',
|
||||||
|
metadataSettings: '/settings/metadata',
|
||||||
|
tomorrowIsOursTvShow: '/tv/72879',
|
||||||
|
monsterTvShow: '/tv/225634',
|
||||||
|
dragonnBallZKaiAnime: '/tv/61709',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SELECTORS = {
|
||||||
|
sidebarToggle: '[data-testid=sidebar-toggle]',
|
||||||
|
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
|
||||||
|
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
|
||||||
|
metadataTestButton: 'button[type="button"]:contains("Test")',
|
||||||
|
metadataSaveButton: '[data-testid="metadata-save-button"]',
|
||||||
|
tmdbStatus: '[data-testid="tmdb-status"]',
|
||||||
|
tvdbStatus: '[data-testid="tvdb-status"]',
|
||||||
|
tvMetadataProviderSelector: '[data-testid="tv-metadata-provider-selector"]',
|
||||||
|
animeMetadataProviderSelector:
|
||||||
|
'[data-testid="anime-metadata-provider-selector"]',
|
||||||
|
seasonSelector: '[data-testid="season-selector"]',
|
||||||
|
season1: 'Season 1',
|
||||||
|
season2: 'Season 2',
|
||||||
|
season3: 'Season 3',
|
||||||
|
episodeList: '[data-testid="episode-list"]',
|
||||||
|
episode9: '9 - Hang Men',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reusable commands
|
||||||
|
const navigateToMetadataSettings = () => {
|
||||||
|
cy.visit(ROUTES.home);
|
||||||
|
cy.get(SELECTORS.sidebarToggle).click();
|
||||||
|
cy.get(SELECTORS.sidebarSettingsMobile).click();
|
||||||
|
cy.get(
|
||||||
|
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.metadataSettings}"]`
|
||||||
|
).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAndVerifyMetadataConnection = () => {
|
||||||
|
cy.intercept('POST', '/api/v1/settings/metadatas/test').as(
|
||||||
|
'testConnection'
|
||||||
|
);
|
||||||
|
cy.get(SELECTORS.metadataTestButton).click();
|
||||||
|
return cy.wait('@testConnection');
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMetadataSettings = (customBody = null) => {
|
||||||
|
if (customBody) {
|
||||||
|
cy.intercept('PUT', '/api/v1/settings/metadatas', (req) => {
|
||||||
|
req.body = customBody;
|
||||||
|
}).as('saveMetadata');
|
||||||
|
} else {
|
||||||
|
// Else just intercept without modifying body
|
||||||
|
cy.intercept('PUT', '/api/v1/settings/metadatas').as('saveMetadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.get(SELECTORS.metadataSaveButton).click();
|
||||||
|
return cy.wait('@saveMetadata');
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Perform login
|
||||||
|
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
|
||||||
|
|
||||||
|
// Navigate to Metadata settings
|
||||||
|
navigateToMetadataSettings();
|
||||||
|
|
||||||
|
// Verify we're on the correct settings page
|
||||||
|
cy.contains('h3', 'Metadata Providers').should('be.visible');
|
||||||
|
|
||||||
|
// Configure TVDB as TV provider and test connection
|
||||||
|
cy.get(SELECTORS.tvMetadataProviderSelector).click();
|
||||||
|
|
||||||
|
// get id react-select-4-option-1
|
||||||
|
cy.get('[class*="react-select__option"]').contains('TheTVDB').click();
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
testAndVerifyMetadataConnection().then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(200);
|
||||||
|
// Check TVDB connection status
|
||||||
|
cy.get(SELECTORS.tvdbStatus).should('contain', 'Operational');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
saveMetadataSettings({
|
||||||
|
anime: 'tvdb',
|
||||||
|
tv: 'tvdb',
|
||||||
|
}).then(({ response }) => {
|
||||||
|
expect(response.statusCode).to.equal(200);
|
||||||
|
expect(response.body.tv).to.equal('tvdb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display "Tomorrow is Ours" show information with multiple seasons from TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.tomorrowIsOursTvShow);
|
||||||
|
|
||||||
|
// Verify that multiple seasons are displayed (TMDB has only 1 season, TVDB has multiple)
|
||||||
|
// cy.get(SELECTORS.seasonSelector).should('exist');
|
||||||
|
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||||
|
// Select Season 2 and verify it loads
|
||||||
|
cy.contains(SELECTORS.season2)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Verify that episodes are displayed for Season 2
|
||||||
|
cy.contains('260 - Episode 506').should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should display "Monster" show information correctly when not existing on TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.monsterTvShow);
|
||||||
|
|
||||||
|
// Intercept season 1 request
|
||||||
|
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
|
||||||
|
|
||||||
|
// Select Season 1
|
||||||
|
cy.contains(SELECTORS.season1)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// Wait for the season data to load
|
||||||
|
cy.wait('@season1');
|
||||||
|
|
||||||
|
// Verify specific episode exists
|
||||||
|
cy.contains(SELECTORS.episode9).should('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display "Dragon Ball Z Kai" show information with multiple only 2 seasons from TVDB', () => {
|
||||||
|
// Navigate to the TV show
|
||||||
|
cy.visit(ROUTES.dragonnBallZKaiAnime);
|
||||||
|
|
||||||
|
// Intercept season 1 request
|
||||||
|
cy.intercept('/api/v1/tv/61709/season/1').as('season1');
|
||||||
|
|
||||||
|
// Select Season 2 and verify it visible
|
||||||
|
cy.contains(SELECTORS.season2)
|
||||||
|
.should('be.visible')
|
||||||
|
.scrollIntoView()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// select season 3 and verify it not visible
|
||||||
|
cy.contains(SELECTORS.season3).should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -519,6 +519,20 @@ components:
|
|||||||
serverID:
|
serverID:
|
||||||
type: string
|
type: string
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
MetadataSettings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
settings:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tv:
|
||||||
|
type: string
|
||||||
|
enum: [tvdb, tmdb]
|
||||||
|
example: 'tvdb'
|
||||||
|
anime:
|
||||||
|
type: string
|
||||||
|
enum: [tvdb, tmdb]
|
||||||
|
example: 'tvdb'
|
||||||
TautulliSettings:
|
TautulliSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1437,6 +1451,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
jsonPayload:
|
jsonPayload:
|
||||||
type: string
|
type: string
|
||||||
|
supportVariables:
|
||||||
|
type: boolean
|
||||||
|
example: false
|
||||||
TelegramSettings:
|
TelegramSettings:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -2568,6 +2585,67 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
thumb:
|
thumb:
|
||||||
type: string
|
type: string
|
||||||
|
/settings/metadatas:
|
||||||
|
get:
|
||||||
|
summary: Get Metadata settings
|
||||||
|
description: Retrieves current Metadata settings.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MetadataSettings'
|
||||||
|
put:
|
||||||
|
summary: Update Metadata settings
|
||||||
|
description: Updates Metadata settings with the provided values.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MetadataSettings'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MetadataSettings'
|
||||||
|
/settings/metadatas/test:
|
||||||
|
post:
|
||||||
|
summary: Test Provider configuration
|
||||||
|
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
|
||||||
|
tags:
|
||||||
|
- settings
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
tmdb:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
tvdb:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Succesfully connected to TVDB
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: 'Successfully connected to TVDB'
|
||||||
/settings/tautulli:
|
/settings/tautulli:
|
||||||
get:
|
get:
|
||||||
summary: Get Tautulli settings
|
summary: Get Tautulli settings
|
||||||
@@ -6472,7 +6550,7 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/TvDetails'
|
$ref: '#/components/schemas/TvDetails'
|
||||||
/tv/{tvId}/season/{seasonId}:
|
/tv/{tvId}/season/{seasonNumber}:
|
||||||
get:
|
get:
|
||||||
summary: Get season details and episode list
|
summary: Get season details and episode list
|
||||||
description: Returns season details with a list of episodes in a JSON object.
|
description: Returns season details with a list of episodes in a JSON object.
|
||||||
@@ -6486,11 +6564,11 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
example: 76479
|
example: 76479
|
||||||
- in: path
|
- in: path
|
||||||
name: seasonId
|
name: seasonNumber
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: number
|
type: number
|
||||||
example: 1
|
example: 123456
|
||||||
- in: query
|
- in: query
|
||||||
name: language
|
name: language
|
||||||
schema:
|
schema:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const DEFAULT_TTL = 300;
|
|||||||
// 10 seconds default rolling buffer (in ms)
|
// 10 seconds default rolling buffer (in ms)
|
||||||
const DEFAULT_ROLLING_BUFFER = 10000;
|
const DEFAULT_ROLLING_BUFFER = 10000;
|
||||||
|
|
||||||
interface ExternalAPIOptions {
|
export interface ExternalAPIOptions {
|
||||||
nodeCache?: NodeCache;
|
nodeCache?: NodeCache;
|
||||||
headers?: Record<string, unknown>;
|
headers?: Record<string, unknown>;
|
||||||
rateLimit?: {
|
rateLimit?: {
|
||||||
|
|||||||
39
server/api/metadata.ts
Normal file
39
server/api/metadata.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import Tvdb from '@server/api/tvdb';
|
||||||
|
import { getSettings, MetadataProviderType } from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
export const getMetadataProvider = async (
|
||||||
|
mediaType: 'movie' | 'tv' | 'anime'
|
||||||
|
): Promise<TvShowProvider> => {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (mediaType == 'movie') {
|
||||||
|
return new TheMovieDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mediaType == 'tv' &&
|
||||||
|
settings.metadataSettings.tv == MetadataProviderType.TVDB
|
||||||
|
) {
|
||||||
|
return await Tvdb.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
mediaType == 'anime' &&
|
||||||
|
settings.metadataSettings.anime == MetadataProviderType.TVDB
|
||||||
|
) {
|
||||||
|
return await Tvdb.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TheMovieDb();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to get metadata provider', {
|
||||||
|
label: 'Metadata',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return new TheMovieDb();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -312,12 +312,25 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
const watchlistDetails = await Promise.all(
|
const watchlistDetails = await Promise.all(
|
||||||
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
|
||||||
async (watchlistItem) => {
|
async (watchlistItem) => {
|
||||||
const detailedResponse = await this.getRolling<MetadataResponse>(
|
let detailedResponse: MetadataResponse;
|
||||||
`/library/metadata/${watchlistItem.ratingKey}`,
|
try {
|
||||||
{
|
detailedResponse = await this.getRolling<MetadataResponse>(
|
||||||
baseURL: 'https://discover.provider.plex.tv',
|
`/library/metadata/${watchlistItem.ratingKey}`,
|
||||||
|
{
|
||||||
|
baseURL: 'https://discover.provider.plex.tv',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.response?.status === 404) {
|
||||||
|
logger.warn(
|
||||||
|
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
|
||||||
|
{ label: 'Plex.TV Metadata API' }
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
const metadata = detailedResponse.MediaContainer.Metadata[0];
|
||||||
|
|
||||||
@@ -343,7 +356,9 @@ class PlexTvAPI extends ExternalAPI {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredList = watchlistDetails.filter((detail) => detail.tmdbId);
|
const filteredList = watchlistDetails.filter(
|
||||||
|
(detail) => detail?.tmdbId
|
||||||
|
) as PlexWatchlistItem[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
offset,
|
offset,
|
||||||
|
|||||||
30
server/api/provider.ts
Normal file
30
server/api/provider.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type {
|
||||||
|
TmdbSeasonWithEpisodes,
|
||||||
|
TmdbTvDetails,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
|
||||||
|
export interface TvShowProvider {
|
||||||
|
getTvShow({
|
||||||
|
tvId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails>;
|
||||||
|
getTvSeason({
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbSeasonWithEpisodes>;
|
||||||
|
getShowByTvdbId({
|
||||||
|
tvdbId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvdbId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import ExternalAPI from '@server/api/externalapi';
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
@@ -120,7 +121,7 @@ interface DiscoverTvOptions {
|
|||||||
certificationCountry?: string;
|
certificationCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TheMovieDb extends ExternalAPI {
|
class TheMovieDb extends ExternalAPI implements TvShowProvider {
|
||||||
private locale: string;
|
private locale: string;
|
||||||
private discoverRegion?: string;
|
private discoverRegion?: string;
|
||||||
private originalLanguage?: string;
|
private originalLanguage?: string;
|
||||||
@@ -341,6 +342,13 @@ class TheMovieDb extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
data.episodes = data.episodes.map((episode) => {
|
||||||
|
if (episode.still_path) {
|
||||||
|
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
|
||||||
|
}
|
||||||
|
return episode;
|
||||||
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
|
|||||||
show_id: number;
|
show_id: number;
|
||||||
still_path: string;
|
still_path: string;
|
||||||
vote_average: number;
|
vote_average: number;
|
||||||
vote_cuont: number;
|
vote_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TmdbTvSeasonResult {
|
export interface TmdbTvSeasonResult {
|
||||||
|
|||||||
563
server/api/tvdb/index.ts
Normal file
563
server/api/tvdb/index.ts
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
import ExternalAPI from '@server/api/externalapi';
|
||||||
|
import type { TvShowProvider } from '@server/api/provider';
|
||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import type {
|
||||||
|
TmdbSeasonWithEpisodes,
|
||||||
|
TmdbTvDetails,
|
||||||
|
TmdbTvEpisodeResult,
|
||||||
|
TmdbTvSeasonResult,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
|
import {
|
||||||
|
convertTmdbLanguageToTvdbWithFallback,
|
||||||
|
type TvdbBaseResponse,
|
||||||
|
type TvdbEpisode,
|
||||||
|
type TvdbLoginResponse,
|
||||||
|
type TvdbSeasonDetails,
|
||||||
|
type TvdbTvDetails,
|
||||||
|
} from '@server/api/tvdb/interfaces';
|
||||||
|
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
|
||||||
|
interface TvdbConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
maxRequestsPerSecond: number;
|
||||||
|
maxRequests: number;
|
||||||
|
cachePrefix: AvailableCacheIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: TvdbConfig = {
|
||||||
|
baseUrl: 'https://api4.thetvdb.com/v4',
|
||||||
|
maxRequestsPerSecond: 50,
|
||||||
|
maxRequests: 20,
|
||||||
|
cachePrefix: 'tvdb' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const enum TvdbIdStatus {
|
||||||
|
INVALID = -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
type TvdbId = number;
|
||||||
|
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
|
||||||
|
|
||||||
|
class Tvdb extends ExternalAPI implements TvShowProvider {
|
||||||
|
static instance: Tvdb;
|
||||||
|
private readonly tmdb: TheMovieDb;
|
||||||
|
private static readonly DEFAULT_CACHE_TTL = 43200;
|
||||||
|
private static readonly DEFAULT_LANGUAGE = 'eng';
|
||||||
|
private token: string;
|
||||||
|
private pin?: string;
|
||||||
|
|
||||||
|
constructor(pin?: string) {
|
||||||
|
const finalConfig = { ...DEFAULT_CONFIG };
|
||||||
|
super(
|
||||||
|
finalConfig.baseUrl,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
|
||||||
|
rateLimit: {
|
||||||
|
maxRequests: finalConfig.maxRequests,
|
||||||
|
maxRPS: finalConfig.maxRequestsPerSecond,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.pin = pin;
|
||||||
|
this.tmdb = new TheMovieDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async getInstance(): Promise<Tvdb> {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new Tvdb();
|
||||||
|
await this.instance.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshToken(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!this.token) {
|
||||||
|
await this.login();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Url = this.token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const payload = JSON.parse(Buffer.from(base64, 'base64').toString());
|
||||||
|
|
||||||
|
if (!payload.exp) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = payload.exp - now;
|
||||||
|
|
||||||
|
// refresh token 1 week before expiration
|
||||||
|
if (diff < 604800) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to refresh token', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async test(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.login();
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Login failed', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(): Promise<TvdbLoginResponse> {
|
||||||
|
let body: { apiKey: string; pin?: string } = {
|
||||||
|
apiKey: 'd00d9ecb-a9d0-4860-958a-74b14a041405',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.pin) {
|
||||||
|
body = {
|
||||||
|
...body,
|
||||||
|
pin: this.pin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.post<TvdbBaseResponse<TvdbLoginResponse>>(
|
||||||
|
'/login',
|
||||||
|
{
|
||||||
|
...body,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.token = response.data.token;
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getShowByTvdbId({
|
||||||
|
tvdbId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvdbId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getShowByTvdbId({
|
||||||
|
tvdbId: tvdbId,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const validTvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (this.isValidTvdbId(validTvdbId)) {
|
||||||
|
return this.enrichTmdbShowWithTvdbData(tmdbTvShow, validTvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmdbTvShow;
|
||||||
|
} catch (error) {
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvShow({
|
||||||
|
tvId,
|
||||||
|
language,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (this.isValidTvdbId(tvdbId)) {
|
||||||
|
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmdbTvShow;
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV show details', error);
|
||||||
|
return this.tmdb.getTvShow({ tvId, language });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getTvSeason({
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
language = Tvdb.DEFAULT_LANGUAGE,
|
||||||
|
}: {
|
||||||
|
tvId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
language?: string;
|
||||||
|
}): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
try {
|
||||||
|
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
|
||||||
|
|
||||||
|
if (!this.isValidTvdbId(tvdbId)) {
|
||||||
|
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.getTvdbSeasonData(
|
||||||
|
tvdbId,
|
||||||
|
seasonNumber,
|
||||||
|
tvId,
|
||||||
|
language
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError('Failed to fetch TV season details', error);
|
||||||
|
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`[TVDB] Failed to fetch TV season details: ${error.message}`
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichTmdbShowWithTvdbData(
|
||||||
|
tmdbTvShow: TmdbTvDetails,
|
||||||
|
tvdbId: ValidTvdbId
|
||||||
|
): Promise<TmdbTvDetails> {
|
||||||
|
try {
|
||||||
|
await this.refreshToken();
|
||||||
|
|
||||||
|
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||||
|
const seasons = this.processSeasons(tvdbData);
|
||||||
|
|
||||||
|
if (!seasons.length) {
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...tmdbTvShow, seasons };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to enrich TMDB show with TVDB data: ${error.message} token: ${this.token}`
|
||||||
|
);
|
||||||
|
return tmdbTvShow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvDetails> {
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbTvDetails>>(
|
||||||
|
`/series/${tvdbId}/extended?meta=episodes&short=true`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Tvdb.DEFAULT_CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private processSeasons(tvdbData: TvdbTvDetails): TmdbTvSeasonResult[] {
|
||||||
|
if (!tvdbData || !tvdbData.seasons || !tvdbData.episodes) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasons = tvdbData.seasons
|
||||||
|
.filter((season) => season.type && season.type.type === 'official')
|
||||||
|
.sort((a, b) => a.number - b.number)
|
||||||
|
.map((season) => this.createSeasonData(season, tvdbData))
|
||||||
|
.filter(
|
||||||
|
(season) => season && season.season_number >= 0
|
||||||
|
) as TmdbTvSeasonResult[];
|
||||||
|
|
||||||
|
return seasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createSeasonData(
|
||||||
|
season: TvdbSeasonDetails,
|
||||||
|
tvdbData: TvdbTvDetails
|
||||||
|
): TmdbTvSeasonResult {
|
||||||
|
const seasonNumber = season.number ?? -1;
|
||||||
|
if (seasonNumber < 0) {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
episode_count: 0,
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
season_number: -1,
|
||||||
|
poster_path: '',
|
||||||
|
air_date: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeCount = tvdbData.episodes.filter(
|
||||||
|
(episode) => episode.seasonNumber === season.number
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tvdbData.id,
|
||||||
|
episode_count: episodeCount,
|
||||||
|
name: `${season.number}`,
|
||||||
|
overview: '',
|
||||||
|
season_number: season.number,
|
||||||
|
poster_path: '',
|
||||||
|
air_date: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTvdbSeasonData(
|
||||||
|
tvdbId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
tvId: number,
|
||||||
|
language: string = Tvdb.DEFAULT_LANGUAGE
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
const tvdbData = await this.fetchTvdbShowData(tvdbId);
|
||||||
|
|
||||||
|
if (!tvdbData) {
|
||||||
|
logger.error(`Failed to fetch TVDB data for ID: ${tvdbId}`);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get season id
|
||||||
|
const season = tvdbData.seasons.find(
|
||||||
|
(season) =>
|
||||||
|
season.number === seasonNumber &&
|
||||||
|
season.type.type &&
|
||||||
|
season.type.type === 'official'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantedTranslation = convertTmdbLanguageToTvdbWithFallback(
|
||||||
|
language,
|
||||||
|
Tvdb.DEFAULT_LANGUAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
// check if translation is available for the season
|
||||||
|
const availableTranslation = season.nameTranslations.filter(
|
||||||
|
(translation) =>
|
||||||
|
translation === wantedTranslation ||
|
||||||
|
translation === Tvdb.DEFAULT_LANGUAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!availableTranslation) {
|
||||||
|
return this.getSeasonWithOriginalLanguage(
|
||||||
|
tvdbId,
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
season
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.getSeasonWithTranslation(
|
||||||
|
tvdbId,
|
||||||
|
tvId,
|
||||||
|
seasonNumber,
|
||||||
|
season,
|
||||||
|
wantedTranslation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSeasonWithTranslation(
|
||||||
|
tvdbId: number,
|
||||||
|
tvId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
season: TvdbSeasonDetails,
|
||||||
|
language: string
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEpisodes = [] as TvdbEpisode[];
|
||||||
|
let page = 0;
|
||||||
|
// Limit to max 50 pages to avoid infinite loops.
|
||||||
|
// 50 pages with 500 items per page = 25_000 episodes in a series which should be more than enough
|
||||||
|
const maxPages = 50;
|
||||||
|
|
||||||
|
while (page < maxPages) {
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||||
|
`/series/${tvdbId}/episodes/default/${language}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
page: page,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resp?.data?.episodes) {
|
||||||
|
logger.warn(
|
||||||
|
`No episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { episodes } = resp.data;
|
||||||
|
|
||||||
|
if (!episodes) {
|
||||||
|
logger.debug(
|
||||||
|
`No more episodes found for TVDB ID: ${tvdbId} on page ${page} for season ${seasonNumber}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allEpisodes.push(...episodes);
|
||||||
|
|
||||||
|
const hasNextPage = resp.links?.next && episodes.length > 0;
|
||||||
|
|
||||||
|
if (!hasNextPage) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page >= maxPages) {
|
||||||
|
logger.warn(
|
||||||
|
`Reached max pages (${maxPages}) for TVDB ID: ${tvdbId} on season ${seasonNumber} with language ${language}. There might be more episodes available.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = this.processEpisodes(
|
||||||
|
{ ...season, episodes: allEpisodes },
|
||||||
|
seasonNumber,
|
||||||
|
tvId
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes,
|
||||||
|
external_ids: { tvdb_id: tvdbId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: season.id,
|
||||||
|
air_date: season.firstAired,
|
||||||
|
season_number: episodes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSeasonWithOriginalLanguage(
|
||||||
|
tvdbId: number,
|
||||||
|
tvId: number,
|
||||||
|
seasonNumber: number,
|
||||||
|
season: TvdbSeasonDetails
|
||||||
|
): Promise<TmdbSeasonWithEpisodes> {
|
||||||
|
if (!season) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to find season ${seasonNumber} for TVDB ID: ${tvdbId}`
|
||||||
|
);
|
||||||
|
return this.createEmptySeasonResponse(tvId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await this.get<TvdbBaseResponse<TvdbSeasonDetails>>(
|
||||||
|
`/seasons/${season.id}/extended`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const seasons = resp.data;
|
||||||
|
|
||||||
|
const episodes = this.processEpisodes(seasons, seasonNumber, tvId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes,
|
||||||
|
external_ids: { tvdb_id: tvdbId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: seasons.id,
|
||||||
|
air_date: seasons.firstAired,
|
||||||
|
season_number: episodes.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private processEpisodes(
|
||||||
|
tvdbSeason: TvdbSeasonDetails,
|
||||||
|
seasonNumber: number,
|
||||||
|
tvId: number
|
||||||
|
): TmdbTvEpisodeResult[] {
|
||||||
|
if (!tvdbSeason || !tvdbSeason.episodes) {
|
||||||
|
logger.error('No episodes found in TVDB season data');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return tvdbSeason.episodes
|
||||||
|
.filter((episode) => episode.seasonNumber === seasonNumber)
|
||||||
|
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEpisodeData(
|
||||||
|
episode: TvdbEpisode,
|
||||||
|
index: number,
|
||||||
|
tvId: number
|
||||||
|
): TmdbTvEpisodeResult {
|
||||||
|
return {
|
||||||
|
id: episode.id,
|
||||||
|
air_date: episode.aired,
|
||||||
|
episode_number: episode.number,
|
||||||
|
name: episode.name || `Episode ${index + 1}`,
|
||||||
|
overview: episode.overview || '',
|
||||||
|
season_number: episode.seasonNumber,
|
||||||
|
production_code: '',
|
||||||
|
show_id: tvId,
|
||||||
|
still_path:
|
||||||
|
episode.image && !episode.image.startsWith('https://')
|
||||||
|
? 'https://artworks.thetvdb.com' + episode.image
|
||||||
|
: '',
|
||||||
|
vote_average: 1,
|
||||||
|
vote_count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
|
||||||
|
return {
|
||||||
|
episodes: [],
|
||||||
|
external_ids: { tvdb_id: tvId },
|
||||||
|
name: '',
|
||||||
|
overview: '',
|
||||||
|
id: 0,
|
||||||
|
air_date: '',
|
||||||
|
season_number: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
|
||||||
|
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
|
||||||
|
return tvdbId !== TvdbIdStatus.INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleError(context: string, error: Error): void {
|
||||||
|
throw new Error(`[TVDB] ${context}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tvdb;
|
||||||
216
server/api/tvdb/interfaces.ts
Normal file
216
server/api/tvdb/interfaces.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { type AvailableLocale } from '@server/types/languages';
|
||||||
|
|
||||||
|
export interface TvdbBaseResponse<T> {
|
||||||
|
data: T;
|
||||||
|
errors: string;
|
||||||
|
links?: TvdbPagination;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbPagination {
|
||||||
|
prev?: string;
|
||||||
|
self: string;
|
||||||
|
next?: string;
|
||||||
|
totalItems: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbLoginResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvDetailsAliases {
|
||||||
|
language: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvDetailsStatus {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
recordType: string;
|
||||||
|
keepUpdated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbTvDetails {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
image: string;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overwiewTranslations: string[];
|
||||||
|
aliases: TvDetailsAliases[];
|
||||||
|
firstAired: Date;
|
||||||
|
lastAired: Date;
|
||||||
|
nextAired: Date | string;
|
||||||
|
score: number;
|
||||||
|
status: TvDetailsStatus;
|
||||||
|
originalCountry: string;
|
||||||
|
originalLanguage: string;
|
||||||
|
defaultSeasonType: string;
|
||||||
|
isOrderRandomized: boolean;
|
||||||
|
lastUpdated: Date;
|
||||||
|
averageRuntime: number;
|
||||||
|
seasons: TvdbSeasonDetails[];
|
||||||
|
episodes: TvdbEpisode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbCompanyType {
|
||||||
|
companyTypeId: number;
|
||||||
|
companyTypeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbParentCompany {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
relation?: {
|
||||||
|
id?: number;
|
||||||
|
typeName?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbCompany {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
nameTranslations?: string[];
|
||||||
|
overviewTranslations?: string[];
|
||||||
|
aliases?: string[];
|
||||||
|
country: string;
|
||||||
|
primaryCompanyType: number;
|
||||||
|
activeDate: string;
|
||||||
|
inactiveDate?: string;
|
||||||
|
companyType: TvdbCompanyType;
|
||||||
|
parentCompany: TvdbParentCompany;
|
||||||
|
tagOptions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbType {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
alternateName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TvdbArtwork {
|
||||||
|
id: number;
|
||||||
|
image: string;
|
||||||
|
thumbnail: string;
|
||||||
|
language: string;
|
||||||
|
type: number;
|
||||||
|
score: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
includesText: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbEpisode {
|
||||||
|
id: number;
|
||||||
|
seriesId: number;
|
||||||
|
name: string;
|
||||||
|
aired: string;
|
||||||
|
runtime: number;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overview?: string;
|
||||||
|
overviewTranslations: string[];
|
||||||
|
image: string;
|
||||||
|
imageType: number;
|
||||||
|
isMovie: number;
|
||||||
|
seasons?: string[];
|
||||||
|
number: number;
|
||||||
|
absoluteNumber: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
finaleType?: string;
|
||||||
|
year: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbSeasonDetails {
|
||||||
|
id: number;
|
||||||
|
seriesId: number;
|
||||||
|
type: TvdbType;
|
||||||
|
number: number;
|
||||||
|
nameTranslations: string[];
|
||||||
|
overviewTranslations: string[];
|
||||||
|
image: string;
|
||||||
|
imageType: number;
|
||||||
|
companies: {
|
||||||
|
studio: TvdbCompany[];
|
||||||
|
network: TvdbCompany[];
|
||||||
|
production: TvdbCompany[];
|
||||||
|
distributor: TvdbCompany[];
|
||||||
|
special_effects: TvdbCompany[];
|
||||||
|
};
|
||||||
|
lastUpdated: string;
|
||||||
|
year: string;
|
||||||
|
episodes: TvdbEpisode[];
|
||||||
|
trailers: string[];
|
||||||
|
artwork: TvdbArtwork[];
|
||||||
|
tagOptions?: string[];
|
||||||
|
firstAired: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TvdbEpisodeTranslation {
|
||||||
|
name: string;
|
||||||
|
overview: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TMDB_TO_TVDB_MAPPING: Record<string, string> & {
|
||||||
|
[key in AvailableLocale]: string;
|
||||||
|
} = {
|
||||||
|
ar: 'ara', // Arabic
|
||||||
|
bg: 'bul', // Bulgarian
|
||||||
|
ca: 'cat', // Catalan
|
||||||
|
cs: 'ces', // Czech
|
||||||
|
da: 'dan', // Danish
|
||||||
|
de: 'deu', // German
|
||||||
|
el: 'ell', // Greek
|
||||||
|
en: 'eng', // English
|
||||||
|
es: 'spa', // Spanish
|
||||||
|
fi: 'fin', // Finnish
|
||||||
|
fr: 'fra', // French
|
||||||
|
he: 'heb', // Hebrew
|
||||||
|
hi: 'hin', // Hindi
|
||||||
|
hr: 'hrv', // Croatian
|
||||||
|
hu: 'hun', // Hungarian
|
||||||
|
it: 'ita', // Italian
|
||||||
|
ja: 'jpn', // Japanese
|
||||||
|
ko: 'kor', // Korean
|
||||||
|
lt: 'lit', // Lithuanian
|
||||||
|
nl: 'nld', // Dutch
|
||||||
|
pl: 'pol', // Polish
|
||||||
|
ro: 'ron', // Romanian
|
||||||
|
ru: 'rus', // Russian
|
||||||
|
sq: 'sqi', // Albanian
|
||||||
|
sr: 'srp', // Serbian
|
||||||
|
sv: 'swe', // Swedish
|
||||||
|
tr: 'tur', // Turkish
|
||||||
|
uk: 'ukr', // Ukrainian
|
||||||
|
|
||||||
|
'es-MX': 'spa', // Spanish (Latin America) -> Spanish
|
||||||
|
'nb-NO': 'nor', // Norwegian Bokmål -> Norwegian
|
||||||
|
'pt-BR': 'pt', // Portuguese (Brazil) -> Portuguese - Brazil (from TVDB data)
|
||||||
|
'pt-PT': 'por', // Portuguese (Portugal) -> Portuguese - Portugal (from TVDB data)
|
||||||
|
'zh-CN': 'zho', // Chinese (Simplified) -> Chinese - China
|
||||||
|
'zh-TW': 'zhtw', // Chinese (Traditional) -> Chinese - Taiwan
|
||||||
|
};
|
||||||
|
|
||||||
|
export function convertTMDBToTVDB(tmdbCode: string): string | null {
|
||||||
|
const normalizedCode = tmdbCode.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
TMDB_TO_TVDB_MAPPING[tmdbCode] ||
|
||||||
|
TMDB_TO_TVDB_MAPPING[normalizedCode] ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertTmdbLanguageToTvdbWithFallback(
|
||||||
|
tmdbCode: string,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
// First try exact match
|
||||||
|
const tvdbCode = convertTMDBToTVDB(tmdbCode);
|
||||||
|
if (tvdbCode) return tvdbCode;
|
||||||
|
|
||||||
|
return tvdbCode || fallback || 'eng'; // Default to English if no match found
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ export type AvailableCacheIds =
|
|||||||
| 'github'
|
| 'github'
|
||||||
| 'plexguid'
|
| 'plexguid'
|
||||||
| 'plextv'
|
| 'plextv'
|
||||||
| 'plexwatchlist';
|
| 'plexwatchlist'
|
||||||
|
| 'tvdb';
|
||||||
|
|
||||||
const DEFAULT_TTL = 300;
|
const DEFAULT_TTL = 300;
|
||||||
const DEFAULT_CHECK_PERIOD = 120;
|
const DEFAULT_CHECK_PERIOD = 120;
|
||||||
@@ -70,6 +71,10 @@ class CacheManager {
|
|||||||
checkPeriod: 60,
|
checkPeriod: 60,
|
||||||
}),
|
}),
|
||||||
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
plexwatchlist: new Cache('plexwatchlist', 'Plex Watchlist'),
|
||||||
|
tvdb: new Cache('tvdb', 'The TVDB API', {
|
||||||
|
stdTtl: 21600,
|
||||||
|
checkPeriod: 60 * 30,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
public getCache(id: AvailableCacheIds): Cache {
|
public getCache(id: AvailableCacheIds): Cache {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ class DiscordAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): DiscordRichEmbed {
|
): DiscordRichEmbed {
|
||||||
const { applicationUrl } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.discord;
|
||||||
|
|
||||||
const appUrl =
|
const appUrl =
|
||||||
applicationUrl || `http://localhost:${process.env.port || 5055}`;
|
applicationUrl || `http://localhost:${process.env.port || 5055}`;
|
||||||
@@ -223,9 +225,11 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fields,
|
fields,
|
||||||
thumbnail: {
|
thumbnail: embedPoster
|
||||||
url: payload.image,
|
? {
|
||||||
},
|
url: payload.image,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ class EmailAgent
|
|||||||
recipientEmail: string,
|
recipientEmail: string,
|
||||||
recipientName?: string
|
recipientName?: string
|
||||||
): EmailOptions | undefined {
|
): EmailOptions | undefined {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl, applicationTitle } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.email;
|
||||||
|
|
||||||
if (type === Notification.TEST_NOTIFICATION) {
|
if (type === Notification.TEST_NOTIFICATION) {
|
||||||
return {
|
return {
|
||||||
@@ -129,7 +131,7 @@ class EmailAgent
|
|||||||
body,
|
body,
|
||||||
mediaName: payload.subject,
|
mediaName: payload.subject,
|
||||||
mediaExtra: payload.extra ?? [],
|
mediaExtra: payload.extra ?? [],
|
||||||
imageUrl: payload.image,
|
imageUrl: embedPoster ? payload.image : undefined,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
requestedBy: payload.request.requestedBy.displayName,
|
requestedBy: payload.request.requestedBy.displayName,
|
||||||
actionUrl: applicationUrl
|
actionUrl: applicationUrl
|
||||||
@@ -176,7 +178,7 @@ class EmailAgent
|
|||||||
issueComment: payload.comment?.message,
|
issueComment: payload.comment?.message,
|
||||||
mediaName: payload.subject,
|
mediaName: payload.subject,
|
||||||
extra: payload.extra ?? [],
|
extra: payload.extra ?? [],
|
||||||
imageUrl: payload.image,
|
imageUrl: embedPoster ? payload.image : undefined,
|
||||||
timestamp: new Date().toTimeString(),
|
timestamp: new Date().toTimeString(),
|
||||||
actionUrl: applicationUrl
|
actionUrl: applicationUrl
|
||||||
? `${applicationUrl}/issues/${payload.issue.id}`
|
? `${applicationUrl}/issues/${payload.issue.id}`
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ class NtfyAgent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||||
const { applicationUrl } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.ntfy;
|
||||||
|
|
||||||
const topic = this.getSettings().options.topic;
|
const topic = this.getSettings().options.topic;
|
||||||
const priority = 3;
|
const priority = 3;
|
||||||
@@ -72,7 +74,7 @@ class NtfyAgent
|
|||||||
message += `\n\n**${extra.name}**\n${extra.value}`;
|
message += `\n\n**${extra.name}**\n${extra.value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attach = payload.image;
|
const attach = embedPoster ? payload.image : undefined;
|
||||||
|
|
||||||
let click;
|
let click;
|
||||||
if (applicationUrl && payload.media) {
|
if (applicationUrl && payload.media) {
|
||||||
|
|||||||
@@ -78,7 +78,9 @@ class PushoverAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Promise<Partial<PushoverPayload>> {
|
): Promise<Partial<PushoverPayload>> {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl, applicationTitle } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.pushover;
|
||||||
|
|
||||||
const title = payload.event ?? payload.subject;
|
const title = payload.event ?? payload.subject;
|
||||||
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
let message = payload.event ? `<b>${payload.subject}</b>` : '';
|
||||||
@@ -155,7 +157,7 @@ class PushoverAgent
|
|||||||
|
|
||||||
let attachment_base64;
|
let attachment_base64;
|
||||||
let attachment_type;
|
let attachment_type;
|
||||||
if (payload.image) {
|
if (embedPoster && payload.image) {
|
||||||
const imagePayload = await this.getImagePayload(payload.image);
|
const imagePayload = await this.getImagePayload(payload.image);
|
||||||
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
if (imagePayload.attachment_base64 && imagePayload.attachment_type) {
|
||||||
attachment_base64 = imagePayload.attachment_base64;
|
attachment_base64 = imagePayload.attachment_base64;
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class SlackAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): SlackBlockEmbed {
|
): SlackBlockEmbed {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl, applicationTitle } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.slack;
|
||||||
|
|
||||||
const fields: EmbedField[] = [];
|
const fields: EmbedField[] = [];
|
||||||
|
|
||||||
@@ -159,13 +161,14 @@ class SlackAgent
|
|||||||
type: 'mrkdwn',
|
type: 'mrkdwn',
|
||||||
text: payload.message,
|
text: payload.message,
|
||||||
},
|
},
|
||||||
accessory: payload.image
|
accessory:
|
||||||
? {
|
embedPoster && payload.image
|
||||||
type: 'image',
|
? {
|
||||||
image_url: payload.image,
|
type: 'image',
|
||||||
alt_text: payload.subject,
|
image_url: payload.image,
|
||||||
}
|
alt_text: payload.subject,
|
||||||
: undefined,
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ class TelegramAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
|
||||||
const { applicationUrl, applicationTitle } = getSettings().main;
|
const settings = getSettings();
|
||||||
|
const { applicationUrl, applicationTitle } = settings.main;
|
||||||
|
const { embedPoster } = settings.notifications.agents.telegram;
|
||||||
|
|
||||||
/* eslint-disable no-useless-escape */
|
/* eslint-disable no-useless-escape */
|
||||||
let message = `\*${this.escapeText(
|
let message = `\*${this.escapeText(
|
||||||
@@ -142,7 +144,7 @@ class TelegramAgent
|
|||||||
}
|
}
|
||||||
/* eslint-enable */
|
/* eslint-enable */
|
||||||
|
|
||||||
return payload.image
|
return embedPoster && payload.image
|
||||||
? {
|
? {
|
||||||
photo: payload.image,
|
photo: payload.image,
|
||||||
caption: message,
|
caption: message,
|
||||||
@@ -160,7 +162,7 @@ class TelegramAgent
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const settings = this.getSettings();
|
const settings = this.getSettings();
|
||||||
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
const endpoint = `${this.baseUrl}bot${settings.options.botAPI}/${
|
||||||
payload.image ? 'sendPhoto' : 'sendMessage'
|
settings.embedPoster && payload.image ? 'sendPhoto' : 'sendMessage'
|
||||||
}`;
|
}`;
|
||||||
const notificationPayload = this.getNotificationPayload(type, payload);
|
const notificationPayload = this.getNotificationPayload(type, payload);
|
||||||
|
|
||||||
|
|||||||
@@ -177,9 +177,27 @@ class WebhookAgent
|
|||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let webhookUrl = settings.options.webhookUrl;
|
||||||
|
|
||||||
|
if (settings.options.supportVariables) {
|
||||||
|
Object.keys(KeyMap).forEach((keymapKey) => {
|
||||||
|
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
|
||||||
|
const variableValue =
|
||||||
|
type === Notification.TEST_NOTIFICATION
|
||||||
|
? 'test'
|
||||||
|
: typeof keymapValue === 'function'
|
||||||
|
? keymapValue(payload, type)
|
||||||
|
: get(payload, keymapValue) || 'test';
|
||||||
|
webhookUrl = webhookUrl.replace(
|
||||||
|
new RegExp(`{{${keymapKey}}}`, 'g'),
|
||||||
|
encodeURIComponent(variableValue)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
await axios.post(
|
||||||
settings.options.webhookUrl,
|
webhookUrl,
|
||||||
this.buildPayload(type, payload),
|
this.buildPayload(type, payload),
|
||||||
settings.options.authHeader
|
settings.options.authHeader
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class WebPushAgent
|
|||||||
type: Notification,
|
type: Notification,
|
||||||
payload: NotificationPayload
|
payload: NotificationPayload
|
||||||
): PushNotificationPayload {
|
): PushNotificationPayload {
|
||||||
|
const { embedPoster } = getSettings().notifications.agents.webpush;
|
||||||
|
|
||||||
const mediaType = payload.media
|
const mediaType = payload.media
|
||||||
? payload.media.mediaType === MediaType.MOVIE
|
? payload.media.mediaType === MediaType.MOVIE
|
||||||
? 'movie'
|
? 'movie'
|
||||||
@@ -128,7 +130,7 @@ class WebPushAgent
|
|||||||
notificationType: Notification[type],
|
notificationType: Notification[type],
|
||||||
subject: payload.subject,
|
subject: payload.subject,
|
||||||
message,
|
message,
|
||||||
image: payload.image,
|
image: embedPoster ? payload.image : undefined,
|
||||||
requestId: payload.request?.id,
|
requestId: payload.request?.id,
|
||||||
actionUrl,
|
actionUrl,
|
||||||
actionUrlTitle,
|
actionUrlTitle,
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||||
import JellyfinAPI from '@server/api/jellyfin';
|
import JellyfinAPI from '@server/api/jellyfin';
|
||||||
|
import { getMetadataProvider } from '@server/api/metadata';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import type {
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbTvDetails,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
@@ -43,6 +48,7 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||||
this.tmdb = new TheMovieDb();
|
this.tmdb = new TheMovieDb();
|
||||||
|
|
||||||
this.isRecentOnly = isRecentOnly ?? false;
|
this.isRecentOnly = isRecentOnly ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +198,42 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getTvShow({
|
||||||
|
tmdbId,
|
||||||
|
tvdbId,
|
||||||
|
}: {
|
||||||
|
tmdbId?: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
let tvShow;
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
tvShow = await this.tmdb.getTvShow({
|
||||||
|
tvId: Number(tmdbId),
|
||||||
|
});
|
||||||
|
} else if (tvdbId) {
|
||||||
|
tvShow = await this.tmdb.getShowByTvdbId({
|
||||||
|
tvdbId: Number(tvdbId),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('No ID provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataProvider = tvShow.keywords.results.some(
|
||||||
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
? await getMetadataProvider('anime')
|
||||||
|
: await getMetadataProvider('tv');
|
||||||
|
|
||||||
|
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||||
|
tvShow = await metadataProvider.getTvShow({
|
||||||
|
tvId: Number(tmdbId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tvShow;
|
||||||
|
}
|
||||||
|
|
||||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
@@ -212,8 +254,8 @@ class JellyfinScanner {
|
|||||||
|
|
||||||
if (metadata.ProviderIds.Tmdb) {
|
if (metadata.ProviderIds.Tmdb) {
|
||||||
try {
|
try {
|
||||||
tvShow = await this.tmdb.getTvShow({
|
tvShow = await this.getTvShow({
|
||||||
tvId: Number(metadata.ProviderIds.Tmdb),
|
tmdbId: Number(metadata.ProviderIds.Tmdb),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
||||||
@@ -223,7 +265,7 @@ class JellyfinScanner {
|
|||||||
}
|
}
|
||||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||||
try {
|
try {
|
||||||
tvShow = await this.tmdb.getShowByTvdbId({
|
tvShow = await this.getTvShow({
|
||||||
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import animeList from '@server/api/animelist';
|
import animeList from '@server/api/animelist';
|
||||||
|
import { getMetadataProvider } from '@server/api/metadata';
|
||||||
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
|
||||||
import PlexAPI from '@server/api/plexapi';
|
import PlexAPI from '@server/api/plexapi';
|
||||||
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import type {
|
||||||
|
TmdbKeyword,
|
||||||
|
TmdbTvDetails,
|
||||||
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import cacheManager from '@server/lib/cache';
|
import cacheManager from '@server/lib/cache';
|
||||||
@@ -249,6 +255,42 @@ class PlexScanner
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getTvShow({
|
||||||
|
tmdbId,
|
||||||
|
tvdbId,
|
||||||
|
}: {
|
||||||
|
tmdbId?: number;
|
||||||
|
tvdbId?: number;
|
||||||
|
}): Promise<TmdbTvDetails> {
|
||||||
|
let tvShow;
|
||||||
|
|
||||||
|
if (tmdbId) {
|
||||||
|
tvShow = await this.tmdb.getTvShow({
|
||||||
|
tvId: Number(tmdbId),
|
||||||
|
});
|
||||||
|
} else if (tvdbId) {
|
||||||
|
tvShow = await this.tmdb.getShowByTvdbId({
|
||||||
|
tvdbId: Number(tvdbId),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('No ID provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataProvider = tvShow.keywords.results.some(
|
||||||
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
? await getMetadataProvider('anime')
|
||||||
|
: await getMetadataProvider('tv');
|
||||||
|
|
||||||
|
if (!(metadataProvider instanceof TheMovieDb)) {
|
||||||
|
tvShow = await metadataProvider.getTvShow({
|
||||||
|
tvId: Number(tmdbId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tvShow;
|
||||||
|
}
|
||||||
|
|
||||||
private async processPlexShow(plexitem: PlexLibraryItem) {
|
private async processPlexShow(plexitem: PlexLibraryItem) {
|
||||||
const ratingKey =
|
const ratingKey =
|
||||||
plexitem.grandparentRatingKey ??
|
plexitem.grandparentRatingKey ??
|
||||||
@@ -273,7 +315,9 @@ class PlexScanner
|
|||||||
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
await this.processHamaSpecials(metadata, mediaIds.tvdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tvShow = await this.tmdb.getTvShow({ tvId: mediaIds.tmdbId });
|
const tvShow = await this.getTvShow({
|
||||||
|
tmdbId: mediaIds.tmdbId,
|
||||||
|
});
|
||||||
|
|
||||||
const seasons = tvShow.seasons;
|
const seasons = tvShow.seasons;
|
||||||
const processableSeasons: ProcessableSeason[] = [];
|
const processableSeasons: ProcessableSeason[] = [];
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ interface Quota {
|
|||||||
quotaDays?: number;
|
quotaDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum MetadataProviderType {
|
||||||
|
TMDB = 'tmdb',
|
||||||
|
TVDB = 'tvdb',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetadataSettings {
|
||||||
|
tv: MetadataProviderType;
|
||||||
|
anime: MetadataProviderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxySettings {
|
||||||
|
enabled: boolean;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
useSsl: boolean;
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
bypassFilter: string;
|
||||||
|
bypassLocalAddresses: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MainSettings {
|
export interface MainSettings {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
@@ -186,6 +207,7 @@ interface FullPublicSettings extends PublicSettings {
|
|||||||
|
|
||||||
export interface NotificationAgentConfig {
|
export interface NotificationAgentConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
embedPoster: boolean;
|
||||||
types?: number;
|
types?: number;
|
||||||
options: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -253,6 +275,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
|
|||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
jsonPayload: string;
|
jsonPayload: string;
|
||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
|
supportVariables?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +362,7 @@ export interface AllSettings {
|
|||||||
notifications: NotificationSettings;
|
notifications: NotificationSettings;
|
||||||
jobs: Record<JobId, JobSettings>;
|
jobs: Record<JobId, JobSettings>;
|
||||||
network: NetworkSettings;
|
network: NetworkSettings;
|
||||||
|
metadataSettings: MetadataSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
|
||||||
@@ -399,6 +423,10 @@ class Settings {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
},
|
},
|
||||||
tautulli: {},
|
tautulli: {},
|
||||||
|
metadataSettings: {
|
||||||
|
tv: MetadataProviderType.TMDB,
|
||||||
|
anime: MetadataProviderType.TMDB,
|
||||||
|
},
|
||||||
radarr: [],
|
radarr: [],
|
||||||
sonarr: [],
|
sonarr: [],
|
||||||
public: {
|
public: {
|
||||||
@@ -408,6 +436,7 @@ class Settings {
|
|||||||
agents: {
|
agents: {
|
||||||
email: {
|
email: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
options: {
|
options: {
|
||||||
userEmailRequired: false,
|
userEmailRequired: false,
|
||||||
emailFrom: '',
|
emailFrom: '',
|
||||||
@@ -422,6 +451,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
@@ -431,6 +461,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
slack: {
|
slack: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
@@ -438,6 +469,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
botAPI: '',
|
botAPI: '',
|
||||||
@@ -448,6 +480,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
pushbullet: {
|
pushbullet: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: false,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
@@ -455,6 +488,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
pushover: {
|
pushover: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
@@ -464,6 +498,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
webhook: {
|
webhook: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
@@ -473,10 +508,12 @@ class Settings {
|
|||||||
},
|
},
|
||||||
webpush: {
|
webpush: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
gotify: {
|
gotify: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: false,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
url: '',
|
url: '',
|
||||||
@@ -486,6 +523,7 @@ class Settings {
|
|||||||
},
|
},
|
||||||
ntfy: {
|
ntfy: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
embedPoster: true,
|
||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
url: '',
|
url: '',
|
||||||
@@ -593,6 +631,14 @@ class Settings {
|
|||||||
this.data.tautulli = data;
|
this.data.tautulli = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get metadataSettings(): MetadataSettings {
|
||||||
|
return this.data.metadataSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
set metadataSettings(data: MetadataSettings) {
|
||||||
|
this.data.metadataSettings = data;
|
||||||
|
}
|
||||||
|
|
||||||
get radarr(): RadarrSettings[] {
|
get radarr(): RadarrSettings[] {
|
||||||
return this.data.radarr;
|
return this.data.radarr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
|
|||||||
seasonNumber: episode.season_number,
|
seasonNumber: episode.season_number,
|
||||||
showId: episode.show_id,
|
showId: episode.show_id,
|
||||||
voteAverage: episode.vote_average,
|
voteAverage: episode.vote_average,
|
||||||
voteCount: episode.vote_cuont,
|
voteCount: episode.vote_count,
|
||||||
stillPath: episode.still_path,
|
stillPath: episode.still_path,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ issueRoutes.get<Record<string, string>, IssueResultsResponse>(
|
|||||||
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
.leftJoinAndSelect('issue.createdBy', 'createdBy')
|
||||||
.leftJoinAndSelect('issue.media', 'media')
|
.leftJoinAndSelect('issue.media', 'media')
|
||||||
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
.leftJoinAndSelect('issue.modifiedBy', 'modifiedBy')
|
||||||
|
.leftJoinAndSelect('issue.comments', 'comments')
|
||||||
.where('issue.status IN (:...issueStatus)', {
|
.where('issue.status IN (:...issueStatus)', {
|
||||||
issueStatus: statusFilter,
|
issueStatus: statusFilter,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { rescheduleJob } from 'node-schedule';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
import metadataRoutes from './metadata';
|
||||||
import notificationRoutes from './notifications';
|
import notificationRoutes from './notifications';
|
||||||
import radarrRoutes from './radarr';
|
import radarrRoutes from './radarr';
|
||||||
import sonarrRoutes from './sonarr';
|
import sonarrRoutes from './sonarr';
|
||||||
@@ -49,6 +50,7 @@ settingsRoutes.use('/notifications', notificationRoutes);
|
|||||||
settingsRoutes.use('/radarr', radarrRoutes);
|
settingsRoutes.use('/radarr', radarrRoutes);
|
||||||
settingsRoutes.use('/sonarr', sonarrRoutes);
|
settingsRoutes.use('/sonarr', sonarrRoutes);
|
||||||
settingsRoutes.use('/discover', discoverSettingRoutes);
|
settingsRoutes.use('/discover', discoverSettingRoutes);
|
||||||
|
settingsRoutes.use('/metadatas', metadataRoutes);
|
||||||
|
|
||||||
const filteredMainSettings = (
|
const filteredMainSettings = (
|
||||||
user: User,
|
user: User,
|
||||||
|
|||||||
153
server/routes/settings/metadata.ts
Normal file
153
server/routes/settings/metadata.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import Tvdb from '@server/api/tvdb';
|
||||||
|
import {
|
||||||
|
getSettings,
|
||||||
|
MetadataProviderType,
|
||||||
|
type MetadataSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
|
import logger from '@server/logger';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
function getTestResultString(testValue: number): string {
|
||||||
|
if (testValue === -1) return 'not tested';
|
||||||
|
if (testValue === 0) return 'failed';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataRoutes = Router();
|
||||||
|
|
||||||
|
metadataRoutes.get('/', (_req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
res.status(200).json({
|
||||||
|
tv: settings.metadataSettings.tv,
|
||||||
|
anime: settings.metadataSettings.anime,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
metadataRoutes.put('/', async (req, res) => {
|
||||||
|
const settings = getSettings();
|
||||||
|
const body = req.body as MetadataSettings;
|
||||||
|
|
||||||
|
let tvdbTest = -1;
|
||||||
|
let tmdbTest = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
body.tv === MetadataProviderType.TVDB ||
|
||||||
|
body.anime === MetadataProviderType.TVDB
|
||||||
|
) {
|
||||||
|
tvdbTest = 0;
|
||||||
|
const tvdb = await Tvdb.getInstance();
|
||||||
|
await tvdb.test();
|
||||||
|
tvdbTest = 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to test metadata provider', {
|
||||||
|
label: 'Metadata',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
body.tv === MetadataProviderType.TMDB ||
|
||||||
|
body.anime === MetadataProviderType.TMDB
|
||||||
|
) {
|
||||||
|
tmdbTest = 0;
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
await tmdb.getTvShow({ tvId: 1054 });
|
||||||
|
tmdbTest = 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to test metadata provider', {
|
||||||
|
label: 'MetadataProvider',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a test failed, return the test results
|
||||||
|
if (tvdbTest === 0 || tmdbTest === 0) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
tests: {
|
||||||
|
tvdb: getTestResultString(tvdbTest),
|
||||||
|
tmdb: getTestResultString(tmdbTest),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.metadataSettings = {
|
||||||
|
tv: body.tv,
|
||||||
|
anime: body.anime,
|
||||||
|
};
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
tv: body.tv,
|
||||||
|
anime: body.anime,
|
||||||
|
tests: {
|
||||||
|
tvdb: getTestResultString(tvdbTest),
|
||||||
|
tmdb: getTestResultString(tmdbTest),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
metadataRoutes.post('/test', async (req, res) => {
|
||||||
|
let tvdbTest = -1;
|
||||||
|
let tmdbTest = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = req.body as { tmdb: boolean; tvdb: boolean };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (body.tmdb) {
|
||||||
|
tmdbTest = 0;
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
await tmdb.getTvShow({ tvId: 1054 });
|
||||||
|
tmdbTest = 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to test metadata provider', {
|
||||||
|
label: 'MetadataProvider',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (body.tvdb) {
|
||||||
|
tvdbTest = 0;
|
||||||
|
const tvdb = await Tvdb.getInstance();
|
||||||
|
await tvdb.test();
|
||||||
|
tvdbTest = 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Failed to test metadata provider', {
|
||||||
|
label: 'MetadataProvider',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = !(tvdbTest === 0 || tmdbTest === 0);
|
||||||
|
const statusCode = success ? 200 : 500;
|
||||||
|
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: success,
|
||||||
|
tests: {
|
||||||
|
tmdb: getTestResultString(tmdbTest),
|
||||||
|
tvdb: getTestResultString(tvdbTest),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
tests: {
|
||||||
|
tmdb: getTestResultString(tmdbTest),
|
||||||
|
tvdb: getTestResultString(tvdbTest),
|
||||||
|
},
|
||||||
|
error: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default metadataRoutes;
|
||||||
@@ -270,6 +270,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
|||||||
|
|
||||||
const response: typeof webhookSettings = {
|
const response: typeof webhookSettings = {
|
||||||
enabled: webhookSettings.enabled,
|
enabled: webhookSettings.enabled,
|
||||||
|
embedPoster: webhookSettings.embedPoster,
|
||||||
types: webhookSettings.types,
|
types: webhookSettings.types,
|
||||||
options: {
|
options: {
|
||||||
...webhookSettings.options,
|
...webhookSettings.options,
|
||||||
@@ -278,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
|
|||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
supportVariables: webhookSettings.options.supportVariables ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,6 +293,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
|||||||
|
|
||||||
settings.notifications.agents.webhook = {
|
settings.notifications.agents.webhook = {
|
||||||
enabled: req.body.enabled,
|
enabled: req.body.enabled,
|
||||||
|
embedPoster: req.body.embedPoster,
|
||||||
types: req.body.types,
|
types: req.body.types,
|
||||||
options: {
|
options: {
|
||||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||||
@@ -298,6 +301,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
|
|||||||
),
|
),
|
||||||
webhookUrl: req.body.options.webhookUrl,
|
webhookUrl: req.body.options.webhookUrl,
|
||||||
authHeader: req.body.options.authHeader,
|
authHeader: req.body.options.authHeader,
|
||||||
|
supportVariables: req.body.options.supportVariables ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await settings.save();
|
await settings.save();
|
||||||
@@ -321,6 +325,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
|||||||
|
|
||||||
const testBody = {
|
const testBody = {
|
||||||
enabled: req.body.enabled,
|
enabled: req.body.enabled,
|
||||||
|
embedPoster: req.body.embedPoster,
|
||||||
types: req.body.types,
|
types: req.body.types,
|
||||||
options: {
|
options: {
|
||||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||||
@@ -328,6 +333,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
|
|||||||
),
|
),
|
||||||
webhookUrl: req.body.options.webhookUrl,
|
webhookUrl: req.body.options.webhookUrl,
|
||||||
authHeader: req.body.options.authHeader,
|
authHeader: req.body.options.authHeader,
|
||||||
|
supportVariables: req.body.options.supportVariables ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { getMetadataProvider } from '@server/api/metadata';
|
||||||
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
import RottenTomatoes from '@server/api/rating/rottentomatoes';
|
||||||
import TheMovieDb from '@server/api/themoviedb';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
|
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
|
||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
@@ -13,12 +16,20 @@ const tvRoutes = Router();
|
|||||||
|
|
||||||
tvRoutes.get('/:id', async (req, res, next) => {
|
tvRoutes.get('/:id', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tv = await tmdb.getTvShow({
|
const tmdbTv = await tmdb.getTvShow({
|
||||||
|
tvId: Number(req.params.id),
|
||||||
|
});
|
||||||
|
const metadataProvider = tmdbTv.keywords.results.some(
|
||||||
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
? await getMetadataProvider('anime')
|
||||||
|
: await getMetadataProvider('tv');
|
||||||
|
const tv = await metadataProvider.getTvShow({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
language: (req.query.language as string) ?? req.locale,
|
language: (req.query.language as string) ?? req.locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = await Media.getMedia(tv.id, MediaType.TV);
|
const media = await Media.getMedia(tv.id, MediaType.TV);
|
||||||
|
|
||||||
const onUserWatchlist = await getRepository(Watchlist).exist({
|
const onUserWatchlist = await getRepository(Watchlist).exist({
|
||||||
@@ -34,7 +45,9 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
|
||||||
if (!data.overview) {
|
if (!data.overview) {
|
||||||
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
|
const tvEnglish = await metadataProvider.getTvShow({
|
||||||
|
tvId: Number(req.params.id),
|
||||||
|
});
|
||||||
data.overview = tvEnglish.overview;
|
data.overview = tvEnglish.overview;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +66,18 @@ tvRoutes.get('/:id', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const season = await tmdb.getTvSeason({
|
const tmdb = new TheMovieDb();
|
||||||
|
const tmdbTv = await tmdb.getTvShow({
|
||||||
|
tvId: Number(req.params.id),
|
||||||
|
});
|
||||||
|
const metadataProvider = tmdbTv.keywords.results.some(
|
||||||
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
||||||
|
)
|
||||||
|
? await getMetadataProvider('anime')
|
||||||
|
: await getMetadataProvider('tv');
|
||||||
|
|
||||||
|
const season = await metadataProvider.getTvSeason({
|
||||||
tvId: Number(req.params.id),
|
tvId: Number(req.params.id),
|
||||||
seasonNumber: Number(req.params.seasonNumber),
|
seasonNumber: Number(req.params.seasonNumber),
|
||||||
language: (req.query.language as string) ?? req.locale,
|
language: (req.query.language as string) ?? req.locale,
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
|
|||||||
b(style='color: #9ca3af; font-weight: 700;')
|
b(style='color: #9ca3af; font-weight: 700;')
|
||||||
| #{extra.name}
|
| #{extra.name}
|
||||||
| #{extra.value}
|
| #{extra.value}
|
||||||
td(rowspan='2' style='width: 7rem;')
|
if imageUrl
|
||||||
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
td(rowspan='2' style='width: 7rem;')
|
||||||
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
a(style='display: block; width: 7rem; overflow: hidden; border-radius: .375rem;' href=actionUrl)
|
||||||
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
div(style='overflow: hidden; box-sizing: border-box; margin: 0px;')
|
||||||
|
img(alt='' src=imageUrl style='box-sizing: border-box; padding: 0px; border: none; margin: auto; display: block; min-width: 100%; max-width: 100%; min-height: 100%; max-height: 100%;')
|
||||||
tr
|
tr
|
||||||
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
|
td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem')
|
||||||
span
|
span
|
||||||
|
|||||||
35
server/types/languages.d.ts
vendored
Normal file
35
server/types/languages.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type AvailableLocale =
|
||||||
|
| 'ar'
|
||||||
|
| 'bg'
|
||||||
|
| 'ca'
|
||||||
|
| 'cs'
|
||||||
|
| 'da'
|
||||||
|
| 'de'
|
||||||
|
| 'en'
|
||||||
|
| 'el'
|
||||||
|
| 'es'
|
||||||
|
| 'es-MX'
|
||||||
|
| 'fi'
|
||||||
|
| 'fr'
|
||||||
|
| 'hr'
|
||||||
|
| 'he'
|
||||||
|
| 'hi'
|
||||||
|
| 'hu'
|
||||||
|
| 'it'
|
||||||
|
| 'ja'
|
||||||
|
| 'ko'
|
||||||
|
| 'lt'
|
||||||
|
| 'nb-NO'
|
||||||
|
| 'nl'
|
||||||
|
| 'pl'
|
||||||
|
| 'pt-BR'
|
||||||
|
| 'pt-PT'
|
||||||
|
| 'ro'
|
||||||
|
| 'ru'
|
||||||
|
| 'sq'
|
||||||
|
| 'sr'
|
||||||
|
| 'sv'
|
||||||
|
| 'tr'
|
||||||
|
| 'uk'
|
||||||
|
| 'zh-CN'
|
||||||
|
| 'zh-TW';
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import { issueOptions } from '@app/components/IssueModal/constants';
|
import { issueOptions } from '@app/components/IssueModal/constants';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -26,6 +27,7 @@ const messages = defineMessages('components.IssueList.IssueItem', {
|
|||||||
opened: 'Opened',
|
opened: 'Opened',
|
||||||
viewissue: 'View Issue',
|
viewissue: 'View Issue',
|
||||||
unknownissuetype: 'Unknown',
|
unknownissuetype: 'Unknown',
|
||||||
|
descriptionpreview: 'Issue Description',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
@@ -107,8 +109,15 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = issue.comments?.[0]?.message || '';
|
||||||
|
const maxDescriptionLength = 120;
|
||||||
|
const shouldTruncate = description.length > maxDescriptionLength;
|
||||||
|
const truncatedDescription = shouldTruncate
|
||||||
|
? description.substring(0, maxDescriptionLength) + '...'
|
||||||
|
: description;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row">
|
<div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:flex-row">
|
||||||
{title.backdropPath && (
|
{title.backdropPath && (
|
||||||
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
<div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -168,8 +177,38 @@ const IssueItem = ({ issue }: IssueItemProps) => {
|
|||||||
>
|
>
|
||||||
{isMovie(title) ? title.title : title.name}
|
{isMovie(title) ? title.title : title.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{description && (
|
||||||
|
<div className="mt-1 max-w-full">
|
||||||
|
<div className="overflow-hidden text-sm text-gray-300">
|
||||||
|
{shouldTruncate ? (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<div className="max-w-sm p-3">
|
||||||
|
<div className="mb-1 text-sm font-medium text-gray-200">
|
||||||
|
Issue Description
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed text-gray-300">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
tooltipConfig={{
|
||||||
|
placement: 'top',
|
||||||
|
offset: [0, 8],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block cursor-help truncate transition-colors hover:text-gray-200">
|
||||||
|
{truncatedDescription}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className="block break-words">{description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{problemSeasonEpisodeLine.length > 0 && (
|
{problemSeasonEpisodeLine.length > 0 && (
|
||||||
<div className="card-field">
|
<div className="card-field mt-1">
|
||||||
{problemSeasonEpisodeLine.map((t, k) => (
|
{problemSeasonEpisodeLine.map((t, k) => (
|
||||||
<span key={k}>{t}</span>
|
<span key={k}>{t}</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
|
||||||
import { availableLanguages } from '@app/context/LanguageContext';
|
import { availableLanguages } from '@app/context/LanguageContext';
|
||||||
import useClickOutside from '@app/hooks/useClickOutside';
|
import useClickOutside from '@app/hooks/useClickOutside';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { LanguageIcon } from '@heroicons/react/24/solid';
|
import { LanguageIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { AvailableLocale } from '@server/types/languages';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import PullToRefresh from '@app/components/Layout/PullToRefresh';
|
|||||||
import SearchInput from '@app/components/Layout/SearchInput';
|
import SearchInput from '@app/components/Layout/SearchInput';
|
||||||
import Sidebar from '@app/components/Layout/Sidebar';
|
import Sidebar from '@app/components/Layout/Sidebar';
|
||||||
import UserDropdown from '@app/components/Layout/UserDropdown';
|
import UserDropdown from '@app/components/Layout/UserDropdown';
|
||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { AvailableLocale } from '@server/types/languages';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|||||||
91
src/components/MetadataSelector/index.tsx
Normal file
91
src/components/MetadataSelector/index.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import Select, { type StylesConfig } from 'react-select';
|
||||||
|
|
||||||
|
enum MetadataProviderType {
|
||||||
|
TMDB = 'tmdb',
|
||||||
|
TVDB = 'tvdb',
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetadataProviderOptionType = {
|
||||||
|
testId?: string;
|
||||||
|
value: MetadataProviderType;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages('components.MetadataSelector', {
|
||||||
|
tmdbLabel: 'The Movie Database (TMDB)',
|
||||||
|
tvdbLabel: 'TheTVDB',
|
||||||
|
selectMetdataProvider: 'Select a metadata provider',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface MetadataSelectorProps {
|
||||||
|
testId: string;
|
||||||
|
value: MetadataProviderType;
|
||||||
|
onChange: (value: MetadataProviderType) => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetadataSelector = ({
|
||||||
|
testId = 'metadata-provider-selector',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isDisabled = false,
|
||||||
|
}: MetadataSelectorProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const metadataProviderOptions: MetadataProviderOptionType[] = [
|
||||||
|
{
|
||||||
|
testId: 'tmdb-option',
|
||||||
|
value: MetadataProviderType.TMDB,
|
||||||
|
label: intl.formatMessage(messages.tmdbLabel),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testId: 'tvdb-option',
|
||||||
|
value: MetadataProviderType.TVDB,
|
||||||
|
label: intl.formatMessage(messages.tvdbLabel),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const customStyles: StylesConfig<MetadataProviderOptionType, false> = {
|
||||||
|
option: (base) => ({
|
||||||
|
...base,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}),
|
||||||
|
singleValue: (base) => ({
|
||||||
|
...base,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOptionLabel = (option: MetadataProviderOptionType) => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span data-testid={option.testId}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid={testId}>
|
||||||
|
<Select
|
||||||
|
options={metadataProviderOptions}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
value={metadataProviderOptions.find((option) => option.value === value)}
|
||||||
|
onChange={(selectedOption) => {
|
||||||
|
if (selectedOption) {
|
||||||
|
onChange(selectedOption.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={intl.formatMessage(messages.selectMetdataProvider)}
|
||||||
|
styles={customStyles}
|
||||||
|
formatOptionLabel={formatOptionLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MetadataProviderType };
|
||||||
|
export default MetadataSelector;
|
||||||
@@ -15,6 +15,7 @@ import * as Yup from 'yup';
|
|||||||
|
|
||||||
const messages = defineMessages('components.Settings.Notifications', {
|
const messages = defineMessages('components.Settings.Notifications', {
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
botUsername: 'Bot Username',
|
botUsername: 'Bot Username',
|
||||||
botAvatarUrl: 'Bot Avatar URL',
|
botAvatarUrl: 'Bot Avatar URL',
|
||||||
webhookUrl: 'Webhook URL',
|
webhookUrl: 'Webhook URL',
|
||||||
@@ -74,6 +75,7 @@ const NotificationsDiscord = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
embedPoster: data.embedPoster,
|
||||||
types: data.types,
|
types: data.types,
|
||||||
botUsername: data?.options.botUsername,
|
botUsername: data?.options.botUsername,
|
||||||
botAvatarUrl: data?.options.botAvatarUrl,
|
botAvatarUrl: data?.options.botAvatarUrl,
|
||||||
@@ -86,6 +88,7 @@ const NotificationsDiscord = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/discord', {
|
await axios.post('/api/v1/settings/notifications/discord', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
botUsername: values.botUsername,
|
botUsername: values.botUsername,
|
||||||
@@ -135,6 +138,7 @@ const NotificationsDiscord = () => {
|
|||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/discord/test', {
|
await axios.post('/api/v1/settings/notifications/discord/test', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
botUsername: values.botUsername,
|
botUsername: values.botUsername,
|
||||||
@@ -176,6 +180,14 @@ const NotificationsDiscord = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="name" className="text-label">
|
<label htmlFor="name" className="text-label">
|
||||||
{intl.formatMessage(messages.webhookUrl)}
|
{intl.formatMessage(messages.webhookUrl)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const messages = defineMessages('components.Settings.Notifications', {
|
|||||||
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
|
validationSmtpHostRequired: 'You must provide a valid hostname or IP address',
|
||||||
validationSmtpPortRequired: 'You must provide a valid port number',
|
validationSmtpPortRequired: 'You must provide a valid port number',
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
userEmailRequired: 'Require user email',
|
userEmailRequired: 'Require user email',
|
||||||
emailsender: 'Sender Address',
|
emailsender: 'Sender Address',
|
||||||
smtpHost: 'SMTP Host',
|
smtpHost: 'SMTP Host',
|
||||||
@@ -122,6 +123,7 @@ const NotificationsEmail = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
embedPoster: data.embedPoster,
|
||||||
userEmailRequired: data.options.userEmailRequired,
|
userEmailRequired: data.options.userEmailRequired,
|
||||||
emailFrom: data.options.emailFrom,
|
emailFrom: data.options.emailFrom,
|
||||||
smtpHost: data.options.smtpHost,
|
smtpHost: data.options.smtpHost,
|
||||||
@@ -145,6 +147,7 @@ const NotificationsEmail = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/email', {
|
await axios.post('/api/v1/settings/notifications/email', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
options: {
|
options: {
|
||||||
userEmailRequired: values.userEmailRequired,
|
userEmailRequired: values.userEmailRequired,
|
||||||
emailFrom: values.emailFrom,
|
emailFrom: values.emailFrom,
|
||||||
@@ -194,6 +197,7 @@ const NotificationsEmail = () => {
|
|||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/email/test', {
|
await axios.post('/api/v1/settings/notifications/email/test', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
options: {
|
options: {
|
||||||
emailFrom: values.emailFrom,
|
emailFrom: values.emailFrom,
|
||||||
smtpHost: values.smtpHost,
|
smtpHost: values.smtpHost,
|
||||||
@@ -241,6 +245,14 @@ const NotificationsEmail = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="userEmailRequired" className="checkbox-label">
|
<label htmlFor="userEmailRequired" className="checkbox-label">
|
||||||
{intl.formatMessage(messages.userEmailRequired)}
|
{intl.formatMessage(messages.userEmailRequired)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const messages = defineMessages(
|
|||||||
'components.Settings.Notifications.NotificationsNtfy',
|
'components.Settings.Notifications.NotificationsNtfy',
|
||||||
{
|
{
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
url: 'Server root URL',
|
url: 'Server root URL',
|
||||||
topic: 'Topic',
|
topic: 'Topic',
|
||||||
usernamePasswordAuth: 'Username + Password authentication',
|
usernamePasswordAuth: 'Username + Password authentication',
|
||||||
@@ -80,6 +81,7 @@ const NotificationsNtfy = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data?.enabled,
|
enabled: data?.enabled,
|
||||||
|
embedPoster: data?.embedPoster,
|
||||||
types: data?.types,
|
types: data?.types,
|
||||||
url: data?.options.url,
|
url: data?.options.url,
|
||||||
topic: data?.options.topic,
|
topic: data?.options.topic,
|
||||||
@@ -94,6 +96,7 @@ const NotificationsNtfy = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/ntfy', {
|
await axios.post('/api/v1/settings/notifications/ntfy', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
url: values.url,
|
url: values.url,
|
||||||
@@ -188,6 +191,14 @@ const NotificationsNtfy = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="url" className="text-label">
|
<label htmlFor="url" className="text-label">
|
||||||
{intl.formatMessage(messages.url)}
|
{intl.formatMessage(messages.url)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const messages = defineMessages(
|
|||||||
'components.Settings.Notifications.NotificationsPushover',
|
'components.Settings.Notifications.NotificationsPushover',
|
||||||
{
|
{
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
accessToken: 'Application API Token',
|
accessToken: 'Application API Token',
|
||||||
accessTokenTip:
|
accessTokenTip:
|
||||||
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
|
'<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr',
|
||||||
@@ -86,6 +87,7 @@ const NotificationsPushover = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data?.enabled,
|
enabled: data?.enabled,
|
||||||
|
embedPoster: data?.embedPoster,
|
||||||
types: data?.types,
|
types: data?.types,
|
||||||
accessToken: data?.options.accessToken,
|
accessToken: data?.options.accessToken,
|
||||||
userToken: data?.options.userToken,
|
userToken: data?.options.userToken,
|
||||||
@@ -96,6 +98,7 @@ const NotificationsPushover = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/pushover', {
|
await axios.post('/api/v1/settings/notifications/pushover', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
accessToken: values.accessToken,
|
accessToken: values.accessToken,
|
||||||
@@ -142,6 +145,7 @@ const NotificationsPushover = () => {
|
|||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
await axios.post('/api/v1/settings/notifications/pushover/test', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
accessToken: values.accessToken,
|
accessToken: values.accessToken,
|
||||||
@@ -181,6 +185,14 @@ const NotificationsPushover = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="accessToken" className="text-label">
|
<label htmlFor="accessToken" className="text-label">
|
||||||
{intl.formatMessage(messages.accessToken)}
|
{intl.formatMessage(messages.accessToken)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const messages = defineMessages(
|
|||||||
'components.Settings.Notifications.NotificationsSlack',
|
'components.Settings.Notifications.NotificationsSlack',
|
||||||
{
|
{
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
webhookUrl: 'Webhook URL',
|
webhookUrl: 'Webhook URL',
|
||||||
webhookUrlTip:
|
webhookUrlTip:
|
||||||
'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration',
|
'Create an <WebhookLink>Incoming Webhook</WebhookLink> integration',
|
||||||
@@ -59,6 +60,7 @@ const NotificationsSlack = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
embedPoster: data.embedPoster,
|
||||||
types: data.types,
|
types: data.types,
|
||||||
webhookUrl: data.options.webhookUrl,
|
webhookUrl: data.options.webhookUrl,
|
||||||
}}
|
}}
|
||||||
@@ -67,6 +69,7 @@ const NotificationsSlack = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/slack', {
|
await axios.post('/api/v1/settings/notifications/slack', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
@@ -111,6 +114,7 @@ const NotificationsSlack = () => {
|
|||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/slack/test', {
|
await axios.post('/api/v1/settings/notifications/slack/test', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
@@ -148,6 +152,14 @@ const NotificationsSlack = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="name" className="text-label">
|
<label htmlFor="name" className="text-label">
|
||||||
{intl.formatMessage(messages.webhookUrl)}
|
{intl.formatMessage(messages.webhookUrl)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import * as Yup from 'yup';
|
|||||||
|
|
||||||
const messages = defineMessages('components.Settings.Notifications', {
|
const messages = defineMessages('components.Settings.Notifications', {
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
botUsername: 'Bot Username',
|
botUsername: 'Bot Username',
|
||||||
botUsernameTip:
|
botUsernameTip:
|
||||||
'Allow users to also start a chat with your bot and configure their own notifications',
|
'Allow users to also start a chat with your bot and configure their own notifications',
|
||||||
@@ -89,6 +90,7 @@ const NotificationsTelegram = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data?.enabled,
|
enabled: data?.enabled,
|
||||||
|
embedPoster: data?.embedPoster,
|
||||||
types: data?.types,
|
types: data?.types,
|
||||||
botUsername: data?.options.botUsername,
|
botUsername: data?.options.botUsername,
|
||||||
botAPI: data?.options.botAPI,
|
botAPI: data?.options.botAPI,
|
||||||
@@ -101,6 +103,7 @@ const NotificationsTelegram = () => {
|
|||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/telegram', {
|
await axios.post('/api/v1/settings/notifications/telegram', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
types: values.types,
|
types: values.types,
|
||||||
options: {
|
options: {
|
||||||
botAPI: values.botAPI,
|
botAPI: values.botAPI,
|
||||||
@@ -191,6 +194,14 @@ const NotificationsTelegram = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="botAPI" className="text-label">
|
<label htmlFor="botAPI" className="text-label">
|
||||||
{intl.formatMessage(messages.botAPI)}
|
{intl.formatMessage(messages.botAPI)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const messages = defineMessages(
|
|||||||
'components.Settings.Notifications.NotificationsWebPush',
|
'components.Settings.Notifications.NotificationsWebPush',
|
||||||
{
|
{
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
|
embedPoster: 'Embed Poster',
|
||||||
webpushsettingssaved: 'Web push notification settings saved successfully!',
|
webpushsettingssaved: 'Web push notification settings saved successfully!',
|
||||||
webpushsettingsfailed: 'Web push notification settings failed to save.',
|
webpushsettingsfailed: 'Web push notification settings failed to save.',
|
||||||
toastWebPushTestSending: 'Sending web push test notification…',
|
toastWebPushTestSending: 'Sending web push test notification…',
|
||||||
@@ -55,11 +56,13 @@ const NotificationsWebPush = () => {
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
|
embedPoster: data.embedPoster,
|
||||||
}}
|
}}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/settings/notifications/webpush', {
|
await axios.post('/api/v1/settings/notifications/webpush', {
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
options: {},
|
options: {},
|
||||||
});
|
});
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
@@ -77,7 +80,7 @@ const NotificationsWebPush = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ isSubmitting }) => {
|
{({ isSubmitting, values }) => {
|
||||||
const testSettings = async () => {
|
const testSettings = async () => {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
let toastId: string | undefined;
|
let toastId: string | undefined;
|
||||||
@@ -94,6 +97,7 @@ const NotificationsWebPush = () => {
|
|||||||
);
|
);
|
||||||
await axios.post('/api/v1/settings/notifications/webpush/test', {
|
await axios.post('/api/v1/settings/notifications/webpush/test', {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
embedPoster: values.embedPoster,
|
||||||
options: {},
|
options: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,6 +132,15 @@ const NotificationsWebPush = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="embedPoster" className="checkbox-label">
|
||||||
|
{intl.formatMessage(messages.embedPoster)}
|
||||||
|
<span className="label-required">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field type="checkbox" id="embedPoster" name="embedPoster" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
import NotificationTypeSelector from '@app/components/NotificationTypeSelector';
|
||||||
|
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { isValidURL } from '@app/utils/urlValidationHelper';
|
import { isValidURL } from '@app/utils/urlValidationHelper';
|
||||||
@@ -73,6 +74,11 @@ const messages = defineMessages(
|
|||||||
{
|
{
|
||||||
agentenabled: 'Enable Agent',
|
agentenabled: 'Enable Agent',
|
||||||
webhookUrl: 'Webhook URL',
|
webhookUrl: 'Webhook URL',
|
||||||
|
webhookUrlTip:
|
||||||
|
'Test Notification URL is set to {testUrl} instead of the actual webhook URL.',
|
||||||
|
supportVariables: 'Support URL Variables',
|
||||||
|
supportVariablesTip:
|
||||||
|
'Available variables are documented in the webhook template variables section',
|
||||||
authheader: 'Authorization Header',
|
authheader: 'Authorization Header',
|
||||||
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
|
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
|
||||||
webhooksettingssaved: 'Webhook notification settings saved successfully!',
|
webhooksettingssaved: 'Webhook notification settings saved successfully!',
|
||||||
@@ -111,8 +117,14 @@ const NotificationsWebhook = () => {
|
|||||||
.test(
|
.test(
|
||||||
'valid-url',
|
'valid-url',
|
||||||
intl.formatMessage(messages.validationWebhookUrl),
|
intl.formatMessage(messages.validationWebhookUrl),
|
||||||
isValidURL
|
function (value) {
|
||||||
|
const { supportVariables } = this.parent;
|
||||||
|
return supportVariables || isValidURL(value);
|
||||||
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
supportVariables: Yup.boolean(),
|
||||||
|
|
||||||
jsonPayload: Yup.string()
|
jsonPayload: Yup.string()
|
||||||
.when('enabled', {
|
.when('enabled', {
|
||||||
is: true,
|
is: true,
|
||||||
@@ -147,6 +159,7 @@ const NotificationsWebhook = () => {
|
|||||||
webhookUrl: data.options.webhookUrl,
|
webhookUrl: data.options.webhookUrl,
|
||||||
jsonPayload: data.options.jsonPayload,
|
jsonPayload: data.options.jsonPayload,
|
||||||
authHeader: data.options.authHeader,
|
authHeader: data.options.authHeader,
|
||||||
|
supportVariables: data.options.supportVariables ?? false,
|
||||||
}}
|
}}
|
||||||
validationSchema={NotificationsWebhookSchema}
|
validationSchema={NotificationsWebhookSchema}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@@ -158,6 +171,7 @@ const NotificationsWebhook = () => {
|
|||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||||
authHeader: values.authHeader,
|
authHeader: values.authHeader,
|
||||||
|
supportVariables: values.supportVariables,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
addToast(intl.formatMessage(messages.webhooksettingssaved), {
|
addToast(intl.formatMessage(messages.webhooksettingssaved), {
|
||||||
@@ -215,6 +229,7 @@ const NotificationsWebhook = () => {
|
|||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||||
authHeader: values.authHeader,
|
authHeader: values.authHeader,
|
||||||
|
supportVariables: values.supportVariables ?? false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -249,10 +264,59 @@ const NotificationsWebhook = () => {
|
|||||||
<Field type="checkbox" id="enabled" name="enabled" />
|
<Field type="checkbox" id="enabled" name="enabled" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="supportVariables" className="checkbox-label">
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.supportVariables)}
|
||||||
|
</span>
|
||||||
|
<SettingsBadge badgeType="experimental" />
|
||||||
|
<span className="label-tip">
|
||||||
|
{intl.formatMessage(messages.supportVariablesTip)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="supportVariables"
|
||||||
|
name="supportVariables"
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setFieldValue('supportVariables', e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{values.supportVariables && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Link
|
||||||
|
href="https://docs.jellyseerr.dev/using-jellyseerr/notifications/webhook#template-variables"
|
||||||
|
passHref
|
||||||
|
legacyBehavior
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
buttonSize="sm"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<QuestionMarkCircleIcon />
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(messages.templatevariablehelp)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="webhookUrl" className="text-label">
|
<label htmlFor="webhookUrl" className="text-label">
|
||||||
{intl.formatMessage(messages.webhookUrl)}
|
{intl.formatMessage(messages.webhookUrl)}
|
||||||
<span className="label-required">*</span>
|
<span className="label-required">*</span>
|
||||||
|
{values.supportVariables && (
|
||||||
|
<div className="label-tip">
|
||||||
|
{intl.formatMessage(messages.webhookUrlTip, {
|
||||||
|
testUrl: '/test',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<div className="form-input-area">
|
<div className="form-input-area">
|
||||||
<div className="form-input-field">
|
<div className="form-input-field">
|
||||||
@@ -312,7 +376,7 @@ const NotificationsWebhook = () => {
|
|||||||
<span>{intl.formatMessage(messages.resetPayload)}</span>
|
<span>{intl.formatMessage(messages.resetPayload)}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
href="https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables"
|
href="https://docs.jellyseerr.dev/using-jellyseerr/notifications/webhook#template-variables"
|
||||||
passHref
|
passHref
|
||||||
legacyBehavior
|
legacyBehavior
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const messages = defineMessages('components.Settings', {
|
|||||||
menuLogs: 'Logs',
|
menuLogs: 'Logs',
|
||||||
menuJobs: 'Jobs & Cache',
|
menuJobs: 'Jobs & Cache',
|
||||||
menuAbout: 'About',
|
menuAbout: 'About',
|
||||||
|
menuMetadataProviders: 'Metadata Providers',
|
||||||
});
|
});
|
||||||
|
|
||||||
type SettingsLayoutProps = {
|
type SettingsLayoutProps = {
|
||||||
@@ -59,6 +60,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
|
|||||||
route: '/settings/network',
|
route: '/settings/network',
|
||||||
regex: /^\/settings\/network/,
|
regex: /^\/settings\/network/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.menuMetadataProviders),
|
||||||
|
route: '/settings/metadata',
|
||||||
|
regex: /^\/settings\/metadata/,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.menuNotifications),
|
text: intl.formatMessage(messages.menuNotifications),
|
||||||
route: '/settings/notifications/email',
|
route: '/settings/notifications/email',
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import LanguageSelector from '@app/components/LanguageSelector';
|
|||||||
import RegionSelector from '@app/components/RegionSelector';
|
import RegionSelector from '@app/components/RegionSelector';
|
||||||
import CopyButton from '@app/components/Settings/CopyButton';
|
import CopyButton from '@app/components/Settings/CopyButton';
|
||||||
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
import SettingsBadge from '@app/components/Settings/SettingsBadge';
|
||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
|
||||||
import { availableLanguages } from '@app/context/LanguageContext';
|
import { availableLanguages } from '@app/context/LanguageContext';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import { Permission, useUser } from '@app/hooks/useUser';
|
import { Permission, useUser } from '@app/hooks/useUser';
|
||||||
@@ -18,6 +17,7 @@ import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
|||||||
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
import { ArrowPathIcon } from '@heroicons/react/24/solid';
|
||||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
import type { MainSettings } from '@server/lib/settings';
|
import type { MainSettings } from '@server/lib/settings';
|
||||||
|
import type { AvailableLocale } from '@server/types/languages';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|||||||
476
src/components/Settings/SettingsMetadata.tsx
Normal file
476
src/components/Settings/SettingsMetadata.tsx
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
import Badge from '@app/components/Common/Badge';
|
||||||
|
import Button from '@app/components/Common/Button';
|
||||||
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import MetadataSelector, {
|
||||||
|
MetadataProviderType,
|
||||||
|
} from '@app/components/MetadataSelector';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { Form, Formik } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings', {
|
||||||
|
metadataProviderSettings: 'Metadata Providers',
|
||||||
|
general: 'General',
|
||||||
|
settings: 'Settings',
|
||||||
|
seriesMetadataProvider: 'Series metadata provider',
|
||||||
|
animeMetadataProvider: 'Anime metadata provider',
|
||||||
|
metadataSettings: 'Settings for metadata provider',
|
||||||
|
clickTest:
|
||||||
|
'Click on the "Test" button to check connectivity with metadata providers',
|
||||||
|
notTested: 'Not Tested',
|
||||||
|
failed: 'Does not work',
|
||||||
|
operational: 'Operational',
|
||||||
|
providerStatus: 'Metadata Provider Status',
|
||||||
|
chooseProvider: 'Choose metadata providers for different content types',
|
||||||
|
metadataProviderSelection: 'Metadata Provider Selection',
|
||||||
|
tmdbProviderDoesnotWork:
|
||||||
|
'TMDB provider does not work, please select another metadata provider',
|
||||||
|
tvdbProviderDoesnotWork:
|
||||||
|
'TVDB provider does not work, please select another metadata provider',
|
||||||
|
allChosenProvidersAreOperational:
|
||||||
|
'All chosen metadata providers are operational',
|
||||||
|
connectionTestFailed: 'Connection test failed',
|
||||||
|
failedToSaveMetadataSettings: 'Failed to save metadata provider settings',
|
||||||
|
metadataSettingsSaved: 'Metadata provider settings saved',
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProviderStatus = 'ok' | 'not tested' | 'failed';
|
||||||
|
|
||||||
|
interface ProviderResponse {
|
||||||
|
tvdb: ProviderStatus;
|
||||||
|
tmdb: ProviderStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataValues {
|
||||||
|
tv: MetadataProviderType;
|
||||||
|
anime: MetadataProviderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataSettings {
|
||||||
|
metadata: MetadataValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsMetadata = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
|
const defaultStatus: ProviderResponse = {
|
||||||
|
tmdb: 'not tested',
|
||||||
|
tvdb: 'not tested',
|
||||||
|
};
|
||||||
|
|
||||||
|
const [providerStatus, setProviderStatus] =
|
||||||
|
useState<ProviderResponse>(defaultStatus);
|
||||||
|
|
||||||
|
const { data, error } = useSWR<MetadataSettings>(
|
||||||
|
'/api/v1/settings/metadatas',
|
||||||
|
async (url: string) => {
|
||||||
|
const response = await axios.get<{
|
||||||
|
tv: MetadataProviderType;
|
||||||
|
anime: MetadataProviderType;
|
||||||
|
}>(url);
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
tv: response.data.tv,
|
||||||
|
anime: response.data.anime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const testConnection = async (
|
||||||
|
values: MetadataValues
|
||||||
|
): Promise<ProviderResponse> => {
|
||||||
|
const useTmdb =
|
||||||
|
values.tv === MetadataProviderType.TMDB ||
|
||||||
|
values.anime === MetadataProviderType.TMDB;
|
||||||
|
const useTvdb =
|
||||||
|
values.tv === MetadataProviderType.TVDB ||
|
||||||
|
values.anime === MetadataProviderType.TVDB;
|
||||||
|
|
||||||
|
const testData = {
|
||||||
|
tmdb: useTmdb,
|
||||||
|
tvdb: useTvdb,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<{
|
||||||
|
success: boolean;
|
||||||
|
tests: ProviderResponse;
|
||||||
|
}>('/api/v1/settings/metadatas/test', testData);
|
||||||
|
|
||||||
|
const newStatus: ProviderResponse = {
|
||||||
|
tmdb: useTmdb ? response.data.tests.tmdb : 'not tested',
|
||||||
|
tvdb: useTvdb ? response.data.tests.tvdb : 'not tested',
|
||||||
|
};
|
||||||
|
|
||||||
|
setProviderStatus(newStatus);
|
||||||
|
return newStatus;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
// If we receive an error response with a valid format
|
||||||
|
const errorData = error.response.data as {
|
||||||
|
success: boolean;
|
||||||
|
tests: ProviderResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (errorData.tests) {
|
||||||
|
const newStatus: ProviderResponse = {
|
||||||
|
tmdb: useTmdb ? errorData.tests.tmdb : 'not tested',
|
||||||
|
tvdb: useTvdb ? errorData.tests.tvdb : 'not tested',
|
||||||
|
};
|
||||||
|
|
||||||
|
setProviderStatus(newStatus);
|
||||||
|
return newStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In case of error without usable data
|
||||||
|
throw new Error('Failed to test connection');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = async (
|
||||||
|
values: MetadataValues
|
||||||
|
): Promise<MetadataSettings> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.put<{
|
||||||
|
success: boolean;
|
||||||
|
tv: MetadataProviderType;
|
||||||
|
anime: MetadataProviderType;
|
||||||
|
tests?: {
|
||||||
|
tvdb: ProviderStatus;
|
||||||
|
tmdb: ProviderStatus;
|
||||||
|
};
|
||||||
|
}>('/api/v1/settings/metadatas', {
|
||||||
|
tv: values.tv,
|
||||||
|
anime: values.anime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update metadata provider status if available
|
||||||
|
if (response.data.tests) {
|
||||||
|
const mapStatusValue = (status: string): ProviderStatus => {
|
||||||
|
if (status === 'ok') return 'ok';
|
||||||
|
if (status === 'failed') return 'failed';
|
||||||
|
return 'not tested';
|
||||||
|
};
|
||||||
|
|
||||||
|
setProviderStatus({
|
||||||
|
tmdb: mapStatusValue(response.data.tests.tmdb),
|
||||||
|
tvdb: mapStatusValue(response.data.tests.tvdb),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapt the response to the format expected by the component
|
||||||
|
return {
|
||||||
|
metadata: {
|
||||||
|
tv: response.data.tv,
|
||||||
|
anime: response.data.anime,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Retrieve test data in case of error
|
||||||
|
if (axios.isAxiosError(error) && error.response?.data) {
|
||||||
|
const errorData = error.response.data as {
|
||||||
|
success: boolean;
|
||||||
|
tests?: {
|
||||||
|
tvdb: string;
|
||||||
|
tmdb: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// If test data is available in the error response
|
||||||
|
if (errorData.tests) {
|
||||||
|
const mapStatusValue = (status: string): ProviderStatus => {
|
||||||
|
if (status === 'ok') return 'ok';
|
||||||
|
if (status === 'failed') return 'failed';
|
||||||
|
return 'not tested';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update metadata provider status with error data
|
||||||
|
setProviderStatus({
|
||||||
|
tmdb: mapStatusValue(errorData.tests.tmdb),
|
||||||
|
tvdb: mapStatusValue(errorData.tests.tvdb),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to save Metadata settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusClass = (status: ProviderStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'text-green-500';
|
||||||
|
case 'not tested':
|
||||||
|
return 'text-yellow-500';
|
||||||
|
case 'failed':
|
||||||
|
return 'text-red-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusMessage = (status: ProviderStatus): string => {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return intl.formatMessage(messages.operational);
|
||||||
|
case 'not tested':
|
||||||
|
return intl.formatMessage(messages.notTested);
|
||||||
|
case 'failed':
|
||||||
|
return intl.formatMessage(messages.failed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBadgeType = (
|
||||||
|
status: ProviderStatus
|
||||||
|
):
|
||||||
|
| 'default'
|
||||||
|
| 'primary'
|
||||||
|
| 'danger'
|
||||||
|
| 'warning'
|
||||||
|
| 'success'
|
||||||
|
| 'dark'
|
||||||
|
| 'light'
|
||||||
|
| undefined => {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'success';
|
||||||
|
case 'not tested':
|
||||||
|
return 'warning';
|
||||||
|
case 'failed':
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialValues: MetadataValues = data?.metadata || {
|
||||||
|
tv: MetadataProviderType.TMDB,
|
||||||
|
anime: MetadataProviderType.TMDB,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTitle
|
||||||
|
title={[
|
||||||
|
intl.formatMessage(messages.general),
|
||||||
|
intl.formatMessage(globalMessages.settings),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="heading">
|
||||||
|
{intl.formatMessage(messages.metadataProviderSettings)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.metadataSettings)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 rounded-lg bg-gray-800 p-4">
|
||||||
|
<h4 className="mb-3 text-lg font-medium">
|
||||||
|
{intl.formatMessage(messages.providerStatus)}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2 w-24">TheMovieDB:</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getStatusClass(providerStatus.tmdb)}`}
|
||||||
|
data-testid="tmdb-status-container"
|
||||||
|
>
|
||||||
|
<Badge badgeType={getBadgeType(providerStatus.tmdb)}>
|
||||||
|
{getStatusMessage(providerStatus.tmdb)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2 w-24">TheTVDB:</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm ${getStatusClass(providerStatus.tvdb)}`}
|
||||||
|
data-testid="tvdb-status"
|
||||||
|
>
|
||||||
|
<Badge badgeType={getBadgeType(providerStatus.tvdb)}>
|
||||||
|
{getStatusMessage(providerStatus.tvdb)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<Formik
|
||||||
|
initialValues={{ metadata: initialValues }}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
const result = await saveSettings(values.metadata);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
data.metadata = result.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||||
|
appearance: 'success',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||||
|
{
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ isSubmitting, isValid, values, setFieldValue }) => {
|
||||||
|
return (
|
||||||
|
<Form className="section" data-testid="settings-main-form">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="heading">
|
||||||
|
{intl.formatMessage(messages.metadataProviderSelection)}
|
||||||
|
</h2>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.chooseProvider)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="tv-metadata-provider"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.seriesMetadataProvider)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<MetadataSelector
|
||||||
|
testId="tv-metadata-provider-selector"
|
||||||
|
value={values.metadata.tv}
|
||||||
|
onChange={(value) => setFieldValue('metadata.tv', value)}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label
|
||||||
|
htmlFor="anime-metadata-provider"
|
||||||
|
className="checkbox-label"
|
||||||
|
>
|
||||||
|
<span className="mr-2">
|
||||||
|
{intl.formatMessage(messages.animeMetadataProvider)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<MetadataSelector
|
||||||
|
testId="anime-metadata-provider-selector"
|
||||||
|
value={values.metadata.anime}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFieldValue('metadata.anime', value)
|
||||||
|
}
|
||||||
|
isDisabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="warning"
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting || !isValid}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsTesting(true);
|
||||||
|
try {
|
||||||
|
const resp = await testConnection(values.metadata);
|
||||||
|
|
||||||
|
if (resp.tvdb === 'failed') {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(
|
||||||
|
messages.tvdbProviderDoesnotWork
|
||||||
|
),
|
||||||
|
{
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (resp.tmdb === 'failed') {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(
|
||||||
|
messages.tmdbProviderDoesnotWork
|
||||||
|
),
|
||||||
|
{
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(
|
||||||
|
messages.allChosenProvidersAreOperational
|
||||||
|
),
|
||||||
|
{
|
||||||
|
appearance: 'success',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.connectionTestFailed),
|
||||||
|
{
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTesting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BeakerIcon />
|
||||||
|
<span>
|
||||||
|
{isTesting
|
||||||
|
? intl.formatMessage(globalMessages.testing)
|
||||||
|
: intl.formatMessage(globalMessages.test)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
data-testid="metadata-save-button"
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !isValid || isTesting}
|
||||||
|
>
|
||||||
|
<ArrowDownOnSquareIcon />
|
||||||
|
<span>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: intl.formatMessage(globalMessages.save)}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsMetadata;
|
||||||
@@ -126,7 +126,7 @@ const SettingsNetwork = () => {
|
|||||||
proxy: {
|
proxy: {
|
||||||
enabled: values.proxyEnabled,
|
enabled: values.proxyEnabled,
|
||||||
hostname: values.proxyHostname,
|
hostname: values.proxyHostname,
|
||||||
port: values.proxyPort,
|
port: Number(values.proxyPort),
|
||||||
useSsl: values.proxySsl,
|
useSsl: values.proxySsl,
|
||||||
user: values.proxyUser,
|
user: values.proxyUser,
|
||||||
password: values.proxyPassword,
|
password: values.proxyPassword,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
|
|||||||
<CachedImage
|
<CachedImage
|
||||||
type="tmdb"
|
type="tmdb"
|
||||||
className="rounded-lg object-contain"
|
className="rounded-lg object-contain"
|
||||||
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`}
|
src={episode.stillPath}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { sortCrewPriority } from '@app/utils/creditHelpers';
|
|||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
||||||
import { Disclosure, Transition } from '@headlessui/react';
|
import { Disclosure, Transition } from '@headlessui/react';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import {
|
import {
|
||||||
ArrowRightCircleIcon,
|
ArrowRightCircleIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
@@ -44,8 +45,7 @@ import {
|
|||||||
MinusCircleIcon,
|
MinusCircleIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/solid';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/solid';
|
|
||||||
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
import type { RTRating } from '@server/api/rating/rottentomatoes';
|
||||||
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
||||||
import { IssueStatus } from '@server/constants/issue';
|
import { IssueStatus } from '@server/constants/issue';
|
||||||
@@ -118,9 +118,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||||
const [showManager, setShowManager] = useState(
|
const [showManager, setShowManager] = useState(router.query.manage == '1');
|
||||||
router.query.manage == '1' ? true : false
|
|
||||||
);
|
|
||||||
const [showIssueModal, setShowIssueModal] = useState(false);
|
const [showIssueModal, setShowIssueModal] = useState(false);
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||||
@@ -156,7 +154,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowManager(router.query.manage == '1' ? true : false);
|
setShowManager(router.query.manage == '1');
|
||||||
}, [router.query.manage]);
|
}, [router.query.manage]);
|
||||||
|
|
||||||
const closeBlacklistModal = useCallback(
|
const closeBlacklistModal = useCallback(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import PageTitle from '@app/components/Common/PageTitle';
|
|||||||
import LanguageSelector from '@app/components/LanguageSelector';
|
import LanguageSelector from '@app/components/LanguageSelector';
|
||||||
import QuotaSelector from '@app/components/QuotaSelector';
|
import QuotaSelector from '@app/components/QuotaSelector';
|
||||||
import RegionSelector from '@app/components/RegionSelector';
|
import RegionSelector from '@app/components/RegionSelector';
|
||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
|
||||||
import { availableLanguages } from '@app/context/LanguageContext';
|
import { availableLanguages } from '@app/context/LanguageContext';
|
||||||
import useLocale from '@app/hooks/useLocale';
|
import useLocale from '@app/hooks/useLocale';
|
||||||
import useSettings from '@app/hooks/useSettings';
|
import useSettings from '@app/hooks/useSettings';
|
||||||
@@ -16,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
|
||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
import type { UserSettingsGeneralResponse } from '@server/interfaces/api/userSettingsInterfaces';
|
||||||
|
import type { AvailableLocale } from '@server/types/languages';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Field, Form, Formik } from 'formik';
|
import { Field, Form, Formik } from 'formik';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
|||||||
@@ -1,41 +1,6 @@
|
|||||||
|
import { type AvailableLocale } from '@server/types/languages';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
export type AvailableLocale =
|
|
||||||
| 'ar'
|
|
||||||
| 'bg'
|
|
||||||
| 'ca'
|
|
||||||
| 'cs'
|
|
||||||
| 'da'
|
|
||||||
| 'de'
|
|
||||||
| 'en'
|
|
||||||
| 'el'
|
|
||||||
| 'es'
|
|
||||||
| 'es-MX'
|
|
||||||
| 'fi'
|
|
||||||
| 'fr'
|
|
||||||
| 'hr'
|
|
||||||
| 'he'
|
|
||||||
| 'hi'
|
|
||||||
| 'hu'
|
|
||||||
| 'it'
|
|
||||||
| 'ja'
|
|
||||||
| 'ko'
|
|
||||||
| 'lt'
|
|
||||||
| 'nb-NO'
|
|
||||||
| 'nl'
|
|
||||||
| 'pl'
|
|
||||||
| 'pt-BR'
|
|
||||||
| 'pt-PT'
|
|
||||||
| 'ro'
|
|
||||||
| 'ru'
|
|
||||||
| 'sq'
|
|
||||||
| 'sr'
|
|
||||||
| 'sv'
|
|
||||||
| 'tr'
|
|
||||||
| 'uk'
|
|
||||||
| 'zh-CN'
|
|
||||||
| 'zh-TW';
|
|
||||||
|
|
||||||
type AvailableLanguageObject = Record<
|
type AvailableLanguageObject = Record<
|
||||||
string,
|
string,
|
||||||
{ code: AvailableLocale; display: string }
|
{ code: AvailableLocale; display: string }
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
"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",
|
||||||
@@ -137,7 +138,6 @@
|
|||||||
"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?",
|
||||||
@@ -180,6 +180,7 @@
|
|||||||
"components.IssueDetails.toaststatusupdated": "Issue status updated successfully!",
|
"components.IssueDetails.toaststatusupdated": "Issue status updated successfully!",
|
||||||
"components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.",
|
"components.IssueDetails.toaststatusupdatefailed": "Something went wrong while updating the issue status.",
|
||||||
"components.IssueDetails.unknownissuetype": "Unknown",
|
"components.IssueDetails.unknownissuetype": "Unknown",
|
||||||
|
"components.IssueList.IssueItem.descriptionpreview": "Issue Description",
|
||||||
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Episode} other {Episodes}}",
|
"components.IssueList.IssueItem.episodes": "{episodeCount, plural, one {Episode} other {Episodes}}",
|
||||||
"components.IssueList.IssueItem.issuestatus": "Status",
|
"components.IssueList.IssueItem.issuestatus": "Status",
|
||||||
"components.IssueList.IssueItem.issuetype": "Type",
|
"components.IssueList.IssueItem.issuetype": "Type",
|
||||||
@@ -307,6 +308,9 @@
|
|||||||
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
||||||
"components.ManageSlideOver.tvshow": "series",
|
"components.ManageSlideOver.tvshow": "series",
|
||||||
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
||||||
|
"components.MetadataSelector.selectMetdataProvider": "Select a metadata provider",
|
||||||
|
"components.MetadataSelector.tmdbLabel": "The Movie Database (TMDB)",
|
||||||
|
"components.MetadataSelector.tvdbLabel": "TheTVDB",
|
||||||
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
||||||
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
|
"components.MovieDetails.MovieCrew.fullcrew": "Full Crew",
|
||||||
"components.MovieDetails.addtowatchlist": "Add To Watchlist",
|
"components.MovieDetails.addtowatchlist": "Add To Watchlist",
|
||||||
@@ -620,6 +624,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL",
|
"components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL",
|
||||||
"components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
"components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
"components.Settings.Notifications.NotificationsNtfy.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsNtfy.agentenabled": "Enable Agent",
|
||||||
|
"components.Settings.Notifications.NotificationsNtfy.embedPoster": "Embed Poster",
|
||||||
"components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.",
|
||||||
"components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!",
|
"components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!",
|
||||||
"components.Settings.Notifications.NotificationsNtfy.password": "Password",
|
"components.Settings.Notifications.NotificationsNtfy.password": "Password",
|
||||||
@@ -650,6 +655,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr",
|
"components.Settings.Notifications.NotificationsPushover.accessTokenTip": "<ApplicationRegistrationLink>Register an application</ApplicationRegistrationLink> for use with Jellyseerr",
|
||||||
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent",
|
||||||
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default",
|
"components.Settings.Notifications.NotificationsPushover.deviceDefault": "Device Default",
|
||||||
|
"components.Settings.Notifications.NotificationsPushover.embedPoster": "Embed Poster",
|
||||||
"components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.",
|
||||||
"components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved successfully!",
|
"components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved successfully!",
|
||||||
"components.Settings.Notifications.NotificationsPushover.sound": "Notification Sound",
|
"components.Settings.Notifications.NotificationsPushover.sound": "Notification Sound",
|
||||||
@@ -662,6 +668,7 @@
|
|||||||
"components.Settings.Notifications.NotificationsPushover.validationTypes": "You must select at least one notification type",
|
"components.Settings.Notifications.NotificationsPushover.validationTypes": "You must select at least one notification type",
|
||||||
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user or group key",
|
"components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a valid user or group key",
|
||||||
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent",
|
||||||
|
"components.Settings.Notifications.NotificationsSlack.embedPoster": "Embed Poster",
|
||||||
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.",
|
||||||
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved successfully!",
|
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved successfully!",
|
||||||
"components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "Slack test notification failed to send.",
|
"components.Settings.Notifications.NotificationsSlack.toastSlackTestFailed": "Slack test notification failed to send.",
|
||||||
@@ -676,6 +683,8 @@
|
|||||||
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
|
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
|
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",
|
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",
|
||||||
|
"components.Settings.Notifications.NotificationsWebhook.supportVariables": "Support URL Variables",
|
||||||
|
"components.Settings.Notifications.NotificationsWebhook.supportVariablesTip": "Available variables are documented in the webhook template variables section",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help",
|
"components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "Webhook test notification failed to send.",
|
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestFailed": "Webhook test notification failed to send.",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Sending webhook test notification…",
|
"components.Settings.Notifications.NotificationsWebhook.toastWebhookTestSending": "Sending webhook test notification…",
|
||||||
@@ -684,9 +693,11 @@
|
|||||||
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "You must select at least one notification type",
|
"components.Settings.Notifications.NotificationsWebhook.validationTypes": "You must select at least one notification type",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "You must provide a valid URL",
|
"components.Settings.Notifications.NotificationsWebhook.validationWebhookUrl": "You must provide a valid URL",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL",
|
"components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL",
|
||||||
|
"components.Settings.Notifications.NotificationsWebhook.webhookUrlTip": "Test Notification URL is set to {testUrl} instead of the actual webhook URL.",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.",
|
"components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.",
|
||||||
"components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved successfully!",
|
"components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved successfully!",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
|
"components.Settings.Notifications.NotificationsWebPush.agentenabled": "Enable Agent",
|
||||||
|
"components.Settings.Notifications.NotificationsWebPush.embedPoster": "Embed Poster",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Jellyseerr must be served over HTTPS.",
|
"components.Settings.Notifications.NotificationsWebPush.httpsRequirement": "In order to receive web push notifications, Jellyseerr must be served over HTTPS.",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
|
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestFailed": "Web push test notification failed to send.",
|
||||||
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
|
"components.Settings.Notifications.NotificationsWebPush.toastWebPushTestSending": "Sending web push test notification…",
|
||||||
@@ -709,6 +720,7 @@
|
|||||||
"components.Settings.Notifications.emailsender": "Sender Address",
|
"components.Settings.Notifications.emailsender": "Sender Address",
|
||||||
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
|
"components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.",
|
||||||
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved successfully!",
|
"components.Settings.Notifications.emailsettingssaved": "Email notification settings saved successfully!",
|
||||||
|
"components.Settings.Notifications.embedPoster": "Embed Poster",
|
||||||
"components.Settings.Notifications.enableMentions": "Enable Mentions",
|
"components.Settings.Notifications.enableMentions": "Enable Mentions",
|
||||||
"components.Settings.Notifications.encryption": "Encryption Method",
|
"components.Settings.Notifications.encryption": "Encryption Method",
|
||||||
"components.Settings.Notifications.encryptionDefault": "Use STARTTLS if available",
|
"components.Settings.Notifications.encryptionDefault": "Use STARTTLS if available",
|
||||||
@@ -1092,12 +1104,17 @@
|
|||||||
"components.Settings.addrule": "New Override Rule",
|
"components.Settings.addrule": "New Override Rule",
|
||||||
"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.allChosenProvidersAreOperational": "All chosen metadata providers are operational",
|
||||||
|
"components.Settings.animeMetadataProvider": "Anime metadata provider",
|
||||||
"components.Settings.apiKey": "API key",
|
"components.Settings.apiKey": "API key",
|
||||||
"components.Settings.blacklistedTagImportInstructions": "Paste blacklist tag configuration below.",
|
"components.Settings.blacklistedTagImportInstructions": "Paste blacklist tag configuration below.",
|
||||||
"components.Settings.blacklistedTagImportTitle": "Import Blacklisted Tag Configuration",
|
"components.Settings.blacklistedTagImportTitle": "Import Blacklisted Tag Configuration",
|
||||||
"components.Settings.blacklistedTagsText": "Blacklisted Tags",
|
"components.Settings.blacklistedTagsText": "Blacklisted Tags",
|
||||||
"components.Settings.cancelscan": "Cancel Scan",
|
"components.Settings.cancelscan": "Cancel Scan",
|
||||||
|
"components.Settings.chooseProvider": "Choose metadata providers for different content types",
|
||||||
"components.Settings.clearBlacklistedTagsConfirm": "Are you sure you want to clear the blacklisted tags?",
|
"components.Settings.clearBlacklistedTagsConfirm": "Are you sure you want to clear the blacklisted tags?",
|
||||||
|
"components.Settings.clickTest": "Click on the \"Test\" button to check connectivity with metadata providers",
|
||||||
|
"components.Settings.connectionTestFailed": "Connection test failed",
|
||||||
"components.Settings.copyBlacklistedTags": "Copied blacklisted tags to clipboard.",
|
"components.Settings.copyBlacklistedTags": "Copied blacklisted tags to clipboard.",
|
||||||
"components.Settings.copyBlacklistedTagsEmpty": "Nothing to copy",
|
"components.Settings.copyBlacklistedTagsEmpty": "Nothing to copy",
|
||||||
"components.Settings.copyBlacklistedTagsTip": "Copy blacklisted tag configuration",
|
"components.Settings.copyBlacklistedTagsTip": "Copy blacklisted tag configuration",
|
||||||
@@ -1110,6 +1127,9 @@
|
|||||||
"components.Settings.enablessl": "Use SSL",
|
"components.Settings.enablessl": "Use SSL",
|
||||||
"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.failed": "Does not work",
|
||||||
|
"components.Settings.failedToSaveMetadataSettings": "Failed to save metadata provider settings",
|
||||||
|
"components.Settings.general": "General",
|
||||||
"components.Settings.hostname": "Hostname or IP Address",
|
"components.Settings.hostname": "Hostname or IP Address",
|
||||||
"components.Settings.importBlacklistedTagsTip": "Import blacklisted tag configuration",
|
"components.Settings.importBlacklistedTagsTip": "Import blacklisted tag configuration",
|
||||||
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
|
"components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",
|
||||||
@@ -1139,21 +1159,28 @@
|
|||||||
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
|
"components.Settings.menuJellyfinSettings": "{mediaServerName}",
|
||||||
"components.Settings.menuJobs": "Jobs & Cache",
|
"components.Settings.menuJobs": "Jobs & Cache",
|
||||||
"components.Settings.menuLogs": "Logs",
|
"components.Settings.menuLogs": "Logs",
|
||||||
|
"components.Settings.menuMetadataProviders": "Metadata Providers",
|
||||||
"components.Settings.menuNetwork": "Network",
|
"components.Settings.menuNetwork": "Network",
|
||||||
"components.Settings.menuNotifications": "Notifications",
|
"components.Settings.menuNotifications": "Notifications",
|
||||||
"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.metadataProviderSelection": "Metadata Provider Selection",
|
||||||
|
"components.Settings.metadataProviderSettings": "Metadata Providers",
|
||||||
|
"components.Settings.metadataSettings": "Settings for metadata provider",
|
||||||
|
"components.Settings.metadataSettingsSaved": "Metadata provider settings saved",
|
||||||
"components.Settings.no": "No",
|
"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.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.nooptions": "No results.",
|
||||||
|
"components.Settings.notTested": "Not Tested",
|
||||||
"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",
|
||||||
"components.Settings.notrunning": "Not Running",
|
"components.Settings.notrunning": "Not Running",
|
||||||
|
"components.Settings.operational": "Operational",
|
||||||
"components.Settings.overrideRules": "Override Rules",
|
"components.Settings.overrideRules": "Override Rules",
|
||||||
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
|
"components.Settings.overrideRulesDescription": "Override rules allow you to specify properties that will be replaced if a request matches the rule.",
|
||||||
"components.Settings.plex": "Plex",
|
"components.Settings.plex": "Plex",
|
||||||
@@ -1162,6 +1189,7 @@
|
|||||||
"components.Settings.plexsettings": "Plex Settings",
|
"components.Settings.plexsettings": "Plex Settings",
|
||||||
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Jellyseerr scans your Plex libraries to determine content availability.",
|
"components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Jellyseerr scans your Plex libraries to determine content availability.",
|
||||||
"components.Settings.port": "Port",
|
"components.Settings.port": "Port",
|
||||||
|
"components.Settings.providerStatus": "Metadata Provider Status",
|
||||||
"components.Settings.radarrsettings": "Radarr Settings",
|
"components.Settings.radarrsettings": "Radarr Settings",
|
||||||
"components.Settings.restartrequiredTooltip": "Jellyseerr must be restarted for changes to this setting to take effect",
|
"components.Settings.restartrequiredTooltip": "Jellyseerr must be restarted for changes to this setting to take effect",
|
||||||
"components.Settings.save": "Save Changes",
|
"components.Settings.save": "Save Changes",
|
||||||
@@ -1170,6 +1198,7 @@
|
|||||||
"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.searchKeywords": "Search keywords…",
|
||||||
|
"components.Settings.seriesMetadataProvider": "Series metadata provider",
|
||||||
"components.Settings.serverLocal": "local",
|
"components.Settings.serverLocal": "local",
|
||||||
"components.Settings.serverRemote": "remote",
|
"components.Settings.serverRemote": "remote",
|
||||||
"components.Settings.serverSecure": "secure",
|
"components.Settings.serverSecure": "secure",
|
||||||
@@ -1180,6 +1209,7 @@
|
|||||||
"components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
|
"components.Settings.serviceSettingsDescription": "Configure your {serverType} server(s) below. You can connect multiple {serverType} servers, but only two of them can be marked as defaults (one non-4K and one 4K). Administrators are able to override the server used to process new requests prior to approval.",
|
||||||
"components.Settings.services": "Services",
|
"components.Settings.services": "Services",
|
||||||
"components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.",
|
"components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter the details manually or select a server retrieved from <RegisterPlexTVLink>plex.tv</RegisterPlexTVLink>. Press the button to the right of the dropdown to fetch the list of available servers.",
|
||||||
|
"components.Settings.settings": "Settings",
|
||||||
"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",
|
||||||
@@ -1191,6 +1221,7 @@
|
|||||||
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
|
"components.Settings.tautulliSettingsDescription": "Optionally configure the settings for your Tautulli server. Jellyseerr fetches watch history data for your Plex media from Tautulli.",
|
||||||
"components.Settings.timeout": "Timeout",
|
"components.Settings.timeout": "Timeout",
|
||||||
"components.Settings.tip": "Tip",
|
"components.Settings.tip": "Tip",
|
||||||
|
"components.Settings.tmdbProviderDoesnotWork": "TMDB provider does not work, please select another metadata provider",
|
||||||
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
"components.Settings.toastPlexConnecting": "Attempting to connect to Plex…",
|
||||||
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
"components.Settings.toastPlexConnectingFailure": "Failed to connect to Plex.",
|
||||||
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
|
"components.Settings.toastPlexConnectingSuccess": "Plex connection established successfully!",
|
||||||
@@ -1199,6 +1230,7 @@
|
|||||||
"components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!",
|
"components.Settings.toastPlexRefreshSuccess": "Plex server list retrieved successfully!",
|
||||||
"components.Settings.toastTautulliSettingsFailure": "Something went wrong while saving Tautulli settings.",
|
"components.Settings.toastTautulliSettingsFailure": "Something went wrong while saving Tautulli settings.",
|
||||||
"components.Settings.toastTautulliSettingsSuccess": "Tautulli settings saved successfully!",
|
"components.Settings.toastTautulliSettingsSuccess": "Tautulli settings saved successfully!",
|
||||||
|
"components.Settings.tvdbProviderDoesnotWork": "TVDB provider does not work, please select another metadata provider",
|
||||||
"components.Settings.urlBase": "URL Base",
|
"components.Settings.urlBase": "URL Base",
|
||||||
"components.Settings.validationApiKey": "You must provide an API key",
|
"components.Settings.validationApiKey": "You must provide an API key",
|
||||||
"components.Settings.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
"components.Settings.validationHostnameRequired": "You must provide a valid hostname or IP address",
|
||||||
@@ -1225,7 +1257,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 to your account",
|
"components.Setup.signin": "Sign In",
|
||||||
"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",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import StatusChecker from '@app/components/StatusChecker';
|
|||||||
import Toast from '@app/components/Toast';
|
import Toast from '@app/components/Toast';
|
||||||
import ToastContainer from '@app/components/ToastContainer';
|
import ToastContainer from '@app/components/ToastContainer';
|
||||||
import { InteractionProvider } from '@app/context/InteractionContext';
|
import { InteractionProvider } from '@app/context/InteractionContext';
|
||||||
import type { AvailableLocale } from '@app/context/LanguageContext';
|
|
||||||
import { LanguageContext } from '@app/context/LanguageContext';
|
import { LanguageContext } from '@app/context/LanguageContext';
|
||||||
import { SettingsProvider } from '@app/context/SettingsContext';
|
import { SettingsProvider } from '@app/context/SettingsContext';
|
||||||
import { UserContext } from '@app/context/UserContext';
|
import { UserContext } from '@app/context/UserContext';
|
||||||
@@ -16,6 +15,7 @@ import '@app/styles/globals.css';
|
|||||||
import { polyfillIntl } from '@app/utils/polyfillIntl';
|
import { polyfillIntl } from '@app/utils/polyfillIntl';
|
||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
|
import type { PublicSettingsResponse } from '@server/interfaces/api/settingsInterfaces';
|
||||||
|
import type { AvailableLocale } from '@server/types/languages';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AppInitialProps, AppProps } from 'next/app';
|
import type { AppInitialProps, AppProps } from 'next/app';
|
||||||
import App from 'next/app';
|
import App from 'next/app';
|
||||||
|
|||||||
16
src/pages/settings/metadata.tsx
Normal file
16
src/pages/settings/metadata.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import SettingsLayout from '@app/components/Settings/SettingsLayout';
|
||||||
|
import SettingsMetadata from '@app/components/Settings/SettingsMetadata';
|
||||||
|
import useRouteGuard from '@app/hooks/useRouteGuard';
|
||||||
|
import { Permission } from '@app/hooks/useUser';
|
||||||
|
import type { NextPage } from 'next';
|
||||||
|
|
||||||
|
const MetadataSettingsPage: NextPage = () => {
|
||||||
|
useRouteGuard(Permission.ADMIN);
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<SettingsMetadata />
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MetadataSettingsPage;
|
||||||
Reference in New Issue
Block a user