diff --git a/src/lib/client/ui/form/RangeScale.svelte b/src/lib/client/ui/form/RangeScale.svelte new file mode 100644 index 0000000..80dcb69 --- /dev/null +++ b/src/lib/client/ui/form/RangeScale.svelte @@ -0,0 +1,243 @@ + + +
+ +
+ +
+ + + {#each markers as marker, index} + {@const percent = valueToPercent(marker.value)} + {@const colors = colorClasses[marker.color]} +
+ + + + +
+ + {marker.label}: {unlimitedValue !== null && marker.value >= unlimitedValue ? 'Unlimited' : `${displayTransform ? displayTransform(marker.value).toFixed(1) : Math.round(marker.value)}${unit ? ` ${unit}` : ''}`} + +
+
+ {/each} +
+
diff --git a/src/lib/client/ui/navigation/pageNav/groupHeader.svelte b/src/lib/client/ui/navigation/pageNav/groupHeader.svelte index 309b6fc..5c0184a 100644 --- a/src/lib/client/ui/navigation/pageNav/groupHeader.svelte +++ b/src/lib/client/ui/navigation/pageNav/groupHeader.svelte @@ -18,7 +18,7 @@ diff --git a/src/lib/client/ui/table/ExpandableTable.svelte b/src/lib/client/ui/table/ExpandableTable.svelte index b6f9e79..bc420ab 100644 --- a/src/lib/client/ui/table/ExpandableTable.svelte +++ b/src/lib/client/ui/table/ExpandableTable.svelte @@ -8,6 +8,7 @@ export let compact: boolean = false; export let emptyMessage: string = 'No data available'; export let defaultSort: SortState | null = null; + export let flushExpanded: boolean = false; let expandedRows: Set = new Set(); let sortState: SortState | null = defaultSort; @@ -175,8 +176,8 @@ {#if expandedRows.has(rowId)} - -
+ +
diff --git a/src/lib/server/pcd/queries/mediaManagement/get.ts b/src/lib/server/pcd/queries/mediaManagement/get.ts new file mode 100644 index 0000000..061d73c --- /dev/null +++ b/src/lib/server/pcd/queries/mediaManagement/get.ts @@ -0,0 +1,243 @@ +/** + * Media Management get queries + */ + +import type { PCDCache } from '../../cache.ts'; +import type { + MediaManagementData, + RadarrMediaManagementData, + SonarrMediaManagementData, + QualityDefinition, + RadarrNaming, + SonarrNaming, + MediaSettings, + PropersRepacks +} from './types.ts'; +import { colonReplacementFromDb, multiEpisodeStyleFromDb, radarrColonReplacementFromDb } from './types.ts'; + +/** + * Get Radarr media management data + */ +export async function getRadarr(cache: PCDCache): Promise { + const db = cache.kb; + + const [qualityDefinitions, naming, mediaSettings] = await Promise.all([ + getRadarrQualityDefinitions(db), + getRadarrNaming(db), + getRadarrMediaSettings(db) + ]); + + return { qualityDefinitions, naming, mediaSettings }; +} + +/** + * Get Sonarr media management data + */ +export async function getSonarr(cache: PCDCache): Promise { + const db = cache.kb; + + const [qualityDefinitions, naming, mediaSettings] = await Promise.all([ + getSonarrQualityDefinitions(db), + getSonarrNaming(db), + getSonarrMediaSettings(db) + ]); + + return { qualityDefinitions, naming, mediaSettings }; +} + +/** + * Get all media management data for a PCD database + */ +export async function get(cache: PCDCache): Promise { + const db = cache.kb; + + // Fetch all data in parallel + const [ + radarrQualityDefs, + sonarrQualityDefs, + radarrNaming, + sonarrNaming, + radarrMediaSettings, + sonarrMediaSettings + ] = await Promise.all([ + getRadarrQualityDefinitions(db), + getSonarrQualityDefinitions(db), + getRadarrNaming(db), + getSonarrNaming(db), + getRadarrMediaSettings(db), + getSonarrMediaSettings(db) + ]); + + return { + qualityDefinitions: { + radarr: radarrQualityDefs, + sonarr: sonarrQualityDefs + }, + naming: { + radarr: radarrNaming, + sonarr: sonarrNaming + }, + mediaSettings: { + radarr: radarrMediaSettings, + sonarr: sonarrMediaSettings + } + }; +} + +/** + * Get Radarr quality definitions with quality names + */ +async function getRadarrQualityDefinitions( + db: PCDCache['kb'] +): Promise { + const rows = await db + .selectFrom('radarr_quality_definitions as rqd') + .innerJoin('qualities as q', 'q.id', 'rqd.quality_id') + .select([ + 'rqd.quality_id', + 'q.name as quality_name', + 'rqd.min_size', + 'rqd.max_size', + 'rqd.preferred_size' + ]) + .orderBy('q.name') + .execute(); + + return rows.map((row) => ({ + quality_id: row.quality_id, + quality_name: row.quality_name, + min_size: row.min_size, + max_size: row.max_size, + preferred_size: row.preferred_size + })); +} + +/** + * Get Sonarr quality definitions with quality names + */ +async function getSonarrQualityDefinitions( + db: PCDCache['kb'] +): Promise { + const rows = await db + .selectFrom('sonarr_quality_definitions as sqd') + .innerJoin('qualities as q', 'q.id', 'sqd.quality_id') + .select([ + 'sqd.quality_id', + 'q.name as quality_name', + 'sqd.min_size', + 'sqd.max_size', + 'sqd.preferred_size' + ]) + .orderBy('q.name') + .execute(); + + return rows.map((row) => ({ + quality_id: row.quality_id, + quality_name: row.quality_name, + min_size: row.min_size, + max_size: row.max_size, + preferred_size: row.preferred_size + })); +} + +/** + * Get Radarr naming settings + */ +async function getRadarrNaming(db: PCDCache['kb']): Promise { + const row = await db + .selectFrom('radarr_naming') + .select([ + 'id', + 'rename', + 'movie_format', + 'movie_folder_format', + 'replace_illegal_characters', + 'colon_replacement_format' + ]) + .executeTakeFirst(); + + if (!row) return null; + + return { + id: row.id, + rename: row.rename === 1, + movie_format: row.movie_format, + movie_folder_format: row.movie_folder_format, + replace_illegal_characters: row.replace_illegal_characters === 1, + colon_replacement_format: radarrColonReplacementFromDb(row.colon_replacement_format as number) + }; +} + +/** + * Get Sonarr naming settings + */ +async function getSonarrNaming(db: PCDCache['kb']): Promise { + const row = await db + .selectFrom('sonarr_naming') + .select([ + 'id', + 'rename', + 'standard_episode_format', + 'daily_episode_format', + 'anime_episode_format', + 'series_folder_format', + 'season_folder_format', + 'replace_illegal_characters', + 'colon_replacement_format', + 'custom_colon_replacement_format', + 'multi_episode_style' + ]) + .executeTakeFirst(); + + if (!row) return null; + + return { + id: row.id, + rename: row.rename === 1, + standard_episode_format: row.standard_episode_format, + daily_episode_format: row.daily_episode_format, + anime_episode_format: row.anime_episode_format, + series_folder_format: row.series_folder_format, + season_folder_format: row.season_folder_format, + replace_illegal_characters: row.replace_illegal_characters === 1, + colon_replacement_format: colonReplacementFromDb(row.colon_replacement_format as number), + custom_colon_replacement_format: row.custom_colon_replacement_format, + multi_episode_style: multiEpisodeStyleFromDb(row.multi_episode_style as number) + }; +} + +/** + * Get Radarr media settings + */ +async function getRadarrMediaSettings(db: PCDCache['kb']): Promise { + const row = await db + .selectFrom('radarr_media_settings') + .select(['id', 'propers_repacks', 'enable_media_info']) + .executeTakeFirst(); + + if (!row) return null; + + return { + id: row.id, + propers_repacks: row.propers_repacks as PropersRepacks, + enable_media_info: row.enable_media_info === 1 + }; +} + +/** + * Get Sonarr media settings + */ +async function getSonarrMediaSettings(db: PCDCache['kb']): Promise { + const row = await db + .selectFrom('sonarr_media_settings') + .select(['id', 'propers_repacks', 'enable_media_info']) + .executeTakeFirst(); + + if (!row) return null; + + return { + id: row.id, + propers_repacks: row.propers_repacks as PropersRepacks, + enable_media_info: row.enable_media_info === 1 + }; +} diff --git a/src/lib/server/pcd/queries/mediaManagement/index.ts b/src/lib/server/pcd/queries/mediaManagement/index.ts new file mode 100644 index 0000000..d3734cd --- /dev/null +++ b/src/lib/server/pcd/queries/mediaManagement/index.ts @@ -0,0 +1,45 @@ +/** + * Media Management queries + */ + +// Export all types +export type { + ArrType, + QualityDefinition, + QualityDefinitionsData, + RadarrNaming, + SonarrNaming, + NamingData, + MediaSettings, + MediaSettingsData, + MediaManagementData, + RadarrMediaManagementData, + SonarrMediaManagementData, + PropersRepacks +} from './types.ts'; + +// Export constants and helpers +export { PROPERS_REPACKS_OPTIONS, getPropersRepacksLabel } from './types.ts'; + +// Export query functions +export { get, getRadarr, getSonarr } from './get.ts'; + +// Export update functions +export type { + UpdateMediaSettingsInput, + UpdateMediaSettingsOptions, + UpdateSonarrNamingInput, + UpdateSonarrNamingOptions, + UpdateRadarrNamingInput, + UpdateRadarrNamingOptions, + UpdateQualityDefinitionInput, + UpdateQualityDefinitionsOptions +} from './update.ts'; +export { + updateRadarrMediaSettings, + updateSonarrMediaSettings, + updateSonarrNaming, + updateRadarrNaming, + updateRadarrQualityDefinitions, + updateSonarrQualityDefinitions +} from './update.ts'; diff --git a/src/lib/server/pcd/queries/mediaManagement/types.ts b/src/lib/server/pcd/queries/mediaManagement/types.ts new file mode 100644 index 0000000..5e01b1c --- /dev/null +++ b/src/lib/server/pcd/queries/mediaManagement/types.ts @@ -0,0 +1,92 @@ +/** + * Media Management query-specific types + */ + +// ============================================================================ +// QUALITY DEFINITIONS +// ============================================================================ + +export interface QualityDefinition { + quality_id: number; + quality_name: string; + min_size: number; + max_size: number; + preferred_size: number; +} + +export interface QualityDefinitionsData { + radarr: QualityDefinition[]; + sonarr: QualityDefinition[]; +} + +// ============================================================================ +// NAMING SETTINGS +// ============================================================================ + +// Re-export naming types from shared +export type { RadarrNaming, SonarrNaming, ColonReplacementFormat, MultiEpisodeStyle, RadarrColonReplacementFormat } from '$lib/shared/mediaManagement.ts'; +export { + COLON_REPLACEMENT_OPTIONS, + getColonReplacementLabel, + colonReplacementFromDb, + colonReplacementToDb, + RADARR_COLON_REPLACEMENT_OPTIONS, + getRadarrColonReplacementLabel, + radarrColonReplacementFromDb, + radarrColonReplacementToDb, + MULTI_EPISODE_STYLE_OPTIONS, + getMultiEpisodeStyleLabel, + multiEpisodeStyleFromDb, + multiEpisodeStyleToDb +} from '$lib/shared/mediaManagement.ts'; + +// Import types for local use in interfaces +import type { RadarrNaming, SonarrNaming } from '$lib/shared/mediaManagement.ts'; + +export interface NamingData { + radarr: RadarrNaming | null; + sonarr: SonarrNaming | null; +} + +// ============================================================================ +// MEDIA SETTINGS +// ============================================================================ + +// Re-export from shared for convenience +export type { PropersRepacks, MediaSettings } from '$lib/shared/mediaManagement.ts'; +export { PROPERS_REPACKS_OPTIONS, getPropersRepacksLabel } from '$lib/shared/mediaManagement.ts'; + +import type { MediaSettings } from '$lib/shared/mediaManagement.ts'; + +export interface MediaSettingsData { + radarr: MediaSettings | null; + sonarr: MediaSettings | null; +} + +// ============================================================================ +// COMBINED DATA +// ============================================================================ + +export interface MediaManagementData { + qualityDefinitions: QualityDefinitionsData; + naming: NamingData; + mediaSettings: MediaSettingsData; +} + +// ============================================================================ +// ARR-TYPE SPECIFIC DATA +// ============================================================================ + +export type ArrType = 'radarr' | 'sonarr'; + +export interface RadarrMediaManagementData { + qualityDefinitions: QualityDefinition[]; + naming: RadarrNaming | null; + mediaSettings: MediaSettings | null; +} + +export interface SonarrMediaManagementData { + qualityDefinitions: QualityDefinition[]; + naming: SonarrNaming | null; + mediaSettings: MediaSettings | null; +} diff --git a/src/lib/server/pcd/queries/mediaManagement/update.ts b/src/lib/server/pcd/queries/mediaManagement/update.ts new file mode 100644 index 0000000..fcc2348 --- /dev/null +++ b/src/lib/server/pcd/queries/mediaManagement/update.ts @@ -0,0 +1,323 @@ +/** + * Media Management update operations + * Uses writeOperation() to append SQL operations to PCD layers + */ + +import type { PCDCache } from '../../cache.ts'; +import { writeOperation, type OperationLayer } from '../../writer.ts'; +import type { + PropersRepacks, + ColonReplacementFormat, + MultiEpisodeStyle, + MediaSettings, + SonarrNaming, + RadarrNaming, + RadarrColonReplacementFormat +} from '$lib/shared/mediaManagement.ts'; +import { colonReplacementToDb, multiEpisodeStyleToDb, radarrColonReplacementToDb } from '$lib/shared/mediaManagement.ts'; + +// ============================================================================ +// MEDIA SETTINGS +// ============================================================================ + +export interface UpdateMediaSettingsInput { + propers_repacks: PropersRepacks; + enable_media_info: boolean; +} + +export interface UpdateMediaSettingsOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + current: MediaSettings; + input: UpdateMediaSettingsInput; +} + +/** + * Update Radarr media settings + */ +export async function updateRadarrMediaSettings(options: UpdateMediaSettingsOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + const query = db + .updateTable('radarr_media_settings') + .set({ + propers_repacks: input.propers_repacks, + enable_media_info: input.enable_media_info ? 1 : 0 + }) + .where('id', '=', current.id) + // Value guards + .where('propers_repacks', '=', current.propers_repacks) + .where('enable_media_info', '=', current.enable_media_info ? 1 : 0) + .compile(); + + return await writeOperation({ + databaseId, + layer, + description: 'update-radarr-media-settings', + queries: [query], + metadata: { + operation: 'update', + entity: 'radarr_media_settings', + name: 'media-settings' + } + }); +} + +/** + * Update Sonarr media settings + */ +export async function updateSonarrMediaSettings(options: UpdateMediaSettingsOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + const query = db + .updateTable('sonarr_media_settings') + .set({ + propers_repacks: input.propers_repacks, + enable_media_info: input.enable_media_info ? 1 : 0 + }) + .where('id', '=', current.id) + // Value guards + .where('propers_repacks', '=', current.propers_repacks) + .where('enable_media_info', '=', current.enable_media_info ? 1 : 0) + .compile(); + + return await writeOperation({ + databaseId, + layer, + description: 'update-sonarr-media-settings', + queries: [query], + metadata: { + operation: 'update', + entity: 'sonarr_media_settings', + name: 'media-settings' + } + }); +} + +// ============================================================================ +// SONARR NAMING +// ============================================================================ + +export interface UpdateSonarrNamingInput { + rename: boolean; + replace_illegal_characters: boolean; + colon_replacement_format: ColonReplacementFormat; + custom_colon_replacement_format: string | null; + standard_episode_format: string; + daily_episode_format: string; + anime_episode_format: string; + series_folder_format: string; + season_folder_format: string; + multi_episode_style: MultiEpisodeStyle; +} + +export interface UpdateSonarrNamingOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + current: SonarrNaming; + input: UpdateSonarrNamingInput; +} + +/** + * Update Sonarr naming settings + */ +export async function updateSonarrNaming(options: UpdateSonarrNamingOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + const query = db + .updateTable('sonarr_naming') + .set({ + rename: input.rename ? 1 : 0, + replace_illegal_characters: input.replace_illegal_characters ? 1 : 0, + colon_replacement_format: colonReplacementToDb(input.colon_replacement_format), + custom_colon_replacement_format: input.custom_colon_replacement_format, + standard_episode_format: input.standard_episode_format, + daily_episode_format: input.daily_episode_format, + anime_episode_format: input.anime_episode_format, + series_folder_format: input.series_folder_format, + season_folder_format: input.season_folder_format, + multi_episode_style: multiEpisodeStyleToDb(input.multi_episode_style) + }) + .where('id', '=', current.id) + // Value guards - check key fields match expected values + .where('rename', '=', current.rename ? 1 : 0) + .where('replace_illegal_characters', '=', current.replace_illegal_characters ? 1 : 0) + .compile(); + + return await writeOperation({ + databaseId, + layer, + description: 'update-sonarr-naming', + queries: [query], + metadata: { + operation: 'update', + entity: 'sonarr_naming', + name: 'naming-settings' + } + }); +} + +// ============================================================================ +// RADARR NAMING +// ============================================================================ + +export interface UpdateRadarrNamingInput { + rename: boolean; + replace_illegal_characters: boolean; + colon_replacement_format: RadarrColonReplacementFormat; + movie_format: string; + movie_folder_format: string; +} + +export interface UpdateRadarrNamingOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + current: RadarrNaming; + input: UpdateRadarrNamingInput; +} + +/** + * Update Radarr naming settings + */ +export async function updateRadarrNaming(options: UpdateRadarrNamingOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + const query = db + .updateTable('radarr_naming') + .set({ + rename: input.rename ? 1 : 0, + replace_illegal_characters: input.replace_illegal_characters ? 1 : 0, + colon_replacement_format: radarrColonReplacementToDb(input.colon_replacement_format), + movie_format: input.movie_format, + movie_folder_format: input.movie_folder_format + }) + .where('id', '=', current.id) + // Value guards - check key fields match expected values + .where('rename', '=', current.rename ? 1 : 0) + .where('replace_illegal_characters', '=', current.replace_illegal_characters ? 1 : 0) + .compile(); + + return await writeOperation({ + databaseId, + layer, + description: 'update-radarr-naming', + queries: [query], + metadata: { + operation: 'update', + entity: 'radarr_naming', + name: 'naming-settings' + } + }); +} + +// ============================================================================ +// QUALITY DEFINITIONS +// ============================================================================ + +import type { QualityDefinition } from './types.ts'; + +export interface UpdateQualityDefinitionInput { + quality_id: number; + min_size: number; + max_size: number; + preferred_size: number; +} + +export interface UpdateQualityDefinitionsOptions { + databaseId: number; + cache: PCDCache; + layer: OperationLayer; + current: QualityDefinition[]; + input: UpdateQualityDefinitionInput[]; +} + +/** + * Update Radarr quality definitions + */ +export async function updateRadarrQualityDefinitions(options: UpdateQualityDefinitionsOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + // Build queries for each changed definition + const queries = input.map((def) => { + const currentDef = current.find((c) => c.quality_id === def.quality_id); + if (!currentDef) { + throw new Error(`Quality definition not found for quality_id: ${def.quality_id}`); + } + + return db + .updateTable('radarr_quality_definitions') + .set({ + min_size: def.min_size, + max_size: def.max_size, + preferred_size: def.preferred_size + }) + .where('quality_id', '=', def.quality_id) + // Value guards + .where('min_size', '=', currentDef.min_size) + .where('max_size', '=', currentDef.max_size) + .where('preferred_size', '=', currentDef.preferred_size) + .compile(); + }); + + return await writeOperation({ + databaseId, + layer, + description: 'update-radarr-quality-definitions', + queries, + metadata: { + operation: 'update', + entity: 'radarr_quality_definitions', + name: 'quality-definitions' + } + }); +} + +/** + * Update Sonarr quality definitions + */ +export async function updateSonarrQualityDefinitions(options: UpdateQualityDefinitionsOptions) { + const { databaseId, cache, layer, current, input } = options; + const db = cache.kb; + + // Build queries for each changed definition + const queries = input.map((def) => { + const currentDef = current.find((c) => c.quality_id === def.quality_id); + if (!currentDef) { + throw new Error(`Quality definition not found for quality_id: ${def.quality_id}`); + } + + return db + .updateTable('sonarr_quality_definitions') + .set({ + min_size: def.min_size, + max_size: def.max_size, + preferred_size: def.preferred_size + }) + .where('quality_id', '=', def.quality_id) + // Value guards + .where('min_size', '=', currentDef.min_size) + .where('max_size', '=', currentDef.max_size) + .where('preferred_size', '=', currentDef.preferred_size) + .compile(); + }); + + return await writeOperation({ + databaseId, + layer, + description: 'update-sonarr-quality-definitions', + queries, + metadata: { + operation: 'update', + entity: 'sonarr_quality_definitions', + name: 'quality-definitions' + } + }); +} diff --git a/src/lib/server/pcd/schema.ts b/src/lib/server/pcd/schema.ts index 08ab11d..3695bcd 100644 --- a/src/lib/server/pcd/schema.ts +++ b/src/lib/server/pcd/schema.ts @@ -239,7 +239,7 @@ export interface RadarrNamingTable { movie_format: string; movie_folder_format: string; replace_illegal_characters: number; - colon_replacement_format: string; + colon_replacement_format: number; created_at: Generated; updated_at: Generated; } diff --git a/src/lib/shared/mediaManagement.ts b/src/lib/shared/mediaManagement.ts new file mode 100644 index 0000000..e1e68eb --- /dev/null +++ b/src/lib/shared/mediaManagement.ts @@ -0,0 +1,289 @@ +/** + * Shared media management types and options + * Used by both UI and sync engine + */ + +// ============================================================================ +// PROPERS AND REPACKS +// ============================================================================ + +export type PropersRepacks = 'doNotPrefer' | 'preferAndUpgrade' | 'doNotUpgradeAutomatically'; + +// ============================================================================ +// MEDIA SETTINGS +// ============================================================================ + +export interface MediaSettings { + id: number; + propers_repacks: PropersRepacks; + enable_media_info: boolean; +} + +export const PROPERS_REPACKS_OPTIONS: { + value: PropersRepacks; + label: string; + description: string; +}[] = [ + { + value: 'doNotPrefer', + label: 'Do Not Prefer', + description: 'Propers and repacks are not preferred over existing files' + }, + { + value: 'preferAndUpgrade', + label: 'Prefer and Upgrade', + description: 'Automatically upgrade to propers and repacks when available' + }, + { + value: 'doNotUpgradeAutomatically', + label: 'Do Not Upgrade Automatically', + description: 'Prefer propers/repacks but do not automatically upgrade' + } +]; + +/** + * Get the display label for a propers_repacks value + */ +export function getPropersRepacksLabel(value: PropersRepacks): string { + const option = PROPERS_REPACKS_OPTIONS.find((o) => o.value === value); + return option?.label ?? value; +} + +// ============================================================================ +// SONARR NAMING +// ============================================================================ + +export type ColonReplacementFormat = + | 'delete' + | 'dash' + | 'spaceDash' + | 'spaceDashSpace' + | 'smart' + | 'custom'; + +export const COLON_REPLACEMENT_OPTIONS: { + value: ColonReplacementFormat; + label: string; +}[] = [ + { value: 'delete', label: 'Delete' }, + { value: 'dash', label: 'Replace with Dash' }, + { value: 'spaceDash', label: 'Replace with Space Dash' }, + { value: 'spaceDashSpace', label: 'Replace with Space Dash Space' }, + { value: 'smart', label: 'Smart Replace' }, + { value: 'custom', label: 'Custom' } +]; + +export function getColonReplacementLabel(value: ColonReplacementFormat): string { + const option = COLON_REPLACEMENT_OPTIONS.find((o) => o.value === value); + return option?.label ?? value; +} + +// Database stores as numbers: 0=delete, 1=dash, 2=spaceDash, 3=spaceDashSpace, 4=smart, 5=custom +const COLON_REPLACEMENT_NUM_MAP: Record = { + 0: 'delete', + 1: 'dash', + 2: 'spaceDash', + 3: 'spaceDashSpace', + 4: 'smart', + 5: 'custom' +}; + +const COLON_REPLACEMENT_STR_MAP: Record = { + delete: 0, + dash: 1, + spaceDash: 2, + spaceDashSpace: 3, + smart: 4, + custom: 5 +}; + +export function colonReplacementFromDb(value: number): ColonReplacementFormat { + return COLON_REPLACEMENT_NUM_MAP[value] ?? 'delete'; +} + +export function colonReplacementToDb(value: ColonReplacementFormat): number { + return COLON_REPLACEMENT_STR_MAP[value] ?? 0; +} + +export type MultiEpisodeStyle = + | 'extend' + | 'duplicate' + | 'repeat' + | 'scene' + | 'range' + | 'prefixedRange'; + +export const MULTI_EPISODE_STYLE_OPTIONS: { + value: MultiEpisodeStyle; + label: string; +}[] = [ + { value: 'extend', label: 'Extend' }, + { value: 'duplicate', label: 'Duplicate' }, + { value: 'repeat', label: 'Repeat' }, + { value: 'scene', label: 'Scene' }, + { value: 'range', label: 'Range' }, + { value: 'prefixedRange', label: 'Prefixed Range' } +]; + +export function getMultiEpisodeStyleLabel(value: MultiEpisodeStyle): string { + const option = MULTI_EPISODE_STYLE_OPTIONS.find((o) => o.value === value); + return option?.label ?? value; +} + +// Database stores as numbers: 0=extend, 1=duplicate, 2=repeat, 3=scene, 4=range, 5=prefixedRange +const MULTI_EPISODE_NUM_MAP: Record = { + 0: 'extend', + 1: 'duplicate', + 2: 'repeat', + 3: 'scene', + 4: 'range', + 5: 'prefixedRange' +}; + +const MULTI_EPISODE_STR_MAP: Record = { + extend: 0, + duplicate: 1, + repeat: 2, + scene: 3, + range: 4, + prefixedRange: 5 +}; + +export function multiEpisodeStyleFromDb(value: number): MultiEpisodeStyle { + return MULTI_EPISODE_NUM_MAP[value] ?? 'extend'; +} + +export function multiEpisodeStyleToDb(value: MultiEpisodeStyle): number { + return MULTI_EPISODE_STR_MAP[value] ?? 0; +} + +// Radarr colon replacement (no custom option) +export type RadarrColonReplacementFormat = + | 'delete' + | 'dash' + | 'spaceDash' + | 'spaceDashSpace' + | 'smart'; + +export const RADARR_COLON_REPLACEMENT_OPTIONS: { + value: RadarrColonReplacementFormat; + label: string; +}[] = [ + { value: 'delete', label: 'Delete' }, + { value: 'dash', label: 'Replace with Dash' }, + { value: 'spaceDash', label: 'Replace with Space Dash' }, + { value: 'spaceDashSpace', label: 'Replace with Space Dash Space' }, + { value: 'smart', label: 'Smart Replace' } +]; + +export function getRadarrColonReplacementLabel(value: RadarrColonReplacementFormat): string { + const option = RADARR_COLON_REPLACEMENT_OPTIONS.find((o) => o.value === value); + return option?.label ?? value; +} + +export function radarrColonReplacementFromDb(value: number): RadarrColonReplacementFormat { + const map: Record = { + 0: 'delete', + 1: 'dash', + 2: 'spaceDash', + 3: 'spaceDashSpace', + 4: 'smart' + }; + return map[value] ?? 'delete'; +} + +export function radarrColonReplacementToDb(value: RadarrColonReplacementFormat): number { + const map: Record = { + delete: 0, + dash: 1, + spaceDash: 2, + spaceDashSpace: 3, + smart: 4 + }; + return map[value] ?? 0; +} + +export interface RadarrNaming { + id: number; + rename: boolean; + movie_format: string; + movie_folder_format: string; + replace_illegal_characters: boolean; + colon_replacement_format: RadarrColonReplacementFormat; +} + +export interface SonarrNaming { + id: number; + rename: boolean; + replace_illegal_characters: boolean; + colon_replacement_format: ColonReplacementFormat; + custom_colon_replacement_format: string | null; + standard_episode_format: string; + daily_episode_format: string; + anime_episode_format: string; + series_folder_format: string; + season_folder_format: string; + multi_episode_style: MultiEpisodeStyle; +} + +// ============================================================================ +// QUALITY DEFINITION RESOLUTION GROUPS +// ============================================================================ + +export type ResolutionGroup = 'SD' | '720p' | '1080p' | '2160p' | 'Prereleases' | 'Other'; + +export const RESOLUTION_GROUP_ORDER: ResolutionGroup[] = [ + '2160p', + '1080p', + '720p', + 'SD', + 'Prereleases', + 'Other' +]; + +export const RESOLUTION_GROUP_LABELS: Record = { + '2160p': '4K Ultra HD (2160p)', + '1080p': 'Full HD (1080p)', + '720p': 'HD (720p)', + SD: 'Standard Definition (SD)', + Prereleases: 'Prereleases', + Other: 'Other' +}; + +// Qualities that belong to Prereleases group +const PRERELEASE_QUALITIES = ['cam', 'dvdscr', 'regional', 'telecine', 'telesync', 'workprint']; + +// Qualities that belong to Other group +const OTHER_QUALITIES = ['raw-hd', 'unknown']; + +/** + * Determine the resolution group from a quality name + * Parses names like "Bluray-1080p", "WEBDL-720p", "HDTV-2160p", etc. + */ +export function getResolutionGroup(qualityName: string): ResolutionGroup { + const name = qualityName.toLowerCase(); + + // Check for prereleases first + if (PRERELEASE_QUALITIES.some((q) => name === q || name.includes(q))) { + return 'Prereleases'; + } + + // Check for other/unknown + if (OTHER_QUALITIES.some((q) => name === q || name.includes(q))) { + return 'Other'; + } + + // Check by resolution + if (name.includes('2160') || name.includes('4k') || name.includes('uhd')) { + return '2160p'; + } + if (name.includes('1080')) { + return '1080p'; + } + if (name.includes('720')) { + return '720p'; + } + + // Everything else is SD (480p, SDTV, DVD, etc.) + return 'SD'; +} diff --git a/src/routes/media-management/+page.server.ts b/src/routes/media-management/+page.server.ts new file mode 100644 index 0000000..7011906 --- /dev/null +++ b/src/routes/media-management/+page.server.ts @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +import type { ServerLoad } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; + +export const load: ServerLoad = () => { + // Get all databases + const databases = pcdManager.getAll(); + + // If there are databases, redirect to the first one's radarr page + if (databases.length > 0) { + throw redirect(303, `/media-management/${databases[0].id}/radarr`); + } + + // If no databases, return empty array (page will show empty state) + return { + databases + }; +}; diff --git a/src/routes/media-management/+page.svelte b/src/routes/media-management/+page.svelte new file mode 100644 index 0000000..5646069 --- /dev/null +++ b/src/routes/media-management/+page.svelte @@ -0,0 +1,17 @@ + + + + Media Management - Profilarr + + + diff --git a/src/routes/media-management/[databaseId]/+layout.server.ts b/src/routes/media-management/[databaseId]/+layout.server.ts new file mode 100644 index 0000000..2c32f0b --- /dev/null +++ b/src/routes/media-management/[databaseId]/+layout.server.ts @@ -0,0 +1,33 @@ +import { error } from '@sveltejs/kit'; +import type { ServerLoad } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; + +export const load: ServerLoad = async ({ params }) => { + const { databaseId } = params; + + // Validate params exist + if (!databaseId) { + throw error(400, 'Missing database ID'); + } + + // Get all databases for tabs + const databases = pcdManager.getAll(); + + // Parse and validate the database ID + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + throw error(400, 'Invalid database ID'); + } + + // Get the current database instance + const currentDatabase = databases.find((db) => db.id === currentDatabaseId); + + if (!currentDatabase) { + throw error(404, 'Database not found'); + } + + return { + databases, + currentDatabase + }; +}; diff --git a/src/routes/media-management/[databaseId]/+layout.svelte b/src/routes/media-management/[databaseId]/+layout.svelte new file mode 100644 index 0000000..d92e21d --- /dev/null +++ b/src/routes/media-management/[databaseId]/+layout.svelte @@ -0,0 +1,47 @@ + + + + Media Management - {data.currentDatabase.name} - Profilarr + + +
+ + + + + + + + +
diff --git a/src/routes/media-management/[databaseId]/+page.server.ts b/src/routes/media-management/[databaseId]/+page.server.ts new file mode 100644 index 0000000..2fe8060 --- /dev/null +++ b/src/routes/media-management/[databaseId]/+page.server.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; +import type { ServerLoad } from '@sveltejs/kit'; + +export const load: ServerLoad = async ({ params }) => { + // Redirect to radarr by default + throw redirect(303, `/media-management/${params.databaseId}/radarr`); +}; diff --git a/src/routes/media-management/[databaseId]/components/MediaSettingsSection.svelte b/src/routes/media-management/[databaseId]/components/MediaSettingsSection.svelte new file mode 100644 index 0000000..8e27bda --- /dev/null +++ b/src/routes/media-management/[databaseId]/components/MediaSettingsSection.svelte @@ -0,0 +1,258 @@ + + +
+
+

+ Media Settings +

+ {#if settings && !isEditing} + + {/if} +
+ + {#if !settings} +
+

+ No media settings configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'} +

+
+ {:else if isEditing} + +
{ + isSaving = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save'); + } else if (result.type === 'success') { + alertStore.add('success', 'Media settings updated'); + isEditing = false; + } + await update(); + isSaving = false; + }; + }} + > + + + +
+
+ +
+ +
+ {#each PROPERS_REPACKS_OPTIONS as option} + + {/each} +
+ +
+ + +
+ + +
+
+ + +
+ + +
+
+
+ {:else} + +
+
+ +
+
+
+ Propers and Repacks +
+
+ How to handle proper and repack releases +
+
+ + {getPropersRepacksLabel(settings.propers_repacks)} + +
+ + +
+
+
+ Analyse video files +
+
+ Extract media information from video files +
+
+ + {settings.enable_media_info ? 'Enabled' : 'Disabled'} + +
+
+
+ {/if} +
+ + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/media-management/[databaseId]/components/NamingSection.svelte b/src/routes/media-management/[databaseId]/components/NamingSection.svelte new file mode 100644 index 0000000..0a1476c --- /dev/null +++ b/src/routes/media-management/[databaseId]/components/NamingSection.svelte @@ -0,0 +1,728 @@ + + +
+
+

+ Naming +

+ {#if naming && !isEditing} + + {/if} +
+ + {#if !naming} +
+

+ No naming settings configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'} +

+
+ {:else if arrType === 'sonarr' && isSonarrNaming(naming) && isEditing} + +
{ + isSaving = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save'); + } else if (result.type === 'success') { + alertStore.add('success', 'Naming settings updated'); + isEditing = false; + } + await update(); + isSaving = false; + }; + }} + > + + +
+
+ +
+ + +
+ + +
+ + +
+ + + {#if formReplaceIllegalCharacters} +
+ + Colon Replacement + +
+ {#each COLON_REPLACEMENT_OPTIONS as option} + + {/each} +
+ +
+ + + {#if formColonReplacement === 'custom'} +
+ + +
+ {/if} + {/if} + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + Multi-Episode Style + +
+ {#each MULTI_EPISODE_STYLE_OPTIONS as option} + + {/each} +
+ +
+
+ + +
+ + +
+
+
+ {:else if arrType === 'radarr' && naming && isRadarrNaming(naming) && isEditing} + +
{ + isSaving = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save'); + } else if (result.type === 'success') { + alertStore.add('success', 'Naming settings updated'); + isEditing = false; + } + await update(); + isSaving = false; + }; + }} + > + + +
+
+ +
+ + +
+ + +
+ + +
+ + + {#if formReplaceIllegalCharacters} +
+ + Colon Replacement + +
+ {#each RADARR_COLON_REPLACEMENT_OPTIONS as option} + + {/each} +
+ +
+ {/if} + + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+
+ {:else} + +
+
+ +
+
+
+ Rename {arrType === 'radarr' ? 'Movies' : 'Episodes'} +
+
+ Rename files when importing +
+
+ + {naming.rename ? 'Enabled' : 'Disabled'} + +
+ + +
+
+
+ Replace Illegal Characters +
+
+ Replace characters not allowed in file names +
+
+ + {naming.replace_illegal_characters ? 'Enabled' : 'Disabled'} + +
+ + {#if arrType === 'sonarr' && isSonarrNaming(naming)} + + {#if naming.replace_illegal_characters} +
+
+
+ Colon Replacement +
+
+ How to replace colons in file names +
+
+ + {getColonReplacementLabel(naming.colon_replacement_format)} + {#if naming.colon_replacement_format === 'custom' && naming.custom_colon_replacement_format} + ({naming.custom_colon_replacement_format}) + {/if} + +
+ {/if} + + +
+
+ Standard Episode Format +
+ + {naming.standard_episode_format} + +
+ + +
+
+ Daily Episode Format +
+ + {naming.daily_episode_format} + +
+ + +
+
+ Anime Episode Format +
+ + {naming.anime_episode_format} + +
+ + +
+
+ Series Folder Format +
+ + {naming.series_folder_format} + +
+ + +
+
+ Season Folder Format +
+ + {naming.season_folder_format} + +
+ + +
+
+
+ Multi-Episode Style +
+
+ How to format multi-episode files +
+
+ + {getMultiEpisodeStyleLabel(naming.multi_episode_style)} + +
+ {:else if arrType === 'radarr' && isRadarrNaming(naming)} + + + {#if naming.replace_illegal_characters} +
+
+
+ Colon Replacement +
+
+ How to replace colons in file names +
+
+ + {getRadarrColonReplacementLabel(naming.colon_replacement_format)} + +
+ {/if} + +
+
+ Movie Format +
+ + {naming.movie_format} + +
+ +
+
+ Movie Folder Format +
+ + {naming.movie_folder_format} + +
+ {/if} +
+
+ {/if} +
+ + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/media-management/[databaseId]/components/QualityDefinitionsSection.svelte b/src/routes/media-management/[databaseId]/components/QualityDefinitionsSection.svelte new file mode 100644 index 0000000..8c9ca98 --- /dev/null +++ b/src/routes/media-management/[databaseId]/components/QualityDefinitionsSection.svelte @@ -0,0 +1,536 @@ + + +
+
+

+ Quality Definitions +

+ +
+ +
+ + + {#if showUnitDropdown} + + {#each unitOptions as unit} + { + selectedUnitId = unit.id; + showUnitDropdown = false; + }} + /> + {/each} + + {/if} +
+ + + {#if definitions.length > 0 && !isEditing} + + {/if} +
+
+ + {#if definitions.length === 0} +
+

+ No quality definitions configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'} +

+
+ {:else if isEditing} + +
{ + isSaving = true; + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save'); + } else if (result.type === 'success') { + alertStore.add('success', 'Quality definitions updated'); + isEditing = false; + } + await update(); + if (result.type === 'success') { + markersMap = {}; + } + isSaving = false; + }; + }} + > + + + +
+ group.resolution} + emptyMessage="No quality definitions" + flushExpanded + > + + {#if column.key === 'label'} + {row.label} + {:else if column.key === 'count'} + + {row.definitions.length} + + {/if} + + + +
+ {#each row.definitions as def (def.quality_id)} + {@const markers = markersMap[def.quality_id] || createMarkers(def)} +
+ +
+ {def.quality_name} +
+ + +
+ syncToDefinition(def.quality_id)} + /> +
+ + +
+
+ Min (MB/m) +
+ syncToDefinition(def.quality_id)} + /> +
+ +
+
+ Pref (MB/m) +
+ syncToDefinition(def.quality_id)} + /> +
+ +
+
+ Max (MB/m) +
+ syncToDefinition(def.quality_id)} + /> +
+
+ {/each} +
+
+
+ + +
+ + +
+
+
+ {:else} + + group.resolution} + emptyMessage="No quality definitions" + flushExpanded + > + + {#if column.key === 'label'} + {row.label} + {:else if column.key === 'count'} + + {row.definitions.length} + + {/if} + + + +
+ {#each row.definitions as def (def.quality_id)} + {@const markers = markersMap[def.quality_id] || createMarkers(def)} +
+ +
+ {def.quality_name} +
+ + +
+ +
+ + +
+
+ Min (MB/m) +
+
+ {def.min_size} +
+
+ +
+
+ Pref (MB/m) +
+
+ {def.preferred_size} +
+
+ +
+
+ Max (MB/m) +
+
+ {def.max_size === baseScaleMax ? 'Unlimited' : def.max_size} +
+
+
+ {/each} +
+
+
+ {/if} +
+ + +{#if canWriteToBase} + (showSaveTargetModal = false)} + /> +{/if} diff --git a/src/routes/media-management/[databaseId]/radarr/+page.server.ts b/src/routes/media-management/[databaseId]/radarr/+page.server.ts new file mode 100644 index 0000000..bd3a48e --- /dev/null +++ b/src/routes/media-management/[databaseId]/radarr/+page.server.ts @@ -0,0 +1,272 @@ +import { error, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { canWriteToBase } from '$pcd/writer.ts'; +import type { OperationLayer } from '$pcd/writer.ts'; +import * as mediaManagementQueries from '$pcd/queries/mediaManagement/index.ts'; +import type { PropersRepacks, RadarrColonReplacementFormat } from '$lib/shared/mediaManagement.ts'; +import { RADARR_COLON_REPLACEMENT_OPTIONS } from '$lib/shared/mediaManagement.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = async ({ params }) => { + const { databaseId } = params; + + // Parse the database ID + const currentDatabaseId = parseInt(databaseId as string, 10); + + // Get the cache for the database + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + throw error(500, 'Database cache not available'); + } + + // Load Radarr media management data + const mediaManagement = await mediaManagementQueries.getRadarr(cache); + + return { + mediaManagement, + canWriteToBase: canWriteToBase(currentDatabaseId) + }; +}; + +export const actions: Actions = { + updateMediaSettings: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getRadarr(cache); + if (!currentData.mediaSettings) { + return fail(404, { error: 'Media settings not found' }); + } + + // Parse form data + const propersRepacks = formData.get('propersRepacks') as PropersRepacks; + const enableMediaInfo = formData.get('enableMediaInfo') === 'on'; + + // Validate propers_repacks + const validOptions: PropersRepacks[] = ['doNotPrefer', 'preferAndUpgrade', 'doNotUpgradeAutomatically']; + if (!validOptions.includes(propersRepacks)) { + await logger.warn('Invalid propers and repacks option', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, propersRepacks } + }); + return fail(400, { error: 'Invalid propers and repacks option' }); + } + + const result = await mediaManagementQueries.updateRadarrMediaSettings({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.mediaSettings, + input: { + propers_repacks: propersRepacks, + enable_media_info: enableMediaInfo + } + }); + + if (!result.success) { + await logger.error('Failed to update Radarr media settings', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update media settings' }); + } + + await logger.info('Radarr media settings updated', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, layer, propersRepacks, enableMediaInfo } + }); + + return { success: true }; + }, + + updateNaming: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getRadarr(cache); + if (!currentData.naming) { + return fail(404, { error: 'Naming settings not found' }); + } + + // Parse form data + const rename = formData.get('rename') === 'on'; + const replaceIllegalCharacters = formData.get('replaceIllegalCharacters') === 'on'; + const colonReplacement = formData.get('colonReplacement') as RadarrColonReplacementFormat; + const movieFormat = formData.get('movieFormat') as string; + const movieFolderFormat = formData.get('movieFolderFormat') as string; + + // Validate colon replacement (only if replace illegal characters is on) + if (replaceIllegalCharacters) { + const validColonOptions = RADARR_COLON_REPLACEMENT_OPTIONS.map(o => o.value); + if (!validColonOptions.includes(colonReplacement)) { + await logger.warn('Invalid colon replacement option', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, colonReplacement } + }); + return fail(400, { error: 'Invalid colon replacement option' }); + } + } + + // Default colon replacement when not replacing illegal characters + const effectiveColonReplacement = replaceIllegalCharacters ? colonReplacement : 'delete'; + + const result = await mediaManagementQueries.updateRadarrNaming({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.naming, + input: { + rename, + replace_illegal_characters: replaceIllegalCharacters, + colon_replacement_format: effectiveColonReplacement, + movie_format: movieFormat, + movie_folder_format: movieFolderFormat + } + }); + + if (!result.success) { + await logger.error('Failed to update Radarr naming settings', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update naming settings' }); + } + + await logger.info('Radarr naming settings updated', { + source: 'MediaManagement', + meta: { + databaseId: currentDatabaseId, + layer, + rename, + replaceIllegalCharacters, + colonReplacement: effectiveColonReplacement + } + }); + + return { success: true }; + }, + + updateQualityDefinitions: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getRadarr(cache); + if (!currentData.qualityDefinitions || currentData.qualityDefinitions.length === 0) { + return fail(404, { error: 'Quality definitions not found' }); + } + + // Parse the definitions from form data (JSON string) + const definitionsJson = formData.get('definitions') as string; + if (!definitionsJson) { + return fail(400, { error: 'Missing definitions data' }); + } + + let definitions: { quality_id: number; min_size: number; max_size: number; preferred_size: number }[]; + try { + definitions = JSON.parse(definitionsJson); + } catch { + return fail(400, { error: 'Invalid definitions JSON' }); + } + + // Validate definitions + if (!Array.isArray(definitions) || definitions.length === 0) { + return fail(400, { error: 'Invalid definitions format' }); + } + + const result = await mediaManagementQueries.updateRadarrQualityDefinitions({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.qualityDefinitions, + input: definitions + }); + + if (!result.success) { + await logger.error('Failed to update Radarr quality definitions', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update quality definitions' }); + } + + await logger.info('Radarr quality definitions updated', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, layer, definitionsCount: definitions.length } + }); + + return { success: true }; + } +}; diff --git a/src/routes/media-management/[databaseId]/radarr/+page.svelte b/src/routes/media-management/[databaseId]/radarr/+page.svelte new file mode 100644 index 0000000..06224d6 --- /dev/null +++ b/src/routes/media-management/[databaseId]/radarr/+page.svelte @@ -0,0 +1,43 @@ + + +
+ {#if !hasAnyData} +
+

+ No Radarr media management settings configured +

+
+ {:else} + + + + + + {/if} +
diff --git a/src/routes/media-management/[databaseId]/sonarr/+page.server.ts b/src/routes/media-management/[databaseId]/sonarr/+page.server.ts new file mode 100644 index 0000000..fe3be2b --- /dev/null +++ b/src/routes/media-management/[databaseId]/sonarr/+page.server.ts @@ -0,0 +1,294 @@ +import { error, fail } from '@sveltejs/kit'; +import type { ServerLoad, Actions } from '@sveltejs/kit'; +import { pcdManager } from '$pcd/pcd.ts'; +import { canWriteToBase } from '$pcd/writer.ts'; +import type { OperationLayer } from '$pcd/writer.ts'; +import * as mediaManagementQueries from '$pcd/queries/mediaManagement/index.ts'; +import type { PropersRepacks, ColonReplacementFormat, MultiEpisodeStyle } from '$lib/shared/mediaManagement.ts'; +import { COLON_REPLACEMENT_OPTIONS, MULTI_EPISODE_STYLE_OPTIONS } from '$lib/shared/mediaManagement.ts'; +import { logger } from '$logger/logger.ts'; + +export const load: ServerLoad = async ({ params }) => { + const { databaseId } = params; + + // Parse the database ID + const currentDatabaseId = parseInt(databaseId as string, 10); + + // Get the cache for the database + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + throw error(500, 'Database cache not available'); + } + + // Load Sonarr media management data + const mediaManagement = await mediaManagementQueries.getSonarr(cache); + + return { + mediaManagement, + canWriteToBase: canWriteToBase(currentDatabaseId) + }; +}; + +export const actions: Actions = { + updateMediaSettings: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getSonarr(cache); + if (!currentData.mediaSettings) { + return fail(404, { error: 'Media settings not found' }); + } + + // Parse form data + const propersRepacks = formData.get('propersRepacks') as PropersRepacks; + const enableMediaInfo = formData.get('enableMediaInfo') === 'on'; + + // Validate propers_repacks + const validOptions: PropersRepacks[] = ['doNotPrefer', 'preferAndUpgrade', 'doNotUpgradeAutomatically']; + if (!validOptions.includes(propersRepacks)) { + await logger.warn('Invalid propers and repacks option', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, propersRepacks } + }); + return fail(400, { error: 'Invalid propers and repacks option' }); + } + + const result = await mediaManagementQueries.updateSonarrMediaSettings({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.mediaSettings, + input: { + propers_repacks: propersRepacks, + enable_media_info: enableMediaInfo + } + }); + + if (!result.success) { + await logger.error('Failed to update Sonarr media settings', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update media settings' }); + } + + await logger.info('Sonarr media settings updated', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, layer, propersRepacks, enableMediaInfo } + }); + + return { success: true }; + }, + + updateNaming: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getSonarr(cache); + if (!currentData.naming) { + return fail(404, { error: 'Naming settings not found' }); + } + + // Parse form data + const rename = formData.get('rename') === 'on'; + const replaceIllegalCharacters = formData.get('replaceIllegalCharacters') === 'on'; + const colonReplacement = formData.get('colonReplacement') as ColonReplacementFormat; + const customColonReplacement = formData.get('customColonReplacement') as string | null; + const standardEpisodeFormat = formData.get('standardEpisodeFormat') as string; + const dailyEpisodeFormat = formData.get('dailyEpisodeFormat') as string; + const animeEpisodeFormat = formData.get('animeEpisodeFormat') as string; + const seriesFolderFormat = formData.get('seriesFolderFormat') as string; + const seasonFolderFormat = formData.get('seasonFolderFormat') as string; + const multiEpisodeStyle = formData.get('multiEpisodeStyle') as MultiEpisodeStyle; + + // Validate colon replacement (only if replace illegal characters is on) + if (replaceIllegalCharacters) { + const validColonOptions = COLON_REPLACEMENT_OPTIONS.map(o => o.value); + if (!validColonOptions.includes(colonReplacement)) { + await logger.warn('Invalid colon replacement option', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, colonReplacement } + }); + return fail(400, { error: 'Invalid colon replacement option' }); + } + } + + // Validate multi-episode style + const validMultiEpisodeOptions = MULTI_EPISODE_STYLE_OPTIONS.map(o => o.value); + if (!validMultiEpisodeOptions.includes(multiEpisodeStyle)) { + await logger.warn('Invalid multi-episode style option', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, multiEpisodeStyle } + }); + return fail(400, { error: 'Invalid multi-episode style option' }); + } + + // Default colon replacement when not replacing illegal characters + const effectiveColonReplacement = replaceIllegalCharacters ? colonReplacement : 'delete'; + + const result = await mediaManagementQueries.updateSonarrNaming({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.naming, + input: { + rename, + replace_illegal_characters: replaceIllegalCharacters, + colon_replacement_format: effectiveColonReplacement, + custom_colon_replacement_format: effectiveColonReplacement === 'custom' ? customColonReplacement : null, + standard_episode_format: standardEpisodeFormat, + daily_episode_format: dailyEpisodeFormat, + anime_episode_format: animeEpisodeFormat, + series_folder_format: seriesFolderFormat, + season_folder_format: seasonFolderFormat, + multi_episode_style: multiEpisodeStyle + } + }); + + if (!result.success) { + await logger.error('Failed to update Sonarr naming settings', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update naming settings' }); + } + + await logger.info('Sonarr naming settings updated', { + source: 'MediaManagement', + meta: { + databaseId: currentDatabaseId, + layer, + rename, + replaceIllegalCharacters, + colonReplacement: effectiveColonReplacement, + customColonReplacement: effectiveColonReplacement === 'custom' ? customColonReplacement : null, + multiEpisodeStyle + } + }); + + return { success: true }; + }, + + updateQualityDefinitions: async ({ request, params }) => { + const { databaseId } = params; + + if (!databaseId) { + return fail(400, { error: 'Missing database ID' }); + } + + const currentDatabaseId = parseInt(databaseId, 10); + if (isNaN(currentDatabaseId)) { + return fail(400, { error: 'Invalid database ID' }); + } + + const cache = pcdManager.getCache(currentDatabaseId); + if (!cache) { + return fail(500, { error: 'Database cache not available' }); + } + + const formData = await request.formData(); + + // Get layer + const layer = (formData.get('layer') as OperationLayer) || 'user'; + + // Check layer permission + if (layer === 'base' && !canWriteToBase(currentDatabaseId)) { + return fail(403, { error: 'Cannot write to base layer without personal access token' }); + } + + // Get current data for value guards + const currentData = await mediaManagementQueries.getSonarr(cache); + if (!currentData.qualityDefinitions || currentData.qualityDefinitions.length === 0) { + return fail(404, { error: 'Quality definitions not found' }); + } + + // Parse the definitions from form data (JSON string) + const definitionsJson = formData.get('definitions') as string; + if (!definitionsJson) { + return fail(400, { error: 'Missing definitions data' }); + } + + let definitions: { quality_id: number; min_size: number; max_size: number; preferred_size: number }[]; + try { + definitions = JSON.parse(definitionsJson); + } catch { + return fail(400, { error: 'Invalid definitions JSON' }); + } + + // Validate definitions + if (!Array.isArray(definitions) || definitions.length === 0) { + return fail(400, { error: 'Invalid definitions format' }); + } + + const result = await mediaManagementQueries.updateSonarrQualityDefinitions({ + databaseId: currentDatabaseId, + cache, + layer, + current: currentData.qualityDefinitions, + input: definitions + }); + + if (!result.success) { + await logger.error('Failed to update Sonarr quality definitions', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, error: result.error } + }); + return fail(500, { error: result.error || 'Failed to update quality definitions' }); + } + + await logger.info('Sonarr quality definitions updated', { + source: 'MediaManagement', + meta: { databaseId: currentDatabaseId, layer, definitionsCount: definitions.length } + }); + + return { success: true }; + } +}; diff --git a/src/routes/media-management/[databaseId]/sonarr/+page.svelte b/src/routes/media-management/[databaseId]/sonarr/+page.svelte new file mode 100644 index 0000000..ed1a4bb --- /dev/null +++ b/src/routes/media-management/[databaseId]/sonarr/+page.svelte @@ -0,0 +1,43 @@ + + +
+ {#if !hasAnyData} +
+

+ No Sonarr media management settings configured +

+
+ {:else} + + + + + + {/if} +