mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
243
src/lib/client/ui/form/RangeScale.svelte
Normal file
243
src/lib/client/ui/form/RangeScale.svelte
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
: ''}"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
243
src/lib/server/pcd/queries/mediaManagement/get.ts
Normal file
243
src/lib/server/pcd/queries/mediaManagement/get.ts
Normal 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
|
||||
};
|
||||
}
|
||||
45
src/lib/server/pcd/queries/mediaManagement/index.ts
Normal file
45
src/lib/server/pcd/queries/mediaManagement/index.ts
Normal 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';
|
||||
92
src/lib/server/pcd/queries/mediaManagement/types.ts
Normal file
92
src/lib/server/pcd/queries/mediaManagement/types.ts
Normal 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;
|
||||
}
|
||||
323
src/lib/server/pcd/queries/mediaManagement/update.ts
Normal file
323
src/lib/server/pcd/queries/mediaManagement/update.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
289
src/lib/shared/mediaManagement.ts
Normal file
289
src/lib/shared/mediaManagement.ts
Normal 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';
|
||||
}
|
||||
18
src/routes/media-management/+page.server.ts
Normal file
18
src/routes/media-management/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
src/routes/media-management/+page.svelte
Normal file
17
src/routes/media-management/+page.svelte
Normal 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}
|
||||
/>
|
||||
33
src/routes/media-management/[databaseId]/+layout.server.ts
Normal file
33
src/routes/media-management/[databaseId]/+layout.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
47
src/routes/media-management/[databaseId]/+layout.svelte
Normal file
47
src/routes/media-management/[databaseId]/+layout.svelte
Normal 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>
|
||||
7
src/routes/media-management/[databaseId]/+page.server.ts
Normal file
7
src/routes/media-management/[databaseId]/+page.server.ts
Normal 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`);
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
272
src/routes/media-management/[databaseId]/radarr/+page.server.ts
Normal file
272
src/routes/media-management/[databaseId]/radarr/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
43
src/routes/media-management/[databaseId]/radarr/+page.svelte
Normal file
43
src/routes/media-management/[databaseId]/radarr/+page.svelte
Normal 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>
|
||||
294
src/routes/media-management/[databaseId]/sonarr/+page.server.ts
Normal file
294
src/routes/media-management/[databaseId]/sonarr/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
43
src/routes/media-management/[databaseId]/sonarr/+page.svelte
Normal file
43
src/routes/media-management/[databaseId]/sonarr/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user