feat(media-management): add Quality Definitions, Media, Naming sections for Radarr and Sonarr

- Implemented QualityDefinitionsSection component to manage quality definitions for Radarr and Sonarr.
- Added server-side logic for loading and updating quality definitions in Radarr and Sonarr.
- Created new pages for Radarr and Sonarr media management, integrating the QualityDefinitionsSection.
- Enhanced media settings and naming settings management for both Radarr and Sonarr.
- Introduced validation and logging for media settings updates.
This commit is contained in:
Sam Chau
2025-12-30 04:56:54 +10:30
parent 7e7561e35a
commit 4aa914664e
22 changed files with 3537 additions and 5 deletions

View File

@@ -0,0 +1,243 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
// Types
export type MarkerColor = 'accent' | 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'neutral';
export interface Marker {
id: string;
label: string;
color: MarkerColor;
value: number;
}
// Props
export let orientation: 'horizontal' | 'vertical' = 'horizontal';
export let direction: 'start' | 'end' = 'start'; // start = min at left/top
export let min: number = 0;
export let max: number = 100;
export let step: number = 1;
export let minSeparation: number = 20; // minimum px between markers
export let markers: Marker[] = [];
export let unit: string = ''; // optional unit suffix for badge display
export let unlimitedValue: number | null = null; // value that should display as "Unlimited"
export let displayTransform: ((value: number) => number) | null = null; // optional transform for display values
const dispatch = createEventDispatcher();
// Track container and dragging state
let container: HTMLDivElement;
let draggingIndex: number | null = null;
// Color classes for markers
const colorClasses: Record<MarkerColor, { dot: string; badge: string }> = {
accent: {
dot: 'bg-accent-500',
badge: 'bg-accent-100 text-accent-700 dark:bg-accent-900/40 dark:text-accent-300'
},
blue: {
dot: 'bg-blue-500',
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
},
green: {
dot: 'bg-green-500',
badge: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
},
orange: {
dot: 'bg-orange-500',
badge: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300'
},
red: {
dot: 'bg-red-500',
badge: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
},
purple: {
dot: 'bg-purple-500',
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
},
neutral: {
dot: 'bg-neutral-500',
badge: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'
}
};
// Convert value to position percentage
function valueToPercent(value: number): number {
const percent = ((value - min) / (max - min)) * 100;
return direction === 'start' ? percent : 100 - percent;
}
// Convert position to value
function positionToValue(position: number, containerSize: number): number {
let percent = (position / containerSize) * 100;
if (direction === 'end') {
percent = 100 - percent;
}
const rawValue = min + (percent / 100) * (max - min);
// Round to step
const stepped = Math.round(rawValue / step) * step;
// Clamp to min/max
return Math.max(min, Math.min(max, stepped));
}
// Get container size based on orientation
function getContainerSize(): number {
if (!container) return 0;
return orientation === 'horizontal' ? container.offsetWidth : container.offsetHeight;
}
// Calculate min/max allowed value for a marker based on neighbors and minSeparation
function getMarkerBounds(index: number): { minVal: number; maxVal: number } {
const containerSize = getContainerSize();
const range = max - min;
// Convert minSeparation pixels to value units
const separationValue = containerSize > 0 ? (minSeparation / containerSize) * range : 0;
let minVal = min;
let maxVal = max;
// Constrain by previous marker
if (index > 0) {
minVal = markers[index - 1].value + separationValue;
}
// Constrain by next marker
if (index < markers.length - 1) {
maxVal = markers[index + 1].value - separationValue;
}
return { minVal, maxVal };
}
// Handle drag start
function handleDragStart(index: number, event: MouseEvent | TouchEvent) {
event.preventDefault();
draggingIndex = index;
const moveHandler = (e: MouseEvent | TouchEvent) => handleDragMove(e);
const upHandler = () => {
draggingIndex = null;
window.removeEventListener('mousemove', moveHandler);
window.removeEventListener('mouseup', upHandler);
window.removeEventListener('touchmove', moveHandler);
window.removeEventListener('touchend', upHandler);
};
window.addEventListener('mousemove', moveHandler);
window.addEventListener('mouseup', upHandler);
window.addEventListener('touchmove', moveHandler);
window.addEventListener('touchend', upHandler);
}
// Handle drag move
function handleDragMove(event: MouseEvent | TouchEvent) {
if (draggingIndex === null || !container) return;
const rect = container.getBoundingClientRect();
const clientPos = 'touches' in event ? event.touches[0] : event;
let position: number;
if (orientation === 'horizontal') {
position = clientPos.clientX - rect.left;
} else {
position = clientPos.clientY - rect.top;
}
const containerSize = getContainerSize();
position = Math.max(0, Math.min(containerSize, position));
let newValue = positionToValue(position, containerSize);
// Apply ordering constraints
const bounds = getMarkerBounds(draggingIndex);
newValue = Math.max(bounds.minVal, Math.min(bounds.maxVal, newValue));
// Round to step again after constraints
newValue = Math.round(newValue / step) * step;
// Update the marker value
if (markers[draggingIndex].value !== newValue) {
markers[draggingIndex].value = newValue;
markers = markers; // trigger reactivity
dispatch('change', { index: draggingIndex, value: newValue, markers });
}
}
// Reactive: ensure markers stay within bounds when values change externally
$: {
let needsUpdate = false;
const updatedMarkers = markers.map((marker, index) => {
const bounds = getMarkerBounds(index);
let value = marker.value;
// Clamp to scale bounds
value = Math.max(min, Math.min(max, value));
// Note: We don't enforce neighbor constraints here to allow external updates
// The constraints are only enforced during drag
if (value !== marker.value) {
needsUpdate = true;
return { ...marker, value };
}
return marker;
});
if (needsUpdate) {
markers = updatedMarkers;
}
}
</script>
<div
class="relative select-none"
class:w-full={orientation === 'horizontal'}
class:h-full={orientation === 'vertical'}
>
<!-- Track container -->
<div
bind:this={container}
class="relative"
class:h-2={orientation === 'horizontal'}
class:w-2={orientation === 'vertical'}
class:w-full={orientation === 'horizontal'}
class:h-full={orientation === 'vertical'}
>
<!-- Track line -->
<div
class="absolute rounded-full bg-neutral-200 dark:bg-neutral-700 {orientation === 'horizontal' ? 'h-1 w-full top-1/2 -translate-y-1/2' : 'w-1 h-full left-1/2 -translate-x-1/2'}"
></div>
<!-- Markers -->
{#each markers as marker, index}
{@const percent = valueToPercent(marker.value)}
{@const colors = colorClasses[marker.color]}
<div
class="absolute {orientation === 'horizontal' ? 'top-1/2 -translate-y-1/2' : 'left-1/2 -translate-x-1/2'}"
style="{orientation === 'horizontal' ? 'left' : 'top'}: {percent}%;"
>
<!-- Dot -->
<button
type="button"
on:mousedown={(e) => handleDragStart(index, e)}
on:touchstart={(e) => handleDragStart(index, e)}
class="relative -translate-x-1/2 h-4 w-4 cursor-grab rounded-full shadow-sm transition-transform hover:scale-125 {colors.dot} {orientation === 'vertical' ? '-translate-y-1/2' : ''} {draggingIndex === index ? 'scale-150 cursor-grabbing' : ''}"
aria-label="Drag to adjust {marker.label}"
></button>
<!-- Badge label (alternate above/below) -->
<div
class="absolute whitespace-nowrap {orientation === 'horizontal'
? `left-0 -translate-x-1/2 ${index % 2 === 0 ? 'top-6' : 'bottom-6'}`
: 'left-6 translate-x-0'}"
>
<span
class="inline-block rounded px-1.5 py-0.5 text-xs font-medium {colors.badge}"
>
{marker.label}: {unlimitedValue !== null && marker.value >= unlimitedValue ? 'Unlimited' : `${displayTransform ? displayTransform(marker.value).toFixed(1) : Math.round(marker.value)}${unit ? ` ${unit}` : ''}`}
</span>
</div>
</div>
{/each}
</div>
</div>

