feat: simplify language support in quality profiles

- moved language field in quality profile general page
- simplify transformation for sonarr by making languages optional
This commit is contained in:
Sam Chau
2026-01-22 14:02:43 +10:30
parent 12ba7540f7
commit 4efefe63ca
23 changed files with 5153 additions and 440 deletions

View File

@@ -9,6 +9,7 @@ export interface CreateQualityProfileInput {
name: string;
description: string | null;
tags: string[];
language: string | null;
}
export interface CreateQualityProfileOptions {
@@ -89,6 +90,16 @@ export async function create(options: CreateQualityProfileOptions) {
queries.push(insertQuality);
}
// 4. Insert language if one is selected
if (input.language !== null) {
const insertLanguage = {
sql: `INSERT INTO quality_profile_languages (quality_profile_name, language_name, type) VALUES ('${esc(input.name)}', '${esc(input.language)}', 'simple')`,
parameters: [],
query: {} as never
};
queries.push(insertLanguage);
}
// Write the operation
const result = await writeOperation({
databaseId,

View File

@@ -32,6 +32,13 @@ export async function general(
.orderBy('t.name')
.execute();
// Get language for this profile (first one if exists)
const languageRow = await db
.selectFrom('quality_profile_languages as qpl')
.select(['qpl.language_name'])
.where('qpl.quality_profile_name', '=', profile.name)
.executeTakeFirst();
return {
id: profile.id,
name: profile.name,
@@ -39,6 +46,7 @@ export async function general(
tags: tags.map((tag) => ({
name: tag.tag_name,
created_at: tag.tag_created_at
}))
})),
language: languageRow?.language_name ?? null
};
}

View File

@@ -40,6 +40,7 @@ export interface QualityProfileGeneral {
name: string;
description: string; // Raw markdown
tags: Tag[];
language: string | null; // Language name, null means "Any"
}
/** Language configuration for a quality profile */

View File

@@ -11,6 +11,7 @@ export interface UpdateGeneralInput {
name: string;
description: string;
tags: string[];
language: string | null; // Language name, null means no language set
}
export interface UpdateGeneralOptions {
@@ -92,6 +93,27 @@ export async function updateGeneral(options: UpdateGeneralOptions) {
queries.push(linkTag);
}
// 3. Handle language changes
const profileNameForLanguage = input.name !== current.name ? input.name : current.name;
// Delete existing language for this profile
const deleteLanguage = {
sql: `DELETE FROM quality_profile_languages WHERE quality_profile_name = '${esc(profileNameForLanguage)}'`,
parameters: [],
query: {} as never
};
queries.push(deleteLanguage);
// Insert new language if one is selected
if (input.language !== null) {
const insertLanguage = {
sql: `INSERT INTO quality_profile_languages (quality_profile_name, language_name, type) VALUES ('${esc(profileNameForLanguage)}', '${esc(input.language)}', 'simple')`,
parameters: [],
query: {} as never
};
queries.push(insertLanguage);
}
// Log what's being changed
const changes: Record<string, { from: unknown; to: unknown }> = {};
@@ -104,6 +126,9 @@ export async function updateGeneral(options: UpdateGeneralOptions) {
if (tagsToAdd.length > 0 || tagsToRemove.length > 0) {
changes.tags = { from: currentTagNames, to: input.tags };
}
if (current.language !== input.language) {
changes.language = { from: current.language, to: input.language };
}
await logger.info(`Save quality profile "${input.name}"`, {
source: 'QualityProfile',

View File

@@ -246,7 +246,13 @@ export const LANGUAGES: Record<SyncArrType, Record<string, LanguageDefinition>>
malayalam: { id: 48, name: 'Malayalam' },
kannada: { id: 49, name: 'Kannada' },
albanian: { id: 50, name: 'Albanian' },
afrikaans: { id: 51, name: 'Afrikaans' }
afrikaans: { id: 51, name: 'Afrikaans' },
marathi: { id: 52, name: 'Marathi' },
tagalog: { id: 53, name: 'Tagalog' },
urdu: { id: 54, name: 'Urdu' },
romansh: { id: 55, name: 'Romansh' },
mongolian: { id: 56, name: 'Mongolian' },
georgian: { id: 57, name: 'Georgian' }
},
sonarr: {
unknown: { id: 0, name: 'Unknown' },
@@ -479,3 +485,60 @@ export function getLanguageForProfile(name: string, arrType: SyncArrType): Langu
return getLanguage(name, arrType);
}
/**
* Get all Radarr languages as an array (for UI dropdowns)
* Returns languages sorted by name, with Any and Original at the top
*/
export function getRadarrLanguages(): LanguageDefinition[] {
const languages = Object.values(LANGUAGES.radarr);
// Sort: Any first, Original second, then alphabetically
return languages.sort((a, b) => {
if (a.id === -1) return -1;
if (b.id === -1) return 1;
if (a.id === -2) return -1;
if (b.id === -2) return 1;
return a.name.localeCompare(b.name);
});
}
/**
* Language with arr type support information (for conditions UI)
*/
export interface LanguageWithSupport {
name: string;
radarr: boolean;
sonarr: boolean;
}
/**
* Get all languages with their arr type support (for conditions page)
* Returns sorted array with Original first, then alphabetically
*/
export function getLanguagesWithSupport(): LanguageWithSupport[] {
const radarrLangs = new Set(Object.values(LANGUAGES.radarr).map((l) => l.name));
const sonarrLangs = new Set(Object.values(LANGUAGES.sonarr).map((l) => l.name));
// Combine all language names
const allNames = new Set([...radarrLangs, ...sonarrLangs]);
// Build result with support flags
const result: LanguageWithSupport[] = [];
for (const name of allNames) {
// Skip "Any" - it's only for quality profiles, not conditions
if (name === 'Any') continue;
result.push({
name,
radarr: radarrLangs.has(name),
sonarr: sonarrLangs.has(name)
});
}
// Sort: Original first, then alphabetically
return result.sort((a, b) => {
if (a.name === 'Original') return -1;
if (b.name === 'Original') return 1;
return a.name.localeCompare(b.name);
});
}

View File

@@ -345,7 +345,7 @@ export class QualityProfileSyncer extends BaseSyncer {
syncedProfiles.push({
name: arrProfile.name,
action: isUpdate ? 'updated' : 'created',
language: arrProfile.language.name,
language: arrProfile.language?.name ?? 'N/A',
cutoffFormatScore: arrProfile.cutoffFormatScore,
minFormatScore: arrProfile.minFormatScore,
formats: scoredFormats

View File

@@ -34,7 +34,7 @@ export interface ArrQualityProfile {
id?: number;
name: string;
items: ArrQualityItem[];
language: { id: number; name: string };
language?: { id: number; name: string }; // Radarr only, Sonarr ignores this
upgradeAllowed: boolean;
cutoff: number;
minFormatScore: number;
@@ -201,9 +201,11 @@ export function transformQualityProfile(
// Reverse items to match arr expected order
items.reverse();
// Build language config
const languageName = profile.language?.name ?? 'any';
const language = getLanguageForProfile(languageName, arrType);
// Build language config (Radarr only - Sonarr uses custom formats for language filtering)
const language =
arrType === 'radarr'
? getLanguageForProfile(profile.language?.name ?? 'any', arrType)
: undefined;
// Build format items
const formatItems: ArrFormatItem[] = [];
@@ -236,7 +238,7 @@ export function transformQualityProfile(
return {
name: profile.name,
items,
language,
...(language && { language }), // Only include for Radarr
upgradeAllowed: profile.upgradesAllowed,
cutoff: cutoffId ?? items[items.length - 1]?.quality?.id ?? 0,
minFormatScore: profile.minimumCustomFormatScore,

View File

@@ -607,7 +607,7 @@ export interface ArrQualityProfilePayload {
id?: number;
name: string;
items: ArrQualityProfileItem[];
language: ArrLanguage;
language?: ArrLanguage; // Radarr only - Sonarr uses custom formats for language filtering
upgradeAllowed: boolean;
cutoff: number;
minFormatScore: number;

View File

@@ -2,7 +2,7 @@
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import { page } from '$app/stores';
import { FileText, Scale, Layers, Earth } from 'lucide-svelte';
import { FileText, Scale, Layers } from 'lucide-svelte';
$: databaseId = $page.params.databaseId;
$: profileId = $page.params.id;
@@ -26,12 +26,6 @@
href: `/quality-profiles/${databaseId}/${profileId}/qualities`,
active: currentPath.includes('/qualities'),
icon: Layers
},
{
label: 'Languages',
href: `/quality-profiles/${databaseId}/${profileId}/languages`,
active: currentPath.includes('/languages'),
icon: Earth
}
];

View File

@@ -3,6 +3,7 @@ import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import { getRadarrLanguages } from '$lib/server/sync/mappings.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = async ({ params }) => {
@@ -44,9 +45,13 @@ export const load: ServerLoad = async ({ params }) => {
throw error(404, 'Quality profile not found');
}
// Get Radarr languages (language field is Radarr-only)
const availableLanguages = getRadarrLanguages();
return {
currentDatabase,
profile,
availableLanguages,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
@@ -83,6 +88,8 @@ export const actions: Actions = {
const name = formData.get('name') as string;
const description = (formData.get('description') as string) || '';
const tagsJson = formData.get('tags') as string;
const languageRaw = formData.get('language') as string;
const language = languageRaw && languageRaw.trim() !== '' ? languageRaw.trim() : null;
const layer = (formData.get('layer') as OperationLayer) || 'user';
// Validate
@@ -122,7 +129,8 @@ export const actions: Actions = {
input: {
name: name.trim(),
description: description.trim(),
tags
tags,
language
}
});

View File

@@ -8,7 +8,8 @@
$: initialData = {
name: data.profile.name,
tags: data.profile.tags.map((t) => t.name),
description: data.profile.description ?? ''
description: data.profile.description ?? '',
language: data.profile.language ?? null
};
</script>
@@ -22,5 +23,6 @@
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
{initialData}
availableLanguages={data.availableLanguages}
/>
</div>

View File

@@ -1,123 +0,0 @@
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 * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import * as languageQueries from '$pcd/queries/languages.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = async ({ params }) => {
const { databaseId, id } = params;
// Validate params exist
if (!databaseId || !id) {
throw error(400, 'Missing required parameters');
}
// Parse and validate the database ID
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
throw error(400, 'Invalid database ID');
}
// Parse and validate the profile ID
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
throw error(400, 'Invalid profile ID');
}
// Get the cache for the database
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
throw error(500, 'Database cache not available');
}
// Get profile name from ID
const profile = await cache.kb
.selectFrom('quality_profiles')
.select('name')
.where('id', '=', profileId)
.executeTakeFirst();
if (!profile) {
throw error(404, 'Quality profile not found');
}
// Load languages for the quality profile
const languagesData = await qualityProfileQueries.languages(cache, profile.name);
// Load all available languages
const availableLanguages = languageQueries.list(cache);
return {
languages: languagesData.languages,
availableLanguages,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
export const actions: Actions = {
update: async ({ request, params }) => {
const { databaseId, id } = params;
if (!databaseId || !id) {
return fail(400, { error: 'Missing required parameters' });
}
const currentDatabaseId = parseInt(databaseId, 10);
if (isNaN(currentDatabaseId)) {
return fail(400, { error: 'Invalid database ID' });
}
const profileId = parseInt(id, 10);
if (isNaN(profileId)) {
return fail(400, { error: 'Invalid profile ID' });
}
const cache = pcdManager.getCache(currentDatabaseId);
if (!cache) {
return fail(500, { error: 'Database cache not available' });
}
const formData = await request.formData();
// Parse form data
const layer = (formData.get('layer') as OperationLayer) || 'user';
const languageName = formData.get('languageName') as string | null;
const type = (formData.get('type') as 'must' | 'only' | 'not' | 'simple') || 'simple';
// Check layer permission
if (layer === 'base' && !canWriteToBase(currentDatabaseId)) {
return fail(403, { error: 'Cannot write to base layer without personal access token' });
}
// Get profile name for metadata
const profile = await cache.kb
.selectFrom('quality_profiles')
.select('name')
.where('id', '=', profileId)
.executeTakeFirst();
if (!profile) {
return fail(404, { error: 'Quality profile not found' });
}
// Update the languages
const result = await qualityProfileQueries.updateLanguages({
databaseId: currentDatabaseId,
cache,
layer,
profileName: profile.name,
input: {
languageName,
type
}
});
if (!result.success) {
return fail(500, { error: result.error || 'Failed to update languages' });
}
return { success: true };
}
};

View File

@@ -1,262 +0,0 @@
<script lang="ts">
import { ChevronDown, Info, Save } from 'lucide-svelte';
import { enhance } from '$app/forms';
import InfoModal from '$ui/modal/InfoModal.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import { initEdit, current, isDirty, update } from '$lib/client/stores/dirty';
import type { PageData, ActionData } from './$types';
import type { OperationLayer } from '$pcd/writer';
export let data: PageData;
export let form: ActionData;
let showInfoModal = false;
let showSaveModal = false;
let isSaving = false;
let formElement: HTMLFormElement;
const typeOptions: Array<{ value: 'simple' | 'must' | 'only' | 'not'; label: string }> = [
{ value: 'simple', label: 'Preferred' },
{ value: 'must', label: 'Must Include' },
{ value: 'only', label: 'Must Only Be' },
{ value: 'not', label: 'Does Not Include' }
];
// Build initial data from server
$: initialData = {
type: data.languages[0]?.type || 'simple',
languageName: data.languages[0]?.name || null
};
// Initialize dirty tracking
$: initEdit(initialData);
// Reactive getters for current values
$: selectedType = ($current.type ?? 'simple') as 'must' | 'only' | 'not' | 'simple';
$: selectedLanguageName = ($current.languageName ?? null) as string | null;
// Search query tracks the display name
let searchQuery = data.languages[0]?.name || '';
let showTypeDropdown = false;
let showLanguageDropdown = false;
$: selectedLanguage = data.availableLanguages.find((l) => l.name === selectedLanguageName);
$: filteredLanguages = data.availableLanguages.filter((lang) =>
lang.name.toLowerCase().includes(searchQuery.toLowerCase())
);
$: isValidLanguage = searchQuery === '' || selectedLanguageName !== null;
$: showValidationError = searchQuery !== '' && !isValidLanguage;
function selectType(type: 'must' | 'only' | 'not' | 'simple') {
update('type', type);
showTypeDropdown = false;
}
function selectLanguage(language: { id: number; name: string }) {
update('languageName', language.name);
searchQuery = language.name;
showLanguageDropdown = false;
}
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
searchQuery = target.value;
showLanguageDropdown = true;
const exactMatch = data.availableLanguages.find(
(l) => l.name.toLowerCase() === searchQuery.toLowerCase()
);
if (!exactMatch) {
update('languageName', null);
} else {
update('languageName', exactMatch.name);
}
}
function handleFocus() {
showLanguageDropdown = true;
}
function handleBlur() {
setTimeout(() => {
showLanguageDropdown = false;
if (selectedLanguageName) {
searchQuery = selectedLanguage?.name || '';
}
}, 200);
}
function handleSaveClick() {
if (data.canWriteToBase) {
showSaveModal = true;
} else {
submitForm('user');
}
}
function submitForm(layer: OperationLayer) {
showSaveModal = false;
const layerInput = formElement.querySelector('input[name="layer"]') as HTMLInputElement;
if (layerInput) layerInput.value = layer;
formElement.requestSubmit();
}
</script>
<svelte:head>
<title>Languages - Profilarr</title>
</svelte:head>
<form
bind:this={formElement}
method="POST"
action="?/update"
use:enhance={() => {
isSaving = true;
return async ({ update: formUpdate }) => {
await formUpdate();
isSaving = false;
if (form?.success) {
initEdit(initialData);
}
};
}}
>
<input type="hidden" name="layer" value="user" />
<input type="hidden" name="languageName" value={selectedLanguageName ?? ''} />
<input type="hidden" name="type" value={selectedType} />
</form>
<div class="mt-6 space-y-3">
<div class="flex items-start justify-between">
<div>
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">Language</div>
<p class="mt-1 text-xs text-neutral-600 dark:text-neutral-400">
Configure the language preference for this profile
</p>
</div>
<div class="flex items-center gap-2">
{#if $isDirty}
<button
type="button"
on:click={handleSaveClick}
disabled={isSaving || showValidationError}
class="flex items-center gap-1.5 rounded-lg border border-accent-500 bg-accent-500 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
{/if}
<button
type="button"
on:click={() => (showInfoModal = true)}
class="flex 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"
>
<Info size={14} />
Info
</button>
</div>
</div>
<div class="flex gap-2">
<!-- Type Dropdown -->
<div class="relative w-48">
<button
type="button"
on:click={() => (showTypeDropdown = !showTypeDropdown)}
on:blur={() => setTimeout(() => (showTypeDropdown = false), 200)}
class="flex w-full items-center justify-between rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
<span>{typeOptions.find((t) => t.value === selectedType)?.label}</span>
<ChevronDown size={14} />
</button>
{#if showTypeDropdown}
<div
class="absolute top-full z-50 mt-1 w-full rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each typeOptions as option}
<button
type="button"
on:click={() => selectType(option.value)}
class="w-full px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{option.label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Language Autocomplete -->
<div class="relative flex-1">
<input
type="text"
bind:value={searchQuery}
on:input={handleInput}
on:focus={handleFocus}
on:blur={handleBlur}
placeholder="Search for a language..."
class="block w-full rounded-lg border px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:ring-1 focus:outline-none dark:text-neutral-50 dark:placeholder-neutral-500 {showValidationError
? 'border-red-300 bg-red-50 focus:border-red-500 focus:ring-red-500 dark:border-red-700 dark:bg-red-950 dark:focus:border-red-500'
: 'border-neutral-300 bg-white focus:border-accent-500 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800'}"
/>
{#if showValidationError}
<p class="mt-1 text-xs text-red-600 dark:text-red-400">
"{searchQuery}" is not a valid language. Please select from the dropdown.
</p>
{/if}
{#if showLanguageDropdown && filteredLanguages.length > 0}
<div
class="absolute top-full z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each filteredLanguages as language}
<button
type="button"
on:mousedown={() => selectLanguage(language)}
class="w-full px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{language.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<InfoModal bind:open={showInfoModal} header="Language Options">
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
<div>
<div class="flex items-center gap-2 font-medium text-neutral-900 dark:text-neutral-100">
<span>Preferred</span>
<span
class="inline-flex items-center rounded bg-accent-100 px-1.5 py-0.5 text-xs font-medium text-accent-800 dark:bg-accent-900 dark:text-accent-200"
>Radarr Only</span
>
</div>
<div class="mt-1">Uses Radarr's built-in language preference setting</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Must Include</div>
<div class="mt-1">Release must include the specified language</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Must Only Be</div>
<div class="mt-1">Release must only contain the specified language</div>
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Does Not Include</div>
<div class="mt-1">Release must not include the specified language</div>
</div>
</div>
</InfoModal>
<SaveTargetModal
bind:open={showSaveModal}
on:select={(e) => submitForm(e.detail)}
on:cancel={() => (showSaveModal = false)}
/>

View File

@@ -6,6 +6,7 @@
import TagInput from '$ui/form/TagInput.svelte';
import Modal from '$ui/modal/Modal.svelte';
import SaveTargetModal from '$ui/modal/SaveTargetModal.svelte';
import Button from '$ui/button/Button.svelte';
import { alertStore } from '$alerts/store';
import { current, isDirty, initEdit, initCreate, update } from '$lib/client/stores/dirty';
@@ -14,14 +15,22 @@
name: string;
tags: string[];
description: string;
language: string | null;
[key: string]: unknown;
}
// Language option
interface LanguageOption {
id: number;
name: string;
}
// Props
export let mode: 'create' | 'edit';
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
export let initialData: GeneralFormData;
export let availableLanguages: LanguageOption[] = [];
// Event handlers
export let onCancel: (() => void) | undefined = undefined;
@@ -29,7 +38,8 @@
const defaults: GeneralFormData = {
name: '',
tags: [],
description: ''
description: '',
language: null
};
if (mode === 'create') {
@@ -68,6 +78,57 @@
$: name = ($current.name ?? '') as string;
$: tags = ($current.tags ?? []) as string[];
$: description = ($current.description ?? '') as string;
$: selectedLanguageName = ($current.language ?? null) as string | null;
// Language autocomplete state
let languageSearchQuery = initialData.language || '';
let showLanguageDropdown = false;
$: filteredLanguages = availableLanguages.filter((lang) =>
lang.name.toLowerCase().includes(languageSearchQuery.toLowerCase())
);
function selectLanguage(language: LanguageOption) {
update('language', language.name);
languageSearchQuery = language.name;
showLanguageDropdown = false;
}
function clearLanguage() {
update('language', null);
languageSearchQuery = '';
showLanguageDropdown = false;
}
function handleLanguageInput(e: Event) {
const target = e.target as HTMLInputElement;
languageSearchQuery = target.value;
showLanguageDropdown = true;
const exactMatch = availableLanguages.find(
(l) => l.name.toLowerCase() === languageSearchQuery.toLowerCase()
);
if (!exactMatch) {
update('language', null);
} else {
update('language', exactMatch.name);
}
}
function handleLanguageFocus() {
showLanguageDropdown = true;
}
function handleLanguageBlur() {
setTimeout(() => {
showLanguageDropdown = false;
if (selectedLanguageName) {
languageSearchQuery = selectedLanguageName;
} else if (languageSearchQuery && !availableLanguages.find((l) => l.name === languageSearchQuery)) {
languageSearchQuery = '';
}
}, 200);
}
// Validation
$: isValid = name.trim() !== '';
@@ -146,7 +207,9 @@
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="description" value={description} />
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
<input type="hidden" name="language" value={selectedLanguageName ?? ''} />
<input type="hidden" name="layer" value={selectedLayer} />
<div class="space-y-6">
@@ -172,7 +235,6 @@
<!-- Description -->
<MarkdownInput
id="description"
name="description"
label="Description"
description="Add any notes or details about this profile's purpose and configuration."
value={description}
@@ -188,53 +250,89 @@
<TagInput {tags} onchange={(newTags) => update('tags', newTags)} />
</div>
<!-- Language -->
{#if availableLanguages.length > 0}
<div class="space-y-2">
<div class="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
Language
</div>
<p class="text-xs text-neutral-600 dark:text-neutral-400">
Set the preferred language for this profile. Leave empty for "Any". Radarr only. Sonarr uses custom formats for language filtering.
</p>
<div class="relative">
<input
type="text"
bind:value={languageSearchQuery}
oninput={handleLanguageInput}
onfocus={handleLanguageFocus}
onblur={handleLanguageBlur}
placeholder="Search for a language..."
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 transition-colors focus:border-accent-500 focus:ring-1 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
{#if selectedLanguageName}
<button
type="button"
onclick={clearLanguage}
aria-label="Clear language"
class="absolute top-1/2 right-3 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
{#if showLanguageDropdown && filteredLanguages.length > 0}
<div
class="absolute top-full z-50 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
>
{#each filteredLanguages as language}
<button
type="button"
onmousedown={() => selectLanguage(language)}
class="w-full px-3 py-2 text-left text-sm text-neutral-900 transition-colors hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-700"
>
{language.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Actions -->
<div class="flex items-center justify-between pt-4">
<!-- Left side: Delete (only in edit mode) -->
<div>
{#if mode === 'edit'}
<button
type="button"
<Button
variant="danger"
disabled={deleting}
onclick={handleDeleteClick}
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900/20"
>
{#if deleting}
<Loader2 size={14} class="animate-spin" />
Deleting...
{:else}
<Trash2 size={14} />
Delete
{/if}
</button>
icon={deleting ? Loader2 : Trash2}
text={deleting ? 'Deleting...' : 'Delete'}
on:click={handleDeleteClick}
/>
{/if}
</div>
<!-- Right side: Cancel and Save -->
<div class="flex gap-3">
{#if onCancel}
<button
type="button"
onclick={onCancel}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel
</button>
<Button
variant="secondary"
text="Cancel"
on:click={onCancel}
/>
{/if}
<button
type="button"
<Button
variant="primary"
disabled={saving || !isValid || !$isDirty}
onclick={handleSaveClick}
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 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 saving}
<Loader2 size={14} class="animate-spin" />
{mode === 'create' ? 'Creating...' : 'Saving...'}
{:else}
<Save size={14} />
{submitButtonText}
{/if}
</button>
icon={saving ? Loader2 : Save}
text={saving ? (mode === 'create' ? 'Creating...' : 'Saving...') : submitButtonText}
on:click={handleSaveClick}
/>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import type { ServerLoad, Actions } from '@sveltejs/kit';
import { pcdManager } from '$pcd/pcd.ts';
import { canWriteToBase } from '$pcd/writer.ts';
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
import { getRadarrLanguages } from '$lib/server/sync/mappings.ts';
import type { OperationLayer } from '$pcd/writer.ts';
export const load: ServerLoad = ({ params }) => {
@@ -22,8 +23,12 @@ export const load: ServerLoad = ({ params }) => {
throw error(404, 'Database not found');
}
// Get Radarr languages (language field is Radarr-only)
const availableLanguages = getRadarrLanguages();
return {
currentDatabase,
availableLanguages,
canWriteToBase: canWriteToBase(currentDatabaseId)
};
};
@@ -52,6 +57,8 @@ export const actions: Actions = {
const name = formData.get('name') as string;
const description = (formData.get('description') as string) || null;
const tagsJson = formData.get('tags') as string;
const languageRaw = formData.get('language') as string;
const language = languageRaw && languageRaw.trim() !== '' ? languageRaw.trim() : null;
const layer = (formData.get('layer') as OperationLayer) || 'user';
// Validate
@@ -88,7 +95,8 @@ export const actions: Actions = {
input: {
name: name.trim(),
description: description?.trim() || null,
tags
tags,
language
}
});

View File

@@ -10,7 +10,8 @@
const initialData = {
name: '',
tags: [],
description: ''
description: '',
language: null
};
function handleCancel() {
@@ -27,6 +28,7 @@
mode="create"
canWriteToBase={data.canWriteToBase}
{initialData}
availableLanguages={data.availableLanguages}
onCancel={handleCancel}
/>
</div>