mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
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:
15
bruno/sonarr/get-quality-profile.bru
Normal file
15
bruno/sonarr/get-quality-profile.bru
Normal 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}}
|
||||
}
|
||||
15
bruno/sonarr/get-quality-profiles.bru
Normal file
15
bruno/sonarr/get-quality-profiles.bru
Normal 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}}
|
||||
}
|
||||
1212
bruno/sonarr/update-quality-profile-any.bru
Normal file
1212
bruno/sonarr/update-quality-profile-any.bru
Normal file
File diff suppressed because it is too large
Load Diff
1212
bruno/sonarr/update-quality-profile-english.bru
Normal file
1212
bruno/sonarr/update-quality-profile-english.bru
Normal file
File diff suppressed because it is too large
Load Diff
1208
bruno/sonarr/update-quality-profile-no-language.bru
Normal file
1208
bruno/sonarr/update-quality-profile-no-language.bru
Normal file
File diff suppressed because it is too large
Load Diff
1212
bruno/sonarr/update-quality-profile.bru
Normal file
1212
bruno/sonarr/update-quality-profile.bru
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user