feat: support database sync priority per Arr instance (#694)

This commit is contained in:
santiagosayshey
2026-06-27 14:06:52 +09:30
committed by GitHub
parent 9152507540
commit 6fe959604a
21 changed files with 1100 additions and 224 deletions

View File

@@ -175,8 +175,10 @@ Each entity carries:
- `section` and `sectionLabel` (e.g. `custom_formats` / `Custom Format`)
- `state` and `stateLabel` (`missing` / `modified` / `extra`)
- optional `databaseId` / `databaseName` for database-scoped QP and CF drift
- `tone` for badge color signaling
- `summary` (one-line description, e.g. `3 changes detected`)
- optional duplicate drift metadata for lower-priority database duplicates
- `changes[]`: per-field `DriftDisplayChange` rows with `label`, optional
`detail`, and `expected` / `actual` `DriftDisplayValue`s. Values carry
`text`, optional `mono`, and optional `tone`.
@@ -198,6 +200,29 @@ flags, minimum score, order, and tags into friendly field rows.
For media management the formatter turns media settings, naming, and quality
definition changes into friendly field rows.
### Duplicate Drift Metadata
Quality profile sync can select profiles from multiple databases. When more
than one selected database manages the same quality profile or custom format
name, priority determines the effective winner. The highest-priority database
is synced last and owns the Arr state for that name.
Drift display entities can mark lower-priority rows with duplicate metadata:
| Field | Purpose |
| -------------------- | -------------------------------------------------- |
| `reason` | Currently `lower_priority_duplicate` |
| `key` | Stable section/name key for the duplicated entity |
| `winnerDatabaseId` | Database id that owns the effective synced version |
| `winnerDatabaseName` | Database name shown on the drift page |
Current behavior is intentionally conservative: duplicate drift is still shown,
counted, hashed, included in progress chips, included in tab badges, and
eligible for notifications. The metadata only exposes which rows are
lower-priority duplicates. A future settings-backed "include duplicate drift"
preference may use the same metadata to control drift page visibility, sync
progress counts, badge counts, and notifications.
Display types live in `src/lib/shared/drift.ts`.
## Arr Drift Page
@@ -216,6 +241,8 @@ Layout:
columns, mirroring the dev changes-page diff idiom. Each drifted entity is
one row; expanding shows a `DriftFieldDiffTable` with `Field` / `Profilarr`
/ `<Arr type> - <Arr name>` columns rendering the entity's `changes[]`.
- Lower-priority duplicate drift rows are labelled with the duplicate marker
and the winning database name. They remain visible in the table.
State rendering inside the entities section:

View File

@@ -21,6 +21,12 @@ control.
- [Processor Flow](#processor-flow)
- [Status](#status)
- [Section Registry](#section-registry)
- [Database Priority](#database-priority)
- [Schema](#schema)
- [Lifecycle](#lifecycle)
- [Sync Order](#sync-order)
- [UI](#ui)
- [Open Questions](#open-questions)
- [Transformation](#transformation)
- [Custom Formats](#custom-formats)
- [Quality Profiles](#quality-profiles)
@@ -104,11 +110,93 @@ logic. Each section type implements a `SectionHandler` interface:
Three handlers register themselves on import via `registerSection()`:
| Section | Config scope |
| ----------------- | ----------------------------------------------------------------------- |
| `qualityProfiles` | Multi-profile selection from one database |
| `delayProfiles` | Single profile selection (or none) |
| `mediaManagement` | Three independent configs (naming, media settings, quality definitions) |
| Section | Config scope |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `qualityProfiles` | Multi-profile selection from one or more databases (see [Database Priority](#database-priority)) |
| `delayProfiles` | Single profile selection (or none) |
| `mediaManagement` | Three independent configs (naming, media settings, quality definitions) |
## Database Priority
Quality profile sync supports multiple databases per Arr instance. A priority
ordering determines which database's version of an entity wins when more than
one database manages the same name. Other sections (delay profiles, media
management) are unaffected because they are idempotent, single-config
selections where priority has no practical effect.
### Schema
A junction table stores the priority ordering per instance:
```sql
CREATE TABLE arr_sync_database_priority (
instance_id INTEGER NOT NULL,
database_id INTEGER NOT NULL,
priority INTEGER NOT NULL,
PRIMARY KEY (instance_id, database_id),
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE,
FOREIGN KEY (database_id) REFERENCES database_instances(id) ON DELETE CASCADE
);
```
Every (instance, database) pair has a row. Priority 1 is the highest priority.
The table is always fully populated: every Arr instance has a row for every
linked database.
### Lifecycle
Priority rows are created and removed automatically. The user never manually
adds or removes a database from the priority list.
| Event | Action |
| ------------------ | ---------------------------------------------------------------- |
| Database linked | Insert a row for every Arr instance, appended at lowest priority |
| Instance created | Insert a row for every linked database, ordered by database ID |
| Database unlinked | FK cascade deletes all rows for that database |
| Instance deleted | FK cascade deletes all rows for that instance |
| User reorders (UI) | Update priority values to reflect the new drag-and-drop ordering |
On migration, existing (instance, database) pairs are seeded using
`ROW_NUMBER() OVER (PARTITION BY instance_id ORDER BY database_id)` so every
instance gets a clean 1, 2, 3... sequence based on database creation order.
### Sync Order
The syncer groups quality profile selections by database, then iterates
databases in priority order from lowest to highest. The highest-priority
database syncs last, so its version of any shared entity overwrites earlier
versions via Arr's name-based matching.
For a setup with two databases (foo at priority 1, bar at priority 2):
1. Bar syncs first (lower priority). Its profiles and referenced CFs are
pushed to Arr.
2. Foo syncs next (higher priority). Its profiles and referenced CFs are
pushed, overwriting any names that bar already created.
The Arr API's name-based create-or-update behavior handles the overwriting
naturally. No merge logic or conflict resolution is needed.
CFs are pulled along with their profiles as they are today. If both databases
define a CF with the same name, the higher-priority database's version is the
one that persists after sync completes.
### UI
The sync page shows each database as a draggable card, ordered by priority.
Profile toggles are nested inside each database card. Users can select
profiles from multiple databases simultaneously. Drag-and-drop reordering
sets the priority, with the topmost card being highest priority.
### Open Questions
- **On-demand entity sync and hierarchy.** Entity sync takes an explicit
(instanceId, databaseId, entityName) and pushes from that database
regardless of priority. If a user edits an entity in a lower-priority
database and pushes, it overwrites the higher-priority version until the
next full sync restores it. This is accepted for now because the user
explicitly chose to push that edit. Revisit if this causes confusion in
practice.
## Transformation
@@ -271,6 +359,15 @@ Drift currently covers custom formats, quality profiles, and the default delay
profile. Media management coverage (naming, media settings, quality
definitions) is planned as a follow-up.
For custom formats and quality profiles, drift is computed per-database. Each
database's expected state is built independently (no cross-database dedup), so
overlapping entities appear in every database that manages them. If a
higher-priority database overwrites a lower-priority database's entity, the
lower-priority database reports drift for that entity. This is intentional:
it gives visibility into which databases conflict and what each one expects.
Drift counts are raw sums across databases (the same entity drifted in two
databases counts as two).
After a successful sync that touched Arr, the sync handler enqueues an
`arr.drift` job for the same instance so the drift status (and the per-section
progress chips on the sync page) reflect the new state without waiting for the

View File

@@ -1,13 +1,20 @@
<script lang="ts">
export let isDragging = false;
export let onDragHandlePointerDown: ((e: PointerEvent) => void) | undefined = undefined;
export let variant: 'default' | 'ghost' | 'outline' = 'ghost';
export let className = '';
export let contentClass = 'p-3';
export let dragHandleOnboarding: string | undefined = undefined;
const variantClasses = {
default: 'border border-neutral-300 bg-white dark:border-neutral-700/60 dark:bg-neutral-800/50',
ghost: 'bg-neutral-100/60 dark:bg-neutral-800/40',
outline: 'border border-neutral-300 dark:border-neutral-700/60'
};
</script>
<div
class="relative flex overflow-hidden rounded-xl border border-neutral-300 bg-white select-none dark:border-neutral-700/60 dark:bg-neutral-800/50 {isDragging
class="relative flex overflow-hidden rounded-xl select-none {variantClasses[variant]} {isDragging
? 'scale-[0.98] opacity-50'
: ''} {className}"
style="transition: opacity 100ms, transform 100ms;"
@@ -15,33 +22,35 @@
on:keydown
{...$$restProps}
>
<!-- Drag head — desktop only -->
<div
class="group hidden w-7 shrink-0 items-center justify-center border-r border-neutral-100 md:flex dark:border-neutral-700/40 {isDragging
? 'cursor-grabbing'
: 'cursor-grab'}"
on:pointerdown={onDragHandlePointerDown}
on:click|stopPropagation|preventDefault
on:keydown|stopPropagation
role="button"
tabindex="-1"
aria-label="Drag to reorder"
data-onboarding={dragHandleOnboarding}
>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="currentColor"
class="text-neutral-300 transition-colors group-hover:text-neutral-400 dark:text-neutral-600 dark:group-hover:text-neutral-500"
<!-- Drag handle — desktop only -->
<div class="hidden shrink-0 items-center pl-2 md:flex">
<div
class="flex h-7 w-7 items-center justify-center rounded-xl transition-colors {isDragging
? 'cursor-grabbing bg-neutral-100 dark:bg-neutral-700/50'
: 'cursor-grab hover:bg-neutral-100 dark:hover:bg-neutral-700/50'}"
on:pointerdown={onDragHandlePointerDown}
on:click|stopPropagation|preventDefault
on:keydown|stopPropagation
role="button"
tabindex="-1"
aria-label="Drag to reorder"
data-onboarding={dragHandleOnboarding}
>
<circle cx="2" cy="2" r="1.25" />
<circle cx="6" cy="2" r="1.25" />
<circle cx="2" cy="7" r="1.25" />
<circle cx="6" cy="7" r="1.25" />
<circle cx="2" cy="12" r="1.25" />
<circle cx="6" cy="12" r="1.25" />
</svg>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="currentColor"
class="text-neutral-400 dark:text-neutral-500"
>
<circle cx="2" cy="2" r="1.25" />
<circle cx="6" cy="2" r="1.25" />
<circle cx="2" cy="7" r="1.25" />
<circle cx="6" cy="7" r="1.25" />
<circle cx="2" cy="12" r="1.25" />
<circle cx="6" cy="12" r="1.25" />
</svg>
</div>
</div>
<!-- Content -->

View File

@@ -69,6 +69,7 @@ import { migration as migration064 } from './migrations/064_add_fail_on_referenc
import { migration as migration065 } from './migrations/065_create_arr_drift_tables.ts';
import { migration as migration066 } from './migrations/066_add_date_format_setting.ts';
import { migration as migration067 } from './migrations/067_enable_all_arr_instances.ts';
import { migration as migration069 } from './migrations/069_create_arr_sync_database_priority.ts';
export interface Migration {
version: number;
@@ -356,7 +357,8 @@ export function loadMigrations(): Migration[] {
migration064,
migration065,
migration066,
migration067
migration067,
migration069
];
// Sort by version number

View File

@@ -0,0 +1,37 @@
import type { Migration } from '../migrations.ts';
/**
* Migration 069: Create arr_sync_database_priority
*
* Stores a priority ordering of databases per Arr instance for quality profile
* sync. When multiple databases are selected, the syncer processes them from
* lowest priority to highest so the highest-priority database's entities win.
*
* Seeded with every (instance, database) pair so the table is always fully
* populated. Priority 1 is highest.
*/
export const migration: Migration = {
version: 69,
name: 'Create arr sync database priority',
up: `
CREATE TABLE arr_sync_database_priority (
instance_id INTEGER NOT NULL,
database_id INTEGER NOT NULL,
priority INTEGER NOT NULL,
PRIMARY KEY (instance_id, database_id),
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE,
FOREIGN KEY (database_id) REFERENCES database_instances(id) ON DELETE CASCADE
);
INSERT INTO arr_sync_database_priority (instance_id, database_id, priority)
SELECT ai.id, di.id, ROW_NUMBER() OVER (PARTITION BY ai.id ORDER BY di.id)
FROM arr_instances ai
CROSS JOIN database_instances di;
`,
down: `
DROP TABLE IF EXISTS arr_sync_database_priority;
`
};

View File

@@ -1,5 +1,6 @@
import { db } from '../db.ts';
import { normalizeArrInstanceUrl } from '$arr/url.ts';
import { arrSyncQueries } from './arrSync.ts';
/**
* Types for arr_instances table
@@ -64,7 +65,13 @@ export const arrInstancesQueries = {
// Get the last inserted ID
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
return result?.id ?? 0;
const id = result?.id ?? 0;
if (id > 0) {
arrSyncQueries.ensureAllInstancePriorities(id);
}
return id;
},
/**

View File

@@ -902,6 +902,64 @@ export const arrSyncQueries = {
);
},
// ── Database Priority ────────────────────────────────────────────────
getDatabasePriorities(instanceId: number): { databaseId: number; priority: number }[] {
return db
.query<{ database_id: number; priority: number }>(
`SELECT database_id, priority FROM arr_sync_database_priority
WHERE instance_id = ? ORDER BY priority ASC`,
instanceId
)
.map((row) => ({ databaseId: row.database_id, priority: row.priority }));
},
async saveDatabasePriorities(
instanceId: number,
priorities: { databaseId: number; priority: number }[]
): Promise<void> {
await db.transaction(() => {
db.execute('DELETE FROM arr_sync_database_priority WHERE instance_id = ?', instanceId);
for (const { databaseId, priority } of priorities) {
db.execute(
'INSERT INTO arr_sync_database_priority (instance_id, database_id, priority) VALUES (?, ?, ?)',
instanceId,
databaseId,
priority
);
}
});
},
ensureAllDatabasePriorities(databaseId: number): void {
db.execute(
`INSERT INTO arr_sync_database_priority (instance_id, database_id, priority)
SELECT ai.id, ?,
COALESCE((SELECT MAX(priority) FROM arr_sync_database_priority WHERE instance_id = ai.id), 0) + 1
FROM arr_instances ai
WHERE NOT EXISTS (
SELECT 1 FROM arr_sync_database_priority
WHERE instance_id = ai.id AND database_id = ?
)`,
databaseId,
databaseId
);
},
ensureAllInstancePriorities(instanceId: number): void {
db.execute(
`INSERT INTO arr_sync_database_priority (instance_id, database_id, priority)
SELECT ?, di.id, ROW_NUMBER() OVER (ORDER BY di.id)
FROM database_instances di
WHERE NOT EXISTS (
SELECT 1 FROM arr_sync_database_priority
WHERE instance_id = ? AND database_id = di.id
)`,
instanceId,
instanceId
);
},
getInstanceIdsForTrigger(trigger: SyncTrigger): number[] {
const rows = db.query<{ instance_id: number }>(
`SELECT instance_id FROM arr_sync_quality_profiles_config WHERE trigger = ?

View File

@@ -1,5 +1,6 @@
import { db } from '../db.ts';
import { toUTC } from '$shared/utils/dates.ts';
import { arrSyncQueries } from './arrSync.ts';
export type ConflictStrategy = 'override' | 'align' | 'ask';
@@ -124,7 +125,13 @@ export const databaseInstancesQueries = {
// Get the last inserted ID
const result = db.queryFirst<{ id: number }>('SELECT last_insert_rowid() as id');
return result?.id ?? 0;
const id = result?.id ?? 0;
if (id > 0) {
arrSyncQueries.ensureAllDatabasePriorities(id);
}
return id;
},
/**

View File

@@ -418,6 +418,21 @@ CREATE TABLE arr_sync_quality_profiles (
FOREIGN KEY (database_id) REFERENCES database_instances(id) ON DELETE CASCADE
);
-- ==============================================================================
-- TABLE: arr_sync_database_priority
-- Purpose: Store database priority ordering per Arr instance for QP sync
-- Migration: 069_create_arr_sync_database_priority.ts
-- ==============================================================================
CREATE TABLE arr_sync_database_priority (
instance_id INTEGER NOT NULL,
database_id INTEGER NOT NULL,
priority INTEGER NOT NULL,
PRIMARY KEY (instance_id, database_id),
FOREIGN KEY (instance_id) REFERENCES arr_instances(id) ON DELETE CASCADE,
FOREIGN KEY (database_id) REFERENCES database_instances(id) ON DELETE CASCADE
);
-- ==============================================================================
-- TABLE: arr_sync_quality_profiles_config
-- Purpose: Store quality profile sync trigger configuration (one per instance)

View File

@@ -1,11 +1,17 @@
import type { BaseArrClient } from '$arr/base.ts';
import type { DriftCounts, DriftDiff } from '$shared/drift.ts';
import type { SyncArrType } from '$sync/mappings.ts';
import { compareCustomFormatDrift, buildExpectedCustomFormats } from './customFormats.ts';
import {
buildExpectedCustomFormatsPerDatabase,
compareCustomFormatDriftPerDatabase
} from './customFormats.ts';
import { checkDelayProfileDrift } from './delayProfiles.ts';
import { hashDriftDiff } from './hash.ts';
import { checkMediaManagementDrift } from './mediaManagement.ts';
import { checkQualityProfileDrift } from './qualityProfiles.ts';
import {
buildExpectedQualityProfilesPerDatabase,
compareQualityProfileDriftPerDatabase
} from './qualityProfiles.ts';
export interface DriftCheckResult {
status: 'clean' | 'drift_detected';
@@ -27,20 +33,27 @@ export async function checkArrDrift(
instanceId: number,
arrType: SyncArrType
): Promise<DriftCheckResult> {
const [expectedCustomFormats, actualCustomFormats, delayProfiles, mediaManagement] =
await Promise.all([
buildExpectedCustomFormats(instanceId, arrType),
client.getCustomFormats(),
checkDelayProfileDrift(client, instanceId),
checkMediaManagementDrift(client, instanceId, arrType)
]);
const customFormats = compareCustomFormatDrift(expectedCustomFormats, actualCustomFormats);
const qualityProfiles = await checkQualityProfileDrift(
client,
instanceId,
arrType,
actualCustomFormats
const [expectedCfPerDb, actualCustomFormats, delayProfiles, mediaManagement] = await Promise.all([
buildExpectedCustomFormatsPerDatabase(instanceId, arrType),
client.getCustomFormats(),
checkDelayProfileDrift(client, instanceId),
checkMediaManagementDrift(client, instanceId, arrType)
]);
const customFormats = compareCustomFormatDriftPerDatabase(expectedCfPerDb, actualCustomFormats);
const [expectedQpPerDb, actualProfiles] = await Promise.all([
buildExpectedQualityProfilesPerDatabase(instanceId, arrType, actualCustomFormats),
client.getQualityProfiles()
]);
const qualityProfiles = compareQualityProfileDriftPerDatabase(
expectedQpPerDb,
actualProfiles,
actualCustomFormats,
arrType
);
const counts: DriftCounts = {
custom_formats: customFormats.count,
quality_profiles: qualityProfiles.count,
@@ -48,8 +61,8 @@ export async function checkArrDrift(
media_management: mediaManagement.count
};
const diff: DriftDiff = {
custom_formats: customFormats.diff,
quality_profiles: qualityProfiles.diff,
custom_formats: customFormats.diffs,
quality_profiles: qualityProfiles.diffs,
delay_profiles: delayProfiles.diff,
media_management: mediaManagement.diff
};

View File

@@ -1,4 +1,5 @@
import { arrSyncQueries } from '$db/queries/arrSync.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { getCache } from '$pcd/index.ts';
import { getCustomFormatsForProfile } from '$pcd/references.ts';
import type { BaseArrClient } from '$arr/base.ts';
@@ -36,6 +37,12 @@ export interface CustomFormatDriftResult {
diff: CustomFormatDriftDiff;
}
export interface DatabaseCustomFormatDriftDiff {
databaseId: number;
databaseName: string;
diff: CustomFormatDriftDiff;
}
type NormalizedField = {
name: string;
value: unknown;
@@ -271,6 +278,76 @@ export async function buildExpectedCustomFormats(
return [...expected.values()];
}
export interface PerDatabaseExpectedFormats {
databaseName: string;
formats: ArrCustomFormat[];
}
export async function buildExpectedCustomFormatsPerDatabase(
instanceId: number,
arrType: SyncArrType
): Promise<Map<number, PerDatabaseExpectedFormats>> {
const syncConfig = arrSyncQueries.getQualityProfilesSync(instanceId);
const result = new Map<number, PerDatabaseExpectedFormats>();
if (syncConfig.selections.length === 0) return result;
const selectionsByDb = new Map<number, typeof syncConfig.selections>();
for (const selection of syncConfig.selections) {
const existing = selectionsByDb.get(selection.databaseId) ?? [];
existing.push(selection);
selectionsByDb.set(selection.databaseId, existing);
}
for (const [databaseId, selections] of selectionsByDb) {
const cache = getCache(databaseId);
if (!cache) {
throw new Error(`PCD cache not found for database ${databaseId}`);
}
const dbInstance = databaseInstancesQueries.getById(databaseId);
const databaseName = dbInstance?.name ?? `Database ${databaseId}`;
const expected = new Map<string, ArrCustomFormat>();
for (const selection of selections) {
const formatNames = await getCustomFormatsForProfile(cache, selection.profileName, arrType);
for (const formatName of formatNames) {
if (expected.has(formatName)) continue;
const pcdFormat = await fetchCustomFormatFromPcd(cache, formatName);
if (!pcdFormat) {
throw new Error(`Custom format "${formatName}" not found in database ${databaseId}`);
}
const arrFormat = transformCustomFormat(pcdFormat, arrType);
arrFormat.name = formatName;
expected.set(formatName, arrFormat);
}
}
if (expected.size > 0) {
result.set(databaseId, { databaseName, formats: [...expected.values()] });
}
}
return result;
}
export function compareCustomFormatDriftPerDatabase(
perDb: Map<number, PerDatabaseExpectedFormats>,
actualFormats: ArrCustomFormat[]
): { count: number; diffs: DatabaseCustomFormatDriftDiff[] } {
const diffs: DatabaseCustomFormatDriftDiff[] = [];
let count = 0;
for (const [databaseId, { databaseName, formats }] of perDb) {
const result = compareCustomFormatDrift(formats, actualFormats);
count += result.count;
diffs.push({ databaseId, databaseName, diff: result.diff });
}
return { count, diffs };
}
export async function checkCustomFormatDrift(
client: Pick<BaseArrClient, 'getCustomFormats'>,
instanceId: number,

View File

@@ -10,11 +10,15 @@ import {
import type {
DriftDiff,
DriftDisplayChange,
DriftDisplayDuplicateDrift,
DriftDisplayEntity,
DriftDisplayQualityItem,
DriftDisplayTone,
DriftDisplayValue
} from '$shared/drift.ts';
import { arrSyncQueries } from '$db/queries/arrSync.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { buildExpectedCustomFormatsPerDatabase } from './customFormats.ts';
interface CustomFormatDiff {
missing?: unknown[];
@@ -116,51 +120,195 @@ const IMPLEMENTATION_LABELS: Record<string, string> = {
YearSpecification: 'Year'
};
type DuplicateDriftLookup = Map<string, DriftDisplayDuplicateDrift>;
interface DuplicateCandidate {
databaseId: number;
databaseName: string;
name: string;
}
export function buildDriftDisplayEntities(
diff: DriftDiff,
arrType: string | undefined
arrType: string | undefined,
duplicateDriftLookup: DuplicateDriftLookup = new Map()
): DriftDisplayEntity[] {
const syncArrType = arrType === 'radarr' || arrType === 'sonarr' ? arrType : undefined;
return [
...buildCustomFormatEntities(diff.custom_formats, syncArrType),
...buildQualityProfileEntities(diff.quality_profiles, syncArrType),
...buildCustomFormatEntities(diff.custom_formats, syncArrType, duplicateDriftLookup),
...buildQualityProfileEntities(diff.quality_profiles, syncArrType, duplicateDriftLookup),
...buildDelayProfileEntities(diff.delay_profiles),
...buildMediaManagementEntities(diff.media_management)
];
}
export async function buildDriftDisplayEntitiesForInstance(
diff: DriftDiff,
arrType: string | undefined,
instanceId: number
): Promise<DriftDisplayEntity[]> {
const syncArrType = arrType === 'radarr' || arrType === 'sonarr' ? arrType : undefined;
if (!syncArrType) return buildDriftDisplayEntities(diff, arrType);
try {
const duplicateDriftLookup = await buildDuplicateDriftLookup(instanceId, syncArrType);
return buildDriftDisplayEntities(diff, arrType, duplicateDriftLookup);
} catch {
return buildDriftDisplayEntities(diff, arrType);
}
}
async function buildDuplicateDriftLookup(
instanceId: number,
arrType: SyncArrType
): Promise<DuplicateDriftLookup> {
const lookup: DuplicateDriftLookup = new Map();
const priorities = new Map(
arrSyncQueries.getDatabasePriorities(instanceId).map((row) => [row.databaseId, row.priority])
);
const getDatabaseName = (databaseId: number) =>
databaseInstancesQueries.getById(databaseId)?.name ?? `Database ${databaseId}`;
const qualityProfileCandidates = arrSyncQueries
.getQualityProfilesSync(instanceId)
.selections.map((selection) => ({
databaseId: selection.databaseId,
databaseName: getDatabaseName(selection.databaseId),
name: selection.profileName
}));
addDuplicateCandidates(lookup, 'quality_profiles', qualityProfileCandidates, priorities);
try {
const perDbFormats = await buildExpectedCustomFormatsPerDatabase(instanceId, arrType);
const customFormatCandidates: DuplicateCandidate[] = [];
for (const [databaseId, { databaseName, formats }] of perDbFormats) {
for (const format of formats) {
customFormatCandidates.push({ databaseId, databaseName, name: format.name });
}
}
addDuplicateCandidates(lookup, 'custom_formats', customFormatCandidates, priorities);
} catch {
// Duplicate drift is display metadata only, so stored drift should still render if caches moved.
}
return lookup;
}
function addDuplicateCandidates(
lookup: DuplicateDriftLookup,
section: 'custom_formats' | 'quality_profiles',
candidates: DuplicateCandidate[],
priorities: Map<number, number>
): void {
const byName = new Map<string, Map<number, DuplicateCandidate>>();
for (const candidate of candidates) {
const entries = byName.get(candidate.name) ?? new Map<number, DuplicateCandidate>();
entries.set(candidate.databaseId, candidate);
byName.set(candidate.name, entries);
}
for (const [name, entries] of byName) {
const sorted = [...entries.values()].sort(
(a, b) =>
priorityFor(a.databaseId, priorities) - priorityFor(b.databaseId, priorities) ||
a.databaseId - b.databaseId
);
if (sorted.length < 2) continue;
const winner = sorted[0];
for (const candidate of sorted.slice(1)) {
lookup.set(duplicateLookupKey(section, candidate.databaseId, name), {
reason: 'lower_priority_duplicate',
key: `${section}:${name}`,
winnerDatabaseId: winner.databaseId,
winnerDatabaseName: winner.databaseName
});
}
}
}
function priorityFor(databaseId: number, priorities: Map<number, number>): number {
return priorities.get(databaseId) ?? Number.MAX_SAFE_INTEGER;
}
function duplicateLookupKey(
section: 'custom_formats' | 'quality_profiles',
databaseId: number,
name: string
): string {
return `${section}:${databaseId}:${name}`;
}
function buildCustomFormatEntities(
raw: unknown,
arrType: SyncArrType | undefined
arrType: SyncArrType | undefined,
duplicateDriftLookup: DuplicateDriftLookup
): DriftDisplayEntity[] {
if (Array.isArray(raw)) {
const entities: DriftDisplayEntity[] = [];
for (const entry of raw) {
if (!isRecord(entry)) continue;
const databaseId = recordNumber(entry, 'databaseId');
const databaseName = recordString(entry, 'databaseName');
const diff = asCustomFormatDiff(entry.diff);
if (!diff) continue;
entities.push(
...buildCustomFormatDiffEntities(
diff,
arrType,
duplicateDriftLookup,
databaseId,
databaseName
)
);
}
return entities;
}
const diff = asCustomFormatDiff(raw);
if (!diff) return [];
return buildCustomFormatDiffEntities(diff, arrType, duplicateDriftLookup);
}
function buildCustomFormatDiffEntities(
diff: CustomFormatDiff,
arrType: SyncArrType | undefined,
duplicateDriftLookup: DuplicateDriftLookup,
databaseId?: number | null,
databaseName?: string | null
): DriftDisplayEntity[] {
const entities: DriftDisplayEntity[] = [];
const idPrefix = databaseId != null ? `custom_formats:${databaseId}` : 'custom_formats';
for (const item of diff.missing ?? []) {
const name = recordString(item, 'name');
if (!name) continue;
entities.push({
id: `custom_formats:missing:${name}`,
id: `${idPrefix}:missing:${name}`,
section: 'custom_formats',
sectionLabel: 'Custom Format',
title: name,
databaseId: databaseId ?? undefined,
databaseName: databaseName ?? undefined,
state: 'missing',
stateLabel: 'Missing',
tone: 'danger',
summary: 'Profilarr expects this custom format, but Arr does not have it.',
changes: [
{
id: `custom_formats:missing:${name}:format`,
id: `${idPrefix}:missing:${name}:format`,
label: 'Custom format',
detail: 'Missing from Arr',
expected: value('Present'),
actual: value('Missing', { tone: 'danger' }),
tone: 'danger'
}
]
],
duplicateDrift:
databaseId != null
? duplicateDriftLookup.get(duplicateLookupKey('custom_formats', databaseId, name))
: undefined
});
}
@@ -174,15 +322,23 @@ function buildCustomFormatEntities(
);
entities.push({
id: `custom_formats:modified:${modified.name}`,
id: `${idPrefix}:modified:${modified.name}`,
section: 'custom_formats',
sectionLabel: 'Custom Format',
title: modified.name,
databaseId: databaseId ?? undefined,
databaseName: databaseName ?? undefined,
state: 'modified',
stateLabel: 'Modified',
tone: 'warning',
summary: `${changes.length} ${changes.length === 1 ? 'change' : 'changes'} detected`,
changes
changes,
duplicateDrift:
databaseId != null
? duplicateDriftLookup.get(
duplicateLookupKey('custom_formats', databaseId, modified.name)
)
: undefined
});
}
@@ -191,36 +347,74 @@ function buildCustomFormatEntities(
function buildQualityProfileEntities(
raw: unknown,
arrType: SyncArrType | undefined
arrType: SyncArrType | undefined,
duplicateDriftLookup: DuplicateDriftLookup
): DriftDisplayEntity[] {
if (Array.isArray(raw)) {
const entities: DriftDisplayEntity[] = [];
for (const entry of raw) {
if (!isRecord(entry)) continue;
const databaseId = recordNumber(entry, 'databaseId');
const databaseName = recordString(entry, 'databaseName');
const diff = asQualityProfileDiff(entry.diff);
if (!diff) continue;
entities.push(
...buildQualityProfileDiffEntities(
diff,
arrType,
duplicateDriftLookup,
databaseId,
databaseName
)
);
}
return entities;
}
const diff = asQualityProfileDiff(raw);
if (!diff) return [];
return buildQualityProfileDiffEntities(diff, arrType, duplicateDriftLookup);
}
function buildQualityProfileDiffEntities(
diff: QualityProfileDiff,
arrType: SyncArrType | undefined,
duplicateDriftLookup: DuplicateDriftLookup,
databaseId?: number | null,
databaseName?: string | null
): DriftDisplayEntity[] {
const entities: DriftDisplayEntity[] = [];
const idPrefix = databaseId != null ? `quality_profiles:${databaseId}` : 'quality_profiles';
for (const item of diff.missing ?? []) {
const name = recordString(item, 'name');
if (!name) continue;
entities.push({
id: `quality_profiles:missing:${name}`,
id: `${idPrefix}:missing:${name}`,
section: 'quality_profiles',
sectionLabel: 'Quality Profile',
title: name,
databaseId: databaseId ?? undefined,
databaseName: databaseName ?? undefined,
state: 'missing',
stateLabel: 'Missing',
tone: 'danger',
summary: 'Profilarr expects this quality profile, but Arr does not have it.',
changes: [
{
id: `quality_profiles:missing:${name}:profile`,
id: `${idPrefix}:missing:${name}:profile`,
label: 'Quality profile',
detail: 'Missing from Arr',
expected: value('Present'),
actual: value('Missing', { tone: 'danger' }),
tone: 'danger'
}
]
],
duplicateDrift:
databaseId != null
? duplicateDriftLookup.get(duplicateLookupKey('quality_profiles', databaseId, name))
: undefined
});
}
@@ -234,15 +428,23 @@ function buildQualityProfileEntities(
);
entities.push({
id: `quality_profiles:modified:${modified.name}`,
id: `${idPrefix}:modified:${modified.name}`,
section: 'quality_profiles',
sectionLabel: 'Quality Profile',
title: modified.name,
databaseId: databaseId ?? undefined,
databaseName: databaseName ?? undefined,
state: 'modified',
stateLabel: 'Modified',
tone: 'warning',
summary: `${changes.length} ${changes.length === 1 ? 'change' : 'changes'} detected`,
changes
changes,
duplicateDrift:
databaseId != null
? duplicateDriftLookup.get(
duplicateLookupKey('quality_profiles', databaseId, modified.name)
)
: undefined
});
}
@@ -1328,6 +1530,12 @@ function recordString(raw: unknown, key: string): string | null {
return typeof value === 'string' ? value : null;
}
function recordNumber(raw: unknown, key: string): number | null {
if (!isRecord(raw)) return null;
const value = raw[key];
return typeof value === 'number' ? value : null;
}
function isRecord(raw: unknown): raw is Record<string, unknown> {
return typeof raw === 'object' && raw !== null;
}

View File

@@ -1,4 +1,5 @@
import { arrSyncQueries } from '$db/queries/arrSync.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import type { BaseArrClient } from '$arr/base.ts';
import type {
ArrCustomFormat,
@@ -37,6 +38,12 @@ export interface QualityProfileDriftResult {
diff: QualityProfileDriftDiff;
}
export interface DatabaseQualityProfileDriftDiff {
databaseId: number;
databaseName: string;
diff: QualityProfileDriftDiff;
}
export interface QualityProfileDriftExpected {
profile: ArrQualityProfilePayload;
managedCustomFormatNames: string[];
@@ -346,6 +353,98 @@ export async function buildExpectedQualityProfiles(
return expected;
}
export interface PerDatabaseExpectedProfiles {
databaseName: string;
profiles: QualityProfileDriftExpected[];
}
export async function buildExpectedQualityProfilesPerDatabase(
instanceId: number,
arrType: SyncArrType,
actualCustomFormats: ArrCustomFormat[]
): Promise<Map<number, PerDatabaseExpectedProfiles>> {
const syncConfig = arrSyncQueries.getQualityProfilesSync(instanceId);
const result = new Map<number, PerDatabaseExpectedProfiles>();
if (syncConfig.selections.length === 0) return result;
const formatIdMap = new Map<string, number>();
for (const format of actualCustomFormats) {
if (format.id !== undefined) formatIdMap.set(format.name, format.id);
}
const selectionsByDb = new Map<number, typeof syncConfig.selections>();
for (const selection of syncConfig.selections) {
const existing = selectionsByDb.get(selection.databaseId) ?? [];
existing.push(selection);
selectionsByDb.set(selection.databaseId, existing);
}
for (const [databaseId, selections] of selectionsByDb) {
const cache = getCache(databaseId);
if (!cache) {
throw new Error(`PCD cache not found for database ${databaseId}`);
}
const dbInstance = databaseInstancesQueries.getById(databaseId);
const databaseName = dbInstance?.name ?? `Database ${databaseId}`;
const profiles: QualityProfileDriftExpected[] = [];
for (const selection of selections) {
const pcdProfile = await fetchQualityProfileFromPcd(cache, selection.profileName, arrType);
if (!pcdProfile) {
throw new Error(
`Quality profile "${selection.profileName}" not found in database ${databaseId}`
);
}
const [qualityMappings, managedCustomFormatNames] = await Promise.all([
getQualityApiMappings(cache, arrType),
getCustomFormatsForProfile(cache, selection.profileName, arrType)
]);
const profile = transformQualityProfile(pcdProfile, arrType, qualityMappings, formatIdMap);
profile.name = pcdProfile.name;
profiles.push({
profile,
managedCustomFormatNames,
managedCustomFormatScores: pcdProfile.customFormats.map((format) => ({
name: format.formatName,
score: format.score
}))
});
}
if (profiles.length > 0) {
result.set(databaseId, { databaseName, profiles });
}
}
return result;
}
export function compareQualityProfileDriftPerDatabase(
perDb: Map<number, PerDatabaseExpectedProfiles>,
actualProfiles: ArrQualityProfile[],
actualCustomFormats: ArrCustomFormat[],
arrType: SyncArrType
): { count: number; diffs: DatabaseQualityProfileDriftDiff[] } {
const diffs: DatabaseQualityProfileDriftDiff[] = [];
let count = 0;
for (const [databaseId, { databaseName, profiles }] of perDb) {
const result = compareQualityProfileDrift(
profiles,
actualProfiles,
actualCustomFormats,
arrType
);
count += result.count;
diffs.push({ databaseId, databaseName, diff: result.diff });
}
return { count, diffs };
}
export async function checkQualityProfileDrift(
client: Pick<BaseArrClient, 'getQualityProfiles'>,
instanceId: number,

View File

@@ -52,7 +52,12 @@ function buildDriftedItemBlocks(
blocks.push({
kind: 'section',
title: label,
content: group.map((entity) => `- ${entity.title}: ${entity.summary}`).join('\n')
content: group
.map((entity) => {
const db = entity.databaseName ? ` (${entity.databaseName})` : '';
return `- ${entity.title}${db}: ${entity.summary}`;
})
.join('\n')
});
}

View File

@@ -2,16 +2,18 @@
* Quality profile syncer
* Syncs quality profiles from PCD to arr instances
*
* All selected profiles must come from a single database (enforced by the UI).
* Supports multiple databases per instance via priority ordering. Databases
* are processed from lowest priority to highest so the highest-priority
* database's entities overwrite earlier ones via Arr's name-based matching.
*
* Sync order:
* Sync order per database:
* 1. Fetch profiles and referenced CFs from the database's PCD cache
* 2. Sync custom formats → build formatIdMap
* 2. Sync custom formats (accumulate into shared formatIdMap)
* 3. Sync quality profiles using the formatIdMap
*/
import { BaseSyncer, type SyncResult } from '../base.ts';
import { arrSyncQueries } from '$db/queries/arrSync.ts';
import { arrSyncQueries, type ProfileSelection } from '$db/queries/arrSync.ts';
import { getCache, getCachedDatabaseIds } from '$pcd/index.ts';
import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts';
import { logger } from '$logger/logger.ts';
@@ -64,14 +66,15 @@ export class QualityProfileSyncer extends BaseSyncer {
}
/**
* Override sync to handle the quality profile sync flow
* Override sync to handle multi-database quality profile sync flow.
* Processes databases from lowest priority to highest so the highest-
* priority database's entities are what remain in Arr after sync.
*/
override async sync(): Promise<SyncResult> {
try {
// 1. Fetch profiles and CFs from the single database
const { profiles, customFormats, databaseId } = await this.fetchSyncData();
const syncConfig = arrSyncQueries.getQualityProfilesSync(this.instanceId);
if (profiles.length === 0) {
if (syncConfig.selections.length === 0) {
await logger.debug(`No quality profiles to sync for "${this.instanceName}"`, {
source: 'Sync:QualityProfiles',
meta: { instanceId: this.instanceId }
@@ -79,47 +82,115 @@ export class QualityProfileSyncer extends BaseSyncer {
return { success: true, itemsSynced: 0 };
}
// 2. Sync custom formats
const formatIdMap = await syncCustomFormats(
this.client,
this.instanceId,
this.instanceType,
customFormats
);
const priorities = arrSyncQueries.getDatabasePriorities(this.instanceId);
// 3. Get quality API mappings
const cache = getCache(databaseId);
if (!cache) throw new Error(`PCD cache not found for database ${databaseId}`);
const qualityMappings = await getQualityApiMappings(cache, this.instanceType);
// Group selections by database
const selectionsByDb = new Map<number, ProfileSelection[]>();
for (const sel of syncConfig.selections) {
if (!selectionsByDb.has(sel.databaseId)) {
selectionsByDb.set(sel.databaseId, []);
}
selectionsByDb.get(sel.databaseId)!.push(sel);
}
// Sort: highest priority number first (lowest priority syncs first, highest last wins)
const sortedDbIds = [...selectionsByDb.keys()].sort((a, b) => {
const pa = priorities.find((p) => p.databaseId === a)?.priority ?? Infinity;
const pb = priorities.find((p) => p.databaseId === b)?.priority ?? Infinity;
return pb - pa;
});
// 4. Sync quality profiles
const existingProfiles = await this.client.getQualityProfiles();
const existingMap = new Map(existingProfiles.map((p) => [p.name, p.id]));
const syncedProfiles = await this.syncQualityProfiles(
profiles,
formatIdMap,
qualityMappings,
existingMap
);
const allFormatIdMap = new Map<string, number>();
const allSyncedProfiles: SyncedProfileSummary[] = [];
const failures: { databaseId: number; error: string }[] = [];
for (const databaseId of sortedDbIds) {
const dbSelections = selectionsByDb.get(databaseId)!;
try {
const { profiles, customFormats } = await this.fetchSyncDataForDatabase(
databaseId,
dbSelections
);
if (profiles.length === 0) continue;
const formatIdMap = await syncCustomFormats(
this.client,
this.instanceId,
this.instanceType,
customFormats
);
for (const [k, v] of formatIdMap) allFormatIdMap.set(k, v);
const cache = getCache(databaseId);
if (!cache) throw new Error(`PCD cache not found for database ${databaseId}`);
const qualityMappings = await getQualityApiMappings(cache, this.instanceType);
const synced = await this.syncQualityProfiles(
profiles,
allFormatIdMap,
qualityMappings,
existingMap
);
allSyncedProfiles.push(...synced);
await logger.info(
`Synced ${synced.length} profile(s) from database ${databaseId} for "${this.instanceName}"`,
{
source: 'Sync:QualityProfiles',
meta: {
instanceId: this.instanceId,
databaseId,
profiles: synced.map((p) => ({
name: p.name,
action: p.action,
formats: p.formats.length
}))
}
}
);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await logger.error(
`Failed syncing database ${databaseId} for "${this.instanceName}", continuing`,
{
source: 'Sync:QualityProfiles',
meta: { instanceId: this.instanceId, databaseId, error: errorMsg }
}
);
failures.push({ databaseId, error: errorMsg });
}
}
if (failures.length > 0) {
const error = failures
.map((failure) => `database ${failure.databaseId}: ${failure.error}`)
.join('; ');
return {
success: false,
itemsSynced: allSyncedProfiles.length,
items: allSyncedProfiles.map((p) => ({ name: p.name, action: p.action })),
error: `Quality profile sync failed for ${failures.length} database(s): ${error}`
};
}
await logger.info(`Completed quality profile sync for "${this.instanceName}"`, {
source: 'Sync:QualityProfiles',
meta: {
instanceId: this.instanceId,
databaseId,
profiles: syncedProfiles.map((p) => ({
name: p.name,
action: p.action,
formats: p.formats.length
}))
databases: sortedDbIds,
totalProfiles: allSyncedProfiles.length
}
});
return {
success: true,
itemsSynced: syncedProfiles.length,
items: syncedProfiles.map((p) => ({ name: p.name, action: p.action }))
itemsSynced: allSyncedProfiles.length,
items: allSyncedProfiles.map((p) => ({ name: p.name, action: p.action }))
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
@@ -134,30 +205,22 @@ export class QualityProfileSyncer extends BaseSyncer {
}
/**
* Fetch profiles and CFs from the single database.
* All selections must belong to one database (enforced by frontend).
* Fetch profiles and CFs from a specific database for the given selections.
*/
private async fetchSyncData(): Promise<{
private async fetchSyncDataForDatabase(
databaseId: number,
selections: ProfileSelection[]
): Promise<{
profiles: ProfileSyncData[];
customFormats: Map<string, PcdCustomFormat>;
databaseId: number;
}> {
const syncConfig = arrSyncQueries.getQualityProfilesSync(this.instanceId);
if (syncConfig.selections.length === 0) {
return { profiles: [], customFormats: new Map(), databaseId: 0 };
}
// All selections come from a single database
const databaseId = syncConfig.selections[0].databaseId;
const dbInstance = databaseInstancesQueries.getById(databaseId);
if (!dbInstance) {
await logger.warn(`Skipping sync for deleted database ${databaseId}`, {
source: 'Sync:QualityProfiles',
meta: { instanceId: this.instanceId, databaseId }
});
return { profiles: [], customFormats: new Map(), databaseId };
throw new Error(`Database ${databaseId} not found`);
}
const cache = getCache(databaseId);
@@ -174,14 +237,13 @@ export class QualityProfileSyncer extends BaseSyncer {
databaseName: dbInstance?.name ?? null
}
});
return { profiles: [], customFormats: new Map(), databaseId };
throw new Error(`PCD cache not found for database ${databaseId}`);
}
const profiles: ProfileSyncData[] = [];
const customFormats = new Map<string, PcdCustomFormat>();
for (const selection of syncConfig.selections) {
// Fetch the quality profile
for (const selection of selections) {
const pcdProfile = await fetchQualityProfileFromPcd(
cache,
selection.profileName,
@@ -198,7 +260,6 @@ export class QualityProfileSyncer extends BaseSyncer {
continue;
}
// Get referenced custom format names
const referencedFormatNames = await getCustomFormatsForProfile(
cache,
selection.profileName,
@@ -207,7 +268,6 @@ export class QualityProfileSyncer extends BaseSyncer {
profiles.push({ pcdProfile, referencedFormatNames });
// Fetch custom formats (dedupe by name)
for (const formatName of referencedFormatNames) {
if (!customFormats.has(formatName)) {
const pcdFormat = await fetchCustomFormatFromPcd(cache, formatName);
@@ -218,7 +278,7 @@ export class QualityProfileSyncer extends BaseSyncer {
}
}
return { profiles, customFormats, databaseId };
return { profiles, customFormats };
}
/**
@@ -254,7 +314,6 @@ export class QualityProfileSyncer extends BaseSyncer {
try {
const isUpdate = existingMap.has(pcdProfile.name);
if (isUpdate) {
// Update existing
const existingId = existingMap.get(pcdProfile.name)!;
arrProfile.id = existingId;
await this.client.updateQualityProfile(existingId, arrProfile);
@@ -263,7 +322,6 @@ export class QualityProfileSyncer extends BaseSyncer {
meta: { instanceId: this.instanceId, profileId: existingId, pcdName: pcdProfile.name }
});
} else {
// Create new
const response = await this.client.createQualityProfile(arrProfile);
existingMap.set(pcdProfile.name, response.id);
await logger.debug(`Created quality profile "${pcdProfile.name}"`, {
@@ -272,7 +330,6 @@ export class QualityProfileSyncer extends BaseSyncer {
});
}
// Build summary for completion log
const scoredFormats = arrProfile.formatItems
.filter((f) => f.score !== 0)
.map((f) => ({ name: f.name, score: f.score }));

View File

@@ -39,14 +39,24 @@ export interface DriftDisplayChange {
tone: DriftDisplayTone;
}
export interface DriftDisplayDuplicateDrift {
reason: 'lower_priority_duplicate';
key: string;
winnerDatabaseId: number;
winnerDatabaseName: string;
}
export interface DriftDisplayEntity {
id: string;
section: DriftSection;
sectionLabel: string;
title: string;
databaseId?: number;
databaseName?: string;
state: DriftDisplayState;
stateLabel: string;
tone: DriftDisplayTone;
summary: string;
changes: DriftDisplayChange[];
duplicateDrift?: DriftDisplayDuplicateDrift;
}

View File

@@ -9,9 +9,9 @@ import { enqueueJob } from '$lib/server/jobs/queueService.ts';
import { buildJobDisplayName } from '$lib/server/jobs/display.ts';
import { calculateNextRun, validateCronExpression } from '$lib/server/jobs/scheduleUtils.ts';
import { FEATURES } from '$shared/features.ts';
import { buildDriftDisplayEntities } from '$drift/display.ts';
import { buildDriftDisplayEntitiesForInstance } from '$drift/display.ts';
export const load: ServerLoad = ({ params }) => {
export const load: ServerLoad = async ({ params }) => {
const id = parseInt(params.id || '', 10);
if (isNaN(id)) {
@@ -27,6 +27,8 @@ export const load: ServerLoad = ({ params }) => {
const driftStatus = arrDriftStatusQueries.getByInstanceId(id);
const { api_key: _, ...safeInstance } = instance;
const diff = driftStatus?.diff ?? {};
const driftEntities = await buildDriftDisplayEntitiesForInstance(diff, instance.type, id);
const duplicateDriftCount = driftEntities.filter((entity) => entity.duplicateDrift).length;
return {
instance: safeInstance,
@@ -41,7 +43,11 @@ export const load: ServerLoad = ({ params }) => {
lastCheckedAt: driftStatus?.lastCheckedAt ?? null,
lastError: driftStatus?.lastError ?? null
},
driftEntities: buildDriftDisplayEntities(diff, instance.type)
driftEntities,
driftView: {
duplicateDriftCount,
hasDuplicateDrift: duplicateDriftCount > 0
}
};
};

View File

@@ -212,6 +212,12 @@
<Label variant="secondary" size="md" rounded="md" mono>
Last {formatSmartDateTime(data.status.lastCheckedAt, $serverTimezone, $dateFormat)}
</Label>
{#if data.driftView.hasDuplicateDrift}
<Label variant="info" size="md" rounded="md">
{data.driftView.duplicateDriftCount}
{data.driftView.duplicateDriftCount === 1 ? 'duplicate' : 'duplicates'}
</Label>
{/if}
</div>
{/if}
</div>
@@ -276,9 +282,22 @@
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'title'}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{row.title}
</span>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{row.title}
</span>
{#if row.duplicateDrift}
<Label variant="info" size="sm" rounded="md">Duplicate</Label>
<Label variant="secondary" size="sm" rounded="md">
{row.duplicateDrift.winnerDatabaseName} wins
</Label>
{/if}
</div>
{#if row.databaseName}
<span class="text-xs text-neutral-400 dark:text-neutral-500">
{row.databaseName}
</span>
{/if}
{#if row.summary}
<span class="text-xs text-neutral-500 dark:text-neutral-400">
{row.summary}

View File

@@ -15,11 +15,12 @@ import { calculateNextRun } from '$lib/server/sync/utils.ts';
import { scheduleArrSyncForInstance } from '$lib/server/jobs/init.ts';
import { enqueueJob } from '$lib/server/jobs/queueService.ts';
import { buildJobDisplayName } from '$lib/server/jobs/display.ts';
import { buildExpectedCustomFormats } from '$drift/customFormats.ts';
import type { CustomFormatDriftDiff } from '$drift/customFormats.ts';
import { buildExpectedCustomFormatsPerDatabase } from '$drift/customFormats.ts';
import type { CustomFormatDriftDiff, DatabaseCustomFormatDriftDiff } from '$drift/customFormats.ts';
import type {
QualityProfileDriftDiff,
QualityProfileModifiedDiff
QualityProfileModifiedDiff,
DatabaseQualityProfileDriftDiff
} from '$drift/qualityProfiles.ts';
import type { MediaManagementDriftDiff } from '$drift/mediaManagement.ts';
import { FEATURES } from '$shared/features.ts';
@@ -72,39 +73,69 @@ function isTransitiveOnlyQp(modified: QualityProfileModifiedDiff): boolean {
return modified.fields.every(fieldIsMissingCustomFormat);
}
function classifyQpDrift(qpDiff: QualityProfileDriftDiff | undefined) {
function extractQpDiffs(raw: unknown): QualityProfileDriftDiff[] {
if (Array.isArray(raw)) {
return raw
.filter(
(entry): entry is DatabaseQualityProfileDriftDiff =>
entry != null && typeof entry === 'object' && 'diff' in entry
)
.map((entry) => entry.diff);
}
if (raw != null && typeof raw === 'object' && ('missing' in raw || 'modified' in raw)) {
return [raw as QualityProfileDriftDiff];
}
return [];
}
function extractCfDiffs(raw: unknown): CustomFormatDriftDiff[] {
if (Array.isArray(raw)) {
return raw
.filter(
(entry): entry is DatabaseCustomFormatDriftDiff =>
entry != null && typeof entry === 'object' && 'diff' in entry
)
.map((entry) => entry.diff);
}
if (raw != null && typeof raw === 'object' && ('missing' in raw || 'modified' in raw)) {
return [raw as CustomFormatDriftDiff];
}
return [];
}
function classifyQpDrift(qpDiffs: QualityProfileDriftDiff[]) {
const result = {
directNames: [] as string[],
transitiveNames: [] as string[],
affectedQpNames: new Set<string>()
};
if (!qpDiff) return result;
// Missing QPs (selected but not in Arr) are always direct.
for (const m of qpDiff.missing) {
result.directNames.push(m.name);
}
for (const m of qpDiff.modified) {
if (isTransitiveOnlyQp(m)) {
result.transitiveNames.push(m.name);
result.affectedQpNames.add(m.name);
} else {
for (const qpDiff of qpDiffs) {
for (const m of qpDiff.missing) {
result.directNames.push(m.name);
// A direct drift can also reference missing CFs; track that too.
if (m.fields.some(fieldIsMissingCustomFormat)) {
}
for (const m of qpDiff.modified) {
if (isTransitiveOnlyQp(m)) {
result.transitiveNames.push(m.name);
result.affectedQpNames.add(m.name);
} else {
result.directNames.push(m.name);
if (m.fields.some(fieldIsMissingCustomFormat)) {
result.affectedQpNames.add(m.name);
}
}
}
}
return result;
}
function collectCfNames(cfDiff: CustomFormatDriftDiff | undefined): string[] {
if (!cfDiff) return [];
function collectCfNames(cfDiffs: CustomFormatDriftDiff[]): string[] {
const names: string[] = [];
for (const m of cfDiff.missing) names.push(m.name);
for (const m of cfDiff.modified) names.push(m.name);
for (const cfDiff of cfDiffs) {
for (const m of cfDiff.missing) names.push(m.name);
for (const m of cfDiff.modified) names.push(m.name);
}
return names;
}
@@ -149,7 +180,15 @@ async function loadDriftProgress(
const qpTotal = qpSync.selections.length;
const dpTotal = dpSync.databaseId && dpSync.profileName ? 1 : 0;
const cfTotal = qpTotal > 0 ? (await buildExpectedCustomFormats(instanceId, arrType)).length : 0;
let cfTotal = 0;
if (qpTotal > 0) {
const perDb = await buildExpectedCustomFormatsPerDatabase(instanceId, arrType);
for (const { formats } of perDb.values()) {
cfTotal += formats.length;
}
}
const namingTotal = mmSync.namingDatabaseId !== null && mmSync.namingConfigName ? 1 : 0;
const qualityDefinitionsTotal =
mmSync.qualityDefinitionsDatabaseId !== null && mmSync.qualityDefinitionsConfigName ? 1 : 0;
@@ -157,16 +196,12 @@ async function loadDriftProgress(
mmSync.mediaSettingsDatabaseId !== null && mmSync.mediaSettingsConfigName ? 1 : 0;
const counts = status.counts ?? {};
const diff = status.diff as
| {
quality_profiles?: QualityProfileDriftDiff;
custom_formats?: CustomFormatDriftDiff;
media_management?: MediaManagementDriftDiff;
}
| undefined;
const qpClass = classifyQpDrift(diff?.quality_profiles);
const cfNames = collectCfNames(diff?.custom_formats);
const mm = diff?.media_management;
const diff = status.diff as Record<string, unknown> | undefined;
const qpDiffs = extractQpDiffs(diff?.quality_profiles);
const cfDiffsList = extractCfDiffs(diff?.custom_formats);
const qpClass = classifyQpDrift(qpDiffs);
const cfNames = collectCfNames(cfDiffsList);
const mm = diff?.media_management as MediaManagementDriftDiff | undefined;
const progress: DriftProgress = {};
if (qpTotal > 0) {
@@ -305,6 +340,7 @@ export const load: ServerLoad = async ({ params }) => {
// Load existing sync data
const syncData = arrSyncQueries.getFullSyncData(id);
const driftProgress = await loadDriftProgress(id, arrType);
const databasePriorities = arrSyncQueries.getDatabasePriorities(id);
const { api_key: _, ...safeInstance } = instance;
@@ -312,7 +348,8 @@ export const load: ServerLoad = async ({ params }) => {
instance: safeInstance,
databases: databasesWithProfiles,
syncData,
driftProgress
driftProgress,
databasePriorities
};
};
@@ -326,6 +363,7 @@ export const actions: Actions = {
const instance = arrInstancesQueries.getById(id);
const formData = await request.formData();
const selectionsJson = formData.get('selections') as string;
const prioritiesJson = formData.get('priorities') as string;
const trigger = formData.get('trigger') as SyncTrigger;
const cron = formData.get('cron') as string | null;
@@ -339,6 +377,13 @@ export const actions: Actions = {
nextRunAt: effectiveTrigger === 'schedule' ? calculateNextRun(effectiveCron) : null
});
const priorities: { databaseId: number; priority: number }[] = JSON.parse(
prioritiesJson || '[]'
);
if (priorities.length > 0) {
await arrSyncQueries.saveDatabasePriorities(id, priorities);
}
await logger.info(`Quality profiles sync config saved for "${instance?.name}"`, {
source: 'sync',
meta: { instanceId: id, profileCount: selections.length, trigger }

View File

@@ -168,6 +168,7 @@
/>
<QualityProfiles
databases={data.databases}
databasePriorities={data.databasePriorities}
bind:state={qualityProfileState}
bind:syncTrigger={qualityProfileTrigger}
bind:cronExpression={qualityProfileCron}
@@ -190,13 +191,11 @@
</div>
<div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">
One Database Per Instance
</div>
<div class="font-medium text-neutral-900 dark:text-neutral-100">Database Priority</div>
<p class="mt-1">
Each Arr instance syncs from a single database. Quality profiles and their custom formats
all come from the same database. If you want to use profiles from a different database,
sync it to a separate Arr instance.
You can sync quality profiles from multiple databases. Drag to reorder databases by
priority. The topmost database has the highest priority and its profiles will overwrite
any matching profiles from lower-priority databases.
</p>
</div>

View File

@@ -1,9 +1,12 @@
<script lang="ts">
import type { QualityProfileTableRow } from '$shared/pcd/display.ts';
import Toggle from '$ui/toggle/Toggle.svelte';
import DraggableCard from '$ui/list/DraggableCard.svelte';
import SyncFooter from './SyncFooter.svelte';
import ProgressIndicator from '$ui/arr/ProgressIndicator.svelte';
import Tooltip from '$ui/tooltip/Tooltip.svelte';
import Label from '$ui/label/Label.svelte';
import Button from '$ui/button/Button.svelte';
import { ChevronUp, ChevronDown } from '@lucide/svelte';
import { alertStore } from '$lib/client/alerts/store.ts';
import { deserialize } from '$app/forms';
import { jobStatus } from '$stores/jobStatus';
@@ -20,7 +23,13 @@
message?: string;
}
interface DatabasePriority {
databaseId: number;
priority: number;
}
export let databases: DatabaseWithProfiles[];
export let databasePriorities: DatabasePriority[] = [];
export let state: Record<number, Record<string, boolean>> = {};
export let syncTrigger: 'manual' | 'on_pull' | 'schedule' = 'manual';
export let cronExpression: string = '0 * * * *';
@@ -33,11 +42,18 @@
let syncing = false;
// Track saved state for dirty detection
let savedState = JSON.stringify({ state, syncTrigger, cronExpression });
$: currentState = JSON.stringify({ state, syncTrigger, cronExpression });
let savedState = JSON.stringify({ state, syncTrigger, cronExpression, databasePriorities });
$: currentState = JSON.stringify({ state, syncTrigger, cronExpression, databasePriorities });
export let isDirty = false;
$: isDirty = currentState !== savedState;
// Order databases by priority
$: orderedDatabases = [...databases].sort((a, b) => {
const pa = databasePriorities.find((p) => p.databaseId === a.id)?.priority ?? Infinity;
const pb = databasePriorities.find((p) => p.databaseId === b.id)?.priority ?? Infinity;
return pa - pb;
});
// Initialize state for all databases/profiles
$: {
for (const db of databases) {
@@ -69,29 +85,9 @@
Object.values(db).some((selected) => selected)
);
// Track which database currently has selections (null = none)
$: activeDatabaseId = (() => {
for (const [dbId, profiles] of Object.entries(state)) {
if (Object.values(profiles).some((selected) => selected)) {
return parseInt(dbId);
}
}
return null;
})();
function setProfile(databaseId: number, profileName: string, checked: boolean) {
if (checked) {
// Clear selections from other databases (1 database per category)
for (const dbId of Object.keys(state)) {
if (parseInt(dbId) !== databaseId) {
for (const name of Object.keys(state[parseInt(dbId)])) {
state[parseInt(dbId)][name] = false;
}
}
}
}
state[databaseId][profileName] = checked;
state = { ...state }; // Reassign to trigger reactivity
state = { ...state };
}
function getSelections(): { databaseId: number; profileName: string }[] {
@@ -106,11 +102,77 @@
return selections;
}
// ── Drag-and-drop reordering ────────────────────────────────────────
let draggedDb: { id: number; index: number } | null = null;
let hoverTargetIndex: number | null = null;
let lastTargetIndex: number | null = null;
function getTargetFromPoint(x: number, y: number): { index: number } | null {
const el = document.elementFromPoint(x, y);
const card = el?.closest('[data-db-index]') as HTMLElement | null;
if (!card) return null;
const idx = parseInt(card.dataset.dbIndex!, 10);
if (isNaN(idx) || idx < 0 || idx >= orderedDatabases.length) return null;
return { index: idx };
}
function handlePointerDown(e: PointerEvent, database: DatabaseWithProfiles, index: number) {
e.preventDefault();
document.body.classList.add('dragging');
draggedDb = { id: database.id, index };
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(e: PointerEvent) {
if (!draggedDb) return;
const target = getTargetFromPoint(e.clientX, e.clientY);
if (!target || target.index === lastTargetIndex) return;
lastTargetIndex = target.index;
hoverTargetIndex = target.index;
if (target.index !== draggedDb.index) {
const newOrder = [...orderedDatabases];
const [moved] = newOrder.splice(draggedDb.index, 1);
newOrder.splice(target.index, 0, moved);
orderedDatabases = newOrder;
databasePriorities = newOrder.map((db, i) => ({ databaseId: db.id, priority: i + 1 }));
draggedDb = { ...draggedDb, index: target.index };
}
}
function handlePointerUp() {
resetDragState();
}
function resetDragState() {
draggedDb = null;
hoverTargetIndex = null;
lastTargetIndex = null;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.body.classList.remove('dragging');
}
// Mobile reorder buttons
function moveDatabase(index: number, direction: -1 | 1) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= orderedDatabases.length) return;
const newOrder = [...orderedDatabases];
[newOrder[index], newOrder[newIndex]] = [newOrder[newIndex], newOrder[index]];
orderedDatabases = newOrder;
databasePriorities = newOrder.map((db, i) => ({ databaseId: db.id, priority: i + 1 }));
}
// ── Save / Sync ─────────────────────────────────────────────────────
async function handleSave() {
saving = true;
try {
const formData = new FormData();
formData.set('selections', JSON.stringify(getSelections()));
formData.set('priorities', JSON.stringify(databasePriorities));
formData.set('trigger', syncTrigger);
formData.set('cron', cronExpression);
@@ -121,8 +183,7 @@
if (response.ok) {
alertStore.add('success', 'Quality profiles sync config saved');
// Update saved state to current
savedState = JSON.stringify({ state, syncTrigger, cronExpression });
savedState = JSON.stringify({ state, syncTrigger, cronExpression, databasePriorities });
} else {
alertStore.add('error', 'Failed to save quality profiles sync config');
}
@@ -165,7 +226,7 @@
<div class="min-w-0 md:flex-1">
<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.
Select quality profiles to sync. Drag databases to set priority.
</p>
</div>
{#if qpProgress || cfProgress}
@@ -210,43 +271,61 @@
<!-- Content -->
<div class="p-6">
{#if databases.length === 0}
{#if orderedDatabases.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}
{@const isInactive = activeDatabaseId !== null && activeDatabaseId !== database.id}
<div class="space-y-3">
<h3
class="text-sm font-semibold text-neutral-900 dark:text-neutral-50"
class:opacity-50={isInactive}
>
{database.name}
</h3>
<div class="space-y-3">
{#each orderedDatabases as database, index (database.id)}
<DraggableCard
isDragging={draggedDb?.index === index}
onDragHandlePointerDown={(e) => handlePointerDown(e, database, index)}
contentClass="p-4"
data-db-index={index}
>
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-neutral-900 dark:text-neutral-50">
{database.name}
</h3>
<!-- Mobile reorder buttons -->
<div class="flex gap-1 md:hidden">
<Button
icon={ChevronUp}
size="xs"
disabled={index === 0}
on:click={() => moveDatabase(index, -1)}
/>
<Button
icon={ChevronDown}
size="xs"
disabled={index === orderedDatabases.length - 1}
on:click={() => moveDatabase(index, 1)}
/>
</div>
</div>
<Label variant="info" size="sm" rounded="md">
Priority #{index + 1}
</Label>
</div>
{#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-1 gap-2 sm:grid-cols-3 md:grid-cols-5">
{#each database.qualityProfiles as profile}
<Tooltip
text={isInactive ? 'Only one database can be used per instance.' : ''}
position="bottom"
fullWidth
>
{#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-1 gap-2 sm:grid-cols-3 md:grid-cols-5">
{#each database.qualityProfiles as profile}
<Toggle
checked={isSelected(database.id, profile.name)}
disabled={isInactive}
label={profile.name}
fullWidth
ariaLabel={`Toggle quality profile ${profile.name} from ${database.name}`}
on:change={(e) => setProfile(database.id, profile.name, e.detail)}
/>
</Tooltip>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</DraggableCard>
{/each}
</div>
{/if}