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

@@ -0,0 +1,15 @@
meta {
name: Get Quality Profile
type: http
seq: 1
}
get {
url: {{sonarrUrl}}/api/v3/qualityprofile/7
body: none
auth: none
}
headers {
X-Api-Key: {{sonarrApiKey}}
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Quality Profiles
type: http
seq: 1
}
get {
url: {{sonarrUrl}}/api/v3/qualityprofile
body: none
auth: none
}
headers {
X-Api-Key: {{sonarrApiKey}}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
- maybe move langauges to general tab or put info directly below it to fill
space
- adding a database requires double click??? im not running into this personally
- new quality handling
- copy button on logs page not woprking? cant recreate
# Adaptive Backoff

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>