View File

@@ -18,7 +18,7 @@
<!-- Main navigation button (left side) - rounded left, square right (or fully rounded if no items) -->
<a
{href}
class="flex flex-1 items-center gap-2 py-1.5 pr-2 pl-3 font-mono text-sm font-semibold text-neutral-700 transition-colors group-hover/header:bg-neutral-200 hover:bg-neutral-200 dark:text-neutral-300 dark:group-hover/header:bg-neutral-800 dark:hover:bg-neutral-800 {hasItems
class="flex flex-1 items-center gap-2 py-1.5 pr-2 pl-3 font-sans text-sm font-semibold text-neutral-700 transition-colors group-hover/header:bg-neutral-200 hover:bg-neutral-200 dark:text-neutral-300 dark:group-hover/header:bg-neutral-800 dark:hover:bg-neutral-800 {hasItems
? 'rounded-l-lg'
: 'rounded-lg'} {isActive
? 'bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700'

View File

@@ -13,7 +13,7 @@
<a
{href}
class="block rounded-lg py-1.5 pr-2 pl-3 font-mono text-sm font-semibold text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100 {isActive
class="block rounded-lg py-1.5 pr-2 pl-3 font-sans text-sm font-semibold text-neutral-600 transition-colors hover:bg-neutral-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100 {isActive
? 'bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
: ''}"
>

View File

