mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-25 20:32:26 +01:00
feat: add sync configuration for ARR instances
- Introduced a new sync page for ARR instances, allowing users to configure quality profiles, delay profiles, and media management settings. - Implemented backend logic to handle saving sync configurations. - Enhanced the cache management system to support debouncing based on specific paths. - Updated the layout to include a new "Sync" tab in the navigation. - Added UI components for managing quality profiles, delay profiles, and media management settings with appropriate state management. - Included informative modals to guide users on how the sync process works.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export let icon: ComponentType | undefined = undefined;
|
||||
export let iconClass: string = '';
|
||||
export let square: boolean = true; // Fixed size square button
|
||||
export let hasDropdown: boolean = false;
|
||||
export let dropdownPosition: 'left' | 'right' | 'middle' = 'left';
|
||||
@@ -39,7 +40,7 @@
|
||||
on:click
|
||||
>
|
||||
{#if icon}
|
||||
<svelte:component this={icon} size={20} class="text-neutral-700 dark:text-neutral-300" />
|
||||
<svelte:component this={icon} size={20} class="text-neutral-700 dark:text-neutral-300 {iconClass}" />
|
||||
{/if}
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export let checked: boolean = false;
|
||||
export let icon: ComponentType;
|
||||
export let color: string = 'blue'; // blue, green, red, or hex color like #FFC230
|
||||
export let color: string = 'accent'; // accent, blue, green, red, or hex color like #FFC230
|
||||
export let shape: 'square' | 'circle' | 'rounded' = 'rounded';
|
||||
export let disabled: boolean = false;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
$: shapeClass = shapeClasses[shape] || shapeClasses.rounded;
|
||||
$: isCustomColor = color.startsWith('#');
|
||||
$: isAccent = color === 'accent';
|
||||
</script>
|
||||
|
||||
{#if isCustomColor}
|
||||
@@ -38,6 +39,23 @@
|
||||
<svelte:component this={icon} size={14} class="text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else if isAccent}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
{disabled}
|
||||
on:click
|
||||
class="flex h-5 w-5 items-center justify-center border-2 transition-all {shapeClass} {checked
|
||||
? 'bg-accent-600 border-accent-600 dark:bg-accent-500 dark:border-accent-500 hover:brightness-110'
|
||||
: 'bg-neutral-50 border-neutral-300 hover:bg-neutral-100 hover:border-neutral-400 dark:bg-neutral-800 dark:border-neutral-700 dark:hover:bg-neutral-700 dark:hover:border-neutral-500'} {disabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer focus:outline-none'}"
|
||||
>
|
||||
{#if checked}
|
||||
<svelte:component this={icon} size={14} class="text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,8 @@ import { migration as migration010 } from './migrations/010_add_is_private.ts';
|
||||
import { migration as migration011 } from './migrations/011_create_upgrade_configs.ts';
|
||||
import { migration as migration012 } from './migrations/012_add_upgrade_last_run.ts';
|
||||
import { migration as migration013 } from './migrations/013_add_upgrade_dry_run.ts';
|
||||
import { migration as migration014 } from './migrations/014_create_ai_settings.ts';
|
||||
import { migration as migration015 } from './migrations/015_create_arr_sync_tables.ts';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
@@ -245,7 +247,9 @@ export function loadMigrations(): Migration[] {
|
||||
migration010,
|
||||
migration011,
|
||||
migration012,
|
||||
migration013
|
||||
migration013,
|
||||
migration014,
|
||||
migration015
|
||||
];
|
||||
|
||||
// Sort by version number
|
||||
|
||||
42
src/lib/server/db/migrations/014_create_ai_settings.ts
Normal file
42
src/lib/server/db/migrations/014_create_ai_settings.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 014: Create ai_settings table
|
||||
*
|
||||
* Creates a table to store AI configuration settings.
|
||||
* Uses a singleton pattern (single row with id=1).
|
||||
*
|
||||
* Settings:
|
||||
* - enabled: Master switch for AI features
|
||||
* - api_url: OpenAI-compatible API endpoint
|
||||
* - api_key: API key for authentication (encrypted/obfuscated in storage)
|
||||
* - model: Model name to use for generation
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 14,
|
||||
name: 'Create ai_settings table',
|
||||
|
||||
up: `
|
||||
CREATE TABLE ai_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
|
||||
-- AI Configuration
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
api_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1',
|
||||
api_key TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT 'gpt-4o-mini',
|
||||
|
||||
-- Metadata
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default settings
|
||||
INSERT INTO ai_settings (id) VALUES (1);
|
||||
`,
|
||||
|
||||
down: `
|
||||
DROP TABLE IF EXISTS ai_settings;
|
||||
`
|
||||
};
|
||||
78
src/lib/server/db/migrations/015_create_arr_sync_tables.ts
Normal file
78
src/lib/server/db/migrations/015_create_arr_sync_tables.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Migration } from '../migrations.ts';
|
||||
|
||||
/**
|
||||
* Migration 015: Create arr sync tables
|
||||
*
|
||||
* Creates tables for storing sync configuration per arr instance.
|
||||
* - Quality profile selections and trigger config
|
||||
* - Delay profile selections and trigger config
|
||||
* - Media management settings and trigger config
|
||||
*
|
||||
* Trigger types: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule'
|
||||
*/
|
||||
|
||||
export const migration: Migration = {
|
||||
version: 15,
|
||||
name: 'Create arr sync tables',
|
||||
|
||||
up: `
|
||||
-- Quality profile selections (many-to-many)
|
||||
CREATE TABLE arr_sync_quality_profiles (
|
||||
instance_id INTEGER NOT NULL,
|
||||
database_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (instance_id, database_id, profile_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Quality profile trigger config (one per instance)
|
||||
CREATE TABLE arr_sync_quality_profiles_config (
|
||||
instance_id INTEGER PRIMARY KEY,
|
||||
trigger TEXT NOT NULL DEFAULT 'none',
|
||||
cron TEXT,
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Delay profile selections (many-to-many)
|
||||
CREATE TABLE arr_sync_delay_profiles (
|
||||
instance_id INTEGER NOT NULL,
|
||||
database_id INTEGER NOT NULL,
|
||||
profile_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (instance_id, database_id, profile_id),
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Delay profile trigger config (one per instance)
|
||||
CREATE TABLE arr_sync_delay_profiles_config (
|
||||
instance_id INTEGER PRIMARY KEY,
|
||||
trigger TEXT NOT NULL DEFAULT 'none',
|
||||
cron TEXT,
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Media management (one row per instance)
|
||||
CREATE TABLE arr_sync_media_management (
|
||||
instance_id INTEGER PRIMARY KEY,
|
||||
naming_database_id INTEGER,
|
||||
quality_definitions_database_id INTEGER,
|
||||
media_settings_database_id INTEGER,
|
||||
trigger TEXT NOT NULL DEFAULT 'none',
|
||||
cron TEXT,
|
||||
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for faster lookups
|
||||
CREATE INDEX idx_arr_sync_quality_profiles_instance ON arr_sync_quality_profiles(instance_id);
|
||||
CREATE INDEX idx_arr_sync_delay_profiles_instance ON arr_sync_delay_profiles(instance_id);
|
||||
`,
|
||||
|
||||
down: `
|
||||
DROP INDEX IF EXISTS idx_arr_sync_delay_profiles_instance;
|
||||
DROP INDEX IF EXISTS idx_arr_sync_quality_profiles_instance;
|
||||
DROP TABLE IF EXISTS arr_sync_media_management;
|
||||
DROP TABLE IF EXISTS arr_sync_delay_profiles_config;
|
||||
DROP TABLE IF EXISTS arr_sync_delay_profiles;
|
||||
DROP TABLE IF EXISTS arr_sync_quality_profiles_config;
|
||||
DROP TABLE IF EXISTS arr_sync_quality_profiles;
|
||||
`
|
||||
};
|
||||
261
src/lib/server/db/queries/arrSync.ts
Normal file
261
src/lib/server/db/queries/arrSync.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { db } from '../db.ts';
|
||||
|
||||
// Types
|
||||
export type SyncTrigger = 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule';
|
||||
|
||||
export interface ProfileSelection {
|
||||
databaseId: number;
|
||||
profileId: number;
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
trigger: SyncTrigger;
|
||||
cron: string | null;
|
||||
}
|
||||
|
||||
export interface QualityProfilesSyncData {
|
||||
selections: ProfileSelection[];
|
||||
config: SyncConfig;
|
||||
}
|
||||
|
||||
export interface DelayProfilesSyncData {
|
||||
selections: ProfileSelection[];
|
||||
config: SyncConfig;
|
||||
}
|
||||
|
||||
export interface MediaManagementSyncData {
|
||||
namingDatabaseId: number | null;
|
||||
qualityDefinitionsDatabaseId: number | null;
|
||||
mediaSettingsDatabaseId: number | null;
|
||||
trigger: SyncTrigger;
|
||||
cron: string | null;
|
||||
}
|
||||
|
||||
// Row types
|
||||
interface ProfileSelectionRow {
|
||||
instance_id: number;
|
||||
database_id: number;
|
||||
profile_id: number;
|
||||
}
|
||||
|
||||
interface ConfigRow {
|
||||
instance_id: number;
|
||||
trigger: string;
|
||||
cron: string | null;
|
||||
}
|
||||
|
||||
interface MediaManagementRow {
|
||||
instance_id: number;
|
||||
naming_database_id: number | null;
|
||||
quality_definitions_database_id: number | null;
|
||||
media_settings_database_id: number | null;
|
||||
trigger: string;
|
||||
cron: string | null;
|
||||
}
|
||||
|
||||
export const arrSyncQueries = {
|
||||
// ========== Quality Profiles ==========
|
||||
|
||||
getQualityProfilesSync(instanceId: number): QualityProfilesSyncData {
|
||||
const selectionRows = db.query<ProfileSelectionRow>(
|
||||
'SELECT * FROM arr_sync_quality_profiles WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
|
||||
const configRow = db.queryFirst<ConfigRow>(
|
||||
'SELECT * FROM arr_sync_quality_profiles_config WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
|
||||
return {
|
||||
selections: selectionRows.map((row) => ({
|
||||
databaseId: row.database_id,
|
||||
profileId: row.profile_id
|
||||
})),
|
||||
config: {
|
||||
trigger: (configRow?.trigger as SyncTrigger) ?? 'none',
|
||||
cron: configRow?.cron ?? null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
saveQualityProfilesSync(
|
||||
instanceId: number,
|
||||
selections: ProfileSelection[],
|
||||
config: SyncConfig
|
||||
): void {
|
||||
// Clear existing selections
|
||||
db.execute('DELETE FROM arr_sync_quality_profiles WHERE instance_id = ?', instanceId);
|
||||
|
||||
// Insert new selections
|
||||
for (const sel of selections) {
|
||||
db.execute(
|
||||
'INSERT INTO arr_sync_quality_profiles (instance_id, database_id, profile_id) VALUES (?, ?, ?)',
|
||||
instanceId,
|
||||
sel.databaseId,
|
||||
sel.profileId
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert config
|
||||
db.execute(
|
||||
`INSERT INTO arr_sync_quality_profiles_config (instance_id, trigger, cron)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?`,
|
||||
instanceId,
|
||||
config.trigger,
|
||||
config.cron,
|
||||
config.trigger,
|
||||
config.cron
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Delay Profiles ==========
|
||||
|
||||
getDelayProfilesSync(instanceId: number): DelayProfilesSyncData {
|
||||
const selectionRows = db.query<ProfileSelectionRow>(
|
||||
'SELECT * FROM arr_sync_delay_profiles WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
|
||||
const configRow = db.queryFirst<ConfigRow>(
|
||||
'SELECT * FROM arr_sync_delay_profiles_config WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
|
||||
return {
|
||||
selections: selectionRows.map((row) => ({
|
||||
databaseId: row.database_id,
|
||||
profileId: row.profile_id
|
||||
})),
|
||||
config: {
|
||||
trigger: (configRow?.trigger as SyncTrigger) ?? 'none',
|
||||
cron: configRow?.cron ?? null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
saveDelayProfilesSync(
|
||||
instanceId: number,
|
||||
selections: ProfileSelection[],
|
||||
config: SyncConfig
|
||||
): void {
|
||||
// Clear existing selections
|
||||
db.execute('DELETE FROM arr_sync_delay_profiles WHERE instance_id = ?', instanceId);
|
||||
|
||||
// Insert new selections
|
||||
for (const sel of selections) {
|
||||
db.execute(
|
||||
'INSERT INTO arr_sync_delay_profiles (instance_id, database_id, profile_id) VALUES (?, ?, ?)',
|
||||
instanceId,
|
||||
sel.databaseId,
|
||||
sel.profileId
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert config
|
||||
db.execute(
|
||||
`INSERT INTO arr_sync_delay_profiles_config (instance_id, trigger, cron)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(instance_id) DO UPDATE SET trigger = ?, cron = ?`,
|
||||
instanceId,
|
||||
config.trigger,
|
||||
config.cron,
|
||||
config.trigger,
|
||||
config.cron
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Media Management ==========
|
||||
|
||||
getMediaManagementSync(instanceId: number): MediaManagementSyncData {
|
||||
const row = db.queryFirst<MediaManagementRow>(
|
||||
'SELECT * FROM arr_sync_media_management WHERE instance_id = ?',
|
||||
instanceId
|
||||
);
|
||||
|
||||
return {
|
||||
namingDatabaseId: row?.naming_database_id ?? null,
|
||||
qualityDefinitionsDatabaseId: row?.quality_definitions_database_id ?? null,
|
||||
mediaSettingsDatabaseId: row?.media_settings_database_id ?? null,
|
||||
trigger: (row?.trigger as SyncTrigger) ?? 'none',
|
||||
cron: row?.cron ?? null
|
||||
};
|
||||
},
|
||||
|
||||
saveMediaManagementSync(instanceId: number, data: MediaManagementSyncData): void {
|
||||
db.execute(
|
||||
`INSERT INTO arr_sync_media_management
|
||||
(instance_id, naming_database_id, quality_definitions_database_id, media_settings_database_id, trigger, cron)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(instance_id) DO UPDATE SET
|
||||
naming_database_id = ?,
|
||||
quality_definitions_database_id = ?,
|
||||
media_settings_database_id = ?,
|
||||
trigger = ?,
|
||||
cron = ?`,
|
||||
instanceId,
|
||||
data.namingDatabaseId,
|
||||
data.qualityDefinitionsDatabaseId,
|
||||
data.mediaSettingsDatabaseId,
|
||||
data.trigger,
|
||||
data.cron,
|
||||
data.namingDatabaseId,
|
||||
data.qualityDefinitionsDatabaseId,
|
||||
data.mediaSettingsDatabaseId,
|
||||
data.trigger,
|
||||
data.cron
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Full Sync Data ==========
|
||||
|
||||
getFullSyncData(instanceId: number) {
|
||||
return {
|
||||
qualityProfiles: this.getQualityProfilesSync(instanceId),
|
||||
delayProfiles: this.getDelayProfilesSync(instanceId),
|
||||
mediaManagement: this.getMediaManagementSync(instanceId)
|
||||
};
|
||||
},
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
/**
|
||||
* Remove orphaned profile references when a profile is deleted
|
||||
*/
|
||||
removeQualityProfileReference(databaseId: number, profileId: number): number {
|
||||
return db.execute(
|
||||
'DELETE FROM arr_sync_quality_profiles WHERE database_id = ? AND profile_id = ?',
|
||||
databaseId,
|
||||
profileId
|
||||
);
|
||||
},
|
||||
|
||||
removeDelayProfileReference(databaseId: number, profileId: number): number {
|
||||
return db.execute(
|
||||
'DELETE FROM arr_sync_delay_profiles WHERE database_id = ? AND profile_id = ?',
|
||||
databaseId,
|
||||
profileId
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all references to a database (when database is deleted)
|
||||
*/
|
||||
removeDatabaseReferences(databaseId: number): void {
|
||||
db.execute('DELETE FROM arr_sync_quality_profiles WHERE database_id = ?', databaseId);
|
||||
db.execute('DELETE FROM arr_sync_delay_profiles WHERE database_id = ?', databaseId);
|
||||
db.execute(
|
||||
'UPDATE arr_sync_media_management SET naming_database_id = NULL WHERE naming_database_id = ?',
|
||||
databaseId
|
||||
);
|
||||
db.execute(
|
||||
'UPDATE arr_sync_media_management SET quality_definitions_database_id = NULL WHERE quality_definitions_database_id = ?',
|
||||
databaseId
|
||||
);
|
||||
db.execute(
|
||||
'UPDATE arr_sync_media_management SET media_settings_database_id = NULL WHERE media_settings_database_id = ?',
|
||||
databaseId
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -224,9 +224,9 @@ const caches = new Map<number, PCDCache>();
|
||||
const watchers = new Map<number, Deno.FsWatcher>();
|
||||
|
||||
/**
|
||||
* Debounce timers - maps database instance ID to timer
|
||||
* Debounce timers - maps "databaseInstanceId:pcdPath" to timer
|
||||
*/
|
||||
const debounceTimers = new Map<number, number>();
|
||||
const debounceTimers = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Debounce delay in milliseconds
|
||||
@@ -397,18 +397,29 @@ export async function startWatch(pcdPath: string, databaseInstanceId: number): P
|
||||
/**
|
||||
* Stop watching a PCD for changes
|
||||
*/
|
||||
function stopWatch(databaseInstanceId: number): void {
|
||||
function stopWatch(databaseInstanceId: number, pcdPath?: string): void {
|
||||
const watcher = watchers.get(databaseInstanceId);
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
watchers.delete(databaseInstanceId);
|
||||
}
|
||||
|
||||
// Clear any pending debounce timer
|
||||
const timer = debounceTimers.get(databaseInstanceId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(databaseInstanceId);
|
||||
// Clear any pending debounce timer for this specific path
|
||||
if (pcdPath) {
|
||||
const timerKey = `${databaseInstanceId}:${pcdPath}`;
|
||||
const timer = debounceTimers.get(timerKey);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(timerKey);
|
||||
}
|
||||
} else {
|
||||
// Clear all timers for this databaseInstanceId (fallback)
|
||||
for (const [key, timer] of debounceTimers.entries()) {
|
||||
if (key.startsWith(`${databaseInstanceId}:`)) {
|
||||
clearTimeout(timer);
|
||||
debounceTimers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,8 +427,10 @@ function stopWatch(databaseInstanceId: number): void {
|
||||
* Schedule a rebuild with debouncing
|
||||
*/
|
||||
function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void {
|
||||
// Clear existing timer
|
||||
const existingTimer = debounceTimers.get(databaseInstanceId);
|
||||
const timerKey = `${databaseInstanceId}:${pcdPath}`;
|
||||
|
||||
// Clear existing timer for this specific path
|
||||
const existingTimer = debounceTimers.get(timerKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
@@ -426,7 +439,7 @@ function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void {
|
||||
const timer = setTimeout(async () => {
|
||||
await logger.info('Rebuilding cache due to file changes', {
|
||||
source: 'PCDCache',
|
||||
meta: { databaseInstanceId }
|
||||
meta: { databaseInstanceId, pcdPath }
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -436,12 +449,12 @@ function scheduleRebuild(pcdPath: string, databaseInstanceId: number): void {
|
||||
} catch (error) {
|
||||
await logger.error('Failed to rebuild cache', {
|
||||
source: 'PCDCache',
|
||||
meta: { error: String(error), databaseInstanceId }
|
||||
meta: { error: String(error), databaseInstanceId, pcdPath }
|
||||
});
|
||||
}
|
||||
|
||||
debounceTimers.delete(databaseInstanceId);
|
||||
debounceTimers.delete(timerKey);
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
debounceTimers.set(databaseInstanceId, timer);
|
||||
debounceTimers.set(timerKey, timer);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Tabs from '$ui/navigation/tabs/Tabs.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Library, ArrowUpCircle, ScrollText } from 'lucide-svelte';
|
||||
import { Library, RefreshCw, ArrowUpCircle, ScrollText } from 'lucide-svelte';
|
||||
|
||||
$: instanceId = $page.params.id;
|
||||
$: currentPath = $page.url.pathname;
|
||||
@@ -13,6 +13,12 @@
|
||||
active: currentPath.includes('/library'),
|
||||
icon: Library
|
||||
},
|
||||
{
|
||||
label: 'Sync',
|
||||
href: `/arr/${instanceId}/sync`,
|
||||
active: currentPath.includes('/sync'),
|
||||
icon: RefreshCw
|
||||
},
|
||||
{
|
||||
label: 'Upgrades',
|
||||
href: `/arr/${instanceId}/upgrades`,
|
||||
|
||||
169
src/routes/arr/[id]/sync/+page.server.ts
Normal file
169
src/routes/arr/[id]/sync/+page.server.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import type { ServerLoad, Actions } from '@sveltejs/kit';
|
||||
import { arrInstancesQueries } from '$db/queries/arrInstances.ts';
|
||||
import { arrSyncQueries, type SyncTrigger, type ProfileSelection } from '$db/queries/arrSync.ts';
|
||||
import { pcdManager } from '$pcd/pcd.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
import * as qualityProfileQueries from '$pcd/queries/qualityProfiles/index.ts';
|
||||
import * as delayProfileQueries from '$pcd/queries/delayProfiles/index.ts';
|
||||
|
||||
export const load: ServerLoad = async ({ params }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
error(404, `Invalid instance ID: ${params.id}`);
|
||||
}
|
||||
|
||||
const instance = arrInstancesQueries.getById(id);
|
||||
|
||||
if (!instance) {
|
||||
error(404, `Instance not found: ${id}`);
|
||||
}
|
||||
|
||||
// Get all databases
|
||||
const databases = pcdManager.getAll();
|
||||
|
||||
// Fetch profiles from each database
|
||||
const databasesWithProfiles = await Promise.all(
|
||||
databases.map(async (db) => {
|
||||
const cache = pcdManager.getCache(db.id);
|
||||
if (!cache) {
|
||||
return {
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
qualityProfiles: [],
|
||||
delayProfiles: []
|
||||
};
|
||||
}
|
||||
|
||||
const [qualityProfiles, delayProfiles] = await Promise.all([
|
||||
qualityProfileQueries.list(cache),
|
||||
delayProfileQueries.list(cache)
|
||||
]);
|
||||
|
||||
return {
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
qualityProfiles,
|
||||
delayProfiles
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Load existing sync data
|
||||
const syncData = arrSyncQueries.getFullSyncData(id);
|
||||
|
||||
return {
|
||||
instance,
|
||||
databases: databasesWithProfiles,
|
||||
syncData
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveQualityProfiles: async ({ params, request }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { error: 'Invalid instance ID' });
|
||||
}
|
||||
|
||||
const instance = arrInstancesQueries.getById(id);
|
||||
const formData = await request.formData();
|
||||
const selectionsJson = formData.get('selections') as string;
|
||||
const trigger = formData.get('trigger') as SyncTrigger;
|
||||
const cron = formData.get('cron') as string | null;
|
||||
|
||||
try {
|
||||
const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]');
|
||||
arrSyncQueries.saveQualityProfilesSync(id, selections, {
|
||||
trigger: trigger || 'none',
|
||||
cron: cron || null
|
||||
});
|
||||
|
||||
await logger.info(`Quality profiles sync config saved for "${instance?.name}"`, {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, profileCount: selections.length, trigger }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
await logger.error('Failed to save quality profiles sync config', {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, error: e }
|
||||
});
|
||||
return fail(500, { error: 'Failed to save quality profiles sync config' });
|
||||
}
|
||||
},
|
||||
|
||||
saveDelayProfiles: async ({ params, request }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { error: 'Invalid instance ID' });
|
||||
}
|
||||
|
||||
const instance = arrInstancesQueries.getById(id);
|
||||
const formData = await request.formData();
|
||||
const selectionsJson = formData.get('selections') as string;
|
||||
const trigger = formData.get('trigger') as SyncTrigger;
|
||||
const cron = formData.get('cron') as string | null;
|
||||
|
||||
try {
|
||||
const selections: ProfileSelection[] = JSON.parse(selectionsJson || '[]');
|
||||
arrSyncQueries.saveDelayProfilesSync(id, selections, {
|
||||
trigger: trigger || 'none',
|
||||
cron: cron || null
|
||||
});
|
||||
|
||||
await logger.info(`Delay profiles sync config saved for "${instance?.name}"`, {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, profileCount: selections.length, trigger }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
await logger.error('Failed to save delay profiles sync config', {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, error: e }
|
||||
});
|
||||
return fail(500, { error: 'Failed to save delay profiles sync config' });
|
||||
}
|
||||
},
|
||||
|
||||
saveMediaManagement: async ({ params, request }) => {
|
||||
const id = parseInt(params.id || '', 10);
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { error: 'Invalid instance ID' });
|
||||
}
|
||||
|
||||
const instance = arrInstancesQueries.getById(id);
|
||||
const formData = await request.formData();
|
||||
const namingDatabaseId = formData.get('namingDatabaseId') as string | null;
|
||||
const qualityDefinitionsDatabaseId = formData.get('qualityDefinitionsDatabaseId') as string | null;
|
||||
const mediaSettingsDatabaseId = formData.get('mediaSettingsDatabaseId') as string | null;
|
||||
const trigger = formData.get('trigger') as SyncTrigger;
|
||||
const cron = formData.get('cron') as string | null;
|
||||
|
||||
try {
|
||||
arrSyncQueries.saveMediaManagementSync(id, {
|
||||
namingDatabaseId: namingDatabaseId ? parseInt(namingDatabaseId, 10) : null,
|
||||
qualityDefinitionsDatabaseId: qualityDefinitionsDatabaseId ? parseInt(qualityDefinitionsDatabaseId, 10) : null,
|
||||
mediaSettingsDatabaseId: mediaSettingsDatabaseId ? parseInt(mediaSettingsDatabaseId, 10) : null,
|
||||
trigger: trigger || 'none',
|
||||
cron: cron || null
|
||||
});
|
||||
|
||||
await logger.info(`Media management sync config saved for "${instance?.name}"`, {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, trigger }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
await logger.error('Failed to save media management sync config', {
|
||||
source: 'sync',
|
||||
meta: { instanceId: id, error: e }
|
||||
});
|
||||
return fail(500, { error: 'Failed to save media management sync config' });
|
||||
}
|
||||
}
|
||||
};
|
||||
155
src/routes/arr/[id]/sync/+page.svelte
Normal file
155
src/routes/arr/[id]/sync/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { Info } from 'lucide-svelte';
|
||||
import InfoModal from '$ui/modal/InfoModal.svelte';
|
||||
import QualityProfiles from './components/QualityProfiles.svelte';
|
||||
import DelayProfiles from './components/DelayProfiles.svelte';
|
||||
import MediaManagement from './components/MediaManagement.svelte';
|
||||
import type { SyncTrigger } from '$db/queries/arrSync.ts';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let showInfoModal = false;
|
||||
|
||||
// Initialize state from loaded sync data
|
||||
function buildProfileState(
|
||||
selections: { databaseId: number; profileId: number }[]
|
||||
): Record<number, Record<number, boolean>> {
|
||||
const state: Record<number, Record<number, boolean>> = {};
|
||||
for (const sel of selections) {
|
||||
if (!state[sel.databaseId]) {
|
||||
state[sel.databaseId] = {};
|
||||
}
|
||||
state[sel.databaseId][sel.profileId] = true;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
let qualityProfileState = buildProfileState(data.syncData.qualityProfiles.selections);
|
||||
let qualityProfileTrigger: SyncTrigger = data.syncData.qualityProfiles.config.trigger;
|
||||
let qualityProfileCron: string = data.syncData.qualityProfiles.config.cron || '0 * * * *';
|
||||
|
||||
let delayProfileState = buildProfileState(data.syncData.delayProfiles.selections);
|
||||
let delayProfileTrigger: SyncTrigger = data.syncData.delayProfiles.config.trigger;
|
||||
let delayProfileCron: string = data.syncData.delayProfiles.config.cron || '0 * * * *';
|
||||
|
||||
let mediaManagementState = {
|
||||
namingDatabaseId: data.syncData.mediaManagement.namingDatabaseId,
|
||||
qualityDefinitionsDatabaseId: data.syncData.mediaManagement.qualityDefinitionsDatabaseId,
|
||||
mediaSettingsDatabaseId: data.syncData.mediaManagement.mediaSettingsDatabaseId
|
||||
};
|
||||
let mediaManagementTrigger: SyncTrigger = data.syncData.mediaManagement.trigger;
|
||||
let mediaManagementCron: string = data.syncData.mediaManagement.cron || '0 * * * *';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.instance.name} - Sync - Profilarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mt-6 space-y-6 pb-32">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-50">
|
||||
Sync Configuration
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure which profiles and settings to sync to this instance.
|
||||
</p>
|
||||
</div>
|
||||
<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} />
|
||||
How it works
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<QualityProfiles
|
||||
databases={data.databases}
|
||||
bind:state={qualityProfileState}
|
||||
bind:syncTrigger={qualityProfileTrigger}
|
||||
bind:cronExpression={qualityProfileCron}
|
||||
/>
|
||||
<DelayProfiles
|
||||
databases={data.databases}
|
||||
bind:state={delayProfileState}
|
||||
bind:syncTrigger={delayProfileTrigger}
|
||||
bind:cronExpression={delayProfileCron}
|
||||
/>
|
||||
<MediaManagement
|
||||
databases={data.databases}
|
||||
bind:state={mediaManagementState}
|
||||
bind:syncTrigger={mediaManagementTrigger}
|
||||
bind:cronExpression={mediaManagementCron}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InfoModal bind:open={showInfoModal} header="How Sync Works">
|
||||
<div class="space-y-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Automatic Dependencies</div>
|
||||
<p class="mt-1">
|
||||
Quality Profiles will automatically sync the custom formats they need - you don't need to select them separately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100">Namespacing</div>
|
||||
<p class="mt-1">
|
||||
Similarly named items from different databases will include invisible namespaces to ensure they don't override each other.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100 mb-3">Sync Methods</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="font-medium text-neutral-800 dark:text-neutral-200">Manual</div>
|
||||
<p class="mt-0.5">You manually click the sync button. Useful for media management settings that rarely get updates.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-800 dark:text-neutral-200">Schedule</div>
|
||||
<p class="mt-0.5">Syncs on a defined schedule using cron expressions.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-800 dark:text-neutral-200">On Pull</div>
|
||||
<p class="mt-0.5">Syncs when the upstream database gets a change (when you pull from remote).</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="font-medium text-neutral-800 dark:text-neutral-200">On Change</div>
|
||||
<p class="mt-0.5">Syncs when anything changes - whether you pull from upstream or change something yourself.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<div class="font-medium text-neutral-900 dark:text-neutral-100 mb-3">Cron Expressions</div>
|
||||
<p class="mb-3">Schedule uses standard cron syntax: <code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">minute hour day month weekday</code></p>
|
||||
<div class="space-y-1.5 font-mono text-xs">
|
||||
<div class="flex gap-3">
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 * * * *</code>
|
||||
<span class="font-sans">Every hour</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">*/15 * * * *</code>
|
||||
<span class="font-sans">Every 15 minutes</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 0 * * *</code>
|
||||
<span class="font-sans">Daily at midnight</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-neutral-800">0 6 * * 1</code>
|
||||
<span class="font-sans">Every Monday at 6am</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InfoModal>
|
||||
122
src/routes/arr/[id]/sync/components/DelayProfiles.svelte
Normal file
122
src/routes/arr/[id]/sync/components/DelayProfiles.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import type { DelayProfileTableRow } from '$pcd/queries/delayProfiles/types.ts';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import SyncFooter from './SyncFooter.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store.ts';
|
||||
|
||||
interface DatabaseWithProfiles {
|
||||
id: number;
|
||||
name: string;
|
||||
delayProfiles: DelayProfileTableRow[];
|
||||
}
|
||||
|
||||
export let databases: DatabaseWithProfiles[];
|
||||
export let state: Record<number, Record<number, boolean>> = {};
|
||||
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
|
||||
export let cronExpression: string = '0 * * * *';
|
||||
|
||||
let saving = false;
|
||||
|
||||
// Initialize state for all databases/profiles
|
||||
$: {
|
||||
for (const db of databases) {
|
||||
if (!state[db.id]) {
|
||||
state[db.id] = {};
|
||||
}
|
||||
for (const profile of db.delayProfiles) {
|
||||
if (state[db.id][profile.id] === undefined) {
|
||||
state[db.id][profile.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelections(): { databaseId: number; profileId: number }[] {
|
||||
const selections: { databaseId: number; profileId: number }[] = [];
|
||||
for (const [dbId, profiles] of Object.entries(state)) {
|
||||
for (const [profileId, selected] of Object.entries(profiles)) {
|
||||
if (selected) {
|
||||
selections.push({ databaseId: parseInt(dbId), profileId: parseInt(profileId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('selections', JSON.stringify(getSelections()));
|
||||
formData.set('trigger', syncTrigger);
|
||||
formData.set('cron', cronExpression);
|
||||
|
||||
const response = await fetch('?/saveDelayProfiles', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alertStore.add('success', 'Delay profiles sync config saved');
|
||||
} else {
|
||||
alertStore.add('error', 'Failed to save delay profiles sync config');
|
||||
}
|
||||
} catch {
|
||||
alertStore.add('error', 'Failed to save delay profiles sync config');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Delay Profiles</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Select delay profiles to sync to this instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
{#if databases.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No databases configured</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each databases as database}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
{database.name}
|
||||
</h3>
|
||||
|
||||
{#if database.delayProfiles.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No delay profiles</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each database.delayProfiles as profile}
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={state[database.id]?.[profile.id] ?? false}
|
||||
icon={Check}
|
||||
shape="rounded"
|
||||
on:click={() => {
|
||||
state[database.id][profile.id] = !state[database.id][profile.id];
|
||||
}}
|
||||
/>
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-sm text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">
|
||||
{profile.name}
|
||||
</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
|
||||
</div>
|
||||
229
src/routes/arr/[id]/sync/components/MediaManagement.svelte
Normal file
229
src/routes/arr/[id]/sync/components/MediaManagement.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Check } from 'lucide-svelte';
|
||||
import SyncFooter from './SyncFooter.svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store.ts';
|
||||
|
||||
interface Database {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export let databases: Database[];
|
||||
export let state: {
|
||||
namingDatabaseId: number | null;
|
||||
qualityDefinitionsDatabaseId: number | null;
|
||||
mediaSettingsDatabaseId: number | null;
|
||||
} = {
|
||||
namingDatabaseId: null,
|
||||
qualityDefinitionsDatabaseId: null,
|
||||
mediaSettingsDatabaseId: null
|
||||
};
|
||||
|
||||
let showNamingDropdown = false;
|
||||
let showQualityDropdown = false;
|
||||
let showMediaDropdown = false;
|
||||
|
||||
function getSelectedName(id: number | null): string {
|
||||
if (id === null) return 'None';
|
||||
return databases.find((db) => db.id === id)?.name ?? 'None';
|
||||
}
|
||||
|
||||
function selectNaming(id: number | null) {
|
||||
state.namingDatabaseId = id;
|
||||
showNamingDropdown = false;
|
||||
}
|
||||
|
||||
function selectQuality(id: number | null) {
|
||||
state.qualityDefinitionsDatabaseId = id;
|
||||
showQualityDropdown = false;
|
||||
}
|
||||
|
||||
function selectMedia(id: number | null) {
|
||||
state.mediaSettingsDatabaseId = id;
|
||||
showMediaDropdown = false;
|
||||
}
|
||||
|
||||
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
|
||||
export let cronExpression: string = '0 * * * *';
|
||||
|
||||
let saving = false;
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('namingDatabaseId', state.namingDatabaseId?.toString() ?? '');
|
||||
formData.set('qualityDefinitionsDatabaseId', state.qualityDefinitionsDatabaseId?.toString() ?? '');
|
||||
formData.set('mediaSettingsDatabaseId', state.mediaSettingsDatabaseId?.toString() ?? '');
|
||||
formData.set('trigger', syncTrigger);
|
||||
formData.set('cron', cronExpression);
|
||||
|
||||
const response = await fetch('?/saveMediaManagement', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alertStore.add('success', 'Media management sync config saved');
|
||||
} else {
|
||||
alertStore.add('error', 'Failed to save media management sync config');
|
||||
}
|
||||
} catch {
|
||||
alertStore.add('error', 'Failed to save media management sync config');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Media Management</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Select which database to use for each media management setting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<div class="grid gap-6 sm:grid-cols-3">
|
||||
<!-- Naming -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Naming
|
||||
</span>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showNamingDropdown = !showNamingDropdown)}
|
||||
on:blur={() => setTimeout(() => (showNamingDropdown = 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>{getSelectedName(state.namingDatabaseId)}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
{#if showNamingDropdown}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectNaming(null)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>None</span>
|
||||
{#if state.namingDatabaseId === null}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{#each databases as database}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectNaming(database.id)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>{database.name}</span>
|
||||
{#if state.namingDatabaseId === database.id}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quality Definitions -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Quality Definitions
|
||||
</span>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showQualityDropdown = !showQualityDropdown)}
|
||||
on:blur={() => setTimeout(() => (showQualityDropdown = 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>{getSelectedName(state.qualityDefinitionsDatabaseId)}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
{#if showQualityDropdown}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectQuality(null)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>None</span>
|
||||
{#if state.qualityDefinitionsDatabaseId === null}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{#each databases as database}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectQuality(database.id)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>{database.name}</span>
|
||||
{#if state.qualityDefinitionsDatabaseId === database.id}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Media Settings -->
|
||||
<div class="space-y-2">
|
||||
<span class="block text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Media Settings
|
||||
</span>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showMediaDropdown = !showMediaDropdown)}
|
||||
on:blur={() => setTimeout(() => (showMediaDropdown = 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>{getSelectedName(state.mediaSettingsDatabaseId)}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
|
||||
{#if showMediaDropdown}
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectMedia(null)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>None</span>
|
||||
{#if state.mediaSettingsDatabaseId === null}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{#each databases as database}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => selectMedia(database.id)}
|
||||
class="flex w-full items-center justify-between 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"
|
||||
>
|
||||
<span>{database.name}</span>
|
||||
{#if state.mediaSettingsDatabaseId === database.id}
|
||||
<Check size={14} class="text-accent-600 dark:text-accent-400" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
|
||||
</div>
|
||||
122
src/routes/arr/[id]/sync/components/QualityProfiles.svelte
Normal file
122
src/routes/arr/[id]/sync/components/QualityProfiles.svelte
Normal file
@@ -0,0 +1,122 @@
|
||||
<script lang="ts">
|
||||
import type { QualityProfileTableRow } from '$pcd/queries/qualityProfiles/types.ts';
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import SyncFooter from './SyncFooter.svelte';
|
||||
import { Check } from 'lucide-svelte';
|
||||
import { alertStore } from '$lib/client/alerts/store.ts';
|
||||
|
||||
interface DatabaseWithProfiles {
|
||||
id: number;
|
||||
name: string;
|
||||
qualityProfiles: QualityProfileTableRow[];
|
||||
}
|
||||
|
||||
export let databases: DatabaseWithProfiles[];
|
||||
export let state: Record<number, Record<number, boolean>> = {};
|
||||
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
|
||||
export let cronExpression: string = '0 * * * *';
|
||||
|
||||
let saving = false;
|
||||
|
||||
// Initialize state for all databases/profiles
|
||||
$: {
|
||||
for (const db of databases) {
|
||||
if (!state[db.id]) {
|
||||
state[db.id] = {};
|
||||
}
|
||||
for (const profile of db.qualityProfiles) {
|
||||
if (state[db.id][profile.id] === undefined) {
|
||||
state[db.id][profile.id] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSelections(): { databaseId: number; profileId: number }[] {
|
||||
const selections: { databaseId: number; profileId: number }[] = [];
|
||||
for (const [dbId, profiles] of Object.entries(state)) {
|
||||
for (const [profileId, selected] of Object.entries(profiles)) {
|
||||
if (selected) {
|
||||
selections.push({ databaseId: parseInt(dbId), profileId: parseInt(profileId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return selections;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set('selections', JSON.stringify(getSelections()));
|
||||
formData.set('trigger', syncTrigger);
|
||||
formData.set('cron', cronExpression);
|
||||
|
||||
const response = await fetch('?/saveQualityProfiles', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alertStore.add('success', 'Quality profiles sync config saved');
|
||||
} else {
|
||||
alertStore.add('error', 'Failed to save quality profiles sync config');
|
||||
}
|
||||
} catch {
|
||||
alertStore.add('error', 'Failed to save quality profiles sync config');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">Quality Profiles</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Select quality profiles to sync to this instance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
{#if databases.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No databases configured</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each databases as database}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
{database.name}
|
||||
</h3>
|
||||
|
||||
{#if database.qualityProfiles.length === 0}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">No quality profiles</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{#each database.qualityProfiles as profile}
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={state[database.id]?.[profile.id] ?? false}
|
||||
icon={Check}
|
||||
shape="rounded"
|
||||
on:click={() => {
|
||||
state[database.id][profile.id] = !state[database.id][profile.id];
|
||||
}}
|
||||
/>
|
||||
<code class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-sm text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50">
|
||||
{profile.name}
|
||||
</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SyncFooter bind:syncTrigger bind:cronExpression {saving} on:save={handleSave} />
|
||||
</div>
|
||||
70
src/routes/arr/[id]/sync/components/SyncFooter.svelte
Normal file
70
src/routes/arr/[id]/sync/components/SyncFooter.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import IconCheckbox from '$ui/form/IconCheckbox.svelte';
|
||||
import { Check, RefreshCw, Save, Loader2 } from 'lucide-svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let syncTrigger: 'none' | 'manual' | 'on_pull' | 'on_change' | 'schedule' = 'none';
|
||||
export let cronExpression: string = '0 * * * *';
|
||||
export let saving: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ save: void }>();
|
||||
|
||||
const triggerOptions = [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'on_pull', label: 'On Pull' },
|
||||
{ value: 'on_change', label: 'On Change' },
|
||||
{ value: 'schedule', label: 'Schedule' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="border-t border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-neutral-500 dark:text-neutral-400">Trigger</span>
|
||||
{#each triggerOptions as option}
|
||||
<div class="flex items-center gap-2">
|
||||
<IconCheckbox
|
||||
checked={syncTrigger === option.value}
|
||||
icon={Check}
|
||||
shape="rounded"
|
||||
on:click={() => (syncTrigger = option.value)}
|
||||
/>
|
||||
<span class="text-sm text-neutral-700 dark:text-neutral-300">{option.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if syncTrigger === 'schedule'}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={cronExpression}
|
||||
placeholder="0 * * * *"
|
||||
class="w-32 rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-mono text-sm text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Sync Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
on:click={() => dispatch('save')}
|
||||
class="flex items-center gap-1.5 rounded-lg bg-accent-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if saving}
|
||||
<Loader2 size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<Save size={14} />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user