diff --git a/src/lib/client/assets/nggyu.gif b/src/lib/client/assets/nggyu.gif new file mode 100644 index 0000000..762cdc5 Binary files /dev/null and b/src/lib/client/assets/nggyu.gif differ diff --git a/src/lib/client/assets/thanks.gif b/src/lib/client/assets/thanks.gif new file mode 100644 index 0000000..4dc99ec Binary files /dev/null and b/src/lib/client/assets/thanks.gif differ diff --git a/src/lib/client/ui/dropdown/CustomGroupManager.svelte b/src/lib/client/ui/dropdown/CustomGroupManager.svelte new file mode 100644 index 0000000..bb23db4 --- /dev/null +++ b/src/lib/client/ui/dropdown/CustomGroupManager.svelte @@ -0,0 +1,88 @@ + + + +
+
+ Add Custom Group +
+
+ + + +
+
+ + +{#if customGroups.length > 0} +
+ {#each customGroups as group} +
+ + +
+ {/each} +
+{/if} diff --git a/src/lib/client/ui/dropdown/Dropdown.svelte b/src/lib/client/ui/dropdown/Dropdown.svelte index 3cd3690..5ac5031 100644 --- a/src/lib/client/ui/dropdown/Dropdown.svelte +++ b/src/lib/client/ui/dropdown/Dropdown.svelte @@ -16,7 +16,7 @@
diff --git a/src/lib/client/ui/form/IconCheckbox.svelte b/src/lib/client/ui/form/IconCheckbox.svelte new file mode 100644 index 0000000..066b7f2 --- /dev/null +++ b/src/lib/client/ui/form/IconCheckbox.svelte @@ -0,0 +1,58 @@ + + +{#if isCustomColor} + +{:else} + +{/if} diff --git a/src/lib/client/ui/form/NumberInput.svelte b/src/lib/client/ui/form/NumberInput.svelte index 8d1ebac..32a4410 100644 --- a/src/lib/client/ui/form/NumberInput.svelte +++ b/src/lib/client/ui/form/NumberInput.svelte @@ -10,6 +10,9 @@ export let step: number = 1; export let required: boolean = false; export let disabled: boolean = false; + export let font: 'mono' | 'sans' | undefined = undefined; + + $: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : ''; // Increment/decrement handlers function increment() { @@ -55,7 +58,7 @@ {step} {required} {disabled} - class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + class="block w-full [appearance:textfield] rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 text-neutral-900 placeholder-neutral-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-neutral-100 disabled:text-neutral-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500 dark:disabled:bg-neutral-900 dark:disabled:text-neutral-600 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none {fontClass}" /> diff --git a/src/lib/client/ui/modal/InfoModal.svelte b/src/lib/client/ui/modal/InfoModal.svelte index c2abf43..4afe361 100644 --- a/src/lib/client/ui/modal/InfoModal.svelte +++ b/src/lib/client/ui/modal/InfoModal.svelte @@ -20,10 +20,12 @@ } } - $: if (open) { - window.addEventListener('keydown', handleKeydown); - } else { - window.removeEventListener('keydown', handleKeydown); + $: if (typeof window !== 'undefined') { + if (open) { + window.addEventListener('keydown', handleKeydown); + } else { + window.removeEventListener('keydown', handleKeydown); + } } diff --git a/src/lib/client/ui/navigation/tabs/Tabs.svelte b/src/lib/client/ui/navigation/tabs/Tabs.svelte index 4fa0fd5..f0711b3 100644 --- a/src/lib/client/ui/navigation/tabs/Tabs.svelte +++ b/src/lib/client/ui/navigation/tabs/Tabs.svelte @@ -24,6 +24,7 @@ {#each tabs as tab (tab.href)} - import type { Column } from './types'; + import type { Column, SortDirection, SortState } from './types'; /** * Props @@ -10,6 +10,12 @@ export let compact: boolean = false; export let emptyMessage: string = 'No data available'; export let onRowClick: ((row: T) => void) | undefined = undefined; + export let initialSort: SortState | null = null; + export let onSortChange: ((sort: SortState | null) => void) | undefined = undefined; + + let sortKey: string | null = initialSort?.key ?? null; + let sortDirection: SortDirection = initialSort?.direction ?? 'asc'; + let sortedData: T[] = data; /** * Get cell value by key path (supports nested properties like 'user.name') @@ -31,6 +37,75 @@ return 'text-left'; } } + + function toggleSort(column: Column) { + if (!column.sortable) { + return; + } + + if (sortKey === column.key) { + if (sortDirection === 'asc') { + sortDirection = 'desc'; + } else { + sortKey = null; + onSortChange?.(null); + return; + } + } else { + sortKey = column.key; + sortDirection = column.defaultSortDirection ?? 'asc'; + } + + onSortChange?.(sortKey ? { key: sortKey, direction: sortDirection } : null); + } + + function compareValues(a: any, b: any): number { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + if (a instanceof Date && b instanceof Date) { + return a.getTime() - b.getTime(); + } + + return String(a).localeCompare(String(b)); + } + + function getSortValue(row: T, column: Column) { + if (column.sortAccessor) { + return column.sortAccessor(row); + } + return getCellValue(row, column.key); + } + + function sortData(rows: T[]): T[] { + if (!sortKey) { + return rows; + } + + const column = columns.find((col) => col.key === sortKey); + if (!column) { + return rows; + } + + const sorted = [...rows].sort((a, b) => { + if (column.sortComparator) { + return column.sortComparator(a, b); + } + + const aValue = getSortValue(a, column); + const bValue = getSortValue(b, column); + return compareValues(aValue, bValue); + }); + + return sortDirection === 'desc' ? sorted.reverse() : sorted; + } + + $: sortedData = sortData(data);
@@ -44,12 +119,40 @@ -
- {#if column.headerIcon} - - {/if} - {column.header} -
+ {#if column.sortable} + + {:else} +
+ {#if column.headerIcon} + + {/if} + {column.header} +
+ {/if} {/each} @@ -65,7 +168,7 @@ - {#if data.length === 0} + {#if sortedData.length === 0} {:else} - {#each data as row, rowIndex} + {#each sortedData as row, rowIndex} + import NumberInput from '$ui/form/NumberInput.svelte'; + import IconCheckbox from '$ui/form/IconCheckbox.svelte'; + import { Info, ArrowUpDown, ArrowUp, ArrowDown, Layers, Check, LayoutGrid, Settings, User, X } from 'lucide-svelte'; + import InfoModal from '$ui/modal/InfoModal.svelte'; + import UnsavedChangesModal from '$ui/modal/UnsavedChangesModal.svelte'; + import { useUnsavedChanges } from '$lib/client/utils/unsavedChanges.svelte'; + import ActionsBar from '$ui/actions/ActionsBar.svelte'; + import SearchAction from '$ui/actions/SearchAction.svelte'; + import ActionButton from '$ui/actions/ActionButton.svelte'; + import Dropdown from '$ui/dropdown/Dropdown.svelte'; + import CustomGroupManager from '$ui/dropdown/CustomGroupManager.svelte'; + import ScoringTable from './components/ScoringTable.svelte'; + import { createSearchStore } from '$lib/client/stores/search'; + import { onMount } from 'svelte'; + import type { PageData } from './$types'; + + export let data: PageData; + + const unsavedChanges = useUnsavedChanges(); + const searchStore = createSearchStore({ debounceMs: 200 }); + + let showInfoModal = false; + let showOptionsInfoModal = false; + + type SortKey = 'name' | 'radarr' | 'sonarr'; + type SortDirection = 'asc' | 'desc'; + + let sortState: { key: SortKey; direction: SortDirection } | null = null; + + // Tiling + let tileColumns: number = 1; + const TILING_STORAGE_KEY = 'scoring-tile-columns'; + + // General options + let hideUnscoredFormats: boolean = false; + const HIDE_UNSCORED_STORAGE_KEY = 'scoring-hide-unscored'; + + // Grouping + type GroupKey = string; + let selectedGroups = new Set(); + + const GROUPING_STORAGE_KEY = 'scoring-selected-groups'; + const CUSTOM_GROUPS_STORAGE_KEY = 'scoring-custom-groups'; + + // Define built-in groups with their tags (order matters - first match wins) + const builtInGroups = [ + { name: 'Audio', key: 'audio' as const, tags: ['audio'], custom: false }, + { name: 'HDR/Colour', key: 'hdr' as const, tags: ['hdr', 'colour grade'], custom: false }, + { name: 'Release Group', key: 'release-group' as const, tags: ['release group', 'release groups'], custom: false }, + { name: 'Release Group Tier', key: 'release-group-tier' as const, tags: ['release group tier', 'release group tiers'], custom: false }, + { name: 'Streaming Service', key: 'streaming-service' as const, tags: ['streaming service'], custom: false }, + { name: 'Codec', key: 'codec' as const, tags: ['codec'], custom: false }, + { name: 'Storage', key: 'storage' as const, tags: ['storage'], custom: false }, + { name: 'Source', key: 'source' as const, tags: ['source'], custom: false }, + { name: 'Resolution', key: 'resolution' as const, tags: ['sd', '480p', '576p', '720p', '1080p', '2160p', '4k', '8k', 'resolution'], custom: false }, + { name: 'Indexer Flag', key: 'indexer-flag' as const, tags: ['flag'], custom: false }, + { name: 'Edition', key: 'edition' as const, tags: ['edition'], custom: false }, + { name: 'Enhancement', key: 'enhancement' as const, tags: ['enhancement', 'enhancements'], custom: false }, + { name: 'Languages', key: 'languages' as const, tags: ['languages'], custom: false } + ]; + + // Custom groups from localStorage + let customGroups: Array<{ name: string; key: string; tags: string[]; custom: boolean }> = []; + + // Combined groups + $: groups = [...builtInGroups, ...customGroups]; + + // Check if current config matches active profile - uncheck if different + $: if (currentProfileId && !isLoadingProfile) { + const activeProfile = profiles.find(p => p.id === currentProfileId); + if (activeProfile) { + const searchQuery = $searchStore.query ?? ''; + const selectedGroupsArray = [...selectedGroups]; + const customGroupsClean = customGroups.map(({ name, key, tags }) => ({ name, key, tags })); + + // Compare all settings + const configChanged = + searchQuery !== activeProfile.searchQuery || + JSON.stringify(sortState) !== JSON.stringify(activeProfile.sortState) || + JSON.stringify(selectedGroupsArray) !== JSON.stringify(activeProfile.selectedGroups) || + JSON.stringify(customGroupsClean) !== JSON.stringify(activeProfile.customGroups) || + tileColumns !== activeProfile.tileColumns || + hideUnscoredFormats !== activeProfile.hideUnscoredFormats; + + if (configChanged) { + currentProfileId = null; + } + } + } + + // Profiles + type Profile = { + id: string; + name: string; + searchQuery: string; + sortState: { key: SortKey; direction: SortDirection } | null; + selectedGroups: GroupKey[]; + customGroups: Array<{ name: string; key: string; tags: string[] }>; + tileColumns: number; + hideUnscoredFormats: boolean; + }; + let profiles: Profile[] = []; + let currentProfileId: string | null = null; + let newProfileName = ''; + let isLoadingProfile = false; + const PROFILES_STORAGE_KEY = 'scoring-profiles'; + + onMount(() => { + // Load custom groups from localStorage + const savedCustomGroups = localStorage.getItem(CUSTOM_GROUPS_STORAGE_KEY); + if (savedCustomGroups) { + try { + const parsed = JSON.parse(savedCustomGroups); + customGroups = parsed.map((g: any) => ({ ...g, custom: true })); + } catch { + customGroups = []; + } + } + + // Load grouping preference from localStorage + const saved = localStorage.getItem(GROUPING_STORAGE_KEY); + if (saved) { + try { + const parsed = JSON.parse(saved) as GroupKey[]; + selectedGroups = new Set(parsed.filter(key => groups.some(g => g.key === key))); + } catch { + selectedGroups = new Set(); + } + } + + // Load tiling preference from localStorage + const savedTiling = localStorage.getItem(TILING_STORAGE_KEY); + if (savedTiling) { + const parsed = parseInt(savedTiling, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= 3) { + tileColumns = parsed; + } + } + + // Load hide unscored preference from localStorage + const savedHideUnscored = localStorage.getItem(HIDE_UNSCORED_STORAGE_KEY); + if (savedHideUnscored === 'true') { + hideUnscoredFormats = true; + } + + // Load profiles from localStorage + const savedProfiles = localStorage.getItem(PROFILES_STORAGE_KEY); + if (savedProfiles) { + try { + profiles = JSON.parse(savedProfiles); + } catch { + profiles = []; + } + } + }); + + function saveGroupingPreference() { + localStorage.setItem(GROUPING_STORAGE_KEY, JSON.stringify([...selectedGroups])); + } + + function toggleGroup(key: GroupKey) { + if (selectedGroups.has(key)) { + selectedGroups.delete(key); + } else { + selectedGroups.add(key); + } + selectedGroups = selectedGroups; // Trigger reactivity + saveGroupingPreference(); + } + + function clearGrouping() { + selectedGroups = new Set(); + saveGroupingPreference(); + } + + function setTileColumns(columns: number) { + tileColumns = columns; + localStorage.setItem(TILING_STORAGE_KEY, columns.toString()); + } + + function toggleHideUnscored() { + hideUnscoredFormats = !hideUnscoredFormats; + localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString()); + } + + function saveCustomGroups() { + const toSave = customGroups.map(({ name, key, tags }) => ({ name, key, tags })); + localStorage.setItem(CUSTOM_GROUPS_STORAGE_KEY, JSON.stringify(toSave)); + } + + function addCustomGroup(name: string, tags: string[]) { + const key = `custom-${Date.now()}`; + customGroups = [...customGroups, { name, key, tags, custom: true }]; + saveCustomGroups(); + } + + function deleteCustomGroup(key: string) { + customGroups = customGroups.filter((g) => g.key !== key); + // Remove from selected groups if it was selected + selectedGroups.delete(key); + selectedGroups = selectedGroups; + saveCustomGroups(); + saveGroupingPreference(); + } + + // Profile management + function saveProfile(name: string) { + const id = `profile-${Date.now()}`; + const profile: Profile = { + id, + name, + searchQuery: $searchStore.query ?? '', + sortState, + selectedGroups: [...selectedGroups], + customGroups: customGroups.map(({ name, key, tags }) => ({ name, key, tags })), + tileColumns, + hideUnscoredFormats + }; + profiles = [...profiles, profile]; + localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles)); + currentProfileId = id; + } + + function loadProfile(id: string) { + const profile = profiles.find(p => p.id === id); + if (!profile) return; + + isLoadingProfile = true; + + // Apply profile settings + searchStore.setQuery(profile.searchQuery); + sortState = profile.sortState as { key: SortKey; direction: SortDirection } | null; + selectedGroups = new Set(profile.selectedGroups); + customGroups = profile.customGroups.map(g => ({ ...g, custom: true })); + tileColumns = profile.tileColumns; + hideUnscoredFormats = profile.hideUnscoredFormats; + + // Save to individual storage keys + saveGroupingPreference(); + saveCustomGroups(); + localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString()); + localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString()); + + currentProfileId = id; + isLoadingProfile = false; + } + + function deleteProfile(id: string) { + profiles = profiles.filter(p => p.id !== id); + localStorage.setItem(PROFILES_STORAGE_KEY, JSON.stringify(profiles)); + if (currentProfileId === id) { + currentProfileId = null; + } + } + + function handleSaveProfile() { + if (newProfileName.trim()) { + saveProfile(newProfileName.trim()); + newProfileName = ''; + } + } + + function loadDefaultProfile() { + isLoadingProfile = true; + + // Apply default settings + searchStore.setQuery(''); + sortState = { key: 'radarr', direction: 'desc' }; + selectedGroups = new Set(); + customGroups = []; + tileColumns = 1; + hideUnscoredFormats = false; + + // Save to individual storage keys + saveGroupingPreference(); + saveCustomGroups(); + localStorage.setItem(TILING_STORAGE_KEY, tileColumns.toString()); + localStorage.setItem(HIDE_UNSCORED_STORAGE_KEY, hideUnscoredFormats.toString()); + + currentProfileId = null; + isLoadingProfile = false; + } + + // Arr type color mapping + const arrTypeColors: Record = { + radarr: '#FFC230', + sonarr: '#00CCFF' + }; + + function getArrTypeColor(arrType: string): string { + return arrTypeColors[arrType] || '#3b82f6'; // default to blue + } + + // Initialize state based on scoring data + function initializeState(scoring: any) { + const minimumScore = scoring.minimum_custom_format_score; + const upgradeUntilScore = scoring.upgrade_until_score; + const upgradeScoreIncrement = scoring.upgrade_score_increment; + + // Custom format scores - create a reactive map + const customFormatScores: Record> = {}; + const customFormatEnabled: Record> = {}; + + // Initialize scores and enabled state from data + scoring.customFormats.forEach((cf: any) => { + customFormatScores[cf.id] = { ...cf.scores }; + customFormatEnabled[cf.id] = {}; + scoring.arrTypes.forEach((arrType: string) => { + customFormatEnabled[cf.id][arrType] = cf.scores[arrType] !== null; + }); + }); + + return { + minimumScore, + upgradeUntilScore, + upgradeScoreIncrement, + customFormatScores, + customFormatEnabled + }; + } + + function toggleSort(key: SortKey, defaultDirection: SortDirection = 'asc') { + if (sortState?.key === key) { + // Toggle direction + sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' }; + } else { + // New sort key + sortState = { key, direction: defaultDirection }; + } + } + + function sortFormats(formats: any[], state: any, sortState: { key: SortKey; direction: SortDirection } | null) { + if (!sortState) return formats; + + const sorted = [...formats].sort((a, b) => { + let aVal: any; + let bVal: any; + + if (sortState.key === 'name') { + aVal = a.name?.toLowerCase() || ''; + bVal = b.name?.toLowerCase() || ''; + return sortState.direction === 'asc' + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal); + } else { + // Sort by score (radarr or sonarr) + aVal = state.customFormatScores[a.id]?.[sortState.key] ?? null; + bVal = state.customFormatScores[b.id]?.[sortState.key] ?? null; + + // Handle nulls - always put them at the end + if (aVal === null && bVal === null) return 0; + if (aVal === null) return 1; + if (bVal === null) return -1; + + return sortState.direction === 'desc' + ? bVal - aVal + : aVal - bVal; + } + }); + + return sorted; + } + + function groupFormats(formats: any[], selectedGroups: Set) { + // If no groups selected, show all formats in one table + if (selectedGroups.size === 0) { + return [{ name: null, formats }]; + } + + const result: { name: string | null; formats: any[] }[] = []; + const assigned = new Set(); + + // Process each group in order + for (const group of groups) { + if (!selectedGroups.has(group.key)) continue; + + const groupFormats = formats.filter((format) => { + // Skip if already assigned + if (assigned.has(format.id)) return false; + + // Check if format has any of the group's tags + return format.tags?.some((tag: string) => group.tags.includes(tag.toLowerCase())); + }); + + // Mark as assigned + groupFormats.forEach((f) => assigned.add(f.id)); + + result.push({ name: group.name, formats: groupFormats }); + } + + // Add remaining formats to "Other" group + const otherFormats = formats.filter((f) => !assigned.has(f.id)); + if (otherFormats.length > 0) { + result.push({ name: 'Other', formats: otherFormats }); + } + + return result; + } + + + + + Scoring - Profilarr + + + + +{#await data.streamed.scoring} + +
+ +
+ {#each [1, 2, 3] as _} +
+
+
+
+
+ {/each} +
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+ {#each [1, 2, 3, 4, 5] as _} +
+
+
+
+
+ {/each} +
+
+
+
+{:then scoring} + {@const state = initializeState(scoring)} + {@const searchQuery = ($searchStore.query ?? '').trim().toLowerCase()} + {@const filteredCustomFormats = scoring.customFormats.filter((format) => { + // Filter by search + if (searchQuery && !format.name?.toLowerCase().includes(searchQuery)) { + return false; + } + + // Filter unscored formats if option is enabled + if (hideUnscoredFormats) { + const hasAnyScore = scoring.arrTypes.some((arrType) => format.scores[arrType] !== null); + if (!hasAnyScore) return false; + } + + return true; + })} + {@const defaultSortKey = (scoring.arrTypes.includes('radarr') ? 'radarr' : scoring.arrTypes[0]) as SortKey} + {@const _applyDefaultSort = (() => { + if (!sortState && defaultSortKey) { + sortState = { key: defaultSortKey, direction: 'desc' }; + } + return null; + })()} + {@const sortedCustomFormats = sortFormats(filteredCustomFormats, state, sortState)} + {@const groupedFormats = groupFormats(sortedCustomFormats, selectedGroups)} +
+ +
+
+ +

+ Minimum custom format score required to download +

+ +
+ +
+ +

+ Stop upgrading when this score is reached +

+ +
+ +
+ +

+ Minimum score improvement needed to upgrade +

+ +
+
+ + +
+
+
+ Custom Format Scoring +
+

+ Configure custom format scores for each Arr type +

+
+ +
+ + + + + + +
+ + + +
+
+
+
+ + + +
+ + {#each builtInGroups as group} + + {/each} +
+ +
+
+
+ + + +
+ {#each [1, 2, 3] as columns} + + {/each} +
+
+
+
+ + + +
+ +
+
+
+
+ + + + +
+
+ Save Current Settings +
+
+ + +
+
+ + +
+ +
+ +
+ + {#if profiles.length > 0} +
+ {#each profiles as profile} +
+ + +
+ {/each} + {/if} +
+
+
+
+ (showOptionsInfoModal = true)} /> +
+ + + {#if searchQuery === 'santiagoisthebest'} +
+ Thanks! +
+ {:else if searchQuery === 'rickroll' || searchQuery === 'nevergonnagiveyouup'} +
+ Never gonna give you up +
+ {:else if sortedCustomFormats.length === 0} +
+
+ {#if scoring.customFormats.length === 0} + No custom formats found + {:else} + No custom formats match your search + {/if} +
+
+ {:else} +
+ {#each groupedFormats as group} + {#if group.formats.length > 0} +
+ +
+ {/if} + {/each} +
+ {/if} +
+{/await} + + +
+
+
Custom Format Scores
+
+ Each custom format can have different scores for different Arr types (Radarr, Sonarr). The + score determines how much a release is preferred when it matches the custom format. +
+
+ +
+
Minimum Score
+
+ The minimum total custom format score required for a release to be downloaded. Releases + with scores below this threshold will be rejected. +
+
+ +
+
Upgrade Until Score
+
+ Once a release reaches this score, the system will stop looking for upgrades. This prevents + unnecessary upgrades when you've already got a good quality release. +
+
+ +
+
+ Upgrade Score Increment +
+
+ The minimum score improvement required to trigger an upgrade. This prevents minor upgrades + that don't significantly improve quality. +
+
+ +
+
Positive vs Negative Scores
+
+ Positive scores increase preference for releases matching the custom format. Negative + scores decrease preference or can block releases entirely when combined with the minimum + score setting. +
+
+
+
+ + +
+
+
Search
+
+ Filter custom formats by name. The search is case-insensitive and matches any part of the format name. +
+
+ +
+
Sort
+
+ Sort custom formats by Name (A-Z), Radarr score, or Sonarr score. Click the same option again to reverse the sort direction (ascending ↑ or descending ↓). Formats with no score are always shown at the end. +
+
+ +
+
Grouping
+
+ Organize custom formats into separate tables based on their tags. You can select multiple groups at once. Available groups include Audio, HDR/Colour, Release Group, Codec, Resolution, and more. Formats that don't match any selected group appear in the "Other" table. If a format matches multiple groups, it appears in the first matching group only. +
+
+ +
+
Custom Groups
+
+ Create your own custom groups by entering a name and comma-separated tags at the bottom of the Grouping dropdown. Your custom groups are saved to your browser and can be deleted at any time. Custom groups work the same as built-in groups - formats are assigned to the first matching group based on their tags. +
+
+ +
+
Tiling
+
+ Display tables in 1, 2, or 3 columns. This is especially useful when using grouping to view multiple format categories side-by-side. +
+
+ +
+
Hide Unscored Formats
+
+ Hide custom formats that have no score assigned for any Arr type. This helps focus on formats that are currently being used in your quality profile. +
+
+ +
+
Profiles
+
+ Save your current display configuration (search, sort, grouping, custom groups, tiling, and options) as a named profile. Load saved profiles to quickly switch between different views. The "Default" profile resets everything to the baseline configuration. Profiles are stored in your browser and automatically uncheck when you modify any settings. +
+
+
+
diff --git a/src/routes/quality-profiles/[databaseId]/[id]/scoring/components/ScoringTable.svelte b/src/routes/quality-profiles/[databaseId]/[id]/scoring/components/ScoringTable.svelte new file mode 100644 index 0000000..701323a --- /dev/null +++ b/src/routes/quality-profiles/[databaseId]/[id]/scoring/components/ScoringTable.svelte @@ -0,0 +1,108 @@ + + +{#if title} +
+

{title}

+
+{/if} + +
+ + + + + + {#each arrTypes as arrType} + + {/each} + + + + + + {#if formats.length === 0} + + + + {:else} + {#each formats as format} + {@const rowDisabled = arrTypes.every( + (arrType) => !state.customFormatEnabled[format.id]?.[arrType] + )} + + + {#each arrTypes as arrType} + + {/each} + + {/each} + {/if} + +
+ Custom Format + + {arrType} +
+ No custom formats found +
+ {format.name} + +
+ { + const isEnabled = state.customFormatEnabled[format.id][arrType]; + if (isEnabled) { + state.customFormatScores[format.id][arrType] = null; + } else { + if (state.customFormatScores[format.id][arrType] === null) { + state.customFormatScores[format.id][arrType] = 0; + } + } + state.customFormatEnabled[format.id][arrType] = !isEnabled; + }} + /> +
+ +
+
+
+