@@ -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<string | number> = new Set();
let sortState: SortState | null = defaultSort;
@@ -175,8 +176,8 @@
<!-- Expanded Row -->
{#if expandedRows.has(rowId)}
<tr class="bg-neutral-50 dark:bg-neutral-800/30">
<td colspan={columns.length + 1} class="{compact ? 'px-4 py-3' : 'px-6 py-4'}">
<div class="ml-6">
<td colspan={columns.length + 1} class="{flushExpanded ? '' : compact ? 'px-4 py-3' : 'px-6 py-4'}">
<div class="{flushExpanded ? '' : 'ml-6'}">
<slot name="expanded" {row}>
<!-- Default expanded content -->
<div class="text-sm text-neutral-500 dark:text-neutral-400">

View File

@@ -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<RadarrMediaManagementData> {
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<SonarrMediaManagementData> {
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<MediaManagementData> {
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<QualityDefinition[]> {
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<QualityDefinition[]> {
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<RadarrNaming | null> {
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<SonarrNaming | null> {
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<MediaSettings | null> {
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<MediaSettings | null> {
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
};
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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'
}
});
}

View File

@@ -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<string>;
updated_at: Generated<string>;
}

View File

@@ -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<number, ColonReplacementFormat> = {
0: 'delete',
1: 'dash',
2: 'spaceDash',
3: 'spaceDashSpace',
4: 'smart',
5: 'custom'
};
const COLON_REPLACEMENT_STR_MAP: Record<ColonReplacementFormat, number> = {
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<number, MultiEpisodeStyle> = {
0: 'extend',
1: 'duplicate',
2: 'repeat',
3: 'scene',
4: 'range',
5: 'prefixedRange'
};
const MULTI_EPISODE_STR_MAP: Record<MultiEpisodeStyle, number> = {
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<number, RadarrColonReplacementFormat> = {
0: 'delete',
1: 'dash',
2: 'spaceDash',
3: 'spaceDashSpace',
4: 'smart'
};
return map[value] ?? 'delete';
}
export function radarrColonReplacementToDb(value: RadarrColonReplacementFormat): number {
const map: Record<RadarrColonReplacementFormat, number> = {
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<ResolutionGroup, string> = {
'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';
}

View File

@@ -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
};
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Database, Plus } from 'lucide-svelte';
import EmptyState from '$ui/state/EmptyState.svelte';
</script>
<svelte:head>
<title>Media Management - Profilarr</title>
</svelte:head>
<EmptyState
icon={Database}
title="No Databases Linked"
description="Link a Profilarr Compliant Database to manage media settings."
buttonText="Link Database"
buttonHref="/databases/new"
buttonIcon={Plus}
/>

View File

@@ -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
};
};

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import { page } from '$app/stores';
import type { LayoutData } from './$types';
export let data: LayoutData;
// Map databases to tabs
$: databaseTabs = data.databases.map((db) => ({
label: db.name,
href: `/media-management/${db.id}/radarr`,
active: db.id === data.currentDatabase.id
}));
// Determine current arr type from URL
$: currentPath = $page.url.pathname;
$: currentArrType = currentPath.endsWith('/sonarr') ? 'sonarr' : 'radarr';
// Arr type tabs
$: arrTypeTabs = [
{
label: 'Radarr',
href: `/media-management/${data.currentDatabase.id}/radarr`,
active: currentArrType === 'radarr'
},
{
label: 'Sonarr',
href: `/media-management/${data.currentDatabase.id}/sonarr`,
active: currentArrType === 'sonarr'
}
];
</script>
<svelte:head>
<title>Media Management - {data.currentDatabase.name} - Profilarr</title>
</svelte:head>
<div class="space-y-6 p-8">
<!-- Database Tabs -->
<Tabs tabs={databaseTabs} />
<!-- Arr Type Tabs -->
<Tabs tabs={arrTypeTabs} />
<!-- Page Content -->
<slot />
</div>

View File

@@ -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`);
};

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Check, Pencil, X, Loader2 } from 'lucide-svelte';
import { alertStore } from '$lib/client/alerts/store';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import {
PROPERS_REPACKS_OPTIONS,
getPropersRepacksLabel,
type PropersRepacks,
type MediaSettings
} from '$lib/shared/mediaManagement';
export let settings: MediaSettings | null;
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
let isSaving = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
let showSaveTargetModal = false;
let formElement: HTMLFormElement;
// Form values (initialized from settings)
let formPropersRepacks: PropersRepacks = settings?.propers_repacks ?? 'doNotPrefer';
let formEnableMediaInfo: boolean = settings?.enable_media_info ?? true;
// Reset form to current settings
function resetForm() {
formPropersRepacks = settings?.propers_repacks ?? 'doNotPrefer';
formEnableMediaInfo = settings?.enable_media_info ?? true;
}
function startEditing() {
resetForm();
isEditing = true;
}
function cancelEditing() {
resetForm();
isEditing = false;
}
async function handleSaveClick() {
if (canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
formElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
formElement?.requestSubmit();
}
</script>
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Media Settings
</h2>
{#if settings && !isEditing}
<button
type="button"
on:click={startEditing}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Pencil size={14} />
Edit
</button>
{/if}
</div>
{#if !settings}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No media settings configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}
</p>
</div>
{:else if isEditing}
<!-- Edit Mode -->
<form
bind:this={formElement}
method="POST"
action="?/updateMediaSettings"
use:enhance={() => {
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;
};
}}
>
<input type="hidden" name="arrType" value={arrType} />
<input type="hidden" name="layer" value={selectedLayer} />
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="space-y-6 p-4">
<!-- Propers and Repacks -->
<div>
<label class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Propers and Repacks
</label>
<div class="space-y-2">
{#each PROPERS_REPACKS_OPTIONS as option}
<button
type="button"
on:click={() => (formPropersRepacks = option.value)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={formPropersRepacks === option.value}
shape="circle"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{option.description}
</div>
</div>
</button>
{/each}
</div>
<input type="hidden" name="propersRepacks" value={formPropersRepacks} />
</div>
<!-- Enable Media Info -->
<div>
<button
type="button"
on:click={() => (formEnableMediaInfo = !formEnableMediaInfo)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={formEnableMediaInfo}
shape="rounded"
/>
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Analyse video files
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Extract media information like resolution, runtime, and codecs from video files
</div>
</div>
</button>
<input type="hidden" name="enableMediaInfo" value={formEnableMediaInfo ? 'on' : ''} />
</div>
</div>
<!-- Actions -->
<div
class="flex justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/50"
>
<button
type="button"
on:click={cancelEditing}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<X size={14} />
Cancel
</button>
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />
Saving...
{:else}
<Check size={14} />
Save
{/if}
</button>
</div>
</div>
</form>
{:else}
<!-- Display Mode -->
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="divide-y divide-neutral-100 dark:divide-neutral-800">
<!-- Propers and Repacks -->
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Propers and Repacks
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
How to handle proper and repack releases
</div>
</div>
<span
class="rounded-md bg-neutral-100 px-2.5 py-1 text-sm font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{getPropersRepacksLabel(settings.propers_repacks)}
</span>
</div>
<!-- Enable Media Info -->
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Analyse video files
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Extract media information from video files
</div>
</div>
<span
class="rounded-md px-2.5 py-1 text-sm font-medium {settings.enable_media_info
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'}"
>
{settings.enable_media_info ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
</div>
{/if}
</section>
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}

View File

@@ -0,0 +1,728 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import { Check, Pencil, X, Loader2 } from 'lucide-svelte';
import { alertStore } from '$lib/client/alerts/store';
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import type {
RadarrNaming,
SonarrNaming,
ColonReplacementFormat,
RadarrColonReplacementFormat,
MultiEpisodeStyle
} from '$lib/shared/mediaManagement';
import {
COLON_REPLACEMENT_OPTIONS,
getColonReplacementLabel,
RADARR_COLON_REPLACEMENT_OPTIONS,
getRadarrColonReplacementLabel,
MULTI_EPISODE_STYLE_OPTIONS,
getMultiEpisodeStyleLabel
} from '$lib/shared/mediaManagement';
export let naming: RadarrNaming | SonarrNaming | null;
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
let isSaving = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
let showSaveTargetModal = false;
let formElement: HTMLFormElement;
// Type guards
function isRadarrNaming(n: RadarrNaming | SonarrNaming): n is RadarrNaming {
return 'movie_format' in n;
}
function isSonarrNaming(n: RadarrNaming | SonarrNaming): n is SonarrNaming {
return 'standard_episode_format' in n;
}
// Form values for Sonarr (initialized from settings)
let formRename: boolean = false;
let formReplaceIllegalCharacters: boolean = false;
let formColonReplacement: ColonReplacementFormat = 'delete';
let formCustomColonReplacement: string = '';
let formStandardEpisodeFormat: string = '';
let formDailyEpisodeFormat: string = '';
let formAnimeEpisodeFormat: string = '';
let formSeriesFolderFormat: string = '';
let formSeasonFolderFormat: string = '';
let formMultiEpisodeStyle: MultiEpisodeStyle = 'extend';
// Form values for Radarr
let formRadarrColonReplacement: RadarrColonReplacementFormat = 'delete';
let formMovieFormat: string = '';
let formMovieFolderFormat: string = '';
// Reset form to current settings
function resetForm() {
if (naming && isSonarrNaming(naming)) {
formRename = naming.rename;
formReplaceIllegalCharacters = naming.replace_illegal_characters;
formColonReplacement = naming.colon_replacement_format;
formCustomColonReplacement = naming.custom_colon_replacement_format ?? '';
formStandardEpisodeFormat = naming.standard_episode_format;
formDailyEpisodeFormat = naming.daily_episode_format;
formAnimeEpisodeFormat = naming.anime_episode_format;
formSeriesFolderFormat = naming.series_folder_format;
formSeasonFolderFormat = naming.season_folder_format;
formMultiEpisodeStyle = naming.multi_episode_style;
} else if (naming && isRadarrNaming(naming)) {
formRename = naming.rename;
formReplaceIllegalCharacters = naming.replace_illegal_characters;
formRadarrColonReplacement = naming.colon_replacement_format;
formMovieFormat = naming.movie_format;
formMovieFolderFormat = naming.movie_folder_format;
}
}
function startEditing() {
resetForm();
isEditing = true;
}
function cancelEditing() {
resetForm();
isEditing = false;
}
async function handleSaveClick() {
if (canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
formElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
formElement?.requestSubmit();
}
</script>
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Naming
</h2>
{#if naming && !isEditing}
<button
type="button"
on:click={startEditing}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Pencil size={14} />
Edit
</button>
{/if}
</div>
{#if !naming}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No naming settings configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}
</p>
</div>
{:else if arrType === 'sonarr' && isSonarrNaming(naming) && isEditing}
<!-- Sonarr Edit Mode -->
<form
bind:this={formElement}
method="POST"
action="?/updateNaming"
use:enhance={() => {
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;
};
}}
>
<input type="hidden" name="layer" value={selectedLayer} />
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="space-y-6 p-4">
<!-- Rename Episodes -->
<div>
<button
type="button"
on:click={() => (formRename = !formRename)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox icon={Check} checked={formRename} shape="rounded" />
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Rename Episodes
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Rename episode files when importing
</div>
</div>
</button>
<input type="hidden" name="rename" value={formRename ? 'on' : ''} />
</div>
<!-- Replace Illegal Characters -->
<div>
<button
type="button"
on:click={() => (formReplaceIllegalCharacters = !formReplaceIllegalCharacters)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox icon={Check} checked={formReplaceIllegalCharacters} shape="rounded" />
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Replace Illegal Characters
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Replace characters that are not allowed in file names
</div>
</div>
</button>
<input type="hidden" name="replaceIllegalCharacters" value={formReplaceIllegalCharacters ? 'on' : ''} />
</div>
<!-- Colon Replacement (only if Replace Illegal Characters is on) -->
{#if formReplaceIllegalCharacters}
<div>
<span class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Colon Replacement
</span>
<div class="space-y-2">
{#each COLON_REPLACEMENT_OPTIONS as option}
<button
type="button"
on:click={() => (formColonReplacement = option.value)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={formColonReplacement === option.value}
shape="circle"
/>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
</button>
{/each}
</div>
<input type="hidden" name="colonReplacement" value={formColonReplacement} />
</div>
<!-- Custom Colon Replacement (only if custom is selected) -->
{#if formColonReplacement === 'custom'}
<div>
<label for="customColonReplacement" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Custom Colon Replacement
</label>
<input
type="text"
id="customColonReplacement"
name="customColonReplacement"
bind:value={formCustomColonReplacement}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
placeholder="Enter custom replacement"
/>
</div>
{/if}
{/if}
<!-- Standard Episode Format -->
<div>
<label for="standardEpisodeFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Standard Episode Format
</label>
<input
type="text"
id="standardEpisodeFormat"
name="standardEpisodeFormat"
bind:value={formStandardEpisodeFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Daily Episode Format -->
<div>
<label for="dailyEpisodeFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Daily Episode Format
</label>
<input
type="text"
id="dailyEpisodeFormat"
name="dailyEpisodeFormat"
bind:value={formDailyEpisodeFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Anime Episode Format -->
<div>
<label for="animeEpisodeFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Anime Episode Format
</label>
<input
type="text"
id="animeEpisodeFormat"
name="animeEpisodeFormat"
bind:value={formAnimeEpisodeFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Series Folder Format -->
<div>
<label for="seriesFolderFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Series Folder Format
</label>
<input
type="text"
id="seriesFolderFormat"
name="seriesFolderFormat"
bind:value={formSeriesFolderFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Season Folder Format -->
<div>
<label for="seasonFolderFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Season Folder Format
</label>
<input
type="text"
id="seasonFolderFormat"
name="seasonFolderFormat"
bind:value={formSeasonFolderFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Multi Episode Style -->
<div>
<span class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Multi-Episode Style
</span>
<div class="space-y-2">
{#each MULTI_EPISODE_STYLE_OPTIONS as option}
<button
type="button"
on:click={() => (formMultiEpisodeStyle = option.value)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={formMultiEpisodeStyle === option.value}
shape="circle"
/>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
</button>
{/each}
</div>
<input type="hidden" name="multiEpisodeStyle" value={formMultiEpisodeStyle} />
</div>
</div>
<!-- Actions -->
<div
class="flex justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/50"
>
<button
type="button"
on:click={cancelEditing}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<X size={14} />
Cancel
</button>
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />
Saving...
{:else}
<Check size={14} />
Save
{/if}
</button>
</div>
</div>
</form>
{:else if arrType === 'radarr' && naming && isRadarrNaming(naming) && isEditing}
<!-- Radarr Edit Mode -->
<form
bind:this={formElement}
method="POST"
action="?/updateNaming"
use:enhance={() => {
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;
};
}}
>
<input type="hidden" name="layer" value={selectedLayer} />
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="space-y-6 p-4">
<!-- Rename Movies -->
<div>
<button
type="button"
on:click={() => (formRename = !formRename)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox icon={Check} checked={formRename} shape="rounded" />
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Rename Movies
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Rename movie files when importing
</div>
</div>
</button>
<input type="hidden" name="rename" value={formRename ? 'on' : ''} />
</div>
<!-- Replace Illegal Characters -->
<div>
<button
type="button"
on:click={() => (formReplaceIllegalCharacters = !formReplaceIllegalCharacters)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 text-left transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox icon={Check} checked={formReplaceIllegalCharacters} shape="rounded" />
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Replace Illegal Characters
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Replace characters that are not allowed in file names
</div>
</div>
</button>
<input type="hidden" name="replaceIllegalCharacters" value={formReplaceIllegalCharacters ? 'on' : ''} />
</div>
<!-- Colon Replacement (only if Replace Illegal Characters is on) -->
{#if formReplaceIllegalCharacters}
<div>
<span class="mb-3 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Colon Replacement
</span>
<div class="space-y-2">
{#each RADARR_COLON_REPLACEMENT_OPTIONS as option}
<button
type="button"
on:click={() => (formRadarrColonReplacement = option.value)}
class="flex w-full cursor-pointer items-center gap-3 rounded-lg border p-3 text-left transition-colors border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600"
>
<IconCheckbox
icon={Check}
checked={formRadarrColonReplacement === option.value}
shape="circle"
/>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{option.label}
</div>
</button>
{/each}
</div>
<input type="hidden" name="colonReplacement" value={formRadarrColonReplacement} />
</div>
{/if}
<!-- Movie Format -->
<div>
<label for="movieFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Movie Format
</label>
<input
type="text"
id="movieFormat"
name="movieFormat"
bind:value={formMovieFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
<!-- Movie Folder Format -->
<div>
<label for="movieFolderFormat" class="mb-2 block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Movie Folder Format
</label>
<input
type="text"
id="movieFolderFormat"
name="movieFolderFormat"
bind:value={formMovieFolderFormat}
class="w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 font-mono text-sm text-neutral-900 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
/>
</div>
</div>
<!-- Actions -->
<div
class="flex justify-end gap-2 border-t border-neutral-200 bg-neutral-50 px-4 py-3 dark:border-neutral-800 dark:bg-neutral-800/50"
>
<button
type="button"
on:click={cancelEditing}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<X size={14} />
Cancel
</button>
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />
Saving...
{:else}
<Check size={14} />
Save
{/if}
</button>
</div>
</div>
</form>
{:else}
<!-- Display Mode -->
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<div class="divide-y divide-neutral-100 dark:divide-neutral-800">
<!-- Rename toggle -->
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Rename {arrType === 'radarr' ? 'Movies' : 'Episodes'}
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Rename files when importing
</div>
</div>
<span
class="rounded-md px-2.5 py-1 text-sm font-medium {naming.rename
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'}"
>
{naming.rename ? 'Enabled' : 'Disabled'}
</span>
</div>
<!-- Replace Illegal Characters -->
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Replace Illegal Characters
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Replace characters not allowed in file names
</div>
</div>
<span
class="rounded-md px-2.5 py-1 text-sm font-medium {naming.replace_illegal_characters
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300'}"
>
{naming.replace_illegal_characters ? 'Enabled' : 'Disabled'}
</span>
</div>
{#if arrType === 'sonarr' && isSonarrNaming(naming)}
<!-- Colon Replacement (only show if replace_illegal_characters is on) -->
{#if naming.replace_illegal_characters}
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Colon Replacement
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
How to replace colons in file names
</div>
</div>
<span
class="rounded-md bg-neutral-100 px-2.5 py-1 text-sm font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{getColonReplacementLabel(naming.colon_replacement_format)}
{#if naming.colon_replacement_format === 'custom' && naming.custom_colon_replacement_format}
<span class="text-neutral-500">({naming.custom_colon_replacement_format})</span>
{/if}
</span>
</div>
{/if}
<!-- Standard Episode Format -->
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Standard Episode Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.standard_episode_format}
</code>
</div>
<!-- Daily Episode Format -->
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Daily Episode Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.daily_episode_format}
</code>
</div>
<!-- Anime Episode Format -->
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Anime Episode Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.anime_episode_format}
</code>
</div>
<!-- Series Folder Format -->
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Series Folder Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.series_folder_format}
</code>
</div>
<!-- Season Folder Format -->
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Season Folder Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.season_folder_format}
</code>
</div>
<!-- Multi-Episode Style -->
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Multi-Episode Style
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
How to format multi-episode files
</div>
</div>
<span
class="rounded-md bg-neutral-100 px-2.5 py-1 text-sm font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{getMultiEpisodeStyleLabel(naming.multi_episode_style)}
</span>
</div>
{:else if arrType === 'radarr' && isRadarrNaming(naming)}
<!-- Radarr-specific fields -->
<!-- Colon Replacement (only show if replace_illegal_characters is on) -->
{#if naming.replace_illegal_characters}
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Colon Replacement
</div>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
How to replace colons in file names
</div>
</div>
<span
class="rounded-md bg-neutral-100 px-2.5 py-1 text-sm font-medium text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300"
>
{getRadarrColonReplacementLabel(naming.colon_replacement_format)}
</span>
</div>
{/if}
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Movie Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.movie_format}
</code>
</div>
<div class="p-4">
<div class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
Movie Folder Format
</div>
<code
class="mt-2 block break-all rounded bg-neutral-100 px-3 py-2 text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{naming.movie_folder_format}
</code>
</div>
{/if}
</div>
</div>
{/if}
</section>
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}

View File

@@ -0,0 +1,536 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { tick } from 'svelte';
import type { QualityDefinition } from '$pcd/queries/mediaManagement';
import RangeScale from '$ui/form/RangeScale.svelte';
import type { Marker } from '$ui/form/RangeScale.svelte';
import NumberInput from '$ui/form/NumberInput.svelte';
import ExpandableTable from '$ui/table/ExpandableTable.svelte';
import type { Column } from '$ui/table/types';
import { ChevronDown, Check, Pencil, X, Loader2 } from 'lucide-svelte';
import Dropdown from '$ui/dropdown/Dropdown.svelte';
import DropdownItem from '$ui/dropdown/DropdownItem.svelte';
import { alertStore } from '$lib/client/alerts/store';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import {
type ResolutionGroup,
RESOLUTION_GROUP_ORDER,
RESOLUTION_GROUP_LABELS,
getResolutionGroup
} from '$lib/shared/mediaManagement';
export let definitions: QualityDefinition[];
export let arrType: 'radarr' | 'sonarr';
export let canWriteToBase: boolean = false;
// Edit mode state
let isEditing = false;
let isSaving = false;
// Layer selection
let selectedLayer: 'user' | 'base' = 'user';
let showSaveTargetModal = false;
let formElement: HTMLFormElement;
// Store original definitions for cancel
let originalDefinitions: QualityDefinition[] = [];
// Unit options with conversion multipliers (base unit is MB/min)
interface UnitOption {
id: string;
label: string;
short: string;
multiplier: number;
}
const RADARR_UNITS: UnitOption[] = [
{ id: 'mb-min', label: 'MB per minute', short: 'MB/m', multiplier: 1 },
{ id: 'gb-hr', label: 'GB per hour', short: 'GB/h', multiplier: 60 / 1024 },
{ id: 'gb-90', label: 'GB per 90 min', short: 'GB/90m', multiplier: 90 / 1024 },
{ id: 'gb-2hr', label: 'GB per 2 hours', short: 'GB/2h', multiplier: 120 / 1024 }
];
const SONARR_UNITS: UnitOption[] = [
{ id: 'mb-min', label: 'MB per minute', short: 'MB/m', multiplier: 1 },
{ id: 'mb-30', label: 'MB per 30 min', short: 'MB/30m', multiplier: 30 },
{ id: 'gb-45', label: 'GB per 45 min', short: 'GB/45m', multiplier: 45 / 1024 },
{ id: 'gb-hr', label: 'GB per hour', short: 'GB/h', multiplier: 60 / 1024 }
];
$: unitOptions = arrType === 'radarr' ? RADARR_UNITS : SONARR_UNITS;
$: defaultUnit = arrType === 'radarr' ? 'gb-2hr' : 'gb-45';
let selectedUnitId: string = defaultUnit;
let showUnitDropdown = false;
$: selectedUnit = unitOptions.find((u) => u.id === selectedUnitId) || unitOptions[0];
// Convert from base (MB/min) to display unit
function toDisplayUnit(value: number): number {
return value * selectedUnit.multiplier;
}
// Group definitions by resolution
interface QualityGroup {
resolution: ResolutionGroup;
label: string;
definitions: QualityDefinition[];
}
$: groupedDefinitions = (() => {
const groups: Map<ResolutionGroup, QualityDefinition[]> = new Map();
// Initialize groups in order
for (const res of RESOLUTION_GROUP_ORDER) {
groups.set(res, []);
}
// Group definitions
for (const def of definitions) {
const resolution = getResolutionGroup(def.quality_name);
groups.get(resolution)?.push(def);
}
// Convert to array, filtering empty groups
const result: QualityGroup[] = [];
for (const res of RESOLUTION_GROUP_ORDER) {
const defs = groups.get(res) || [];
if (defs.length > 0) {
result.push({
resolution: res,
label: RESOLUTION_GROUP_LABELS[res],
definitions: defs
});
}
}
return result;
})();
// Table columns
const columns: Column<QualityGroup>[] = [
{
key: 'label',
header: 'Resolution',
sortable: false
},
{
key: 'count',
header: 'Qualities',
align: 'right',
sortable: false
}
];
// Convert definitions to reactive markers for each quality
function createMarkers(def: QualityDefinition): Marker[] {
return [
{ id: 'min', label: 'Min', color: 'blue', value: def.min_size },
{ id: 'preferred', label: 'Preferred', color: 'green', value: def.preferred_size },
{ id: 'max', label: 'Max', color: 'orange', value: def.max_size }
];
}
// Track markers for each definition by quality_id
let markersMap: Record<number, Marker[]> = {};
// Initialize markers from definitions
$: {
definitions.forEach((def) => {
if (!markersMap[def.quality_id]) {
markersMap[def.quality_id] = createMarkers(def);
}
});
}
// Sync marker values back to definitions when they change
function syncToDefinition(qualityId: number) {
const markers = markersMap[qualityId];
const def = definitions.find((d) => d.quality_id === qualityId);
if (markers && def) {
def.min_size = markers[0].value;
def.preferred_size = markers[1].value;
def.max_size = markers[2].value;
definitions = definitions; // trigger reactivity
}
}
// Max scale value based on arr type (in base unit MB/min)
$: baseScaleMax = arrType === 'radarr' ? 2000 : 1000;
// Edit mode functions
function startEditing() {
originalDefinitions = definitions.map((d) => ({ ...d }));
isEditing = true;
}
function cancelEditing() {
definitions = originalDefinitions.map((d) => ({ ...d }));
markersMap = {};
definitions.forEach((def) => {
markersMap[def.quality_id] = createMarkers(def);
});
isEditing = false;
}
async function handleSaveClick() {
if (canWriteToBase) {
showSaveTargetModal = true;
} else {
selectedLayer = 'user';
await tick();
formElement?.requestSubmit();
}
}
async function handleLayerSelect(event: CustomEvent<'user' | 'base'>) {
selectedLayer = event.detail;
showSaveTargetModal = false;
await tick();
formElement?.requestSubmit();
}
// Get changed definitions
$: changedDefinitions = definitions.filter((d) => {
const original = originalDefinitions.find((o) => o.quality_id === d.quality_id);
if (!original) return true;
return (
d.min_size !== original.min_size ||
d.max_size !== original.max_size ||
d.preferred_size !== original.preferred_size
);
});
$: hasChanges = changedDefinitions.length > 0;
$: definitionsForSubmit = JSON.stringify(
changedDefinitions.map((d) => ({
quality_id: d.quality_id,
min_size: d.min_size,
max_size: d.max_size,
preferred_size: d.preferred_size
}))
);
</script>
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Quality Definitions
</h2>
<div class="flex items-center gap-2">
<!-- Unit selector -->
<div class="relative">
<button
type="button"
on:click={() => (showUnitDropdown = !showUnitDropdown)}
on:blur={() => setTimeout(() => (showUnitDropdown = false), 150)}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
{selectedUnit.label}
<ChevronDown
size={14}
class="transition-transform {showUnitDropdown ? 'rotate-180' : ''}"
/>
</button>
{#if showUnitDropdown}
<Dropdown position="right" minWidth="12rem">
{#each unitOptions as unit}
<DropdownItem
label="{unit.label} ({unit.short})"
selected={selectedUnitId === unit.id}
on:click={() => {
selectedUnitId = unit.id;
showUnitDropdown = false;
}}
/>
{/each}
</Dropdown>
{/if}
</div>
<!-- Edit button -->
{#if definitions.length > 0 && !isEditing}
<button
type="button"
on:click={startEditing}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<Pencil size={14} />
Edit
</button>
{/if}
</div>
</div>
{#if definitions.length === 0}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No quality definitions configured for {arrType === 'radarr' ? 'Radarr' : 'Sonarr'}
</p>
</div>
{:else if isEditing}
<!-- Edit Mode -->
<form
bind:this={formElement}
method="POST"
action="?/updateQualityDefinitions"
use:enhance={() => {
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;
};
}}
>
<input type="hidden" name="layer" value={selectedLayer} />
<input type="hidden" name="definitions" value={definitionsForSubmit} />
<div class="space-y-4">
<ExpandableTable
{columns}
data={groupedDefinitions}
getRowId={(group) => group.resolution}
emptyMessage="No quality definitions"
flushExpanded
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'label'}
{row.label}
{:else if column.key === 'count'}
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
>
{row.definitions.length}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="divide-y divide-neutral-200 dark:divide-neutral-700">
{#each row.definitions as def (def.quality_id)}
{@const markers = markersMap[def.quality_id] || createMarkers(def)}
<div class="flex items-center gap-3 bg-white pb-8 pt-5 pl-8 pr-4 dark:bg-neutral-900">
<!-- Quality Name -->
<div
class="w-32 shrink-0 text-sm font-medium text-neutral-900 dark:text-neutral-100"
>
{def.quality_name}
</div>
<!-- Range Scale -->
<div class="min-w-0 flex-1 pl-2 pr-16 pt-4">
<RangeScale
orientation="horizontal"
direction="start"
min={0}
max={baseScaleMax}
step={1}
minSeparation={5}
unit={selectedUnit.short}
unlimitedValue={baseScaleMax}
displayTransform={toDisplayUnit}
bind:markers={markersMap[def.quality_id]}
on:change={() => syncToDefinition(def.quality_id)}
/>
</div>
<!-- Number Inputs -->
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400"
>
Min <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<NumberInput
id="min-{def.quality_id}"
name="min-{def.quality_id}"
bind:value={markers[0].value}
min={0}
max={markers[1].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
/>
</div>
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400"
>
Pref <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<NumberInput
id="preferred-{def.quality_id}"
name="preferred-{def.quality_id}"
bind:value={markers[1].value}
min={markers[0].value}
max={markers[2].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
/>
</div>
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-orange-600 dark:text-orange-400"
>
Max <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<NumberInput
id="max-{def.quality_id}"
name="max-{def.quality_id}"
bind:value={markers[2].value}
min={markers[1].value}
step={1}
on:input={() => syncToDefinition(def.quality_id)}
/>
</div>
</div>
{/each}
</div>
</svelte:fragment>
</ExpandableTable>
<!-- Actions -->
<div class="flex justify-end gap-2">
<button
type="button"
on:click={cancelEditing}
disabled={isSaving}
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
>
<X size={14} />
Cancel
</button>
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving || !hasChanges}
class="flex cursor-pointer items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if isSaving}
<Loader2 size={14} class="animate-spin" />
Saving...
{:else}
<Check size={14} />
Save
{/if}
</button>
</div>
</div>
</form>
{:else}
<!-- Display Mode (read-only) -->
<ExpandableTable
{columns}
data={groupedDefinitions}
getRowId={(group) => group.resolution}
emptyMessage="No quality definitions"
flushExpanded
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'label'}
{row.label}
{:else if column.key === 'count'}
<span
class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400"
>
{row.definitions.length}
</span>
{/if}
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="divide-y divide-neutral-200 dark:divide-neutral-700">
{#each row.definitions as def (def.quality_id)}
{@const markers = markersMap[def.quality_id] || createMarkers(def)}
<div class="flex items-center gap-3 bg-white pb-8 pt-5 pl-8 pr-4 dark:bg-neutral-900">
<!-- Quality Name -->
<div class="w-32 shrink-0 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{def.quality_name}
</div>
<!-- Range Scale (read-only) -->
<div class="pointer-events-none min-w-0 flex-1 pl-2 pr-16 pt-4">
<RangeScale
orientation="horizontal"
direction="start"
min={0}
max={baseScaleMax}
step={1}
minSeparation={5}
unit={selectedUnit.short}
unlimitedValue={baseScaleMax}
displayTransform={toDisplayUnit}
bind:markers={markersMap[def.quality_id]}
/>
</div>
<!-- Values display (read-only) -->
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-blue-600 dark:text-blue-400"
>
Min <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{def.min_size}
</div>
</div>
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400"
>
Pref <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{def.preferred_size}
</div>
</div>
<div class="w-24 shrink-0">
<div
class="mb-1 flex items-center gap-1 text-xs font-medium text-orange-600 dark:text-orange-400"
>
Max <span class="text-neutral-400 dark:text-neutral-500">(MB/m)</span>
</div>
<div
class="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-2 text-sm text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
>
{def.max_size === baseScaleMax ? 'Unlimited' : def.max_size}
</div>
</div>
</div>
{/each}
</div>
</svelte:fragment>
</ExpandableTable>
{/if}
</section>
<!-- Save Target Modal -->
{#if canWriteToBase}
<SaveTargetModal
open={showSaveTargetModal}
mode="save"
on:select={handleLayerSelect}
on:cancel={() => (showSaveTargetModal = false)}
/>
{/if}

View File

@@ -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 };
}
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import QualityDefinitionsSection from '../components/QualityDefinitionsSection.svelte';
import NamingSection from '../components/NamingSection.svelte';
import MediaSettingsSection from '../components/MediaSettingsSection.svelte';
import type { PageData } from './$types';
export let data: PageData;
$: hasAnyData =
data.mediaManagement.qualityDefinitions.length > 0 ||
data.mediaManagement.naming !== null ||
data.mediaManagement.mediaSettings !== null;
</script>
<div class="space-y-8">
{#if !hasAnyData}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No Radarr media management settings configured
</p>
</div>
{:else}
<QualityDefinitionsSection
definitions={data.mediaManagement.qualityDefinitions}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
/>
<NamingSection
naming={data.mediaManagement.naming}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
/>
<MediaSettingsSection
settings={data.mediaManagement.mediaSettings}
arrType="radarr"
canWriteToBase={data.canWriteToBase}
/>
{/if}
</div>

View File

@@ -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 };
}
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import QualityDefinitionsSection from '../components/QualityDefinitionsSection.svelte';
import NamingSection from '../components/NamingSection.svelte';
import MediaSettingsSection from '../components/MediaSettingsSection.svelte';
import type { PageData } from './$types';
export let data: PageData;
$: hasAnyData =
data.mediaManagement.qualityDefinitions.length > 0 ||
data.mediaManagement.naming !== null ||
data.mediaManagement.mediaSettings !== null;
</script>
<div class="space-y-8">
{#if !hasAnyData}
<div
class="rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">
No Sonarr media management settings configured
</p>
</div>
{:else}
<QualityDefinitionsSection
definitions={data.mediaManagement.qualityDefinitions}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
/>
<NamingSection
naming={data.mediaManagement.naming}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
/>
<MediaSettingsSection
settings={data.mediaManagement.mediaSettings}
arrType="sonarr"
canWriteToBase={data.canWriteToBase}
/>
{/if}
</div>