- |
+ |
{(m as unknown as Record string>)[`task_name_${camelCaseId}`]?.() ??
task.name}
-
+
{(m as unknown as Record string>)[`task_desc_${camelCaseId}`]?.() ??
task.description}
diff --git a/src/lib/components/tasks/TasksTable.svelte b/src/lib/components/tasks/TasksTable.svelte
index 885e97953..18f91e37f 100644
--- a/src/lib/components/tasks/TasksTable.svelte
+++ b/src/lib/components/tasks/TasksTable.svelte
@@ -105,15 +105,15 @@
{/each}
-
+
- | {m.task_table_colTask()} |
- {m.task_table_colInterval()} |
- {m.task_table_colLastRun()} |
- {m.task_table_colNextRun()} |
- {m.task_table_colStatus()} |
- |
+ {m.task_table_colTask()} |
+ {m.task_table_colInterval()} |
+ {m.task_table_colLastRun()} |
+ {m.task_table_colNextRun()} |
+ {m.task_table_colStatus()} |
+ |
@@ -193,14 +193,14 @@
{/each}
-
+
- | {m.task_table_colTask()} |
- {m.task_table_colType()} |
- {m.task_table_colLastRun()} |
- {m.task_table_colStatus()} |
- |
+ {m.task_table_colTask()} |
+ {m.task_table_colType()} |
+ {m.task_table_colLastRun()} |
+ {m.task_table_colStatus()} |
+ |
diff --git a/src/lib/components/ui/BreakdownBar.svelte b/src/lib/components/ui/BreakdownBar.svelte
new file mode 100644
index 000000000..a3186c6ac
--- /dev/null
+++ b/src/lib/components/ui/BreakdownBar.svelte
@@ -0,0 +1,122 @@
+
+
+{#if normalizedSegments.length === 0}
+ No data
+{:else}
+
+
+ {#each normalizedSegments as segment (segment.label)}
+ {#if onSegmentClick}
+ onSegmentClick({ label: segment.label, value: segment.value })}
+ onkeydown={(e) => segmentKeydown(e, segment)}
+ >
+ {:else}
+
+ {/if}
+ {/each}
+
+ {#if showLegend}
+
+ {#each normalizedSegments as segment (segment.label)}
+ {#if onSegmentClick}
+ onSegmentClick({ label: segment.label, value: segment.value })}
+ onkeydown={(e) => segmentKeydown(e, segment)}
+ >
+
+ {segment.label}
+ ({valueFormatter(segment.value)})
+
+ {:else}
+
+
+ {segment.label}
+ ({valueFormatter(segment.value)})
+
+ {/if}
+ {/each}
+ {#if totalLabel}
+ {totalLabel}
+ {/if}
+
+ {/if}
+
+{/if}
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts
index fe33f1a1c..218ea44b6 100644
--- a/src/lib/components/ui/index.ts
+++ b/src/lib/components/ui/index.ts
@@ -4,6 +4,7 @@ export { default as Toasts } from './Toasts.svelte';
export { default as TmdbConfigRequired } from './TmdbConfigRequired.svelte';
export { default as ThemeSelector } from './ThemeSelector.svelte';
export { default as LanguageSelector } from './LanguageSelector.svelte';
+export { default as BreakdownBar } from './BreakdownBar.svelte';
// Skeleton loaders
export { default as Skeleton } from './Skeleton.svelte';
diff --git a/src/lib/components/ui/modal/ConfirmationModal.svelte b/src/lib/components/ui/modal/ConfirmationModal.svelte
index 0b7224b64..371739957 100644
--- a/src/lib/components/ui/modal/ConfirmationModal.svelte
+++ b/src/lib/components/ui/modal/ConfirmationModal.svelte
@@ -1,4 +1,5 @@
@@ -1883,6 +1811,7 @@
open={isEditModalOpen}
{series}
qualityProfiles={data.qualityProfiles}
+ delayProfiles={data.delayProfiles}
rootFolders={data.rootFolders}
saving={isSaving}
onClose={handleEditClose}
@@ -1890,24 +1819,17 @@
/>
- {
isSearchModalOpen = false;
searchContext = null;
}}
- onGrab={handleGrab}
/>
diff --git a/src/routes/library/unmatched/+page.server.ts b/src/routes/library/unmatched/+page.server.ts
index 8f819b869..b81a625b4 100644
--- a/src/routes/library/unmatched/+page.server.ts
+++ b/src/routes/library/unmatched/+page.server.ts
@@ -4,8 +4,9 @@ import type { PageServerLoad } from './$types';
* Server load function - minimal since data is loaded client-side
* This prevents unnecessary database queries on initial page load
*/
-export const load: PageServerLoad = async () => {
+export const load: PageServerLoad = async ({ url }) => {
+ const source = url.searchParams.get('source');
// Data is loaded client-side via unmatchedFilesStore
// This avoids loading potentially large datasets on every page request
- return {};
+ return { source };
};
diff --git a/src/routes/settings/+layout.svelte b/src/routes/settings/+layout.svelte
new file mode 100644
index 000000000..a54cfdcb7
--- /dev/null
+++ b/src/routes/settings/+layout.svelte
@@ -0,0 +1,5 @@
+
+
+{@render children()}
diff --git a/src/routes/settings/+page.server.ts b/src/routes/settings/+page.server.ts
new file mode 100644
index 000000000..932cd2467
--- /dev/null
+++ b/src/routes/settings/+page.server.ts
@@ -0,0 +1,5 @@
+import { redirect } from '@sveltejs/kit';
+
+export const load = () => {
+ throw redirect(307, '/settings/library/libraries');
+};
diff --git a/src/routes/settings/blocklist/+layout.svelte b/src/routes/settings/blocklist/+layout.svelte
index 546cff771..fe4d22723 100644
--- a/src/routes/settings/blocklist/+layout.svelte
+++ b/src/routes/settings/blocklist/+layout.svelte
@@ -30,7 +30,7 @@
-
+
{@render children()}
diff --git a/src/routes/settings/blocklist/blocked-extensions/+page.svelte b/src/routes/settings/blocklist/blocked-extensions/+page.svelte
index 18ed58e67..cfbfd136f 100644
--- a/src/routes/settings/blocklist/blocked-extensions/+page.svelte
+++ b/src/routes/settings/blocklist/blocked-extensions/+page.svelte
@@ -3,6 +3,7 @@
import { updateBlockedExtensions } from '$lib/api/settings.js';
import { toasts } from '$lib/stores/toast.svelte';
import { SettingsPage } from '$lib/components/ui/settings';
+ import * as m from '$lib/paraglide/messages.js';
let { data } = $props();
@@ -56,6 +57,10 @@
}
+
+ {m.nav_blockedExtensions()}
+
+
+
+ {m.nav_blockedKeywords()}
+
+
+
+ {m.nav_blockedMedia()}
+
+
+
+
+
+ {#if !debridAvailable}
+ {m.acquisition_debridUnavailableReason()}
+ {/if}
+
+
{#if selectedIds.size > 0}
{
+ stalledBlocklistHours={data.stalledDownloadBlocklistHours}
+ onSaveStalledBehavior={async (timeout, threshold, blocklistHours) => {
try {
await updateMonitoringSettings({
stalledDownloadTimeoutMinutes: timeout,
- stalledDownloadProgressThreshold: threshold
+ stalledDownloadProgressThreshold: threshold,
+ stalledDownloadBlocklistHours: blocklistHours
});
toasts.success(m.toast_settings_stalledSettingsUpdated());
} catch (e) {
diff --git a/src/routes/settings/integrations/indexers/+page.server.ts b/src/routes/settings/integrations/indexers/+page.server.ts
index ef2d8e3bc..5ac4fd5e0 100644
--- a/src/routes/settings/integrations/indexers/+page.server.ts
+++ b/src/routes/settings/integrations/indexers/+page.server.ts
@@ -63,6 +63,7 @@ export const load: PageServerLoad = async () => {
enabled: config.enabled,
upstreamEnabled: config.upstreamEnabled ?? null,
orphaned: config.orphaned ?? false,
+ isBuiltIn: config.isBuiltIn ?? false,
baseUrl: displayBaseUrl,
alternateUrls: config.alternateUrls,
priority: config.priority,
@@ -113,6 +114,7 @@ export const load: PageServerLoad = async () => {
autoSync: prowlarrConn.autoSync,
syncIntervalHours: prowlarrConn.syncIntervalHours,
syncAddNew: prowlarrConn.syncAddNew,
+ useAggregateEndpoint: prowlarrConn.useAggregateEndpoint,
lastSyncAt: prowlarrConn.lastSyncAt,
lastSyncResult: prowlarrConn.lastSyncResult,
lastSyncError: prowlarrConn.lastSyncError
diff --git a/src/routes/settings/integrations/indexers/+page.svelte b/src/routes/settings/integrations/indexers/+page.svelte
index 03b55444d..e3c07fc75 100644
--- a/src/routes/settings/integrations/indexers/+page.svelte
+++ b/src/routes/settings/integrations/indexers/+page.svelte
@@ -65,6 +65,8 @@
const isDeleteTargetProwlarr = $derived.by(() => {
const prowlarrBase = data.prowlarrConnection?.url?.replace(/\/+$/, '');
if (!prowlarrBase || !deleteTarget) return false;
+ if (deleteTarget.definitionId !== 'prowlarr') return false;
+ if ((deleteTarget.settings as Record | null)?.aggregate === 'true') return true;
if (!deleteTarget.baseUrl.startsWith(prowlarrBase + '/')) return false;
const suffix = deleteTarget.baseUrl.slice(prowlarrBase.length + 1).replace(/\/+$/, '');
return /^\d+$/.test(suffix);
@@ -500,17 +502,23 @@
async function handleReorder(indexerIds: string[]) {
try {
- for (const [index, id] of indexerIds.entries()) {
- await updateIndexerById(id, { priority: index + 1 }, 'Failed to reorder priorities');
- }
-
- await invalidateAll();
+ await Promise.all(
+ indexerIds.map((id, index) =>
+ updateIndexerById(id, { priority: index + 1 }, 'Failed to reorder priorities')
+ )
+ );
} catch (e) {
toasts.error(e instanceof Error ? e.message : 'Failed to reorder priorities');
+ } finally {
+ await invalidateAll();
}
}
+
+ {m.nav_indexers()}
+
+
{#snippet actions()}
-
-
-
-
- {m.settings_naming_reviewOutcome()}
- {#if validatingFormats}
-
-
- {m.settings_naming_validating()}
-
- {/if}
-
-
-
- {m.settings_naming_draftVsSaved()}
-
- {#if hasChanges}
- {m.settings_naming_draftChangesExist()}
- {:else}
- {m.settings_naming_draftAndSavedMatch()}
- {/if}
-
-
-
- {m.settings_naming_formatValidation()}
- {#if invalidFormatFields.length > 0}
-
- {#each invalidFormatFields as field (field)}
- -
- {m.settings_naming_hasSyntaxIssues({ field: FORMAT_FIELD_LABELS[field] })}
-
- {/each}
-
- {:else if validationWarningFields.length > 0}
-
- {#each validationWarningFields as field (field)}
- -
- {m.settings_naming_hasWarnings({ field: FORMAT_FIELD_LABELS[field] })}
-
- {/each}
-
- {:else}
- {m.settings_naming_allFieldsClean()}
- {/if}
-
-
- {m.settings_naming_nextStep()}
-
- {m.settings_naming_nextStepDesc()}
-
-
-
-
-
-
-
-
-
-
- {m.settings_naming_tokenBrowser()}
- {#if loadingPreviews}
-
-
- {m.settings_naming_updatingPreviews()}
-
- {/if}
-
-
-
-
-
+
@@ -1128,3 +1061,16 @@
+
+ {
+ const fn = pendingConfirm?.onConfirm;
+ if (fn) void fn();
+ }}
+ onCancel={() => (pendingConfirm = null)}
+/>
diff --git a/src/routes/settings/naming/rename/+page.svelte b/src/routes/settings/library/naming/rename/+page.svelte
similarity index 89%
rename from src/routes/settings/naming/rename/+page.svelte
rename to src/routes/settings/library/naming/rename/+page.svelte
index 8f308e193..ae4e27805 100644
--- a/src/routes/settings/naming/rename/+page.svelte
+++ b/src/routes/settings/library/naming/rename/+page.svelte
@@ -2,7 +2,7 @@
import { page } from '$app/state';
import { SvelteSet } from 'svelte/reactivity';
import * as m from '$lib/paraglide/messages.js';
- import { getRenamePreview, executeRename } from '$lib/api/settings.js';
+ import { getRenamePreview, executeRename, reorganizeFolderBatch } from '$lib/api/settings.js';
import {
RefreshCw,
CheckCircle,
@@ -17,18 +17,18 @@
X,
Files,
RotateCcw,
- FileEdit
+ FileEdit,
+ FolderSync
} from 'lucide-svelte';
- import type {
- RenamePreviewResult,
- RenameExecuteResult
- } from '$lib/server/library/naming/RenamePreviewService';
+ import type { RenamePreviewResult, RenameExecuteResult } from '$lib/library/naming/types.js';
// State
let loading = $state(true);
let executing = $state(false);
+ let reorganizing = $state(false);
let error = $state(null);
let success = $state(null);
+ let renameWarnings = $state([]);
let preview = $state(null);
let executeResult = $state(null);
@@ -39,7 +39,7 @@
let activeTab = $state<'willChange' | 'alreadyCorrect' | 'collisions' | 'errors'>('willChange');
let mediaTypeFilter = $state<'all' | 'movie' | 'tv'>('all');
const hasUnsavedDraft = $derived(page.url.searchParams.get('unsaved') === '1');
- const returnTo = $derived(page.url.searchParams.get('returnTo') || '/settings/naming');
+ const returnTo = $derived(page.url.searchParams.get('returnTo') || '/settings/library/naming');
// Load preview on mount
let previousMediaTypeFilter = $state<'all' | 'movie' | 'tv' | null>(null);
@@ -81,6 +81,7 @@
executing = true;
error = null;
success = null;
+ renameWarnings = [];
try {
const response = await executeRename(
@@ -94,6 +95,10 @@
executeResult = response as unknown as RenameExecuteResult;
+ if (executeResult?.warnings?.length) {
+ renameWarnings = executeResult.warnings;
+ }
+
if (executeResult && executeResult.succeeded > 0) {
success = m.settings_naming_rename_successCount({ count: executeResult.succeeded });
}
@@ -126,6 +131,52 @@
}
}
+ async function reorganizeFolders() {
+ if (!preview) return;
+
+ reorganizing = true;
+ error = null;
+
+ // Collect unique (mediaId, mediaType) from files that were successfully renamed
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- non-reactive local dedup
+ const mediaItems = new Map();
+ for (const item of preview.willChange) {
+ if (!selectedIds.has(item.fileId)) continue;
+ const key = `${item.mediaType}:${item.mediaId}`;
+ if (!mediaItems.has(key)) {
+ mediaItems.set(key, item.mediaType === 'movie' ? 'movie' : 'series');
+ }
+ }
+
+ const items = Array.from(mediaItems.entries()).map(([key, mediaType]) => ({
+ mediaId: key.split(':')[1]!,
+ mediaType
+ }));
+
+ let organized = 0;
+ let failed = 0;
+
+ try {
+ const result = await reorganizeFolderBatch(items);
+ organized = result.organized;
+ failed = result.failed;
+ } catch (e) {
+ error = e instanceof Error ? e.message : 'Folder reorganization failed';
+ reorganizing = false;
+ return;
+ }
+
+ if (organized > 0) {
+ success = m.settings_naming_rename_successCount({ count: organized });
+ }
+ if (failed > 0) {
+ error = `Folder reorganization: ${organized} succeeded, ${failed} failed`;
+ }
+
+ await loadPreview();
+ reorganizing = false;
+ }
+
function toggleSelect(fileId: string) {
if (selectedIds.has(fileId)) {
selectedIds.delete(fileId);
@@ -239,6 +290,17 @@
{/if}
+ {#if renameWarnings.length > 0}
+
+ {#each renameWarnings as warning (warning)}
+
+ {/each}
+
+ {/if}
+
{#if hasUnsavedDraft}
@@ -380,6 +442,19 @@
>{counts.errors}
+
+ {#if reorganizing}
+
+ Reorganizing...
+ {:else}
+
+ Reorganize Folders
+ {/if}
+
diff --git a/src/routes/settings/quality/+page.server.ts b/src/routes/settings/library/quality/+page.server.ts
similarity index 91%
rename from src/routes/settings/quality/+page.server.ts
rename to src/routes/settings/library/quality/+page.server.ts
index ec079043d..c192f09b5 100644
--- a/src/routes/settings/quality/+page.server.ts
+++ b/src/routes/settings/library/quality/+page.server.ts
@@ -4,6 +4,7 @@ import { db } from '$lib/server/db';
import { scoringProfiles, customFormats } from '$lib/server/db/schema';
import { DEFAULT_PROFILES, DEFAULT_RESOLUTION_ORDER, ALL_FORMATS } from '$lib/server/scoring';
import { toNullableNumber } from '$lib/utils/number.js';
+import { delayProfileService } from '$lib/server/monitoring/specifications/DelaySpecification.js';
// Built-in profile IDs - derived from DEFAULT_PROFILES for single source of truth
const BUILT_IN_PROFILE_IDS = DEFAULT_PROFILES.map((p) => p.id);
@@ -40,6 +41,7 @@ export const load: PageServerLoad = async ({ url }) => {
minScoreIncrement: p.minScoreIncrement ?? 0,
resolutionOrder: (p.resolutionOrder as Resolution[] | null) ?? DEFAULT_RESOLUTION_ORDER,
formatScores: p.formatScores ?? {},
+ requiredFormats: p.requiredFormats ?? [],
movieMinSizeGb: toNullableNumber(p.movieMinSizeGb),
movieMaxSizeGb: toNullableNumber(p.movieMaxSizeGb),
episodeMinSizeMb: toNullableNumber(p.episodeMinSizeMb),
@@ -55,6 +57,8 @@ export const load: PageServerLoad = async ({ url }) => {
return {
...p,
preventDowngrades: dbProfile?.preventDowngrades ?? p.preventDowngrades,
+ formatScores: dbProfile?.formatScores ?? p.formatScores ?? {},
+ requiredFormats: dbProfile?.requiredFormats ?? [],
movieMinSizeGb: toNullableNumber(dbProfile?.movieMinSizeGb),
movieMaxSizeGb: toNullableNumber(dbProfile?.movieMaxSizeGb),
episodeMinSizeMb: toNullableNumber(dbProfile?.episodeMinSizeMb),
@@ -106,6 +110,8 @@ export const load: PageServerLoad = async ({ url }) => {
// Combine formats
const allFormats = [...builtInFormats, ...customFormatsList];
+ const delayProfiles = await delayProfileService.getProfiles();
+
return {
activeTab,
// Profiles data
@@ -117,6 +123,8 @@ export const load: PageServerLoad = async ({ url }) => {
total: allFormats.length,
builtIn: builtInFormats.length,
custom: customFormatsList.length
- }
+ },
+ // Delay profiles
+ delayProfiles
};
};
diff --git a/src/routes/settings/quality/+page.svelte b/src/routes/settings/library/quality/+page.svelte
similarity index 92%
rename from src/routes/settings/quality/+page.svelte
rename to src/routes/settings/library/quality/+page.svelte
index 24517f6fa..de2531010 100644
--- a/src/routes/settings/quality/+page.svelte
+++ b/src/routes/settings/library/quality/+page.svelte
@@ -11,7 +11,8 @@
import { ConfirmationModal } from '$lib/components/ui/modal';
import { SettingsPage } from '$lib/components/ui/settings';
import { toasts } from '$lib/stores/toast.svelte';
- import { Sliders, Layers } from 'lucide-svelte';
+ import { Sliders, Layers, Clock } from 'lucide-svelte';
+ import { DelayProfileList } from '$lib/components/delayProfiles';
import {
createScoringProfile,
updateScoringProfile,
@@ -242,8 +243,19 @@
formatDeleteTarget = null;
}
}
+
+ // ===================
+ // Delay Profiles State
+ // ===================
+ // DelayProfileList is now a self-contained component at
+ // $lib/components/delayProfiles. All dp* state, functions, modal, and
+ // delete confirmation live there. This page just renders it.
+
+ {m.nav_qualitySettings()}
+
+
@@ -267,6 +279,16 @@
{m.settings_quality_tabFormats()}
+ setTab('delay-profiles')}
+ >
+
+ {m.settings_quality_delay_tab()}
+
@@ -296,6 +318,8 @@
onCreate={openAddFormatModal}
onDelete={confirmFormatDelete}
/>
+ {:else if activeTab === 'delay-profiles'}
+
{/if}
diff --git a/src/routes/settings/library/root-folders/+page.server.ts b/src/routes/settings/library/root-folders/+page.server.ts
new file mode 100644
index 000000000..a986e8f55
--- /dev/null
+++ b/src/routes/settings/library/root-folders/+page.server.ts
@@ -0,0 +1,6 @@
+import type { PageServerLoad } from './$types';
+import { loadStorageLayoutData } from '$lib/server/settings/layout-data.js';
+
+export const load: PageServerLoad = async () => {
+ return await loadStorageLayoutData();
+};
diff --git a/src/routes/settings/general/root-folders/+page.svelte b/src/routes/settings/library/root-folders/+page.svelte
similarity index 97%
rename from src/routes/settings/general/root-folders/+page.svelte
rename to src/routes/settings/library/root-folders/+page.svelte
index c38c7a9a8..a21848c48 100644
--- a/src/routes/settings/general/root-folders/+page.svelte
+++ b/src/routes/settings/library/root-folders/+page.svelte
@@ -187,13 +187,10 @@
- {m.settings_general_tabRootFolders()}
+ {m.nav_rootFolders()}
-
+
{#snippet actions()}
{
+ return await loadStorageLayoutData();
+};
diff --git a/src/routes/settings/monitoring/+layout.svelte b/src/routes/settings/monitoring/+layout.svelte
new file mode 100644
index 000000000..7ee349f07
--- /dev/null
+++ b/src/routes/settings/monitoring/+layout.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ {@render children()}
+
+
diff --git a/src/routes/settings/monitoring/+page.server.ts b/src/routes/settings/monitoring/+page.server.ts
new file mode 100644
index 000000000..fc4b19b29
--- /dev/null
+++ b/src/routes/settings/monitoring/+page.server.ts
@@ -0,0 +1,5 @@
+import { redirect } from '@sveltejs/kit';
+
+export const load = () => {
+ throw redirect(307, '/settings/monitoring/status');
+};
diff --git a/src/routes/settings/logs/+page.server.ts b/src/routes/settings/monitoring/logs/+page.server.ts
similarity index 100%
rename from src/routes/settings/logs/+page.server.ts
rename to src/routes/settings/monitoring/logs/+page.server.ts
diff --git a/src/routes/settings/logs/+page.svelte b/src/routes/settings/monitoring/logs/+page.svelte
similarity index 95%
rename from src/routes/settings/logs/+page.svelte
rename to src/routes/settings/monitoring/logs/+page.svelte
index 189e423fd..2d1672d54 100644
--- a/src/routes/settings/logs/+page.svelte
+++ b/src/routes/settings/monitoring/logs/+page.svelte
@@ -1,6 +1,7 @@
+
+{#snippet scanProgressBar()}
+ {#if layoutState.scanInProgress && layoutState.scanProgress}
+
+
+ {layoutState.scanProgress.rootFolderPath ?? 'Scanning...'}
+ {layoutState.scanProgress.filesProcessed} / {layoutState.scanProgress.filesFound}
+
+
+
+ {:else if layoutState.scanInProgress}
+
+
+
+ Starting scan...
+
+
+ {/if}
+{/snippet}
+
+{#if layoutState.scanInProgress}
+
+ {@render scanProgressBar()}
+
+{/if}
+
+{@render children()}
diff --git a/src/routes/settings/monitoring/status/+page.svelte b/src/routes/settings/monitoring/status/+page.svelte
new file mode 100644
index 000000000..58de3ae0a
--- /dev/null
+++ b/src/routes/settings/monitoring/status/+page.svelte
@@ -0,0 +1,1084 @@
+
+
+
+ {m.nav_storageMaintenance()}
+
+
+
+ {#snippet actions()}
+
+ void triggerLibraryScan()}
+ disabled={isScanning || data.rootFolders.length === 0}
+ >
+ {#if isScanning}
+
+ {m.settings_general_scanning()}
+ {:else}
+
+ {m.settings_general_scanLibraries()}
+ {/if}
+
+ {#if data.servers?.length > 0}
+ void triggerServerSync()}
+ disabled={isSyncing}
+ >
+
+ {isSyncing ? 'Syncing...' : 'Sync Servers'}
+
+ {/if}
+ {#if activeInsights.length > 0 || dismissedInsights.length > 0}
+ (insightsOpen = true)}
+ >
+
+ Insights
+ {#if activeInsights.length > 0}
+ {activeInsights.length}
+ {/if}
+
+ {/if}
+
+ {/snippet}
+
+
+
+
+
+
+
+ {m.settings_history_title()}
+ {m.settings_history_description()}
+
+
+ {#if retention}
+
+
+
+ {m.settings_history_file_days()}
+ {m.settings_history_file_days_help()}
+
+
+
+ days
+
+
+
+
+
+ {m.settings_history_library_days()}
+
+ {m.settings_history_library_days_help()}
+
+
+
+
+ days
+
+
+
+
+
+ {m.settings_history_scan_days()}
+ {m.settings_history_scan_days_help()}
+
+
+
+ days
+
+
+
+
+ {#if forecast}
+
+
+ {m.settings_history_forecast()}
+
+
+
+
+ {m.settings_history_forecast_current()}
+
+
+ {formatBytes(forecast.currentEstimatedBytes)}
+
+
+
+ {m.settings_history_forecast_30d()}
+
+ {formatBytes(forecast.projectedBytes30d)}
+
+
+
+ {m.settings_history_forecast_90d()}
+
+ {formatBytes(forecast.projectedBytes90d)}
+
+
+
+
+ {/if}
+
+
+
+ {#if retentionSaving}
+
+ {/if}
+ Save
+
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
+{#if insightsOpen}
+
+{/if}
+
+{#if searchModalItem}
+ {@const id = extractIdFromHref(searchModalItem.href)}
+ {@const isMovie = isMovieHref(searchModalItem.href)}
+ {@const seasonEp = parseSeasonEpisode(searchModalItem.title)}
+ (searchModalOpen = false)}
+ />
+{/if}
diff --git a/src/routes/settings/monitoring/status/folders/+page.server.ts b/src/routes/settings/monitoring/status/folders/+page.server.ts
new file mode 100644
index 000000000..cbed9d9c9
--- /dev/null
+++ b/src/routes/settings/monitoring/status/folders/+page.server.ts
@@ -0,0 +1,6 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ parent }) => {
+ await parent();
+ return {};
+};
diff --git a/src/routes/settings/monitoring/status/folders/+page.svelte b/src/routes/settings/monitoring/status/folders/+page.svelte
new file mode 100644
index 000000000..d5fdb7de5
--- /dev/null
+++ b/src/routes/settings/monitoring/status/folders/+page.svelte
@@ -0,0 +1,108 @@
+
+
+
+ {m.status_folders_title()}
+
+
+
+
+
+
+
diff --git a/src/routes/settings/monitoring/status/insights/+page.server.ts b/src/routes/settings/monitoring/status/insights/+page.server.ts
new file mode 100644
index 000000000..6ecbb340b
--- /dev/null
+++ b/src/routes/settings/monitoring/status/insights/+page.server.ts
@@ -0,0 +1,26 @@
+import type { PageServerLoad } from './$types';
+import { db } from '$lib/server/db';
+import { storageInsights } from '$lib/server/db/schema';
+import { sql } from 'drizzle-orm';
+
+export const load: PageServerLoad = async ({ parent }) => {
+ await parent();
+
+ // Load ALL insights (including dismissed) for the full management view
+ const allInsights = db
+ .select()
+ .from(storageInsights)
+ .orderBy(
+ sql`CASE ${storageInsights.dismissedAt} IS NULL WHEN 0 THEN 0 ELSE 1 END`,
+ sql`CASE ${storageInsights.severity}
+ WHEN 'critical' THEN 0
+ WHEN 'warning' THEN 1
+ WHEN 'info' THEN 2
+ ELSE 3
+ END`,
+ storageInsights.insightType
+ )
+ .all();
+
+ return { allInsights };
+};
diff --git a/src/routes/settings/monitoring/status/insights/+page.svelte b/src/routes/settings/monitoring/status/insights/+page.svelte
new file mode 100644
index 000000000..60f886067
--- /dev/null
+++ b/src/routes/settings/monitoring/status/insights/+page.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {m.status_insights_title()}
+
+
+
+
+
+
+
+ {#if dismissedInsights.length > 0}
+
+
+
+ {/if}
+
diff --git a/src/routes/settings/monitoring/status/libraries/+page.server.ts b/src/routes/settings/monitoring/status/libraries/+page.server.ts
new file mode 100644
index 000000000..cbed9d9c9
--- /dev/null
+++ b/src/routes/settings/monitoring/status/libraries/+page.server.ts
@@ -0,0 +1,6 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ parent }) => {
+ await parent();
+ return {};
+};
diff --git a/src/routes/settings/monitoring/status/libraries/+page.svelte b/src/routes/settings/monitoring/status/libraries/+page.svelte
new file mode 100644
index 000000000..5a2a83074
--- /dev/null
+++ b/src/routes/settings/monitoring/status/libraries/+page.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {m.status_libraries_title()}
+
+
+
+
+
+
+{#if libraryModalOpen}
+
+{/if}
diff --git a/src/routes/settings/general/status/media/+page.server.ts b/src/routes/settings/monitoring/status/media/+page.server.ts
similarity index 80%
rename from src/routes/settings/general/status/media/+page.server.ts
rename to src/routes/settings/monitoring/status/media/+page.server.ts
index 712a25d5b..a7dd05bdd 100644
--- a/src/routes/settings/general/status/media/+page.server.ts
+++ b/src/routes/settings/monitoring/status/media/+page.server.ts
@@ -10,6 +10,14 @@ import {
rootFolders,
series
} from '$lib/server/db/schema';
+import {
+ extractResolution,
+ extractVideoCodec,
+ extractHdrFormat,
+ extractAudioCodec,
+ extractContainer
+} from '$lib/server/storage/reconciliation/matchers.js';
+import { aggregateMovieRows } from './aggregate-movies.js';
export type MediaExplorerItem = {
id: string;
@@ -40,53 +48,6 @@ export type MediaExplorerItem = {
posterPath: string | null;
};
-function heightToResolution(height: number | null | undefined): string | null {
- if (!height) return null;
- if (height >= 2160) return '4K';
- if (height >= 1080) return '1080p';
- if (height >= 720) return '720p';
- if (height >= 480) return '480p';
- return 'SD';
-}
-
-function extractResolution(
- quality: { resolution?: string } | null | undefined,
- mediaInfo: { height?: number } | null | undefined
-): string | null {
- if (quality?.resolution) return quality.resolution;
- return heightToResolution(mediaInfo?.height);
-}
-
-function extractVideoCodec(
- quality: { codec?: string } | null | undefined,
- mediaInfo: { videoCodec?: string } | null | undefined
-): string | null {
- if (quality?.codec) return quality.codec.toUpperCase();
- if (mediaInfo?.videoCodec) return mediaInfo.videoCodec.toUpperCase();
- return null;
-}
-
-function extractHdrFormat(
- quality: { hdr?: string } | null | undefined,
- mediaInfo: { videoHdrFormat?: string } | null | undefined
-): string | null {
- if (quality?.hdr) return quality.hdr;
- if (mediaInfo?.videoHdrFormat) return mediaInfo.videoHdrFormat;
- return null;
-}
-
-function extractAudioCodec(mediaInfo: { audioCodec?: string } | null | undefined): string | null {
- if (mediaInfo?.audioCodec) return mediaInfo.audioCodec.toUpperCase();
- return null;
-}
-
-function extractContainer(
- mediaInfo: { containerFormat?: string } | null | undefined
-): string | null {
- if (mediaInfo?.containerFormat) return mediaInfo.containerFormat.toUpperCase();
- return null;
-}
-
export const load: PageServerLoad = async ({ url, parent }) => {
await parent();
@@ -120,9 +81,11 @@ export const load: PageServerLoad = async ({ url, parent }) => {
hasFile: movies.hasFile,
added: movies.added,
posterPath: movies.posterPath,
+ fileId: movieFiles.id,
fileSize: movieFiles.size,
quality: movieFiles.quality,
- mediaInfo: movieFiles.mediaInfo
+ mediaInfo: movieFiles.mediaInfo,
+ relativePath: movieFiles.relativePath
})
.from(movies)
.leftJoin(movieFiles, sql`${movieFiles.movieId} = ${movies.id}`),
@@ -189,42 +152,38 @@ export const load: PageServerLoad = async ({ url, parent }) => {
const allItems: MediaExplorerItem[] = [];
- const seenMovieIds = new Set();
- for (const row of movieRows) {
- if (seenMovieIds.has(row.id)) continue;
- seenMovieIds.add(row.id);
-
- const lib = row.libraryId ? libraryMap.get(row.libraryId) : null;
- const rf = row.rootFolderId ? rootFolderMap.get(row.rootFolderId) : null;
- const plays = row.tmdbId ? playStatsMap.get(row.tmdbId) : undefined;
+ for (const movie of aggregateMovieRows(movieRows)) {
+ const lib = movie.libraryId ? libraryMap.get(movie.libraryId) : null;
+ const rf = movie.rootFolderId ? rootFolderMap.get(movie.rootFolderId) : null;
+ const plays = movie.tmdbId ? playStatsMap.get(movie.tmdbId) : undefined;
allItems.push({
- id: row.id,
- tmdbId: row.tmdbId,
- title: row.title,
- year: row.year ?? null,
+ id: movie.id,
+ tmdbId: movie.tmdbId,
+ title: movie.title,
+ year: movie.year ?? null,
mediaType: 'movie',
mediaSubType: lib?.mediaSubType === 'anime' ? 'anime' : 'standard',
- libraryId: row.libraryId ?? null,
+ libraryId: movie.libraryId ?? null,
libraryName: lib?.name ?? '',
- rootFolderId: row.rootFolderId ?? null,
+ rootFolderId: movie.rootFolderId ?? null,
rootFolderName: rf?.name ?? null,
- monitored: row.monitored ?? true,
- hasFile: row.hasFile ?? false,
- fileSize: row.fileSize ?? 0,
- resolution: extractResolution(row.quality, row.mediaInfo),
- videoCodec: extractVideoCodec(row.quality, row.mediaInfo),
- hdrFormat: extractHdrFormat(row.quality, row.mediaInfo),
- audioCodec: extractAudioCodec(row.mediaInfo),
- containerFormat: extractContainer(row.mediaInfo),
- addedAt: row.added ?? null,
+ monitored: movie.monitored ?? true,
+ hasFile: movie.hasFile ?? false,
+ fileSize: movie.totalFileSize,
+ resolution: extractResolution(movie.bestQuality, movie.bestMediaInfo),
+ videoCodec: extractVideoCodec(movie.bestQuality, movie.bestMediaInfo),
+ hdrFormat: extractHdrFormat(movie.bestQuality, movie.bestMediaInfo),
+ audioCodec: extractAudioCodec(movie.bestMediaInfo),
+ containerFormat: extractContainer(movie.bestMediaInfo),
+ addedAt: movie.added ?? null,
playCount: Number(plays?.playCount ?? 0),
lastPlayedDate: plays?.lastPlayedDate ?? null,
isPlayed: Boolean(plays?.isPlayed),
playedPercentage: plays?.playedPercentage ?? null,
episodeCount: null,
episodeFileCount: null,
- posterPath: row.posterPath ?? null
+ posterPath: movie.posterPath ?? null
});
}
diff --git a/src/routes/settings/general/status/media/+page.svelte b/src/routes/settings/monitoring/status/media/+page.svelte
similarity index 98%
rename from src/routes/settings/general/status/media/+page.svelte
rename to src/routes/settings/monitoring/status/media/+page.svelte
index a15fb5ca1..5bce40f18 100644
--- a/src/routes/settings/general/status/media/+page.svelte
+++ b/src/routes/settings/monitoring/status/media/+page.svelte
@@ -7,6 +7,7 @@
import { createProgressiveRenderer } from '$lib/utils/progressive-render.svelte.ts';
import { formatBytes } from '$lib/utils/format';
import type { PageData } from './$types';
+ import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
@@ -64,13 +65,10 @@
- Media Explorer
+ {m.nav_mediaExplorer()}
-
+
{#snippet actions()}
& { id: string }): MovieJoinedRow => ({
+ tmdbId: 0,
+ title: 'Untitled',
+ year: null,
+ libraryId: null,
+ rootFolderId: null,
+ monitored: true,
+ hasFile: false,
+ added: null,
+ posterPath: null,
+ fileId: null,
+ fileSize: null,
+ quality: null,
+ mediaInfo: null,
+ relativePath: null,
+ ...overrides
+});
+
+describe('aggregateMovieRows', () => {
+ it('sums sizes and keeps the best file quality for a multi-quality movie', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({
+ id: 'm1',
+ title: 'Dune',
+ fileId: 'f1',
+ fileSize: 6000,
+ quality: { resolution: '2160p' },
+ mediaInfo: { videoCodec: 'hevc' },
+ relativePath: 'Dune.2160p.mkv'
+ }),
+ movieRow({
+ id: 'm1',
+ title: 'Dune',
+ fileId: 'f2',
+ fileSize: 3000,
+ quality: { resolution: '1080p' },
+ mediaInfo: { videoCodec: 'h264' },
+ relativePath: 'Dune.1080p.mkv'
+ })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('m1');
+ expect(result[0].totalFileSize).toBe(9000);
+ expect((result[0].bestQuality as { resolution?: string })?.resolution).toBe('2160p');
+ expect(result[0].bestMediaInfo).toEqual({ videoCodec: 'hevc' });
+ });
+
+ it('returns the single file quality and size for a movie with one file', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({
+ id: 'm1',
+ title: 'Solo',
+ fileId: 'f1',
+ fileSize: 4096,
+ quality: { resolution: '1080p' },
+ mediaInfo: { videoCodec: 'h264' },
+ relativePath: 'solo.mkv'
+ })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].totalFileSize).toBe(4096);
+ expect(result[0].bestQuality?.resolution).toBe('1080p');
+ expect(result[0].bestMediaInfo).toEqual({ videoCodec: 'h264' });
+ });
+
+ it('returns null quality/mediaInfo and zero size for a movie with no file rows', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({ id: 'm1', title: 'Ghost', fileId: null, fileSize: null })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].totalFileSize).toBe(0);
+ expect(result[0].bestQuality).toBeNull();
+ expect(result[0].bestMediaInfo).toBeNull();
+ });
+
+ it('produces one record per movie across multiple movies', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({
+ id: 'm1',
+ title: 'A',
+ fileId: 'f1',
+ fileSize: 6000,
+ quality: { resolution: '2160p' },
+ relativePath: 'a.mkv'
+ }),
+ movieRow({
+ id: 'm2',
+ title: 'B',
+ fileId: 'f2',
+ fileSize: 2000,
+ quality: { resolution: '1080p' },
+ relativePath: 'b.mkv'
+ })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect(result).toHaveLength(2);
+ expect(result.map((m) => m.id)).toEqual(['m1', 'm2']);
+ });
+
+ it('preserves movie-level fields from the grouped movie', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({
+ id: 'm1',
+ title: 'Dune',
+ year: 2021,
+ tmdbId: 438631,
+ libraryId: 'lib-1',
+ fileId: 'f1',
+ fileSize: 6000,
+ quality: { resolution: '2160p' },
+ relativePath: 'dune.mkv'
+ }),
+ movieRow({
+ id: 'm1',
+ title: 'Dune',
+ year: 2021,
+ tmdbId: 438631,
+ libraryId: 'lib-1',
+ fileId: 'f2',
+ fileSize: 3000,
+ quality: { resolution: '1080p' },
+ relativePath: 'dune2.mkv'
+ })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect(result[0].title).toBe('Dune');
+ expect(result[0].year).toBe(2021);
+ expect(result[0].tmdbId).toBe(438631);
+ expect(result[0].libraryId).toBe('lib-1');
+ });
+
+ it('defers to selectBestFile ranking (downloaded over strm regardless of resolution)', () => {
+ const rows: MovieJoinedRow[] = [
+ movieRow({
+ id: 'm1',
+ fileId: 'strm4k',
+ fileSize: 100,
+ quality: { resolution: '2160p' },
+ relativePath: 'movie.strm'
+ }),
+ movieRow({
+ id: 'm1',
+ fileId: 'real1080',
+ fileSize: 5000,
+ quality: { resolution: '1080p' },
+ relativePath: 'movie.mkv'
+ })
+ ];
+
+ const result = aggregateMovieRows(rows);
+
+ expect((result[0].bestQuality as { resolution?: string })?.resolution).toBe('1080p');
+ });
+});
diff --git a/src/routes/settings/monitoring/status/media/aggregate-movies.ts b/src/routes/settings/monitoring/status/media/aggregate-movies.ts
new file mode 100644
index 000000000..61d6581d9
--- /dev/null
+++ b/src/routes/settings/monitoring/status/media/aggregate-movies.ts
@@ -0,0 +1,92 @@
+import { selectBestFile, type BucketFile } from '$lib/server/quality/buckets.js';
+import type { movieFiles } from '$lib/server/db/schema.js';
+
+export interface MovieJoinedRow {
+ id: string;
+ tmdbId: number;
+ title: string;
+ year: number | null;
+ libraryId: string | null;
+ rootFolderId: string | null;
+ monitored: boolean | null;
+ hasFile: boolean | null;
+ added: string | null;
+ posterPath: string | null;
+ // per-file fields (one row per movieFile):
+ fileId: string | null;
+ fileSize: number | null;
+ quality: typeof movieFiles.$inferSelect.quality;
+ mediaInfo: typeof movieFiles.$inferSelect.mediaInfo;
+ relativePath: string | null;
+}
+
+export interface AggregatedMovie extends Omit<
+ MovieJoinedRow,
+ 'fileId' | 'fileSize' | 'quality' | 'mediaInfo' | 'relativePath'
+> {
+ totalFileSize: number;
+ bestQuality: typeof movieFiles.$inferSelect.quality;
+ bestMediaInfo: typeof movieFiles.$inferSelect.mediaInfo;
+}
+
+/**
+ * Group joined movie x movieFile rows into one record per movie: sum every
+ * file's size, and keep the quality/mediaInfo of the best file (via
+ * {@link selectBestFile} ranking: downloaded over .strm, then resolution,
+ * then size). Movies with no file rows get null quality/mediaInfo and size 0.
+ */
+export function aggregateMovieRows(rows: MovieJoinedRow[]): AggregatedMovie[] {
+ const groups = new Map();
+ for (const row of rows) {
+ let group = groups.get(row.id);
+ if (!group) {
+ group = [];
+ groups.set(row.id, group);
+ }
+ group.push(row);
+ }
+
+ const result: AggregatedMovie[] = [];
+ for (const group of groups.values()) {
+ // All rows for a movie share movie-level fields, so any row is fine;
+ // the query has no ORDER BY but this is safe.
+ const first = group[0];
+
+ const candidateFiles: BucketFile[] = [];
+ for (const row of group) {
+ if (row.fileId === null) continue;
+ candidateFiles.push({
+ id: row.fileId,
+ relativePath: row.relativePath ?? '',
+ quality: row.quality,
+ size: row.fileSize
+ });
+ }
+
+ const best = selectBestFile(candidateFiles);
+ const bestRow = best ? group.find((r) => r.fileId === best.id) : undefined;
+
+ let totalFileSize = 0;
+ for (const row of group) {
+ totalFileSize += row.fileSize ?? 0;
+ }
+
+ result.push({
+ id: first.id,
+ tmdbId: first.tmdbId,
+ title: first.title,
+ year: first.year,
+ libraryId: first.libraryId,
+ rootFolderId: first.rootFolderId,
+ monitored: first.monitored,
+ hasFile: first.hasFile,
+ added: first.added,
+ posterPath: first.posterPath,
+ totalFileSize,
+ bestQuality: bestRow?.quality ?? null,
+ bestMediaInfo: bestRow?.mediaInfo ?? null
+ });
+ }
+
+ return result;
+}
diff --git a/src/routes/settings/tasks/+page.server.ts b/src/routes/settings/monitoring/tasks/+page.server.ts
similarity index 100%
rename from src/routes/settings/tasks/+page.server.ts
rename to src/routes/settings/monitoring/tasks/+page.server.ts
diff --git a/src/routes/settings/tasks/+page.svelte b/src/routes/settings/monitoring/tasks/+page.svelte
similarity index 100%
rename from src/routes/settings/tasks/+page.svelte
rename to src/routes/settings/monitoring/tasks/+page.svelte
diff --git a/src/routes/settings/profiles/+page.server.ts b/src/routes/settings/profiles/+page.server.ts
deleted file mode 100644
index 95fee067e..000000000
--- a/src/routes/settings/profiles/+page.server.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { redirect } from '@sveltejs/kit';
-import type { PageServerLoad } from './$types';
-
-export const load: PageServerLoad = async () => {
- // Redirect to the new unified quality settings page
- throw redirect(301, '/settings/quality?tab=profiles');
-};
diff --git a/src/routes/settings/profiles/+page.svelte b/src/routes/settings/profiles/+page.svelte
deleted file mode 100644
index d9d39946b..000000000
--- a/src/routes/settings/profiles/+page.svelte
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-{m.settings_profiles_redirecting()}
diff --git a/src/routes/settings/system/+layout.svelte b/src/routes/settings/system/+layout.svelte
index 215425d99..070d559e5 100644
--- a/src/routes/settings/system/+layout.svelte
+++ b/src/routes/settings/system/+layout.svelte
@@ -1,5 +1,5 @@
-
+
diff --git a/src/routes/settings/system/+page.server.ts b/src/routes/settings/system/+page.server.ts
index 028abc0e6..3428e7323 100644
--- a/src/routes/settings/system/+page.server.ts
+++ b/src/routes/settings/system/+page.server.ts
@@ -1,6 +1,5 @@
import { redirect } from '@sveltejs/kit';
-import type { PageServerLoad } from './$types';
-export const load: PageServerLoad = async () => {
- throw redirect(302, '/settings/system/general');
+export const load = () => {
+ throw redirect(307, '/settings/system/general');
};
diff --git a/src/routes/settings/system/+page.svelte b/src/routes/settings/system/+page.svelte
deleted file mode 100644
index 55bf104d2..000000000
--- a/src/routes/settings/system/+page.svelte
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- {m.settings_profiles_redirecting()}
-
-
diff --git a/src/routes/settings/system/captcha/+page.svelte b/src/routes/settings/system/captcha/+page.svelte
index 67deb97c7..98194bd15 100644
--- a/src/routes/settings/system/captcha/+page.svelte
+++ b/src/routes/settings/system/captcha/+page.svelte
@@ -312,30 +312,6 @@
-
-
-
{m.settings_integrations_captcha_timing()}
diff --git a/src/routes/settings/system/cinephage/+page.server.ts b/src/routes/settings/system/cinephage/+page.server.ts
new file mode 100644
index 000000000..515ac70a3
--- /dev/null
+++ b/src/routes/settings/system/cinephage/+page.server.ts
@@ -0,0 +1,50 @@
+import type { PageServerLoad } from './$types';
+import { getCinephageSettingsService } from '$lib/server/cinephage/settings/CinephageSettingsService.js';
+import { getCinephageCore } from '$lib/server/cinephage/core/CinephageCore.js';
+import { getCinephageModuleRegistry } from '$lib/server/cinephage/registry/CinephageModuleRegistry.js';
+
+export const load: PageServerLoad = async () => {
+ const settings = getCinephageSettingsService();
+ const core = getCinephageCore();
+ const registry = getCinephageModuleRegistry();
+
+ const [config, identity] = await Promise.all([settings.getConfig(), core.getIdentity()]);
+
+ const moduleMetadata = registry.getAll().map((m) => ({
+ moduleId: m.id,
+ name: m.name,
+ description: m.description,
+ maturity: m.maturity,
+ capabilities: m.capabilities
+ }));
+
+ const modules = await Promise.all(
+ moduleMetadata.map(async (meta) => {
+ const mod = await settings.getModuleConfig(meta.moduleId);
+ const registered = registry.getById(meta.moduleId);
+ return {
+ ...meta,
+ enabled: mod.enabled,
+ settings: mod.settings,
+ lastError: mod.lastError,
+ effectiveEnabled:
+ config.enabled && mod.enabled && (registered ? registered.isEnabled() : true)
+ };
+ })
+ );
+
+ return {
+ config: {
+ enabled: config.enabled,
+ baseUrl: config.baseUrl,
+ versionOverride: config.versionOverride,
+ commitOverride: config.commitOverride
+ },
+ identity: {
+ version: identity.version,
+ commit: identity.commit,
+ isConfigured: identity.isConfigured
+ },
+ modules
+ };
+};
diff --git a/src/routes/settings/system/cinephage/+page.svelte b/src/routes/settings/system/cinephage/+page.svelte
new file mode 100644
index 000000000..9dc0e838e
--- /dev/null
+++ b/src/routes/settings/system/cinephage/+page.svelte
@@ -0,0 +1,289 @@
+
+
+
+
+
+
+
+
+
+ {m.settings_cinephage_heading()}
+
+ {identity.isConfigured
+ ? m.settings_cinephage_statusConnected()
+ : m.settings_cinephage_statusDisconnected()}
+
+
+
+
+ {testing
+ ? m.settings_cinephage_statusChecking()
+ : m.settings_cinephage_testConnection()}
+
+
+
+
+
+ {#if testResult}
+
+ {testResult.success ? 'Connection successful' : testResult.error}
+
+ {/if}
+
+
+
+ {m.settings_cinephage_detectedIdentity()}:
+
+ v{identity.version}
+ {#if identity.commit}
+ ({identity.commit})
+ {:else}
+ (unknown)
+ {/if}
+
+
+
+ {#if fetchingRelease}
+
+ {:else}
+
+ {/if}
+ {fetchingRelease ? 'Fetching...' : 'Fetch latest release'}
+
+
+
+
+
+
+ {m.settings_cinephage_advanced()}
+
+
+
+
+
+
+
+ {m.settings_cinephage_modules()}
+
+ {#if !config.enabled}
+
+ {m.settings_cinephage_subsystemDisabled()}
+
+ {/if}
+
+
+ {#each modules as mod (mod.moduleId)}
+
+
+
+
+ {mod.name}
+ {mod.description}
+
+ handleToggleModule(mod.moduleId, mod.enabled)}
+ />
+
+
+ {#if mod.moduleId === 'library-streaming'}
+
+
+
+
+ {/if}
+
+ {#if mod.lastError}
+ {mod.lastError}
+ {/if}
+
+
+ {/each}
+
+
+
diff --git a/src/routes/settings/system/general/+page.svelte b/src/routes/settings/system/general/+page.svelte
index b8c7fd7c1..30dc61c99 100644
--- a/src/routes/settings/system/general/+page.svelte
+++ b/src/routes/settings/system/general/+page.svelte
@@ -311,13 +311,28 @@
{m.settings_system_permissions()}:
-
- - {m.settings_system_permM3u()}
- - {m.settings_system_permEpg()}
- - {m.settings_system_permLiveTvStreams()}
- - {m.settings_system_permStreamingContent()}
- - {m.settings_system_permNoLibrary()}
- - {m.settings_system_permNoSettings()}
+
+ -
+ {m.settings_system_permM3u()}
+
+ -
+ {m.settings_system_permEpg()}
+
+ -
+ {m.settings_system_permLiveTvStreams()}
+
+ -
+ {m.settings_system_permStreamingContent()}
+
+ -
+ {m.settings_system_permNoLibrary()}
+
+ -
+ {m.settings_system_permNoSettings()}
+
diff --git a/src/test/db-helper.ts b/src/test/db-helper.ts
index 0ac8c7f28..b5a0e2d71 100644
--- a/src/test/db-helper.ts
+++ b/src/test/db-helper.ts
@@ -19,6 +19,17 @@ export function destroyTestDb(testDb: TestDatabase) {
testDb.sqlite.close();
}
+// Storage / media-server tables. Cleared BEFORE core tables because storage_items
+// references movie_files / episode_files. Children-first ordering.
+const STORAGE_TABLES = [
+ 'storageItemServerLinks',
+ 'storageInsights',
+ 'storageItems',
+ 'mediaServerSyncedItems',
+ 'mediaServerSyncedRuns',
+ 'mediaBrowserServers'
+] as const;
+
const CORE_TABLES = [
'scoringProfiles',
'customFormats',
@@ -30,10 +41,15 @@ const CORE_TABLES = [
'seasons',
'episodes',
'episodeFiles',
- 'downloadQueue'
+ 'downloadQueue',
+ 'rootFolders',
+ 'unmatchedFiles'
] as const;
export function clearTestDb(testDb: TestDatabase) {
+ for (const table of STORAGE_TABLES) {
+ testDb.db.delete(schema[table]).run();
+ }
for (const table of CORE_TABLES) {
testDb.db.delete(schema[table]).run();
}
diff --git a/src/test/fixtures/index.ts b/src/test/fixtures/index.ts
index edbe008a9..53e7b37e7 100644
--- a/src/test/fixtures/index.ts
+++ b/src/test/fixtures/index.ts
@@ -44,3 +44,4 @@ export {
type TestUserFixture,
type TestSessionFixture
} from './auth.js';
+export * from './storage.js';
diff --git a/src/test/fixtures/storage.ts b/src/test/fixtures/storage.ts
new file mode 100644
index 000000000..5b64464e0
--- /dev/null
+++ b/src/test/fixtures/storage.ts
@@ -0,0 +1,74 @@
+import { randomUUID } from 'node:crypto';
+import type { mediaServerSyncedItems, storageItems } from '$lib/server/db/schema.js';
+
+type MediaServerSyncedItemInsert = typeof mediaServerSyncedItems.$inferInsert;
+type StorageItemInsert = typeof storageItems.$inferInsert;
+
+export function createMediaServerItem(
+ overrides: Partial = {}
+): MediaServerSyncedItemInsert {
+ return {
+ id: randomUUID(),
+ serverId: overrides.serverId ?? 'server-1',
+ serverItemId: overrides.serverItemId ?? randomUUID(),
+ tmdbId: overrides.tmdbId ?? 100,
+ tvdbId: overrides.tvdbId ?? null,
+ imdbId: overrides.imdbId ?? null,
+ title: overrides.title ?? 'Test Item',
+ year: overrides.year ?? 2020,
+ itemType: overrides.itemType ?? 'movie',
+ seriesName: overrides.seriesName ?? null,
+ seasonNumber: overrides.seasonNumber ?? null,
+ episodeNumber: overrides.episodeNumber ?? null,
+ playCount: overrides.playCount ?? 0,
+ lastPlayedDate: overrides.lastPlayedDate ?? null,
+ playedPercentage: overrides.playedPercentage ?? null,
+ isPlayed: overrides.isPlayed ?? 0,
+ videoCodec: overrides.videoCodec ?? null,
+ videoProfile: overrides.videoProfile ?? null,
+ videoBitDepth: overrides.videoBitDepth ?? null,
+ width: overrides.width ?? null,
+ height: overrides.height ?? null,
+ isHDR: overrides.isHDR ?? 0,
+ hdrFormat: overrides.hdrFormat ?? null,
+ videoBitrate: overrides.videoBitrate ?? null,
+ audioCodec: overrides.audioCodec ?? null,
+ audioChannels: overrides.audioChannels ?? null,
+ audioChannelLayout: overrides.audioChannelLayout ?? null,
+ audioBitrate: overrides.audioBitrate ?? null,
+ audioLanguages: overrides.audioLanguages ?? [],
+ subtitleLanguages: overrides.subtitleLanguages ?? [],
+ containerFormat: overrides.containerFormat ?? null,
+ fileSize: overrides.fileSize ?? null,
+ bitrate: overrides.bitrate ?? null,
+ duration: overrides.duration ?? null,
+ lastSyncedAt: overrides.lastSyncedAt ?? new Date().toISOString(),
+ createdAt: overrides.createdAt ?? new Date().toISOString(),
+ updatedAt: overrides.updatedAt ?? new Date().toISOString(),
+ ...overrides
+ };
+}
+
+export function createStorageItem(overrides: Partial = {}): StorageItemInsert {
+ return {
+ id: randomUUID(),
+ itemType: overrides.itemType ?? 'movie',
+ tmdbId: overrides.tmdbId ?? 100,
+ tvdbId: overrides.tvdbId ?? null,
+ imdbId: overrides.imdbId ?? null,
+ title: overrides.title ?? 'Test Item',
+ year: overrides.year ?? 2020,
+ seriesName: overrides.seriesName ?? null,
+ seasonNumber: overrides.seasonNumber ?? null,
+ episodeNumber: overrides.episodeNumber ?? null,
+ movieFileId: overrides.movieFileId ?? null,
+ episodeFileId: overrides.episodeFileId ?? null,
+ rootFolderId: overrides.rootFolderId ?? null,
+ libraryId: overrides.libraryId ?? null,
+ sourceSystem: overrides.sourceSystem ?? 'local',
+ matchConfidence: overrides.matchConfidence ?? 'exact',
+ firstSeenAt: overrides.firstSeenAt ?? new Date().toISOString(),
+ lastReconciledAt: overrides.lastReconciledAt ?? new Date().toISOString(),
+ ...overrides
+ };
+}
|