mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-08-02 07:57:42 -04:00
feat: support database sync priority per Arr instance (#694)
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user