app_lib/
db.rs

1//! SQLite database layer for scalable storage of audio samples, analysis caches,
2//! and scan metadata. Replaces JSON file persistence for data that can grow to
3//! millions of rows.
4
5use crate::history::{
6    self, AudioHistory, AudioSample, AudioScanSnapshot, DawHistory, DawProject, DawScanSnapshot,
7    KvrCacheEntry, MidiFile, MidiScanSnapshot, PdfFile, PdfScanSnapshot, PresetFile, PresetHistory,
8    PresetScanSnapshot, ScanHistory, ScanSnapshot,
9};
10use crate::path_norm::{normalize_path_for_db, path_strings_json_normalized};
11use crate::scanner::PluginInfo;
12use regex::{Regex, RegexBuilder};
13use rusqlite::functions::{Context, FunctionFlags};
14use rusqlite::{params, Connection, OptionalExtension, Transaction};
15use serde::{Deserialize, Serialize};
16use std::collections::hash_map::Entry;
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
19use std::sync::{Arc, Mutex, OnceLock};
20
21static GLOBAL_DB: OnceLock<Database> = OnceLock::new();
22/// Serializes [`Database::open`] + migrations on the on-disk file. Without this, many threads can
23/// pass the `GLOBAL_DB` empty check at once and run `migrate()` in parallel against the same path,
24/// which triggers SQLite `database is locked` (seen on multi-core CI runners).
25static INIT_GLOBAL_MUTEX: Mutex<()> = Mutex::new(());
26
27/// Initialize the global database. Call once at startup.
28///
29/// Safe under parallel `cargo test`: [`OnceLock`] stores at most one handle; a mutex ensures only
30/// one thread opens the DB file and runs migrations. Losers of the `set` race return `Ok` without
31/// retaining a second connection.
32pub fn init_global() -> Result<(), String> {
33    if GLOBAL_DB.get().is_some() {
34        return Ok(());
35    }
36    let _guard = INIT_GLOBAL_MUTEX
37        .lock()
38        .map_err(|e| format!("init_global mutex: {e}"))?;
39    if GLOBAL_DB.get().is_some() {
40        return Ok(());
41    }
42    let db = Database::open()?;
43    match GLOBAL_DB.set(db) {
44        Ok(()) => Ok(()),
45        Err(_redundant) => Ok(()),
46    }
47}
48
49/// Returns true after a successful [`init_global`] (including concurrent test runners).
50pub fn global_initialized() -> bool {
51    GLOBAL_DB.get().is_some()
52}
53
54/// Get the global database reference.
55pub fn global() -> &'static Database {
56    GLOBAL_DB.get().expect("Database not initialized")
57}
58
59/// One row for [`Database::batch_update_analysis`]: path, BPM, musical key, LUFS.
60pub type AnalysisBatchRow = (String, Option<f64>, Option<String>, Option<f64>);
61
62/// SQLite with WAL: multiple connections can serve read-heavy queries concurrently.
63/// `write` is the **only** handle used for schema migrations and code paths that need the writer
64/// mutex; **never** mix it into the read round-robin — doing so serialized every `db_query_*` IPC
65/// with every other read/write on that mutex (spinners across tabs when one query held the slot).
66/// `read` is a pool of read-only file handles; [`Database::read_conn`] round-robins **only** here
67/// (always at least one after [`Database::open`]).
68pub struct Database {
69    write: Mutex<Connection>,
70    read: Vec<Mutex<Connection>>,
71    /// Per-slot query-start timestamp (epoch ms) for the progress-handler timeout.
72    read_deadlines: Vec<Arc<AtomicU64>>,
73    read_idx: AtomicUsize,
74    /// `SELECT COUNT(*) FROM midi_library` is O(rows); cache and invalidate on MIDI library writes.
75    midi_library_total_cache: Mutex<Option<u64>>,
76    /// Same pattern as [`Self::midi_library_total_rows`] for `pdf_library`.
77    pdf_library_total_cache: Mutex<Option<u64>>,
78    /// `SELECT COUNT(*) FROM audio_library` — invalidated on audio library writes.
79    audio_library_total_cache: Mutex<Option<u64>>,
80    /// Preset tab header: non-MIDI rows in library (`query_presets` / `preset_filter_stats`).
81    preset_inventory_total_cache: Mutex<Option<u64>>,
82    /// `SELECT COUNT(*) FROM daw_library` — invalidated on DAW library writes.
83    daw_library_total_cache: Mutex<Option<u64>>,
84    /// `SELECT COUNT(*) FROM plugin_library` — invalidated on plugin library writes.
85    plugin_library_total_cache: Mutex<Option<u64>>,
86}
87
88/// Parameters for paginated audio sample queries.
89#[derive(Debug, Deserialize)]
90pub struct AudioQueryParams {
91    #[serde(default)]
92    pub scan_id: Option<String>,
93    #[serde(default)]
94    pub search: Option<String>,
95    /// When true, `search` is a Rust regex (case-insensitive, matches JS `RegExp` `i` flag).
96    /// Uses SQLite `REGEXP` with a user-defined function — not FTS5 phrase search.
97    #[serde(default)]
98    pub search_regex: bool,
99    #[serde(default)]
100    pub format_filter: Option<String>,
101    #[serde(default = "default_sort_key")]
102    pub sort_key: String,
103    #[serde(default = "default_true")]
104    pub sort_asc: bool,
105    #[serde(default)]
106    pub offset: u64,
107    #[serde(default = "default_limit")]
108    pub limit: u64,
109}
110
111fn default_sort_key() -> String {
112    "name".into()
113}
114fn default_true() -> bool {
115    true
116}
117fn default_limit() -> u64 {
118    200
119}
120
121/// Exact FTS match counts above this force a bounded count and skip heavy aggregates (GROUP BY /
122/// size histogram over every hit) — common substrings like "loop" can match hundreds of
123/// thousands of library rows. Shared by audio, presets, MIDI, and PDF inventory queries.
124const FTS_INVENTORY_MATCH_COUNT_CAP: i64 = 100_000;
125
126/// Convert a user search string into an FTS5 phrase query for the trigram
127/// tokenizer. Returns `None` for empty/whitespace input. The result is wrapped
128/// in double quotes (phrase match) with internal quotes doubled per FTS5 syntax.
129/// Trigram tokenizer indexes substrings, so `"foo"` matches any row containing
130/// "foo" as a substring in any indexed column.
131/// Returns an FTS5 phrase for trigram MATCH, or None if the search is empty
132/// or too short (trigram needs ≥3 chars). Callers must fall back to LIKE for
133/// 1–2 char searches.
134fn fts_phrase(search: &str) -> Option<String> {
135    let trimmed = search.trim();
136    if trimmed.len() < 3 {
137        return None;
138    }
139    Some(format!("\"{}\"", trimmed.replace('"', "\"\"")))
140}
141
142/// Build a LIKE pattern for short searches (1–2 chars) where FTS5 trigram
143/// can't help. Returns None for empty input.
144fn short_like(search: &str) -> Option<String> {
145    let trimmed = search.trim();
146    if trimmed.is_empty() || trimmed.len() >= 3 {
147        return None;
148    }
149    let escaped = trimmed
150        .replace('\\', "\\\\")
151        .replace('%', "\\%")
152        .replace('_', "\\_");
153    Some(format!("%{escaped}%"))
154}
155
156/// FTS-backed tabs (name + path): returns `(fts_match, like_pat, regex_pat)` — at most one of the
157/// three is `Some`. Mirrors [`AudioQueryParams::search_regex`] semantics.
158fn classify_fts_name_path_search(
159    search: Option<&str>,
160    search_regex: bool,
161) -> (Option<String>, Option<String>, Option<String>) {
162    if search_regex {
163        let mut like_pat = None;
164        let mut regex_pat = None;
165        if let Some(s) = search {
166            let t = s.trim();
167            if !t.is_empty() {
168                if RegexBuilder::new(t).case_insensitive(true).build().is_ok() {
169                    regex_pat = Some(t.to_string());
170                } else {
171                    let escaped = t
172                        .replace('\\', "\\\\")
173                        .replace('%', "\\%")
174                        .replace('_', "\\_");
175                    like_pat = Some(format!("%{escaped}%"));
176                }
177            }
178        }
179        (None, like_pat, regex_pat)
180    } else {
181        (
182            search.and_then(fts_phrase),
183            search.and_then(short_like),
184            None,
185        )
186    }
187}
188
189/// Plugins tab (name, manufacturer, path): `(regex_pat, like_pat)` — when `regex_pat` is `Some`,
190/// use `REGEXP` on all three columns; otherwise `like_pat` is fuzzy interleaved or invalid-regex
191/// fallback (same binding shape).
192fn classify_plugins_search(
193    search: Option<&str>,
194    search_regex: bool,
195) -> (Option<String>, Option<String>) {
196    let Some(s) = search else {
197        return (None, None);
198    };
199    let t = s.trim();
200    if t.is_empty() {
201        return (None, None);
202    }
203    if search_regex {
204        if RegexBuilder::new(t).case_insensitive(true).build().is_ok() {
205            (Some(t.to_string()), None)
206        } else {
207            let escaped = t
208                .replace('\\', "\\\\")
209                .replace('%', "\\%")
210                .replace('_', "\\_");
211            (None, Some(format!("%{escaped}%")))
212        }
213    } else {
214        let interleaved = format!(
215            "%{}%",
216            t.chars()
217                .map(|c| c.to_string())
218                .collect::<Vec<_>>()
219                .join("%")
220        );
221        (None, Some(interleaved))
222    }
223}
224
225static REGEXP_FUNC_CACHE: OnceLock<Mutex<HashMap<String, Regex>>> = OnceLock::new();
226
227fn regexp_pattern_cache() -> &'static Mutex<HashMap<String, Regex>> {
228    REGEXP_FUNC_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
229}
230
231/// SQLite `regexp(pattern, haystack)` — matches JS `new RegExp(pattern, 'i')` (case-insensitive).
232fn regexp_user_matches(pattern: &str, haystack: &str) -> bool {
233    let mut map = match regexp_pattern_cache().lock() {
234        Ok(m) => m,
235        Err(_) => return false,
236    };
237    if map.len() > 256 {
238        map.clear();
239    }
240    let re = match map.entry(pattern.to_string()) {
241        Entry::Occupied(e) => e.get().clone(),
242        Entry::Vacant(v) => {
243            let r = match RegexBuilder::new(pattern).case_insensitive(true).build() {
244                Ok(r) => r,
245                Err(_) => return false,
246            };
247            v.insert(r.clone());
248            r
249        }
250    };
251    re.is_match(haystack)
252}
253
254fn install_regexp_function(conn: &Connection) -> Result<(), String> {
255    conn.create_scalar_function(
256        "regexp",
257        2,
258        FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC,
259        |ctx: &Context<'_>| -> std::result::Result<i64, rusqlite::Error> {
260            let pattern: String = ctx.get(0)?;
261            let haystack: String = ctx.get(1)?;
262            Ok(if regexp_user_matches(&pattern, &haystack) {
263                1
264            } else {
265                0
266            })
267        },
268    )
269    .map_err(|e| e.to_string())
270}
271
272/// Backfill FTS5 contentless shadow tables from primary tables for rows missing from FTS.
273/// Migration v9 created empty FTS tables; existing `audio_samples` (etc.) rows were never
274/// indexed, so `MATCH` returned no hits while the base tables still showed full library counts.
275fn backfill_contentless_fts(conn: &rusqlite::Connection) -> Result<(), String> {
276    let n_audio: i64 = conn
277        .query_row(
278            "SELECT COUNT(*) FROM audio_samples a WHERE NOT EXISTS (SELECT 1 FROM audio_samples_fts f WHERE f.rowid = a.id)",
279            [],
280            |r| r.get(0),
281        )
282        .unwrap_or(0);
283    if n_audio > 0 {
284        crate::append_log(format!(
285            "DB migration v13: backfilling {n_audio} audio_samples rows into FTS"
286        ));
287        conn.execute(
288            "INSERT INTO audio_samples_fts(rowid, name, path, scan_id)
289             SELECT a.id, a.name, a.path, a.scan_id FROM audio_samples a
290             WHERE NOT EXISTS (SELECT 1 FROM audio_samples_fts f WHERE f.rowid = a.id)",
291            [],
292        )
293        .map_err(|e| e.to_string())?;
294    }
295
296    let n_daw: i64 = conn
297        .query_row(
298            "SELECT COUNT(*) FROM daw_projects p WHERE NOT EXISTS (SELECT 1 FROM daw_projects_fts f WHERE f.rowid = p.id)",
299            [],
300            |r| r.get(0),
301        )
302        .unwrap_or(0);
303    if n_daw > 0 {
304        crate::append_log(format!(
305            "DB migration v13: backfilling {n_daw} daw_projects rows into FTS"
306        ));
307        conn.execute(
308            "INSERT INTO daw_projects_fts(rowid, name, path, daw, scan_id)
309             SELECT p.id, p.name, p.path, p.daw, p.scan_id FROM daw_projects p
310             WHERE NOT EXISTS (SELECT 1 FROM daw_projects_fts f WHERE f.rowid = p.id)",
311            [],
312        )
313        .map_err(|e| e.to_string())?;
314    }
315
316    let n_preset: i64 = conn
317        .query_row(
318            "SELECT COUNT(*) FROM presets p WHERE NOT EXISTS (SELECT 1 FROM presets_fts f WHERE f.rowid = p.id)",
319            [],
320            |r| r.get(0),
321        )
322        .unwrap_or(0);
323    if n_preset > 0 {
324        crate::append_log(format!(
325            "DB migration v13: backfilling {n_preset} presets rows into FTS"
326        ));
327        conn.execute(
328            "INSERT INTO presets_fts(rowid, name, path, format, scan_id)
329             SELECT p.id, p.name, p.path, p.format, p.scan_id FROM presets p
330             WHERE NOT EXISTS (SELECT 1 FROM presets_fts f WHERE f.rowid = p.id)",
331            [],
332        )
333        .map_err(|e| e.to_string())?;
334    }
335
336    let n_midi: i64 = conn
337        .query_row(
338            "SELECT COUNT(*) FROM midi_files m WHERE NOT EXISTS (SELECT 1 FROM midi_files_fts f WHERE f.rowid = m.id)",
339            [],
340            |r| r.get(0),
341        )
342        .unwrap_or(0);
343    if n_midi > 0 {
344        crate::append_log(format!(
345            "DB migration v13: backfilling {n_midi} midi_files rows into FTS"
346        ));
347        conn.execute(
348            "INSERT INTO midi_files_fts(rowid, name, path, scan_id)
349             SELECT m.id, m.name, m.path, m.scan_id FROM midi_files m
350             WHERE NOT EXISTS (SELECT 1 FROM midi_files_fts f WHERE f.rowid = m.id)",
351            [],
352        )
353        .map_err(|e| e.to_string())?;
354    }
355
356    let n_pdf: i64 = conn
357        .query_row(
358            "SELECT COUNT(*) FROM pdfs p WHERE NOT EXISTS (SELECT 1 FROM pdfs_fts f WHERE f.rowid = p.id)",
359            [],
360            |r| r.get(0),
361        )
362        .unwrap_or(0);
363    if n_pdf > 0 {
364        crate::append_log(format!(
365            "DB migration v13: backfilling {n_pdf} pdfs rows into FTS"
366        ));
367        conn.execute(
368            "INSERT INTO pdfs_fts(rowid, name, path, scan_id)
369             SELECT p.id, p.name, p.path, p.scan_id FROM pdfs p
370             WHERE NOT EXISTS (SELECT 1 FROM pdfs_fts f WHERE f.rowid = p.id)",
371            [],
372        )
373        .map_err(|e| e.to_string())?;
374    }
375
376    Ok(())
377}
378
379/// A single row returned from a paginated query, with analysis data inline.
380#[derive(Debug, Serialize)]
381pub struct AudioSampleRow {
382    pub name: String,
383    pub path: String,
384    pub directory: String,
385    pub format: String,
386    pub size: u64,
387    #[serde(rename = "sizeFormatted")]
388    pub size_formatted: String,
389    pub modified: String,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub duration: Option<f64>,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub channels: Option<u16>,
394    #[serde(rename = "sampleRate", skip_serializing_if = "Option::is_none")]
395    pub sample_rate: Option<u32>,
396    #[serde(rename = "bitsPerSample", skip_serializing_if = "Option::is_none")]
397    pub bits_per_sample: Option<u16>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub bpm: Option<f64>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub key: Option<String>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub lufs: Option<f64>,
404    /// True when BPM detection gave up (NULL BPM with key+LUFS filled) — UI shows N/A in the BPM column.
405    #[serde(rename = "bpmExhausted", skip_serializing_if = "std::ops::Not::not")]
406    pub bpm_exhausted: bool,
407}
408
409/// Result of a paginated query.
410#[derive(Debug, Serialize)]
411pub struct AudioQueryResult {
412    pub samples: Vec<AudioSampleRow>,
413    #[serde(rename = "totalCount")]
414    pub total_count: u64,
415    /// True when `total_count` is a floor (`FTS_INVENTORY_MATCH_COUNT_CAP`) — exact count not computed.
416    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
417    pub total_count_capped: bool,
418    #[serde(rename = "totalUnfiltered")]
419    pub total_unfiltered: u64,
420}
421
422/// Aggregate stats for a scan.
423#[derive(Debug, Serialize)]
424pub struct AudioStatsResult {
425    #[serde(rename = "sampleCount")]
426    pub sample_count: u64,
427    #[serde(rename = "totalBytes")]
428    pub total_bytes: u64,
429    #[serde(rename = "formatCounts")]
430    pub format_counts: HashMap<String, u64>,
431    #[serde(rename = "analyzedCount")]
432    pub analyzed_count: u64,
433}
434
435/// Aggregate DAW stats from [`Database::daw_stats`]: library totals (deduped by `path`) when
436/// `scan_id` is omitted or empty; otherwise that scan only.
437#[derive(Debug, Serialize)]
438pub struct DawStatsResult {
439    #[serde(rename = "projectCount")]
440    pub project_count: u64,
441    #[serde(rename = "totalBytes")]
442    pub total_bytes: u64,
443    #[serde(rename = "dawCounts")]
444    pub daw_counts: HashMap<String, u64>,
445}
446
447/// Aggregate preset stats from [`Database::preset_stats`]: library totals (deduped by `path`, MIDI
448/// formats excluded) when `scan_id` is omitted or empty; otherwise that scan only.
449#[derive(Debug, Serialize)]
450pub struct PresetStatsResult {
451    #[serde(rename = "presetCount")]
452    pub preset_count: u64,
453    #[serde(rename = "totalBytes")]
454    pub total_bytes: u64,
455    #[serde(rename = "formatCounts")]
456    pub format_counts: HashMap<String, u64>,
457}
458
459/// Scan metadata (no samples).
460#[derive(Debug, Serialize)]
461pub struct ScanInfo {
462    pub id: String,
463    pub timestamp: String,
464    #[serde(rename = "sampleCount")]
465    pub sample_count: u64,
466    #[serde(rename = "totalBytes")]
467    pub total_bytes: u64,
468    #[serde(rename = "formatCounts")]
469    pub format_counts: HashMap<String, u64>,
470    pub roots: Vec<String>,
471}
472
473/// Stats for a single cache table.
474#[derive(Debug, Serialize)]
475pub struct CacheStat {
476    pub key: String,
477    pub label: String,
478    pub count: u64,
479    pub total: u64,
480    #[serde(rename = "sizeBytes")]
481    pub size_bytes: u64,
482}
483
484/// Approximate on-disk bytes for btree objects (table + indexes) for `scan_table` + `item_table`.
485/// Uses SQLite [`dbstat`](https://www.sqlite.org/dbstat.html) when available.
486/// Returns `None` if `dbstat` is not compiled in (caller splits DB file size by row count).
487fn dbstat_bytes_for_scan_group(
488    conn: &Connection,
489    scan_table: &str,
490    item_table: &str,
491) -> Option<u64> {
492    let mut stmt = conn
493        .prepare(
494            "SELECT name FROM sqlite_master WHERE type IN ('table','index') AND tbl_name IN (?1, ?2)",
495        )
496        .ok()?;
497    let mut rows = stmt.query(rusqlite::params![scan_table, item_table]).ok()?;
498    let mut names = Vec::new();
499    loop {
500        match rows.next() {
501            Ok(Some(row)) => {
502                names.push(row.get::<_, String>(0).ok()?);
503            }
504            Ok(None) => break,
505            Err(_) => return None,
506        }
507    }
508    if names.is_empty() {
509        return Some(0);
510    }
511    let mut total: u64 = 0;
512    for name in names {
513        let v: i64 = conn
514            .query_row(
515                "SELECT COALESCE(SUM(pgsize), 0) FROM dbstat WHERE name = ?1",
516                [&name],
517                |r| r.get(0),
518            )
519            .ok()?;
520        total = total.saturating_add(v.max(0) as u64);
521    }
522    Some(total)
523}
524
525/// Canonical inventory: one row per `path` (highest `id` wins — newest insert for that path).
526/// Materialized in `audio_library` (migration v14) so library queries avoid `GROUP BY path` on hot paths.
527const AUDIO_LIBRARY_IDS: &str = "id IN (SELECT sample_id FROM audio_library)";
528/// Same semantics as `MAX(id) GROUP BY path`, materialized in `daw_library` (migration v16).
529const DAW_LIBRARY_IDS: &str = "id IN (SELECT project_id FROM daw_library)";
530/// Migration v15 — same semantics as `MAX(id) GROUP BY path`, maintained on insert and deletes.
531const PRESET_LIBRARY_IDS: &str = "id IN (SELECT preset_id FROM preset_library)";
532const PDF_LIBRARY_IDS: &str = "id IN (SELECT pdf_id FROM pdf_library)";
533const MIDI_LIBRARY_IDS: &str = "id IN (SELECT midi_id FROM midi_library)";
534/// Materialized in `plugin_library` (migration v17) — same semantics as other `*_library` tables.
535const PLUGIN_LIBRARY_IDS: &str = "id IN (SELECT plugin_id FROM plugin_library)";
536const PLUGIN_LIBRARY_IDS_QUALIFIED: &str = "plugins.id IN (SELECT plugin_id FROM plugin_library)";
537
538/// Comma-separated `update`, `current`, `unknown` — matches `kvr_cache` + frontend `pluginStatusCategory`.
539fn parse_plugin_status_filter(sf: Option<&str>) -> Option<Vec<&'static str>> {
540    let s = sf?;
541    let t = s.trim();
542    if t.is_empty() || t == "all" {
543        return None;
544    }
545    let mut v = Vec::new();
546    for part in t.split(',') {
547        match part.trim() {
548            "update" => v.push("update"),
549            "current" => v.push("current"),
550            "unknown" => v.push("unknown"),
551            _ => {}
552        }
553    }
554    if v.is_empty() { None } else { Some(v) }
555}
556
557/// Latest **complete** DAW scan that has at least one `daw_projects` row. Empty scans remain in history but must not shadow prior results.
558/// Uses child-row presence (not `project_count`) so streaming scans still resolve after finalize quirks.
559const LATEST_DAW_SCAN_ID_SQL: &str = "\
560    SELECT s.id FROM daw_scans s \
561    WHERE s.scan_complete = 1 \
562    AND EXISTS (SELECT 1 FROM daw_projects p WHERE p.scan_id = s.id) \
563    ORDER BY s.timestamp DESC LIMIT 1";
564
565// ── Generic paginated query result for plugins/DAW/presets ──
566
567#[derive(Debug, Serialize)]
568pub struct PluginRow {
569    pub name: String,
570    pub path: String,
571    #[serde(rename = "type")]
572    pub plugin_type: String,
573    pub version: String,
574    pub manufacturer: String,
575    #[serde(rename = "manufacturerUrl", skip_serializing_if = "Option::is_none")]
576    pub manufacturer_url: Option<String>,
577    pub size: String,
578    #[serde(rename = "sizeBytes")]
579    pub size_bytes: u64,
580    pub modified: String,
581    pub architectures: Vec<String>,
582}
583
584#[derive(Debug, Serialize)]
585pub struct PluginQueryResult {
586    pub plugins: Vec<PluginRow>,
587    #[serde(rename = "totalCount")]
588    pub total_count: u64,
589    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
590    pub total_count_capped: bool,
591    #[serde(rename = "totalUnfiltered")]
592    pub total_unfiltered: u64,
593}
594
595#[derive(Debug, Serialize)]
596pub struct DawRow {
597    pub name: String,
598    pub path: String,
599    pub directory: String,
600    pub format: String,
601    pub daw: String,
602    pub size: u64,
603    #[serde(rename = "sizeFormatted")]
604    pub size_formatted: String,
605    pub modified: String,
606}
607
608#[derive(Debug, Serialize)]
609pub struct DawQueryResult {
610    pub projects: Vec<DawRow>,
611    #[serde(rename = "totalCount")]
612    pub total_count: u64,
613    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
614    pub total_count_capped: bool,
615    #[serde(rename = "totalUnfiltered")]
616    pub total_unfiltered: u64,
617}
618
619#[derive(Debug, Serialize)]
620pub struct PresetRow {
621    pub name: String,
622    pub path: String,
623    pub directory: String,
624    pub format: String,
625    pub size: u64,
626    #[serde(rename = "sizeFormatted")]
627    pub size_formatted: String,
628    pub modified: String,
629}
630
631#[derive(Debug, Serialize)]
632pub struct PresetQueryResult {
633    pub presets: Vec<PresetRow>,
634    #[serde(rename = "totalCount")]
635    pub total_count: u64,
636    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
637    pub total_count_capped: bool,
638    #[serde(rename = "totalUnfiltered")]
639    pub total_unfiltered: u64,
640}
641
642#[derive(Debug, Serialize)]
643pub struct MidiQueryResult {
644    #[serde(rename = "midiFiles")]
645    pub midi_files: Vec<MidiFile>,
646    #[serde(rename = "totalCount")]
647    pub total_count: u64,
648    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
649    pub total_count_capped: bool,
650    #[serde(rename = "totalUnfiltered")]
651    pub total_unfiltered: u64,
652}
653
654#[derive(Debug, Serialize)]
655pub struct PdfRow {
656    pub name: String,
657    pub path: String,
658    pub directory: String,
659    pub size: u64,
660    #[serde(rename = "sizeFormatted")]
661    pub size_formatted: String,
662    pub modified: String,
663}
664
665#[derive(Debug, Serialize)]
666pub struct PdfQueryResult {
667    pub pdfs: Vec<PdfRow>,
668    #[serde(rename = "totalCount")]
669    pub total_count: u64,
670    #[serde(rename = "totalCountCapped", skip_serializing_if = "std::ops::Not::not")]
671    pub total_count_capped: bool,
672    #[serde(rename = "totalUnfiltered")]
673    pub total_unfiltered: u64,
674}
675
676#[derive(Debug, Serialize)]
677pub struct PdfStatsResult {
678    #[serde(rename = "pdfCount")]
679    pub pdf_count: u64,
680    #[serde(rename = "totalBytes")]
681    pub total_bytes: u64,
682}
683
684/// One row for heatmap dashboard “top folders” (matches `heatmap-dashboard.js` folder keys).
685#[derive(Debug, Clone, Serialize)]
686pub struct TopFolderRow {
687    pub path: String,
688    pub count: u64,
689}
690
691/// Filtered aggregate stats — count + size + per-type breakdown reflecting
692/// the active search/filter. One round-trip: COUNT + SUM + GROUP BY in SQL.
693#[derive(Debug, Serialize)]
694pub struct FilterStatsResult {
695    pub count: u64,
696    /// True when `count` is a floor — per-type breakdown and bytes were skipped.
697    #[serde(rename = "countCapped", skip_serializing_if = "std::ops::Not::not")]
698    pub count_capped: bool,
699    #[serde(rename = "totalBytes")]
700    pub total_bytes: u64,
701    #[serde(rename = "byType")]
702    pub by_type: HashMap<String, u64>,
703    #[serde(rename = "bytesByType")]
704    pub bytes_by_type: HashMap<String, u64>,
705    #[serde(rename = "totalUnfiltered")]
706    pub total_unfiltered: u64,
707    /// Audio-only: file-size histogram (6 buckets, same thresholds as `heatmap-dashboard.js`).
708    #[serde(default, rename = "sizeBuckets")]
709    pub size_buckets: Vec<u64>,
710    /// Audio-only: BPM histogram (34 bins, 50–220 step 5) for heatmap; empty for non-audio.
711    #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "bpmBuckets")]
712    pub bpm_buckets: Vec<u64>,
713    /// Audio-only: library rows with `bpm IS NOT NULL` in the filtered scope.
714    #[serde(default, rename = "bpmAnalyzedCount")]
715    pub bpm_analyzed_count: u64,
716    /// Audio-only: key_name → count (top keys) for heatmap key card.
717    #[serde(default, skip_serializing_if = "HashMap::is_empty", rename = "keyCounts")]
718    pub key_counts: HashMap<String, u64>,
719    #[serde(default, rename = "keyAnalyzedCount")]
720    pub key_analyzed_count: u64,
721    /// Audio-only: top directory groups (first 3 path segments), sorted by count.
722    /// Always serialized (even `[]`) so the frontend can tell DB aggregates from a missing field.
723    #[serde(default, rename = "topFolders")]
724    pub top_folders: Vec<TopFolderRow>,
725}
726
727impl Default for FilterStatsResult {
728    fn default() -> Self {
729        Self {
730            count: 0,
731            count_capped: false,
732            total_bytes: 0,
733            by_type: HashMap::new(),
734            bytes_by_type: HashMap::new(),
735            total_unfiltered: 0,
736            size_buckets: vec![],
737            bpm_buckets: vec![],
738            bpm_analyzed_count: 0,
739            key_counts: HashMap::new(),
740            key_analyzed_count: 0,
741            top_folders: vec![],
742        }
743    }
744}
745
746/// One row persisted for the last unified home-tree scan (SQLite `unified_scan_run.id` is always 1).
747#[derive(Debug, Clone, Serialize)]
748#[serde(rename_all = "camelCase")]
749pub struct UnifiedScanRunRow {
750    pub run_id: String,
751    pub started_at: String,
752    pub finished_at: Option<String>,
753    pub outcome: String,
754    pub audio_scan_id: Option<String>,
755    pub daw_scan_id: Option<String>,
756    pub preset_scan_id: Option<String>,
757    pub pdf_scan_id: Option<String>,
758    pub roots_json: String,
759    pub last_directory_path: Option<String>,
760    pub error_message: Option<String>,
761}
762
763/// Current schema version — bump when adding migrations.
764#[allow(dead_code)]
765const SCHEMA_VERSION: i64 = 4;
766
767/// Cap on extra connections when [`sqliteReadPoolExtra`] pref is `"auto"`.
768/// Keep modest: explicit `0`–`32` extra still opens `1 + extra` readers — RSS scales with
769/// [`read_pool_cache_kib`] / [`read_pool_mmap_bytes`] (budget-split, not fixed per handle).
770const SQLITE_READ_POOL_AUTO_CAP: usize = 8;
771
772/// Max explicit extra connections from preferences (`0` = one reader + writer; see README).
773const SQLITE_READ_POOL_EXTRA_MAX: usize = 32;
774
775/// Primary SQLite handle (migrations, `write_conn`): large page cache + mmap.
776const SQLITE_CACHE_KIB_PRIMARY: i32 = -262_144; // 256 MiB (negative `cache_size` = KiB)
777const SQLITE_MMAP_BYTES_PRIMARY: i64 = 536_870_912; // 512 MiB
778
779/// ~512 MiB total page-cache budget split across **all** read-pool connections (e.g. `extra = 32`
780/// → 33 readers × ~15.5 MiB each). Caps each reader at 32 MiB when the pool is small.
781fn read_pool_cache_kib(num_read_connections: usize) -> i32 {
782    const TOTAL_KIB_BUDGET: i64 = 512 * 1024; // 512 MiB in KiB units for SQLite negative cache_size
783    let n = num_read_connections.max(1) as i64;
784    let per_kib = (TOTAL_KIB_BUDGET / n).min(32 * 1024).max(256);
785    -(per_kib as i32)
786}
787
788/// ~512 MiB total mmap cap split across read-pool connections; each reader at most 64 MiB.
789fn read_pool_mmap_bytes(num_read_connections: usize) -> i64 {
790    const BUDGET: i64 = 512 * 1024 * 1024;
791    let n = num_read_connections.max(1) as i64;
792    (BUDGET / n).min(64 * 1024 * 1024).max(256 * 1024)
793}
794
795fn init_sqlite_connection_pragmas(
796    conn: &Connection,
797    cache_kib: i32,
798    mmap_cap: i64,
799) -> Result<(), String> {
800    conn.execute_batch(&format!(
801        "PRAGMA journal_mode=WAL;
802         PRAGMA synchronous=NORMAL;
803         PRAGMA cache_size={cache_kib};
804         PRAGMA mmap_size={mmap_cap};
805         PRAGMA foreign_keys=ON;
806         PRAGMA temp_store=MEMORY;
807         PRAGMA wal_autocheckpoint=1000;",
808    ))
809    .map_err(|e| format!("Failed to set pragmas: {e}"))?;
810    Ok(())
811}
812
813/// Per-query timeout for read-pool connections. The SQLite progress handler fires every
814/// [`SQLITE_PROGRESS_HANDLER_OPS`] VM opcodes; if wall-clock time since the query started
815/// exceeds this limit the query is interrupted (`SQLITE_INTERRUPT`).
816const SQLITE_QUERY_TIMEOUT_SECS: u64 = 30;
817
818/// Check interval for the progress handler — every N virtual-machine opcodes.
819/// 1000 is ~50µs on modern hardware; low enough for responsive cancellation,
820/// high enough to avoid measurable overhead on normal queries.
821const SQLITE_PROGRESS_HANDLER_OPS: u32 = 1000;
822
823fn open_db_connection_with_pragmas(
824    db_path: &std::path::Path,
825    cache_kib: i32,
826    mmap_cap: i64,
827) -> Result<Connection, String> {
828    let conn =
829        Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
830    conn.busy_timeout(std::time::Duration::from_secs(30))
831        .map_err(|e| format!("Failed to set busy_timeout: {e}"))?;
832    init_sqlite_connection_pragmas(&conn, cache_kib, mmap_cap)?;
833    install_regexp_function(&conn)?;
834    Ok(conn)
835}
836
837/// Open a read-pool connection with a progress-handler query timeout.
838/// The returned `Arc<AtomicU64>` must be stored alongside the connection and reset
839/// (via [`reset_query_deadline`]) each time the connection is acquired from the pool.
840fn open_read_connection(
841    db_path: &std::path::Path,
842    cache_kib: i32,
843    mmap_cap: i64,
844) -> Result<(Connection, Arc<AtomicU64>), String> {
845    let conn = open_db_connection_with_pragmas(db_path, cache_kib, mmap_cap)?;
846    let start = Arc::new(AtomicU64::new(now_epoch_ms()));
847    let start_for_handler = Arc::clone(&start);
848    let timeout_ms = SQLITE_QUERY_TIMEOUT_SECS * 1000;
849    conn.progress_handler(SQLITE_PROGRESS_HANDLER_OPS as i32, Some(move || {
850        now_epoch_ms().saturating_sub(start_for_handler.load(Ordering::Relaxed)) > timeout_ms
851    }))
852    .map_err(|e| format!("Failed to set progress_handler: {e}"))?;
853    Ok((conn, start))
854}
855
856/// Milliseconds since [`std::time::UNIX_EPOCH`] (monotonic enough for timeout purposes).
857fn now_epoch_ms() -> u64 {
858    std::time::SystemTime::now()
859        .duration_since(std::time::UNIX_EPOCH)
860        .unwrap_or_default()
861        .as_millis() as u64
862}
863
864/// Reset the query-start timestamp so the progress handler measures from *now*.
865#[inline]
866fn reset_query_deadline(start: &AtomicU64) {
867    start.store(now_epoch_ms(), Ordering::Relaxed);
868}
869
870fn sqlite_read_pool_auto() -> usize {
871    num_cpus::get().min(SQLITE_READ_POOL_AUTO_CAP).max(2)
872}
873
874/// Resolves [`performance.sqliteReadPoolExtra`]: `"auto"` → [`sqlite_read_pool_auto`], else `0`..=`32`.
875fn parse_sqlite_read_pool_extra_pref() -> usize {
876    let val = crate::history::get_preference("sqliteReadPoolExtra");
877    match val {
878        Some(serde_json::Value::String(s)) => {
879            let t = s.trim();
880            if t.eq_ignore_ascii_case("auto") || t.is_empty() {
881                sqlite_read_pool_auto()
882            } else if let Ok(n) = t.parse::<usize>() {
883                n.min(SQLITE_READ_POOL_EXTRA_MAX)
884            } else {
885                sqlite_read_pool_auto()
886            }
887        }
888        Some(serde_json::Value::Number(n)) => {
889            if let Some(u) = n.as_u64() {
890                (u as usize).min(SQLITE_READ_POOL_EXTRA_MAX)
891            } else if let Some(i) = n.as_i64() {
892                (i.max(0) as usize).min(SQLITE_READ_POOL_EXTRA_MAX)
893            } else {
894                sqlite_read_pool_auto()
895            }
896        }
897        None => sqlite_read_pool_auto(),
898        _ => sqlite_read_pool_auto(),
899    }
900}
901
902impl Database {
903    /// User pref: *additional* read pool slots beyond the mandatory first reader (`1 + this`, min 1).
904    fn read_pool_extra() -> usize {
905        parse_sqlite_read_pool_extra_pref()
906    }
907
908    /// Count of read-pool connections (each participates in [`read_conn`] round-robin only).
909    pub fn sqlite_read_pool_extra_slots(&self) -> usize {
910        self.read.len()
911    }
912
913    /// Primary writer + read pool (`1 + read.len()` open file handles).
914    pub fn sqlite_read_pool_total_handles(&self) -> usize {
915        1 + self.read.len()
916    }
917
918    /// Exclusive writer — migrations, cache writes, and anything that should not compete with
919    /// [`Self::read_conn`] for the same `Mutex`.
920    #[inline]
921    fn write_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
922        self.write.lock().unwrap_or_else(|e| e.into_inner())
923    }
924
925    /// Round-robin read pool only (never the primary `write` handle — see [`Database`] docs).
926    /// Resets the per-slot query-timeout deadline so the progress handler measures from *now*.
927    #[inline]
928    fn read_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
929        let n = self.read.len();
930        if n == 0 {
931            panic!("Database read pool is empty — Database::open() must add at least one reader");
932        }
933        let i = self.read_idx.fetch_add(1, Ordering::Relaxed) % n;
934        reset_query_deadline(&self.read_deadlines[i]);
935        self.read[i].lock().unwrap_or_else(|e| e.into_inner())
936    }
937
938    fn midi_library_total_rows(&self, conn: &Connection) -> Result<u64, String> {
939        if let Ok(g) = self.midi_library_total_cache.lock() {
940            if let Some(n) = *g {
941                return Ok(n);
942            }
943        }
944        let n: u64 = conn
945            .query_row(
946                "SELECT COUNT(*) FROM midi_library",
947                [],
948                |r| r.get::<_, i64>(0).map(|v| v as u64),
949            )
950            .unwrap_or(0);
951        if let Ok(mut g) = self.midi_library_total_cache.lock() {
952            *g = Some(n);
953        }
954        Ok(n)
955    }
956
957    fn invalidate_midi_library_total_cache(&self) {
958        if let Ok(mut g) = self.midi_library_total_cache.lock() {
959            *g = None;
960        }
961    }
962
963    fn pdf_library_total_rows(&self, conn: &Connection) -> Result<u64, String> {
964        if let Ok(g) = self.pdf_library_total_cache.lock() {
965            if let Some(n) = *g {
966                return Ok(n);
967            }
968        }
969        let n: u64 = conn
970            .query_row(
971                "SELECT COUNT(*) FROM pdf_library",
972                [],
973                |r| r.get::<_, i64>(0).map(|v| v as u64),
974            )
975            .unwrap_or(0);
976        if let Ok(mut g) = self.pdf_library_total_cache.lock() {
977            *g = Some(n);
978        }
979        Ok(n)
980    }
981
982    fn invalidate_pdf_library_total_cache(&self) {
983        if let Ok(mut g) = self.pdf_library_total_cache.lock() {
984            *g = None;
985        }
986    }
987
988    fn audio_library_total_rows(&self, conn: &Connection) -> Result<u64, String> {
989        if let Ok(g) = self.audio_library_total_cache.lock() {
990            if let Some(n) = *g {
991                return Ok(n);
992            }
993        }
994        let n: u64 = conn
995            .query_row(
996                "SELECT COUNT(*) FROM audio_library",
997                [],
998                |r| r.get::<_, i64>(0).map(|v| v as u64),
999            )
1000            .unwrap_or(0);
1001        if let Ok(mut g) = self.audio_library_total_cache.lock() {
1002            *g = Some(n);
1003        }
1004        Ok(n)
1005    }
1006
1007    fn invalidate_audio_library_total_cache(&self) {
1008        if let Ok(mut g) = self.audio_library_total_cache.lock() {
1009            *g = None;
1010        }
1011    }
1012
1013    fn preset_inventory_total_rows(&self, conn: &Connection) -> Result<u64, String> {
1014        if let Ok(g) = self.preset_inventory_total_cache.lock() {
1015            if let Some(n) = *g {
1016                return Ok(n);
1017            }
1018        }
1019        let n: u64 = conn
1020            .query_row(
1021                "SELECT COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID','MIDI')",
1022                [],
1023                |r| r.get::<_, i64>(0).map(|v| v as u64),
1024            )
1025            .unwrap_or(0);
1026        if let Ok(mut g) = self.preset_inventory_total_cache.lock() {
1027            *g = Some(n);
1028        }
1029        Ok(n)
1030    }
1031
1032    fn invalidate_preset_inventory_total_cache(&self) {
1033        if let Ok(mut g) = self.preset_inventory_total_cache.lock() {
1034            *g = None;
1035        }
1036    }
1037
1038    fn daw_library_total_rows(&self, conn: &Connection) -> Result<u64, String> {
1039        if let Ok(g) = self.daw_library_total_cache.lock() {
1040            if let Some(n) = *g {
1041                return Ok(n);
1042            }
1043        }
1044        let n: u64 = conn
1045            .query_row(
1046                "SELECT COUNT(*) FROM daw_library",
1047                [],
1048                |r| r.get::<_, i64>(0).map(|v| v as u64),
1049            )
1050            .unwrap_or(0);
1051        if let Ok(mut g) = self.daw_library_total_cache.lock() {
1052            *g = Some(n);
1053        }
1054        Ok(n)
1055    }
1056
1057    fn invalidate_daw_library_total_cache(&self) {
1058        if let Ok(mut g) = self.daw_library_total_cache.lock() {
1059            *g = None;
1060        }
1061    }
1062
1063    fn plugin_library_total_rows(&self, conn: &Connection) -> Result<u64, String> {
1064        if let Ok(g) = self.plugin_library_total_cache.lock() {
1065            if let Some(n) = *g {
1066                return Ok(n);
1067            }
1068        }
1069        let n: u64 = conn
1070            .query_row(
1071                "SELECT COUNT(*) FROM plugin_library",
1072                [],
1073                |r| r.get::<_, i64>(0).map(|v| v as u64),
1074            )
1075            .unwrap_or(0);
1076        if let Ok(mut g) = self.plugin_library_total_cache.lock() {
1077            *g = Some(n);
1078        }
1079        Ok(n)
1080    }
1081
1082    fn invalidate_plugin_library_total_cache(&self) {
1083        if let Ok(mut g) = self.plugin_library_total_cache.lock() {
1084            *g = None;
1085        }
1086    }
1087
1088    /// SQL bodies for [`sync_*_after_paths_refresh`] (standalone: wrapped in `BEGIN IMMEDIATE` by
1089    /// [`exec_sync_paths_refresh`]). For preset/midi/pdf flows already inside a
1090    /// [`Transaction`], use [`sync_preset_library_after_paths_refresh_tx`] /
1091    /// [`sync_midi_library_after_paths_refresh_tx`] /
1092    /// [`sync_pdf_library_after_paths_refresh_tx`] via [`exec_sync_paths_refresh_tx`] — do **not**
1093    /// nest another `BEGIN` (SQLite rejects it).
1094    const SYNC_AUDIO_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM audio_library WHERE path IN (SELECT path FROM _al_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM audio_samples);
1095INSERT OR REPLACE INTO audio_library (path, sample_id)
1096 SELECT path, MAX(id) FROM audio_samples WHERE path IN (SELECT path FROM _al_refresh_paths) GROUP BY path;
1097DROP TABLE _al_refresh_paths;"#;
1098
1099    const SYNC_PDF_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM pdf_library WHERE path IN (SELECT path FROM _pdf_lib_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM pdfs);
1100INSERT OR REPLACE INTO pdf_library (path, pdf_id)
1101 SELECT path, MAX(id) FROM pdfs WHERE path IN (SELECT path FROM _pdf_lib_refresh_paths) GROUP BY path;
1102DROP TABLE _pdf_lib_refresh_paths;"#;
1103
1104    const SYNC_MIDI_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM midi_library WHERE path IN (SELECT path FROM _midi_lib_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM midi_files);
1105INSERT OR REPLACE INTO midi_library (path, midi_id)
1106 SELECT path, MAX(id) FROM midi_files WHERE path IN (SELECT path FROM _midi_lib_refresh_paths) GROUP BY path;
1107DROP TABLE _midi_lib_refresh_paths;"#;
1108
1109    const SYNC_PRESET_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM preset_library WHERE path IN (SELECT path FROM _preset_lib_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM presets);
1110INSERT OR REPLACE INTO preset_library (path, preset_id)
1111 SELECT path, MAX(id) FROM presets WHERE path IN (SELECT path FROM _preset_lib_refresh_paths) GROUP BY path;
1112DROP TABLE _preset_lib_refresh_paths;"#;
1113
1114    const SYNC_DAW_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM daw_library WHERE path IN (SELECT path FROM _dl_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM daw_projects);
1115INSERT OR REPLACE INTO daw_library (path, project_id)
1116 SELECT path, MAX(id) FROM daw_projects WHERE path IN (SELECT path FROM _dl_refresh_paths) GROUP BY path;
1117DROP TABLE _dl_refresh_paths;"#;
1118
1119    const SYNC_PLUGIN_LIBRARY_PATHS_SQL: &'static str = r#"DELETE FROM plugin_library WHERE path IN (SELECT path FROM _pl_refresh_paths) AND path NOT IN (SELECT DISTINCT path FROM plugins);
1120INSERT OR REPLACE INTO plugin_library (path, plugin_id)
1121 SELECT path, MAX(id) FROM plugins WHERE path IN (SELECT path FROM _pl_refresh_paths) GROUP BY path;
1122DROP TABLE _pl_refresh_paths;"#;
1123
1124    fn exec_sync_paths_refresh(conn: &Connection, sql: &str) -> Result<(), String> {
1125        conn.execute_batch(&format!("BEGIN IMMEDIATE;\n{sql}\nCOMMIT;"))
1126            .map_err(|e| e.to_string())
1127    }
1128
1129    fn exec_sync_paths_refresh_tx(tx: &Transaction<'_>, sql: &str) -> Result<(), String> {
1130        tx.execute_batch(sql).map_err(|e| e.to_string())
1131    }
1132
1133    /// `_al_refresh_paths` lists paths touched by removing `audio_samples` for a scan; those rows
1134    /// must already be deleted. Reconciles `audio_library` with remaining `audio_samples` rows.
1135    fn sync_audio_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1136        Self::exec_sync_paths_refresh(conn, Self::SYNC_AUDIO_LIBRARY_PATHS_SQL)
1137    }
1138
1139    fn sync_pdf_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1140        Self::exec_sync_paths_refresh(conn, Self::SYNC_PDF_LIBRARY_PATHS_SQL)
1141    }
1142
1143    fn sync_pdf_library_after_paths_refresh_tx(tx: &Transaction<'_>) -> Result<(), String> {
1144        Self::exec_sync_paths_refresh_tx(tx, Self::SYNC_PDF_LIBRARY_PATHS_SQL)
1145    }
1146
1147    fn sync_midi_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1148        Self::exec_sync_paths_refresh(conn, Self::SYNC_MIDI_LIBRARY_PATHS_SQL)
1149    }
1150
1151    fn sync_midi_library_after_paths_refresh_tx(tx: &Transaction<'_>) -> Result<(), String> {
1152        Self::exec_sync_paths_refresh_tx(tx, Self::SYNC_MIDI_LIBRARY_PATHS_SQL)
1153    }
1154
1155    fn sync_preset_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1156        Self::exec_sync_paths_refresh(conn, Self::SYNC_PRESET_LIBRARY_PATHS_SQL)
1157    }
1158
1159    fn sync_preset_library_after_paths_refresh_tx(tx: &Transaction<'_>) -> Result<(), String> {
1160        Self::exec_sync_paths_refresh_tx(tx, Self::SYNC_PRESET_LIBRARY_PATHS_SQL)
1161    }
1162
1163    /// `_dl_refresh_paths` lists paths touched by removing `daw_projects` rows for a scan; those rows
1164    /// must already be deleted. Reconciles `daw_library` with remaining `daw_projects` rows.
1165    fn sync_daw_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1166        Self::exec_sync_paths_refresh(conn, Self::SYNC_DAW_LIBRARY_PATHS_SQL)
1167    }
1168
1169    fn rebuild_daw_library(conn: &Connection) -> Result<(), String> {
1170        conn.execute_batch(
1171            "BEGIN IMMEDIATE;
1172             DELETE FROM daw_library;
1173             INSERT INTO daw_library (path, project_id) SELECT path, MAX(id) FROM daw_projects GROUP BY path;
1174             COMMIT;",
1175        )
1176        .map_err(|e| e.to_string())?;
1177        Ok(())
1178    }
1179
1180    /// `_pl_refresh_paths` lists paths touched by removing `plugins` rows for a scan; reconciles
1181    /// `plugin_library` with remaining `plugins` rows (same pattern as `sync_daw_library_after_paths_refresh`).
1182    fn sync_plugin_library_after_paths_refresh(conn: &Connection) -> Result<(), String> {
1183        Self::exec_sync_paths_refresh(conn, Self::SYNC_PLUGIN_LIBRARY_PATHS_SQL)
1184    }
1185
1186    fn rebuild_plugin_library(conn: &Connection) -> Result<(), String> {
1187        conn.execute_batch(
1188            "BEGIN IMMEDIATE;
1189             DELETE FROM plugin_library;
1190             INSERT INTO plugin_library (path, plugin_id) SELECT path, MAX(id) FROM plugins GROUP BY path;
1191             COMMIT;",
1192        )
1193        .map_err(|e| e.to_string())?;
1194        Ok(())
1195    }
1196
1197    /// Full rebuild after bulk deletes (e.g. `prune_old_scans`) where per-path sync is impractical.
1198    ///
1199    /// One transaction: without it, each `DELETE`/`INSERT` autocommits separately — a crash or killed
1200    /// process after `DELETE FROM midi_library` but before `INSERT` could leave `*_library` empty
1201    /// while `midi_files` (etc.) still had rows.
1202    fn rebuild_pdf_midi_preset_daw_libraries(conn: &mut Connection) -> Result<(), String> {
1203        let tx = conn.transaction().map_err(|e| e.to_string())?;
1204        tx.execute_batch(
1205            "DELETE FROM pdf_library;
1206             INSERT INTO pdf_library (path, pdf_id) SELECT path, MAX(id) FROM pdfs GROUP BY path;
1207             DELETE FROM midi_library;
1208             INSERT INTO midi_library (path, midi_id) SELECT path, MAX(id) FROM midi_files GROUP BY path;
1209             DELETE FROM preset_library;
1210             INSERT INTO preset_library (path, preset_id) SELECT path, MAX(id) FROM presets GROUP BY path;
1211             DELETE FROM daw_library;
1212             INSERT INTO daw_library (path, project_id) SELECT path, MAX(id) FROM daw_projects GROUP BY path;
1213             DELETE FROM plugin_library;
1214             INSERT INTO plugin_library (path, plugin_id) SELECT path, MAX(id) FROM plugins GROUP BY path;",
1215        )
1216        .map_err(|e| e.to_string())?;
1217        tx.commit().map_err(|e| e.to_string())?;
1218        Ok(())
1219    }
1220
1221    /// Open or create the database in the app data directory.
1222    pub fn open() -> Result<Self, String> {
1223        let db_path = history::get_data_dir().join("audio_haxor.db");
1224        let _ = std::fs::create_dir_all(db_path.parent().unwrap());
1225        // Parallel `cargo test` processes can open the same path; busy_timeout avoids
1226        // immediate `database is locked` during migrations.
1227        let write = open_db_connection_with_pragmas(
1228            &db_path,
1229            SQLITE_CACHE_KIB_PRIMARY,
1230            SQLITE_MMAP_BYTES_PRIMARY,
1231        )?;
1232        let mut db = Self {
1233            write: Mutex::new(write),
1234            read: Vec::new(),
1235            read_deadlines: Vec::new(),
1236            read_idx: AtomicUsize::new(0),
1237            midi_library_total_cache: Mutex::new(None),
1238            pdf_library_total_cache: Mutex::new(None),
1239            audio_library_total_cache: Mutex::new(None),
1240            preset_inventory_total_cache: Mutex::new(None),
1241            daw_library_total_cache: Mutex::new(None),
1242            plugin_library_total_cache: Mutex::new(None),
1243        };
1244        db.migrate()?;
1245        // At least one dedicated reader so `read_conn` never steals the primary handle (which
1246        // must remain available for migrations / writes without blocking every paginated query).
1247        // Pref `sqliteReadPoolExtra` is "extra" slots: total readers = 1 + extra (min 1).
1248        let n_read = (1 + Self::read_pool_extra()).max(1);
1249        let read_cache_kib = read_pool_cache_kib(n_read);
1250        let read_mmap = read_pool_mmap_bytes(n_read);
1251        for _ in 0..n_read {
1252            let (conn, deadline) = open_read_connection(&db_path, read_cache_kib, read_mmap)?;
1253            db.read.push(Mutex::new(conn));
1254            db.read_deadlines.push(deadline);
1255        }
1256        Ok(db)
1257    }
1258
1259    /// Quick startup path: query planner refresh + cache touch. Safe from any thread; keep fast.
1260    pub fn housekeep_light(&self) {
1261        {
1262            let conn = self.read_conn();
1263            let _ = conn.execute_batch("PRAGMA optimize;");
1264        }
1265        self.prewarm();
1266    }
1267
1268    /// Expensive path: prune old scans (DELETE + full `*_library` rebuild) and optional `VACUUM`.
1269    /// Run **well after** the window and `setup()` have finished so pooled `read_conn()` scopes are
1270    /// not held across first-frame IPC (long prune batches still contend on SQLite + pool slots).
1271    pub fn housekeep_heavy(&self) {
1272        if Self::prune_old_scans_pref_enabled() {
1273            self.prune_old_scans(Self::prune_old_scans_keep_pref());
1274        }
1275        self.vacuum_if_needed();
1276    }
1277
1278    fn prune_old_scans_pref_enabled() -> bool {
1279        crate::history::get_preference("pruneOldScans")
1280            .and_then(|v| v.as_str().map(str::to_owned))
1281            .map(|s| s == "on" || s == "true")
1282            .unwrap_or(false)
1283    }
1284
1285    /// `[performance]` → `pruneOldScansKeep` (default 3, clamped 1..=100).
1286    fn prune_old_scans_keep_pref() -> usize {
1287        const DEFAULT: usize = 3;
1288        const MIN: usize = 1;
1289        const MAX: usize = 100;
1290        let raw = crate::history::get_preference("pruneOldScansKeep")
1291            .and_then(|v| v.as_str().map(str::to_owned))
1292            .unwrap_or_else(|| DEFAULT.to_string());
1293        let n = raw.parse::<usize>().unwrap_or(DEFAULT);
1294        n.clamp(MIN, MAX)
1295    }
1296
1297    /// Full sequence (manual / tests). Startup uses [`Self::housekeep_light`] + delayed [`Self::housekeep_heavy`].
1298    pub fn housekeep(&self) {
1299        self.housekeep_light();
1300        self.housekeep_heavy();
1301    }
1302
1303    /// Prune old scans — keep only the N most recent **complete** scans per type. Incomplete
1304    /// (user-stopped) runs are retained until superseded or cleared so library rows stay addressable.
1305    pub fn prune_old_scans(&self, keep: usize) {
1306        let keep_i = keep as i64;
1307        for (scan_tbl, data_tbl, id_col) in [
1308            ("audio_scans", "audio_samples", "scan_id"),
1309            ("plugin_scans", "plugins", "scan_id"),
1310            ("daw_scans", "daw_projects", "scan_id"),
1311            ("preset_scans", "presets", "scan_id"),
1312            ("midi_scans", "midi_files", "scan_id"),
1313            ("pdf_scans", "pdfs", "scan_id"),
1314        ] {
1315            // One `read_conn()` scope per domain so we do not hold a pooled handle across all
1316            // DELETE batches — other threads can use different handles (or the main thread during
1317            // startup can finish `setup` while prune runs).
1318            let conn = self.read_conn();
1319            let _ = conn.execute_batch(&format!(
1320                "DELETE FROM {data_tbl} WHERE {id_col} IN (\
1321                    SELECT id FROM {scan_tbl} WHERE scan_complete = 1 AND id NOT IN (\
1322                        SELECT id FROM {scan_tbl} WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT {keep_i}\
1323                    )\
1324                );\
1325                DELETE FROM {scan_tbl} WHERE scan_complete = 1 AND id NOT IN (\
1326                    SELECT id FROM {scan_tbl} WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT {keep_i}\
1327                );"
1328            ));
1329        }
1330        let mut conn = self.read_conn();
1331        if let Err(e) = Self::rebuild_pdf_midi_preset_daw_libraries(&mut conn) {
1332            crate::append_log(format!(
1333                "rebuild_pdf_midi_preset_daw_libraries after prune_old_scans failed: {e}"
1334            ));
1335        } else {
1336            self.invalidate_preset_inventory_total_cache();
1337            self.invalidate_midi_library_total_cache();
1338            self.invalidate_pdf_library_total_cache();
1339            self.invalidate_daw_library_total_cache();
1340            self.invalidate_plugin_library_total_cache();
1341        }
1342    }
1343
1344    /// Mark whether a streaming scan finished normally (`complete`) or was user-stopped (partial).
1345    pub fn set_audio_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1346        let conn = self.read_conn();
1347        conn.execute(
1348            "UPDATE audio_scans SET scan_complete = ?2 WHERE id = ?1",
1349            params![id, if complete { 1 } else { 0 }],
1350        )
1351        .map_err(|e| e.to_string())?;
1352        Ok(())
1353    }
1354
1355    pub fn set_plugin_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1356        let conn = self.read_conn();
1357        conn.execute(
1358            "UPDATE plugin_scans SET scan_complete = ?2 WHERE id = ?1",
1359            params![id, if complete { 1 } else { 0 }],
1360        )
1361        .map_err(|e| e.to_string())?;
1362        Ok(())
1363    }
1364
1365    pub fn set_daw_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1366        let conn = self.read_conn();
1367        conn.execute(
1368            "UPDATE daw_scans SET scan_complete = ?2 WHERE id = ?1",
1369            params![id, if complete { 1 } else { 0 }],
1370        )
1371        .map_err(|e| e.to_string())?;
1372        Ok(())
1373    }
1374
1375    pub fn set_preset_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1376        let conn = self.read_conn();
1377        conn.execute(
1378            "UPDATE preset_scans SET scan_complete = ?2 WHERE id = ?1",
1379            params![id, if complete { 1 } else { 0 }],
1380        )
1381        .map_err(|e| e.to_string())?;
1382        Ok(())
1383    }
1384
1385    pub fn set_midi_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1386        let conn = self.read_conn();
1387        conn.execute(
1388            "UPDATE midi_scans SET scan_complete = ?2 WHERE id = ?1",
1389            params![id, if complete { 1 } else { 0 }],
1390        )
1391        .map_err(|e| e.to_string())?;
1392        Ok(())
1393    }
1394
1395    pub fn set_pdf_scan_complete(&self, id: &str, complete: bool) -> Result<(), String> {
1396        let conn = self.read_conn();
1397        conn.execute(
1398            "UPDATE pdf_scans SET scan_complete = ?2 WHERE id = ?1",
1399            params![id, if complete { 1 } else { 0 }],
1400        )
1401        .map_err(|e| e.to_string())?;
1402        Ok(())
1403    }
1404
1405    /// Checkpoint WAL to merge it into the main DB file. Keeps WAL small.
1406    /// Warm the page cache by touching each table + FTS index root. First real
1407    /// query returns ~1 ms instead of the 50-200 ms cold-cache penalty.
1408    pub fn prewarm(&self) {
1409        let conn = self.read_conn();
1410        let _ = conn.execute_batch(
1411            "SELECT COUNT(*) FROM audio_samples WHERE id=1;
1412             SELECT COUNT(*) FROM daw_projects WHERE id=1;
1413             SELECT COUNT(*) FROM presets WHERE id=1;
1414             SELECT COUNT(*) FROM midi_files WHERE id=1;
1415             SELECT COUNT(*) FROM pdfs WHERE id=1;
1416             SELECT COUNT(*) FROM plugins WHERE id=1;
1417             SELECT rowid FROM audio_samples_fts WHERE audio_samples_fts MATCH 'xzyq' LIMIT 1;
1418             SELECT rowid FROM daw_projects_fts WHERE daw_projects_fts MATCH 'xzyq' LIMIT 1;
1419             SELECT rowid FROM presets_fts WHERE presets_fts MATCH 'xzyq' LIMIT 1;
1420             SELECT rowid FROM midi_files_fts WHERE midi_files_fts MATCH 'xzyq' LIMIT 1;
1421             SELECT rowid FROM pdfs_fts WHERE pdfs_fts MATCH 'xzyq' LIMIT 1;",
1422        );
1423    }
1424
1425    pub fn checkpoint(&self) {
1426        let conn = self.read_conn();
1427        let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
1428    }
1429
1430    /// Resolved app UI strings for the given locale (merged with English fallback).
1431    pub fn get_app_strings(&self, locale: &str) -> Result<HashMap<String, String>, String> {
1432        let conn = self.read_conn();
1433        crate::app_i18n::load_merged(&conn, locale)
1434    }
1435
1436    /// Alias for [`Self::get_app_strings`] (legacy command name).
1437    pub fn get_toast_strings(&self, locale: &str) -> Result<HashMap<String, String>, String> {
1438        self.get_app_strings(locale)
1439    }
1440
1441    /// VACUUM if >20% of pages are free (dead space from deleted rows).
1442    pub fn vacuum_if_needed(&self) {
1443        let conn = self.read_conn();
1444        let page_size: u64 = conn
1445            .query_row("PRAGMA page_size", [], |r| r.get::<_, i64>(0))
1446            .unwrap_or(4096) as u64;
1447        let page_count: u64 = conn
1448            .query_row("PRAGMA page_count", [], |r| r.get::<_, i64>(0))
1449            .unwrap_or(0) as u64;
1450        let free_count: u64 = conn
1451            .query_row("PRAGMA freelist_count", [], |r| r.get::<_, i64>(0))
1452            .unwrap_or(0) as u64;
1453        let pct = if page_count > 0 {
1454            free_count * 100 / page_count
1455        } else {
1456            0
1457        };
1458        if pct > 20 {
1459            let before = page_count * page_size;
1460            crate::append_log(format!(
1461                "DB VACUUM — {}% free ({} / {} pages) | before: {}",
1462                pct,
1463                free_count,
1464                page_count,
1465                crate::format_size(before),
1466            ));
1467            drop(conn);
1468            let conn = self.read_conn();
1469            let _ = conn.execute_batch("VACUUM;");
1470            let after: u64 = conn
1471                .query_row("PRAGMA page_count", [], |r| r.get::<_, i64>(0))
1472                .unwrap_or(0) as u64
1473                * page_size;
1474            crate::append_log(format!(
1475                "DB VACUUM DONE — after: {}",
1476                crate::format_size(after)
1477            ));
1478        }
1479    }
1480
1481    /// One-time migration: normalize `plugins.path` and `plugin_scans` directories/roots JSON to
1482    /// [`normalize_path_for_db`] form; remove duplicate `(canonical path, scan_id)` rows (keep max `id`).
1483    fn migrate_plugin_paths_canonical(conn: &Connection) -> Result<(), String> {
1484        use std::collections::{HashMap, HashSet};
1485
1486        let mut stmt = conn
1487            .prepare("SELECT id, path, scan_id FROM plugins")
1488            .map_err(|e| e.to_string())?;
1489        let rows: Vec<(i64, String, String)> = stmt
1490            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1491            .map_err(|e| e.to_string())?
1492            .collect::<Result<Vec<_>, _>>()
1493            .map_err(|e| e.to_string())?;
1494
1495        let mut by_key: HashMap<(String, String), Vec<i64>> = HashMap::new();
1496        for (id, path, scan_id) in &rows {
1497            let canon = normalize_path_for_db(path);
1498            by_key
1499                .entry((canon, scan_id.clone()))
1500                .or_default()
1501                .push(*id);
1502        }
1503
1504        let mut to_delete = HashSet::new();
1505        for ids in by_key.values() {
1506            if ids.len() <= 1 {
1507                continue;
1508            }
1509            let mut v = ids.clone();
1510            v.sort_unstable();
1511            for id in &v[..v.len() - 1] {
1512                to_delete.insert(*id);
1513            }
1514        }
1515
1516        let deleted = to_delete.len();
1517        if !to_delete.is_empty() {
1518            let mut ids: Vec<i64> = to_delete.iter().copied().collect();
1519            ids.sort_unstable();
1520            let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1521            conn.execute(
1522                &format!("DELETE FROM plugins WHERE id IN ({})", placeholders),
1523                rusqlite::params_from_iter(ids.iter().copied()),
1524            )
1525            .map_err(|e| e.to_string())?;
1526        }
1527
1528        let mut path_updates = 0usize;
1529        for (id, path, _) in &rows {
1530            if to_delete.contains(id) {
1531                continue;
1532            }
1533            let canon = normalize_path_for_db(path);
1534            if canon != *path {
1535                conn.execute(
1536                    "UPDATE plugins SET path = ?1 WHERE id = ?2",
1537                    params![canon, id],
1538                )
1539                .map_err(|e| e.to_string())?;
1540                path_updates += 1;
1541            }
1542        }
1543
1544        let mut json_updates = 0usize;
1545        let mut stmt = conn
1546            .prepare("SELECT id, directories, roots FROM plugin_scans")
1547            .map_err(|e| e.to_string())?;
1548        let scan_rows: Vec<(String, String, String)> = stmt
1549            .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1550            .map_err(|e| e.to_string())?
1551            .collect::<Result<Vec<_>, _>>()
1552            .map_err(|e| e.to_string())?;
1553
1554        for (id, dirs, roots) in scan_rows {
1555            let dirs_vec: Vec<String> = serde_json::from_str(&dirs).unwrap_or_default();
1556            let roots_vec: Vec<String> = serde_json::from_str(&roots).unwrap_or_default();
1557            let d2 = path_strings_json_normalized(&dirs_vec);
1558            let r2 = path_strings_json_normalized(&roots_vec);
1559            if d2 != dirs || r2 != roots {
1560                conn.execute(
1561                    "UPDATE plugin_scans SET directories = ?1, roots = ?2 WHERE id = ?3",
1562                    params![d2, r2, id],
1563                )
1564                .map_err(|e| e.to_string())?;
1565                json_updates += 1;
1566            }
1567        }
1568
1569        if deleted > 0 || path_updates > 0 || json_updates > 0 {
1570            crate::append_log(format!(
1571                "DB migration v17: plugins deduped={deleted}, path rewrites={path_updates}, plugin_scans JSON rows={json_updates}"
1572            ));
1573        }
1574
1575        Ok(())
1576    }
1577
1578    /// Run schema migrations.
1579    fn migrate(&self) -> Result<(), String> {
1580        let conn = self.write_conn();
1581
1582        conn.execute_batch(
1583            "CREATE TABLE IF NOT EXISTS schema_version (
1584                version INTEGER NOT NULL
1585            );",
1586        )
1587        .map_err(|e| e.to_string())?;
1588
1589        let current: i64 = conn
1590            .query_row(
1591                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
1592                [],
1593                |row| row.get::<_, i64>(0),
1594            )
1595            .unwrap_or(0);
1596
1597        if current < 1 {
1598            conn.execute_batch(
1599                "CREATE TABLE IF NOT EXISTS audio_samples (
1600                    id              INTEGER PRIMARY KEY,
1601                    name            TEXT NOT NULL,
1602                    path            TEXT NOT NULL,
1603                    directory       TEXT NOT NULL,
1604                    format          TEXT NOT NULL,
1605                    size            INTEGER NOT NULL,
1606                    size_formatted  TEXT NOT NULL,
1607                    modified        TEXT NOT NULL,
1608                    duration        REAL,
1609                    channels        INTEGER,
1610                    sample_rate     INTEGER,
1611                    bits_per_sample INTEGER,
1612                    bpm             REAL,
1613                    key_name        TEXT,
1614                    lufs            REAL,
1615                    bpm_exhausted   INTEGER NOT NULL DEFAULT 0,
1616                    scan_id         TEXT NOT NULL,
1617                    created_at      TEXT NOT NULL DEFAULT (datetime('now'))
1618                );
1619
1620                CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_path_scan
1621                    ON audio_samples(path, scan_id);
1622                CREATE INDEX IF NOT EXISTS idx_samples_name
1623                    ON audio_samples(name COLLATE NOCASE);
1624                CREATE INDEX IF NOT EXISTS idx_samples_format
1625                    ON audio_samples(format);
1626                CREATE INDEX IF NOT EXISTS idx_samples_scan_id
1627                    ON audio_samples(scan_id);
1628                CREATE INDEX IF NOT EXISTS idx_samples_bpm
1629                    ON audio_samples(bpm);
1630                CREATE INDEX IF NOT EXISTS idx_samples_key
1631                    ON audio_samples(key_name);
1632                CREATE INDEX IF NOT EXISTS idx_samples_lufs
1633                    ON audio_samples(lufs);
1634
1635                CREATE TABLE IF NOT EXISTS audio_scans (
1636                    id              TEXT PRIMARY KEY,
1637                    timestamp       TEXT NOT NULL,
1638                    sample_count    INTEGER NOT NULL,
1639                    total_bytes     INTEGER NOT NULL,
1640                    format_counts   TEXT NOT NULL,
1641                    roots           TEXT NOT NULL
1642                );
1643
1644                CREATE TABLE IF NOT EXISTS waveform_cache (
1645                    path TEXT PRIMARY KEY,
1646                    data TEXT NOT NULL
1647                );
1648
1649                CREATE TABLE IF NOT EXISTS spectrogram_cache (
1650                    path TEXT PRIMARY KEY,
1651                    data TEXT NOT NULL
1652                );
1653
1654                INSERT INTO schema_version (version) VALUES (1);",
1655            )
1656            .map_err(|e| format!("Migration v1 failed: {e}"))?;
1657        }
1658
1659        if current < 2 {
1660            conn.execute_batch(
1661                "-- Plugin scan history
1662                CREATE TABLE IF NOT EXISTS plugins (
1663                    id              INTEGER PRIMARY KEY,
1664                    name            TEXT NOT NULL,
1665                    path            TEXT NOT NULL,
1666                    plugin_type     TEXT NOT NULL,
1667                    version         TEXT NOT NULL,
1668                    manufacturer    TEXT NOT NULL,
1669                    manufacturer_url TEXT,
1670                    size            TEXT NOT NULL,
1671                    size_bytes      INTEGER NOT NULL DEFAULT 0,
1672                    modified        TEXT NOT NULL,
1673                    architectures   TEXT NOT NULL DEFAULT '[]',
1674                    scan_id         TEXT NOT NULL
1675                );
1676                CREATE UNIQUE INDEX IF NOT EXISTS idx_plugins_path_scan ON plugins(path, scan_id);
1677                CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name COLLATE NOCASE);
1678                CREATE INDEX IF NOT EXISTS idx_plugins_scan_id ON plugins(scan_id);
1679
1680                CREATE TABLE IF NOT EXISTS plugin_scans (
1681                    id              TEXT PRIMARY KEY,
1682                    timestamp       TEXT NOT NULL,
1683                    plugin_count    INTEGER NOT NULL,
1684                    directories     TEXT NOT NULL,
1685                    roots           TEXT NOT NULL
1686                );
1687
1688                -- DAW project history
1689                CREATE TABLE IF NOT EXISTS daw_projects (
1690                    id              INTEGER PRIMARY KEY,
1691                    name            TEXT NOT NULL,
1692                    path            TEXT NOT NULL,
1693                    directory       TEXT NOT NULL,
1694                    format          TEXT NOT NULL,
1695                    daw             TEXT NOT NULL,
1696                    size            INTEGER NOT NULL,
1697                    size_formatted  TEXT NOT NULL,
1698                    modified        TEXT NOT NULL,
1699                    scan_id         TEXT NOT NULL
1700                );
1701                CREATE UNIQUE INDEX IF NOT EXISTS idx_daw_path_scan ON daw_projects(path, scan_id);
1702                CREATE INDEX IF NOT EXISTS idx_daw_name ON daw_projects(name COLLATE NOCASE);
1703                CREATE INDEX IF NOT EXISTS idx_daw_scan_id ON daw_projects(scan_id);
1704
1705                CREATE TABLE IF NOT EXISTS daw_scans (
1706                    id              TEXT PRIMARY KEY,
1707                    timestamp       TEXT NOT NULL,
1708                    project_count   INTEGER NOT NULL,
1709                    total_bytes     INTEGER NOT NULL,
1710                    daw_counts      TEXT NOT NULL,
1711                    roots           TEXT NOT NULL
1712                );
1713
1714                -- Preset history
1715                CREATE TABLE IF NOT EXISTS presets (
1716                    id              INTEGER PRIMARY KEY,
1717                    name            TEXT NOT NULL,
1718                    path            TEXT NOT NULL,
1719                    directory       TEXT NOT NULL,
1720                    format          TEXT NOT NULL,
1721                    size            INTEGER NOT NULL,
1722                    size_formatted  TEXT NOT NULL,
1723                    modified        TEXT NOT NULL,
1724                    scan_id         TEXT NOT NULL
1725                );
1726                CREATE UNIQUE INDEX IF NOT EXISTS idx_presets_path_scan ON presets(path, scan_id);
1727                CREATE INDEX IF NOT EXISTS idx_presets_name ON presets(name COLLATE NOCASE);
1728                CREATE INDEX IF NOT EXISTS idx_presets_scan_id ON presets(scan_id);
1729
1730                CREATE TABLE IF NOT EXISTS preset_scans (
1731                    id              TEXT PRIMARY KEY,
1732                    timestamp       TEXT NOT NULL,
1733                    preset_count    INTEGER NOT NULL,
1734                    total_bytes     INTEGER NOT NULL,
1735                    format_counts   TEXT NOT NULL,
1736                    roots           TEXT NOT NULL
1737                );
1738
1739                -- KVR version cache
1740                CREATE TABLE IF NOT EXISTS kvr_cache (
1741                    plugin_key      TEXT PRIMARY KEY,
1742                    kvr_url         TEXT,
1743                    update_url      TEXT,
1744                    latest_version  TEXT,
1745                    has_update      INTEGER NOT NULL DEFAULT 0,
1746                    source          TEXT NOT NULL DEFAULT '',
1747                    timestamp       TEXT NOT NULL DEFAULT ''
1748                );
1749
1750                -- Plugin cross-reference cache
1751                CREATE TABLE IF NOT EXISTS xref_cache (
1752                    project_path    TEXT PRIMARY KEY,
1753                    plugins_json    TEXT NOT NULL
1754                );
1755
1756                -- Fingerprint cache
1757                CREATE TABLE IF NOT EXISTS fingerprint_cache (
1758                    path            TEXT PRIMARY KEY,
1759                    fingerprint     TEXT NOT NULL
1760                );
1761
1762                INSERT INTO schema_version (version) VALUES (2);",
1763            )
1764            .map_err(|e| format!("Migration v2 failed: {e}"))?;
1765        }
1766
1767        if current < 3 {
1768            conn.execute_batch(
1769                "-- Composite indexes for common query patterns
1770                CREATE INDEX IF NOT EXISTS idx_samples_scan_format
1771                    ON audio_samples(scan_id, format);
1772                CREATE INDEX IF NOT EXISTS idx_samples_scan_name
1773                    ON audio_samples(scan_id, name COLLATE NOCASE);
1774                CREATE INDEX IF NOT EXISTS idx_daw_scan_format
1775                    ON daw_projects(scan_id, format);
1776                CREATE INDEX IF NOT EXISTS idx_presets_scan_format
1777                    ON presets(scan_id, format);
1778                INSERT INTO schema_version (version) VALUES (3);",
1779            )
1780            .map_err(|e| format!("Migration v3 failed: {e}"))?;
1781        }
1782
1783        if current < 4 {
1784            conn.execute_batch(
1785                "CREATE TABLE IF NOT EXISTS toast_i18n (
1786                    key TEXT NOT NULL,
1787                    locale TEXT NOT NULL,
1788                    value TEXT NOT NULL,
1789                    PRIMARY KEY (key, locale)
1790                );
1791                CREATE INDEX IF NOT EXISTS idx_toast_i18n_locale ON toast_i18n(locale);
1792                INSERT INTO schema_version (version) VALUES (4);",
1793            )
1794            .map_err(|e| format!("Migration v4 failed: {e}"))?;
1795        }
1796
1797        if current < 5 {
1798            let has_toast: bool = conn
1799                .query_row(
1800                    "SELECT 1 FROM sqlite_master WHERE type='table' AND name='toast_i18n'",
1801                    [],
1802                    |_| Ok(()),
1803                )
1804                .is_ok();
1805            if has_toast {
1806                conn.execute_batch(
1807                    "ALTER TABLE toast_i18n RENAME TO app_i18n;
1808                     DROP INDEX IF EXISTS idx_toast_i18n_locale;
1809                     CREATE INDEX IF NOT EXISTS idx_app_i18n_locale ON app_i18n(locale);",
1810                )
1811                .map_err(|e| format!("Migration v5 failed: {e}"))?;
1812            }
1813            conn.execute("INSERT INTO schema_version (version) VALUES (5)", [])
1814                .map_err(|e| format!("Migration v5 schema_version failed: {e}"))?;
1815        }
1816
1817        if current < 6 {
1818            conn.execute_batch(
1819                "CREATE TABLE IF NOT EXISTS pdfs (
1820                    id              INTEGER PRIMARY KEY,
1821                    name            TEXT NOT NULL,
1822                    path            TEXT NOT NULL,
1823                    directory       TEXT NOT NULL,
1824                    size            INTEGER NOT NULL,
1825                    size_formatted  TEXT NOT NULL,
1826                    modified        TEXT NOT NULL,
1827                    scan_id         TEXT NOT NULL
1828                );
1829                CREATE UNIQUE INDEX IF NOT EXISTS idx_pdfs_path_scan ON pdfs(path, scan_id);
1830                CREATE INDEX IF NOT EXISTS idx_pdfs_name ON pdfs(name COLLATE NOCASE);
1831                CREATE INDEX IF NOT EXISTS idx_pdfs_scan_id ON pdfs(scan_id);
1832
1833                CREATE TABLE IF NOT EXISTS pdf_scans (
1834                    id              TEXT PRIMARY KEY,
1835                    timestamp       TEXT NOT NULL,
1836                    pdf_count       INTEGER NOT NULL,
1837                    total_bytes     INTEGER NOT NULL,
1838                    roots           TEXT NOT NULL
1839                );",
1840            )
1841            .map_err(|e| format!("Migration v6 (PDF tables) failed: {e}"))?;
1842            conn.execute("INSERT INTO schema_version (version) VALUES (6)", [])
1843                .map_err(|e| format!("Migration v6 schema_version failed: {e}"))?;
1844        }
1845
1846        if current < 7 {
1847            conn.execute_batch(
1848                "CREATE TABLE IF NOT EXISTS pdf_metadata (
1849                    path        TEXT PRIMARY KEY,
1850                    pages       INTEGER,
1851                    updated_at  TEXT NOT NULL
1852                );",
1853            )
1854            .map_err(|e| format!("Migration v7 (pdf_metadata) failed: {e}"))?;
1855            conn.execute("INSERT INTO schema_version (version) VALUES (7)", [])
1856                .map_err(|e| format!("Migration v7 schema_version failed: {e}"))?;
1857        }
1858
1859        if current < 8 {
1860            conn.execute_batch(
1861                "CREATE TABLE IF NOT EXISTS midi_files (
1862                    id              INTEGER PRIMARY KEY,
1863                    name            TEXT NOT NULL,
1864                    path            TEXT NOT NULL,
1865                    directory       TEXT NOT NULL,
1866                    format          TEXT NOT NULL,
1867                    size            INTEGER NOT NULL,
1868                    size_formatted  TEXT NOT NULL,
1869                    modified        TEXT NOT NULL,
1870                    scan_id         TEXT NOT NULL
1871                );
1872                CREATE UNIQUE INDEX IF NOT EXISTS idx_midi_files_path_scan ON midi_files(path, scan_id);
1873                CREATE INDEX IF NOT EXISTS idx_midi_files_name ON midi_files(name COLLATE NOCASE);
1874                CREATE INDEX IF NOT EXISTS idx_midi_files_scan_id ON midi_files(scan_id);
1875                CREATE INDEX IF NOT EXISTS idx_midi_files_format ON midi_files(format);
1876
1877                CREATE TABLE IF NOT EXISTS midi_scans (
1878                    id              TEXT PRIMARY KEY,
1879                    timestamp       TEXT NOT NULL,
1880                    midi_count      INTEGER NOT NULL,
1881                    total_bytes     INTEGER NOT NULL,
1882                    format_counts   TEXT NOT NULL,
1883                    roots           TEXT NOT NULL
1884                );",
1885            )
1886            .map_err(|e| format!("Migration v8 (MIDI tables) failed: {e}"))?;
1887            conn.execute("INSERT INTO schema_version (version) VALUES (8)", [])
1888                .map_err(|e| format!("Migration v8 schema_version failed: {e}"))?;
1889        }
1890
1891        if current < 9 {
1892            // Composite sort indexes: turn ORDER BY + LIMIT into an index range
1893            // scan instead of a full sort, plus FTS5 virtual tables with the
1894            // trigram tokenizer for fast substring search at millions of rows.
1895            conn.execute_batch(
1896                "-- audio_samples composite sort indexes
1897                 CREATE INDEX IF NOT EXISTS idx_samples_scan_name     ON audio_samples(scan_id, name COLLATE NOCASE, id);
1898                 CREATE INDEX IF NOT EXISTS idx_samples_scan_size     ON audio_samples(scan_id, size, id);
1899                 CREATE INDEX IF NOT EXISTS idx_samples_scan_modified ON audio_samples(scan_id, modified, id);
1900                 CREATE INDEX IF NOT EXISTS idx_samples_scan_format   ON audio_samples(scan_id, format, id);
1901                 CREATE INDEX IF NOT EXISTS idx_samples_scan_duration ON audio_samples(scan_id, duration, id);
1902
1903                 -- daw_projects composite sort indexes
1904                 CREATE INDEX IF NOT EXISTS idx_daw_scan_name     ON daw_projects(scan_id, name COLLATE NOCASE, id);
1905                 CREATE INDEX IF NOT EXISTS idx_daw_scan_size     ON daw_projects(scan_id, size, id);
1906                 CREATE INDEX IF NOT EXISTS idx_daw_scan_modified ON daw_projects(scan_id, modified, id);
1907                 CREATE INDEX IF NOT EXISTS idx_daw_scan_daw      ON daw_projects(scan_id, daw, id);
1908                 CREATE INDEX IF NOT EXISTS idx_daw_scan_format   ON daw_projects(scan_id, format, id);
1909
1910                 -- presets composite sort indexes
1911                 CREATE INDEX IF NOT EXISTS idx_presets_scan_name     ON presets(scan_id, name COLLATE NOCASE, id);
1912                 CREATE INDEX IF NOT EXISTS idx_presets_scan_size     ON presets(scan_id, size, id);
1913                 CREATE INDEX IF NOT EXISTS idx_presets_scan_modified ON presets(scan_id, modified, id);
1914                 CREATE INDEX IF NOT EXISTS idx_presets_scan_format   ON presets(scan_id, format, id);
1915
1916                 -- midi_files composite sort indexes
1917                 CREATE INDEX IF NOT EXISTS idx_midi_scan_name     ON midi_files(scan_id, name COLLATE NOCASE, id);
1918                 CREATE INDEX IF NOT EXISTS idx_midi_scan_size     ON midi_files(scan_id, size, id);
1919                 CREATE INDEX IF NOT EXISTS idx_midi_scan_modified ON midi_files(scan_id, modified, id);
1920                 CREATE INDEX IF NOT EXISTS idx_midi_scan_format   ON midi_files(scan_id, format, id);
1921
1922                 -- pdfs composite sort indexes
1923                 CREATE INDEX IF NOT EXISTS idx_pdfs_scan_name     ON pdfs(scan_id, name COLLATE NOCASE, id);
1924                 CREATE INDEX IF NOT EXISTS idx_pdfs_scan_size     ON pdfs(scan_id, size, id);
1925                 CREATE INDEX IF NOT EXISTS idx_pdfs_scan_modified ON pdfs(scan_id, modified, id);
1926
1927                 -- FTS5 virtual tables with trigram tokenizer (substring search, O(log n)).
1928                 -- Contentless w/ scan_id so we can DELETE per-scan without scanning the whole FTS.
1929                 CREATE VIRTUAL TABLE IF NOT EXISTS audio_samples_fts USING fts5(
1930                    name, path, scan_id UNINDEXED, tokenize='trigram'
1931                 );
1932                 CREATE VIRTUAL TABLE IF NOT EXISTS daw_projects_fts USING fts5(
1933                    name, path, daw, scan_id UNINDEXED, tokenize='trigram'
1934                 );
1935                 CREATE VIRTUAL TABLE IF NOT EXISTS presets_fts USING fts5(
1936                    name, path, format, scan_id UNINDEXED, tokenize='trigram'
1937                 );
1938                 CREATE VIRTUAL TABLE IF NOT EXISTS midi_files_fts USING fts5(
1939                    name, path, scan_id UNINDEXED, tokenize='trigram'
1940                 );
1941                 CREATE VIRTUAL TABLE IF NOT EXISTS pdfs_fts USING fts5(
1942                    name, path, scan_id UNINDEXED, tokenize='trigram'
1943                 );",
1944            )
1945            .map_err(|e| format!("Migration v9 (indexes + FTS5) failed: {e}"))?;
1946            conn.execute("INSERT INTO schema_version (version) VALUES (9)", [])
1947                .map_err(|e| format!("Migration v9 schema_version failed: {e}"))?;
1948        }
1949
1950        if current < 10 {
1951            conn.execute_batch(
1952                "CREATE TABLE IF NOT EXISTS directory_scan_state (
1953                    domain          TEXT NOT NULL,
1954                    path            TEXT NOT NULL,
1955                    mtime_secs      INTEGER NOT NULL,
1956                    last_scan_id    TEXT,
1957                    PRIMARY KEY (domain, path)
1958                );
1959                CREATE INDEX IF NOT EXISTS idx_directory_scan_state_domain
1960                    ON directory_scan_state(domain);",
1961            )
1962            .map_err(|e| format!("Migration v10 (directory_scan_state) failed: {e}"))?;
1963            conn.execute("INSERT INTO schema_version (version) VALUES (10)", [])
1964                .map_err(|e| format!("Migration v10 schema_version failed: {e}"))?;
1965        }
1966
1967        if current < 11 {
1968            conn.execute_batch(
1969                "CREATE TABLE IF NOT EXISTS unified_scan_run (
1970                    id INTEGER PRIMARY KEY CHECK (id = 1),
1971                    run_id            TEXT NOT NULL DEFAULT '',
1972                    started_at        TEXT NOT NULL DEFAULT '',
1973                    finished_at       TEXT,
1974                    outcome           TEXT NOT NULL DEFAULT 'complete',
1975                    audio_scan_id     TEXT,
1976                    daw_scan_id       TEXT,
1977                    preset_scan_id    TEXT,
1978                    pdf_scan_id       TEXT,
1979                    roots_json        TEXT NOT NULL DEFAULT '{}',
1980                    last_directory_path TEXT,
1981                    error_message     TEXT
1982                );
1983                INSERT OR IGNORE INTO unified_scan_run (id, outcome, roots_json)
1984                    VALUES (1, 'complete', '{}');",
1985            )
1986            .map_err(|e| format!("Migration v11 (unified_scan_run) failed: {e}"))?;
1987            conn.execute("INSERT INTO schema_version (version) VALUES (11)", [])
1988                .map_err(|e| format!("Migration v11 schema_version failed: {e}"))?;
1989        }
1990
1991        if current < 12 {
1992            // `scan_complete`: streaming scans start at 0; lib sets 1 when the run finishes without stop.
1993            // History / latest queries filter to complete rows so partial runs are not deletable "junk"
1994            // that still backs library aggregates.
1995            conn.execute_batch(
1996                "ALTER TABLE audio_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;
1997                 ALTER TABLE plugin_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;
1998                 ALTER TABLE daw_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;
1999                 ALTER TABLE preset_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;
2000                 ALTER TABLE midi_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;
2001                 ALTER TABLE pdf_scans ADD COLUMN scan_complete INTEGER NOT NULL DEFAULT 1;",
2002            )
2003            .map_err(|e| format!("Migration v12 (scan_complete) failed: {e}"))?;
2004            conn.execute("INSERT INTO schema_version (version) VALUES (12)", [])
2005                .map_err(|e| format!("Migration v12 schema_version failed: {e}"))?;
2006        }
2007
2008        if current < 13 {
2009            // FTS5 tables from v9 were never populated for rows that existed before FTS or for
2010            // restored/copied DBs — substring search used `MATCH` on empty FTS and found nothing.
2011            backfill_contentless_fts(&conn)?;
2012            conn.execute("INSERT INTO schema_version (version) VALUES (13)", [])
2013                .map_err(|e| format!("Migration v13 schema_version failed: {e}"))?;
2014        }
2015
2016        if current < 14 {
2017            // One canonical `audio_samples` row id per filesystem path (same semantics as
2018            // `MAX(id) GROUP BY path`), maintained on insert and after scan deletes.
2019            conn.execute_batch(
2020                "CREATE TABLE IF NOT EXISTS audio_library (
2021                    path TEXT PRIMARY KEY NOT NULL,
2022                    sample_id INTEGER NOT NULL
2023                );
2024                CREATE INDEX IF NOT EXISTS idx_audio_library_sample_id ON audio_library(sample_id);
2025                INSERT OR REPLACE INTO audio_library (path, sample_id)
2026                SELECT path, MAX(id) AS sample_id FROM audio_samples GROUP BY path;",
2027            )
2028            .map_err(|e| format!("Migration v14 (audio_library) failed: {e}"))?;
2029            conn.execute("INSERT INTO schema_version (version) VALUES (14)", [])
2030                .map_err(|e| format!("Migration v14 schema_version failed: {e}"))?;
2031        }
2032
2033        if current < 15 {
2034            // Materialized library tables for PDF / MIDI / presets — same semantics as
2035            // `MAX(id) GROUP BY path`, maintained on insert and path-affecting deletes.
2036            conn.execute_batch(
2037                "CREATE TABLE IF NOT EXISTS pdf_library (
2038                    path TEXT PRIMARY KEY NOT NULL,
2039                    pdf_id INTEGER NOT NULL
2040                );
2041                CREATE INDEX IF NOT EXISTS idx_pdf_library_pdf_id ON pdf_library(pdf_id);
2042                INSERT OR REPLACE INTO pdf_library (path, pdf_id)
2043                SELECT path, MAX(id) FROM pdfs GROUP BY path;
2044
2045                CREATE TABLE IF NOT EXISTS midi_library (
2046                    path TEXT PRIMARY KEY NOT NULL,
2047                    midi_id INTEGER NOT NULL
2048                );
2049                CREATE INDEX IF NOT EXISTS idx_midi_library_midi_id ON midi_library(midi_id);
2050                INSERT OR REPLACE INTO midi_library (path, midi_id)
2051                SELECT path, MAX(id) FROM midi_files GROUP BY path;
2052
2053                CREATE TABLE IF NOT EXISTS preset_library (
2054                    path TEXT PRIMARY KEY NOT NULL,
2055                    preset_id INTEGER NOT NULL
2056                );
2057                CREATE INDEX IF NOT EXISTS idx_preset_library_preset_id ON preset_library(preset_id);
2058                INSERT OR REPLACE INTO preset_library (path, preset_id)
2059                SELECT path, MAX(id) FROM presets GROUP BY path;",
2060            )
2061            .map_err(|e| format!("Migration v15 (pdf/midi/preset library tables) failed: {e}"))?;
2062            conn.execute("INSERT INTO schema_version (version) VALUES (15)", [])
2063                .map_err(|e| format!("Migration v15 schema_version failed: {e}"))?;
2064        }
2065
2066        if current < 16 {
2067            // One canonical `daw_projects` row id per filesystem path (same semantics as
2068            // `MAX(id) GROUP BY path`), maintained on insert and after scan deletes.
2069            conn.execute_batch(
2070                "CREATE TABLE IF NOT EXISTS daw_library (
2071                    path TEXT PRIMARY KEY NOT NULL,
2072                    project_id INTEGER NOT NULL
2073                );
2074                CREATE INDEX IF NOT EXISTS idx_daw_library_project_id ON daw_library(project_id);
2075                INSERT OR REPLACE INTO daw_library (path, project_id)
2076                SELECT path, MAX(id) AS project_id FROM daw_projects GROUP BY path;",
2077            )
2078            .map_err(|e| format!("Migration v16 (daw_library) failed: {e}"))?;
2079            conn.execute("INSERT INTO schema_version (version) VALUES (16)", [])
2080                .map_err(|e| format!("Migration v16 schema_version failed: {e}"))?;
2081        }
2082
2083        if current < 17 {
2084            // Firmlink path backfill + `plugin_scans` JSON (same `normalize_path_for_db` as inserts),
2085            // then materialize `plugin_library` like v14–v16 for audio/DAW/PDF/MIDI/presets.
2086            Self::migrate_plugin_paths_canonical(&conn)
2087                .map_err(|e| format!("Migration v17 (plugin path canonicalization) failed: {e}"))?;
2088            conn.execute_batch(
2089                "CREATE TABLE IF NOT EXISTS plugin_library (
2090                    path TEXT PRIMARY KEY NOT NULL,
2091                    plugin_id INTEGER NOT NULL
2092                );
2093                CREATE INDEX IF NOT EXISTS idx_plugin_library_plugin_id ON plugin_library(plugin_id);
2094                INSERT OR REPLACE INTO plugin_library (path, plugin_id)
2095                SELECT path, MAX(id) AS plugin_id FROM plugins GROUP BY path;",
2096            )
2097            .map_err(|e| format!("Migration v17 (plugin_library) failed: {e}"))?;
2098            conn.execute("INSERT INTO schema_version (version) VALUES (17)", [])
2099                .map_err(|e| format!("Migration v17 schema_version failed: {e}"))?;
2100        }
2101
2102        if current < 18 {
2103            // Stops background analysis from re-queuing the same files forever when BPM
2104            // detection returns None but key + LUFS succeed (`unanalyzed_paths` used only `bpm IS NULL`).
2105            // Fresh installs may already have `bpm_exhausted` from v1 `CREATE TABLE audio_samples`.
2106            let has_bpm_exhausted: bool = conn
2107                .query_row(
2108                    "SELECT COUNT(*) FROM pragma_table_info('audio_samples') WHERE name = 'bpm_exhausted'",
2109                    [],
2110                    |row| row.get::<_, i64>(0).map(|n| n > 0),
2111                )
2112                .unwrap_or(false);
2113            if !has_bpm_exhausted {
2114                conn.execute(
2115                    "ALTER TABLE audio_samples ADD COLUMN bpm_exhausted INTEGER NOT NULL DEFAULT 0",
2116                    [],
2117                )
2118                .map_err(|e| format!("Migration v18 (bpm_exhausted) failed: {e}"))?;
2119            }
2120            conn.execute("INSERT INTO schema_version (version) VALUES (18)", [])
2121                .map_err(|e| format!("Migration v18 schema_version failed: {e}"))?;
2122        }
2123
2124        if conn
2125            .query_row(
2126                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='app_i18n'",
2127                [],
2128                |_| Ok(()),
2129            )
2130            .is_ok()
2131        {
2132            crate::app_i18n::seed_defaults(&conn)?;
2133        }
2134
2135        Ok(())
2136    }
2137
2138    /// Insert a batch of audio samples in a single transaction.
2139    pub fn audio_scan_parent_create(
2140        &self,
2141        id: &str,
2142        timestamp: &str,
2143        roots: &[String],
2144    ) -> Result<(), String> {
2145        let conn = self.read_conn();
2146        let roots_json = path_strings_json_normalized(roots);
2147        conn.execute(
2148            "INSERT OR REPLACE INTO audio_scans (id, timestamp, sample_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,0,0,'{}',?3,0)",
2149            params![id, timestamp, roots_json],
2150        ).map_err(|e| e.to_string())?;
2151        conn.execute(
2152            "CREATE TEMP TABLE _al_refresh_paths (path TEXT PRIMARY KEY)",
2153            [],
2154        )
2155        .map_err(|e| e.to_string())?;
2156        conn.execute(
2157            "INSERT INTO _al_refresh_paths SELECT DISTINCT path FROM audio_samples WHERE scan_id = ?1",
2158            params![id],
2159        )
2160        .map_err(|e| e.to_string())?;
2161        conn.execute("DELETE FROM audio_samples WHERE scan_id = ?1", params![id])
2162            .map_err(|e| e.to_string())?;
2163        conn.execute(
2164            "DELETE FROM audio_samples_fts WHERE scan_id = ?1",
2165            params![id],
2166        )
2167        .map_err(|e| e.to_string())?;
2168        Self::sync_audio_library_after_paths_refresh(&conn)?;
2169        self.invalidate_audio_library_total_cache();
2170        Ok(())
2171    }
2172
2173    pub fn audio_scan_parent_finalize(
2174        &self,
2175        id: &str,
2176        _sample_count: u64,
2177        _total_bytes: u64,
2178        _format_counts: &HashMap<String, usize>,
2179    ) -> Result<(), String> {
2180        let conn = self.read_conn();
2181        let sample_count: i64 = conn
2182            .query_row("SELECT COUNT(DISTINCT path) FROM audio_samples", [], |r| {
2183                r.get(0)
2184            })
2185            .unwrap_or(0);
2186        let total_bytes: i64 = conn
2187            .query_row(
2188                "SELECT COALESCE(SUM(s.size), 0) FROM audio_samples s INNER JOIN audio_library lib ON s.id = lib.sample_id",
2189                [],
2190                |r| r.get(0),
2191            )
2192            .unwrap_or(0);
2193        let mut format_map: HashMap<String, usize> = HashMap::new();
2194        let mut stmt = conn
2195            .prepare(
2196                "SELECT s.format, COUNT(*) FROM audio_samples s INNER JOIN audio_library lib ON s.id = lib.sample_id GROUP BY s.format",
2197            )
2198            .map_err(|e| e.to_string())?;
2199        let rows = stmt
2200            .query_map([], |row| {
2201                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
2202            })
2203            .map_err(|e| e.to_string())?;
2204        for (fmt, n) in rows.flatten() {
2205            format_map.insert(fmt, n);
2206        }
2207        let fc_json = serde_json::to_string(&format_map).unwrap_or_default();
2208        conn.execute(
2209            "UPDATE audio_scans SET sample_count = ?2, total_bytes = ?3, format_counts = ?4 WHERE id = ?1",
2210            params![id, sample_count, total_bytes, fc_json],
2211        )
2212        .map_err(|e| e.to_string())?;
2213        Ok(())
2214    }
2215
2216    pub fn insert_audio_batch(
2217        &self,
2218        scan_id: &str,
2219        samples: &[AudioSample],
2220    ) -> Result<u64, String> {
2221        let conn = self.read_conn();
2222        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
2223        let mut inserted: u64 = 0;
2224        let mut batch_bytes: u64 = 0;
2225        {
2226            // INSERT OR IGNORE (not REPLACE) so auto-increment ids stay stable —
2227            // FTS5 rowid is linked to audio_samples.id and REPLACE would break that
2228            // link. parent_create clears rows per scan, so conflicts only occur
2229            // within a scan (same path emitted twice) — safe to ignore duplicates.
2230            let mut stmt = tx
2231                .prepare_cached(
2232                    "INSERT OR IGNORE INTO audio_samples
2233                     (name, path, directory, format, size, size_formatted, modified,
2234                      duration, channels, sample_rate, bits_per_sample, scan_id)
2235                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
2236                )
2237                .map_err(|e| e.to_string())?;
2238            let mut fts_stmt = tx
2239                .prepare_cached(
2240                    "INSERT INTO audio_samples_fts(rowid, name, path, scan_id) VALUES (?1, ?2, ?3, ?4)",
2241                )
2242                .map_err(|e| e.to_string())?;
2243            let mut lib_stmt = tx
2244                .prepare_cached(
2245                    "INSERT INTO audio_library (path, sample_id) VALUES (?1, ?2)
2246                     ON CONFLICT(path) DO UPDATE SET sample_id = CASE
2247                       WHEN excluded.sample_id > audio_library.sample_id THEN excluded.sample_id
2248                       ELSE audio_library.sample_id END",
2249                )
2250                .map_err(|e| e.to_string())?;
2251
2252            for s in samples {
2253                let path = normalize_path_for_db(&s.path);
2254                let directory = normalize_path_for_db(&s.directory);
2255                let changed = stmt
2256                    .execute(params![
2257                        s.name,
2258                        path,
2259                        directory,
2260                        s.format,
2261                        s.size as i64,
2262                        s.size_formatted,
2263                        s.modified,
2264                        s.duration,
2265                        s.channels,
2266                        s.sample_rate,
2267                        s.bits_per_sample,
2268                        scan_id,
2269                    ])
2270                    .map_err(|e| e.to_string())?;
2271                if changed > 0 {
2272                    let id = tx.last_insert_rowid();
2273                    fts_stmt
2274                        .execute(params![id, s.name, path, scan_id])
2275                        .map_err(|e| e.to_string())?;
2276                    lib_stmt
2277                        .execute(params![path, id])
2278                        .map_err(|e| e.to_string())?;
2279                    inserted += 1;
2280                    batch_bytes += s.size;
2281                }
2282            }
2283        }
2284        // Increment parent row counts so history is accurate mid-scan.
2285        if inserted > 0 {
2286            tx.execute(
2287                "UPDATE audio_scans SET sample_count = sample_count + ?2, total_bytes = total_bytes + ?3 WHERE id = ?1",
2288                params![scan_id, inserted as i64, batch_bytes as i64],
2289            ).map_err(|e| e.to_string())?;
2290        }
2291        tx.commit().map_err(|e| e.to_string())?;
2292        if inserted > 0 {
2293            self.invalidate_audio_library_total_cache();
2294        }
2295        Ok(inserted)
2296    }
2297
2298    /// Save scan metadata.
2299    pub fn save_scan(
2300        &self,
2301        id: &str,
2302        timestamp: &str,
2303        sample_count: u64,
2304        total_bytes: u64,
2305        format_counts: &HashMap<String, usize>,
2306        roots: &[String],
2307    ) -> Result<(), String> {
2308        let conn = self.read_conn();
2309        let fc_json = serde_json::to_string(format_counts).unwrap_or_default();
2310        let roots_json = path_strings_json_normalized(roots);
2311        conn.execute(
2312            "INSERT OR REPLACE INTO audio_scans
2313             (id, timestamp, sample_count, total_bytes, format_counts, roots, scan_complete)
2314             VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
2315            params![
2316                id,
2317                timestamp,
2318                sample_count as i64,
2319                total_bytes as i64,
2320                fc_json,
2321                roots_json
2322            ],
2323        )
2324        .map_err(|e| e.to_string())?;
2325        Ok(())
2326    }
2327
2328    /// Get the most recent scan ID.
2329    pub fn latest_scan_id(&self) -> Result<Option<String>, String> {
2330        let conn = self.read_conn();
2331        conn.query_row(
2332            "SELECT id FROM audio_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
2333            [],
2334            |row| row.get::<_, String>(0),
2335        )
2336        .optional()
2337        .map_err(|e| e.to_string())
2338    }
2339
2340    /// List all scans (metadata only).
2341    pub fn list_scans(&self) -> Result<Vec<ScanInfo>, String> {
2342        let conn = self.read_conn();
2343        let mut stmt = conn
2344            .prepare(
2345                "SELECT id, timestamp, sample_count, total_bytes, format_counts, roots
2346                 FROM audio_scans WHERE scan_complete = 1 ORDER BY timestamp DESC",
2347            )
2348            .map_err(|e| e.to_string())?;
2349        let rows = stmt
2350            .query_map([], |row| {
2351                let fc_str: String = row.get(4)?;
2352                let roots_str: String = row.get(5)?;
2353                Ok(ScanInfo {
2354                    id: row.get(0)?,
2355                    timestamp: row.get(1)?,
2356                    sample_count: row.get::<_, i64>(2)? as u64,
2357                    total_bytes: row.get::<_, i64>(3)? as u64,
2358                    format_counts: serde_json::from_str(&fc_str).unwrap_or_default(),
2359                    roots: serde_json::from_str(&roots_str).unwrap_or_default(),
2360                })
2361            })
2362            .map_err(|e| e.to_string())?;
2363        rows.collect::<Result<Vec<_>, _>>()
2364            .map_err(|e| e.to_string())
2365    }
2366
2367    /// Bounded FTS hit count for one scan (`audio_samples_fts.scan_id`).
2368    fn audio_fts_bounded_count_scan(
2369        conn: &Connection,
2370        fts_match: &str,
2371        scan_id: &str,
2372    ) -> Result<(u64, bool), String> {
2373        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2374        let raw: i64 = conn
2375            .query_row(
2376                "SELECT COUNT(*) FROM (
2377  SELECT 1 FROM audio_samples_fts
2378  WHERE audio_samples_fts MATCH ?1 AND scan_id = ?2
2379  LIMIT ?3)",
2380                params![fts_match, scan_id, cap],
2381                |row| row.get::<_, i64>(0),
2382            )
2383            .map_err(|e| e.to_string())?;
2384        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2385        let count = if capped {
2386            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2387        } else {
2388            raw as u64
2389        };
2390        Ok((count, capped))
2391    }
2392
2393    /// Bounded FTS hit count for library scope (same `format_filter` rules as [`Self::query_audio`]).
2394    fn audio_fts_bounded_count_library(
2395        conn: &Connection,
2396        fts_match: &str,
2397        format_filter: Option<&str>,
2398    ) -> Result<(u64, bool), String> {
2399        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2400        let raw: i64 = match format_filter {
2401            None => conn
2402                .query_row(
2403                    "SELECT COUNT(*) FROM (
2404  SELECT 1 FROM audio_samples_fts
2405  INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2406  INNER JOIN audio_library lib ON lib.sample_id = s.id
2407  WHERE audio_samples_fts MATCH ?1
2408  LIMIT ?2)",
2409                    params![fts_match, cap],
2410                    |row| row.get::<_, i64>(0),
2411                )
2412                .map_err(|e| e.to_string())?,
2413            Some(f) if f.trim().is_empty() || f == "all" => conn
2414                .query_row(
2415                    "SELECT COUNT(*) FROM (
2416  SELECT 1 FROM audio_samples_fts
2417  INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2418  INNER JOIN audio_library lib ON lib.sample_id = s.id
2419  WHERE audio_samples_fts MATCH ?1
2420  LIMIT ?2)",
2421                    params![fts_match, cap],
2422                    |row| row.get::<_, i64>(0),
2423                )
2424                .map_err(|e| e.to_string())?,
2425            Some(f) if f.contains(',') => {
2426                let in_list = Self::in_list_sql(f);
2427                let sql = format!(
2428                    "SELECT COUNT(*) FROM (
2429  SELECT 1 FROM audio_samples_fts
2430  INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2431  INNER JOIN audio_library lib ON lib.sample_id = s.id
2432  WHERE audio_samples_fts MATCH ?1
2433  AND s.format IN ({in_list})
2434  LIMIT ?2)"
2435                );
2436                conn.query_row(&sql, params![fts_match, cap], |row| row.get::<_, i64>(0))
2437                    .map_err(|e| e.to_string())?
2438            }
2439            Some(f) => conn
2440                .query_row(
2441                    "SELECT COUNT(*) FROM (
2442  SELECT 1 FROM audio_samples_fts
2443  INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2444  INNER JOIN audio_library lib ON lib.sample_id = s.id
2445  WHERE audio_samples_fts MATCH ?1
2446  AND s.format = ?2
2447  LIMIT ?3)",
2448                    params![fts_match, f, cap],
2449                    |row| row.get::<_, i64>(0),
2450                )
2451                .map_err(|e| e.to_string())?,
2452        };
2453        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2454        let count = if capped {
2455            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2456        } else {
2457            raw as u64
2458        };
2459        Ok((count, capped))
2460    }
2461
2462    /// Bounded FTS hit count for presets library (non-MIDI formats only; matches [`Self::query_presets`]).
2463    fn preset_fts_bounded_count_library(
2464        conn: &Connection,
2465        fts_match: &str,
2466        format_filter: Option<&str>,
2467    ) -> Result<(u64, bool), String> {
2468        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2469        let raw: i64 = match format_filter {
2470            None => conn
2471                .query_row(
2472                    "SELECT COUNT(*) FROM (
2473  SELECT 1 FROM presets_fts
2474  INNER JOIN presets p ON p.id = presets_fts.rowid
2475  INNER JOIN preset_library lib ON lib.preset_id = p.id
2476  WHERE presets_fts MATCH ?1
2477  AND p.format NOT IN ('MID','MIDI')
2478  LIMIT ?2)",
2479                    params![fts_match, cap],
2480                    |row| row.get::<_, i64>(0),
2481                )
2482                .map_err(|e| e.to_string())?,
2483            Some(f) if f.trim().is_empty() || f == "all" => conn
2484                .query_row(
2485                    "SELECT COUNT(*) FROM (
2486  SELECT 1 FROM presets_fts
2487  INNER JOIN presets p ON p.id = presets_fts.rowid
2488  INNER JOIN preset_library lib ON lib.preset_id = p.id
2489  WHERE presets_fts MATCH ?1
2490  AND p.format NOT IN ('MID','MIDI')
2491  LIMIT ?2)",
2492                    params![fts_match, cap],
2493                    |row| row.get::<_, i64>(0),
2494                )
2495                .map_err(|e| e.to_string())?,
2496            Some(f) if f.contains(',') => {
2497                let in_list = Self::in_list_sql(f);
2498                let sql = format!(
2499                    "SELECT COUNT(*) FROM (
2500  SELECT 1 FROM presets_fts
2501  INNER JOIN presets p ON p.id = presets_fts.rowid
2502  INNER JOIN preset_library lib ON lib.preset_id = p.id
2503  WHERE presets_fts MATCH ?1
2504  AND p.format NOT IN ('MID','MIDI')
2505  AND p.format IN ({in_list})
2506  LIMIT ?2)"
2507                );
2508                conn.query_row(&sql, params![fts_match, cap], |row| row.get::<_, i64>(0))
2509                    .map_err(|e| e.to_string())?
2510            }
2511            Some(f) => conn
2512                .query_row(
2513                    "SELECT COUNT(*) FROM (
2514  SELECT 1 FROM presets_fts
2515  INNER JOIN presets p ON p.id = presets_fts.rowid
2516  INNER JOIN preset_library lib ON lib.preset_id = p.id
2517  WHERE presets_fts MATCH ?1
2518  AND p.format NOT IN ('MID','MIDI')
2519  AND p.format = ?2
2520  LIMIT ?3)",
2521                    params![fts_match, f, cap],
2522                    |row| row.get::<_, i64>(0),
2523                )
2524                .map_err(|e| e.to_string())?,
2525        };
2526        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2527        let count = if capped {
2528            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2529        } else {
2530            raw as u64
2531        };
2532        Ok((count, capped))
2533    }
2534
2535    /// Bounded FTS hit count for MIDI library (same `format_filter` rules as [`Self::query_midi`]).
2536    fn midi_fts_bounded_count_library(
2537        conn: &Connection,
2538        fts_match: &str,
2539        format_filter: Option<&str>,
2540    ) -> Result<(u64, bool), String> {
2541        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2542        let raw: i64 = match format_filter {
2543            None => conn
2544                .query_row(
2545                    "SELECT COUNT(*) FROM (
2546  SELECT 1 FROM midi_files_fts
2547  INNER JOIN midi_library AS lib ON lib.midi_id = midi_files_fts.rowid
2548  WHERE midi_files_fts MATCH ?1
2549  LIMIT ?2)",
2550                    params![fts_match, cap],
2551                    |row| row.get::<_, i64>(0),
2552                )
2553                .map_err(|e| e.to_string())?,
2554            Some(f) if f.trim().is_empty() || f == "all" => conn
2555                .query_row(
2556                    "SELECT COUNT(*) FROM (
2557  SELECT 1 FROM midi_files_fts
2558  INNER JOIN midi_library AS lib ON lib.midi_id = midi_files_fts.rowid
2559  WHERE midi_files_fts MATCH ?1
2560  LIMIT ?2)",
2561                    params![fts_match, cap],
2562                    |row| row.get::<_, i64>(0),
2563                )
2564                .map_err(|e| e.to_string())?,
2565            Some(f) if f.contains(',') => {
2566                let in_list = Self::in_list_sql(f);
2567                let sql = format!(
2568                    "SELECT COUNT(*) FROM (
2569  SELECT 1 FROM midi_files_fts
2570  INNER JOIN midi_files AS m ON m.id = midi_files_fts.rowid
2571  INNER JOIN midi_library AS lib ON lib.midi_id = m.id
2572  WHERE midi_files_fts MATCH ?1 AND m.format IN ({in_list})
2573  LIMIT ?2)"
2574                );
2575                conn.query_row(&sql, params![fts_match, cap], |row| row.get::<_, i64>(0))
2576                    .map_err(|e| e.to_string())?
2577            }
2578            Some(f) => conn
2579                .query_row(
2580                    "SELECT COUNT(*) FROM (
2581  SELECT 1 FROM midi_files_fts
2582  INNER JOIN midi_files AS m ON m.id = midi_files_fts.rowid
2583  INNER JOIN midi_library AS lib ON lib.midi_id = m.id
2584  WHERE midi_files_fts MATCH ?1 AND m.format = ?2
2585  LIMIT ?3)",
2586                    params![fts_match, f, cap],
2587                    |row| row.get::<_, i64>(0),
2588                )
2589                .map_err(|e| e.to_string())?,
2590        };
2591        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2592        let count = if capped {
2593            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2594        } else {
2595            raw as u64
2596        };
2597        Ok((count, capped))
2598    }
2599
2600    /// Bounded FTS hit count for PDF library ([`Self::query_pdfs`] has no format filter).
2601    fn pdf_fts_bounded_count_library(conn: &Connection, fts_match: &str) -> Result<(u64, bool), String> {
2602        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2603        let raw: i64 = conn
2604            .query_row(
2605                "SELECT COUNT(*) FROM (
2606  SELECT 1 FROM pdfs_fts
2607  INNER JOIN pdf_library AS lib ON lib.pdf_id = pdfs_fts.rowid
2608  WHERE pdfs_fts MATCH ?1
2609  LIMIT ?2)",
2610                params![fts_match, cap],
2611                |row| row.get::<_, i64>(0),
2612            )
2613            .map_err(|e| e.to_string())?;
2614        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2615        let count = if capped {
2616            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2617        } else {
2618            raw as u64
2619        };
2620        Ok((count, capped))
2621    }
2622
2623    /// Bounded FTS hit count for DAW project library ([`Self::query_daw`] `daw_filter` rules).
2624    fn daw_fts_bounded_count_library(
2625        conn: &Connection,
2626        fts_match: &str,
2627        daw_filter: Option<&str>,
2628    ) -> Result<(u64, bool), String> {
2629        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2630        let raw: i64 = match daw_filter {
2631            None => conn
2632                .query_row(
2633                    "SELECT COUNT(*) FROM (
2634  SELECT 1 FROM daw_projects_fts
2635  INNER JOIN daw_projects d ON d.id = daw_projects_fts.rowid
2636  INNER JOIN daw_library lib ON lib.project_id = d.id
2637  WHERE daw_projects_fts MATCH ?1
2638  LIMIT ?2)",
2639                    params![fts_match, cap],
2640                    |row| row.get::<_, i64>(0),
2641                )
2642                .map_err(|e| e.to_string())?,
2643            Some(f) if f.trim().is_empty() || f == "all" => conn
2644                .query_row(
2645                    "SELECT COUNT(*) FROM (
2646  SELECT 1 FROM daw_projects_fts
2647  INNER JOIN daw_projects d ON d.id = daw_projects_fts.rowid
2648  INNER JOIN daw_library lib ON lib.project_id = d.id
2649  WHERE daw_projects_fts MATCH ?1
2650  LIMIT ?2)",
2651                    params![fts_match, cap],
2652                    |row| row.get::<_, i64>(0),
2653                )
2654                .map_err(|e| e.to_string())?,
2655            Some(f) if f.contains(',') => {
2656                let in_list = Self::in_list_sql(f);
2657                let sql = format!(
2658                    "SELECT COUNT(*) FROM (
2659  SELECT 1 FROM daw_projects_fts
2660  INNER JOIN daw_projects d ON d.id = daw_projects_fts.rowid
2661  INNER JOIN daw_library lib ON lib.project_id = d.id
2662  WHERE daw_projects_fts MATCH ?1
2663  AND d.daw IN ({in_list})
2664  LIMIT ?2)"
2665                );
2666                conn.query_row(&sql, params![fts_match, cap], |row| row.get::<_, i64>(0))
2667                    .map_err(|e| e.to_string())?
2668            }
2669            Some(f) => conn
2670                .query_row(
2671                    "SELECT COUNT(*) FROM (
2672  SELECT 1 FROM daw_projects_fts
2673  INNER JOIN daw_projects d ON d.id = daw_projects_fts.rowid
2674  INNER JOIN daw_library lib ON lib.project_id = d.id
2675  WHERE daw_projects_fts MATCH ?1
2676  AND d.daw = ?2
2677  LIMIT ?3)",
2678                    params![fts_match, f, cap],
2679                    |row| row.get::<_, i64>(0),
2680                )
2681                .map_err(|e| e.to_string())?,
2682        };
2683        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2684        let count = if capped {
2685            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2686        } else {
2687            raw as u64
2688        };
2689        Ok((count, capped))
2690    }
2691
2692    /// Bounded COUNT for plugin tab search (LIKE / REGEXP; no FTS). Same cap as inventory FTS paths.
2693    fn plugin_search_bounded_count_library(
2694        conn: &Connection,
2695        from_sql: &str,
2696        where_cl: &str,
2697        regex_pat: &Option<String>,
2698        like_pat: &Option<String>,
2699        type_filter: Option<&str>,
2700    ) -> Result<(u64, bool), String> {
2701        let cap = FTS_INVENTORY_MATCH_COUNT_CAP + 1;
2702        let mut cap_idx = 1usize;
2703        if regex_pat.is_some() || like_pat.is_some() {
2704            cap_idx += 1;
2705        }
2706        if type_filter
2707            .map(|t| !t.is_empty() && t != "all" && !t.contains(','))
2708            .unwrap_or(false)
2709        {
2710            cap_idx += 1;
2711        }
2712        let sql = format!(
2713            "SELECT COUNT(*) FROM (SELECT 1 {from_sql} WHERE {where_cl} LIMIT ?{cap_idx})"
2714        );
2715        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
2716        let mut bi = 1usize;
2717        if let Some(ref p) = regex_pat.as_ref().or(like_pat.as_ref()) {
2718            stmt.raw_bind_parameter(bi, p).map_err(|e| e.to_string())?;
2719            bi += 1;
2720        }
2721        if let Some(tf) = type_filter {
2722            if !tf.is_empty() && tf != "all" && !tf.contains(',') {
2723                stmt.raw_bind_parameter(bi, tf).map_err(|e| e.to_string())?;
2724                bi += 1;
2725            }
2726        }
2727        stmt.raw_bind_parameter(bi, cap).map_err(|e| e.to_string())?;
2728        let mut rows = stmt.raw_query();
2729        let raw: i64 = rows
2730            .next()
2731            .map_err(|e| e.to_string())?
2732            .map(|r| r.get::<_, i64>(0).unwrap_or(0))
2733            .unwrap_or(0);
2734        let capped = raw > FTS_INVENTORY_MATCH_COUNT_CAP;
2735        let count = if capped {
2736            FTS_INVENTORY_MATCH_COUNT_CAP as u64
2737        } else {
2738            raw as u64
2739        };
2740        Ok((count, capped))
2741    }
2742
2743    /// Paginated, sortable, filterable query for audio samples.
2744    ///
2745    /// `scan_id` **Some(non-empty)** → rows for that scan only (history detail).
2746    /// `scan_id` **None** or empty → **library** mode: all rows across scans, deduped by `path`
2747    /// (`MAX(id)` per path).
2748    pub fn query_audio(&self, params: &AudioQueryParams) -> Result<AudioQueryResult, String> {
2749        let conn = self.read_conn();
2750
2751        let single_scan = params
2752            .scan_id
2753            .as_ref()
2754            .map(|s| !s.is_empty())
2755            .unwrap_or(false);
2756        let scan_id = if single_scan {
2757            params.scan_id.as_ref().unwrap().clone()
2758        } else {
2759            String::new()
2760        };
2761
2762        if single_scan && scan_id.is_empty() {
2763            return Ok(AudioQueryResult {
2764                samples: vec![],
2765                total_count: 0,
2766                total_count_capped: false,
2767                total_unfiltered: 0,
2768            });
2769        }
2770
2771        // Build WHERE clause
2772        let mut conditions = if single_scan {
2773            vec!["scan_id = ?1".to_string()]
2774        } else {
2775            vec![AUDIO_LIBRARY_IDS.to_string()]
2776        };
2777        let mut bind_idx = if single_scan { 2 } else { 1 };
2778
2779        // FTS5 trigram for ≥3 char searches; LIKE fallback for 1–2 chars.
2780        // Regex mode (UI `.*` toggle): real ECMA-style regex via SQLite `REGEXP`, not FTS phrase.
2781        let (fts_match, like_pat, regex_pat) =
2782            classify_fts_name_path_search(params.search.as_deref(), params.search_regex);
2783        if fts_match.is_some() {
2784            if single_scan {
2785                conditions.push(format!(
2786                    "id IN (SELECT rowid FROM audio_samples_fts WHERE audio_samples_fts MATCH ?{bind_idx} AND scan_id = ?{scan_idx})",
2787                    scan_idx = bind_idx + 1,
2788                ));
2789                bind_idx += 2;
2790            } else {
2791                // Library scope is already `AUDIO_LIBRARY_IDS` above; do not nest a second
2792                // `sample_id IN audio_library` inside the FTS subquery (same semantics, worse plan).
2793                conditions.push(format!(
2794                    "id IN (SELECT rowid FROM audio_samples_fts WHERE audio_samples_fts MATCH ?{bind_idx})",
2795                    bind_idx = bind_idx,
2796                ));
2797                bind_idx += 1;
2798            }
2799        } else if regex_pat.is_some() {
2800            conditions.push(format!(
2801                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
2802            ));
2803            bind_idx += 1;
2804        } else if like_pat.is_some() {
2805            conditions.push(format!(
2806                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
2807            ));
2808            bind_idx += 1;
2809        }
2810
2811        if let Some(fmt) = &params.format_filter {
2812            if !fmt.is_empty() && fmt != "all" {
2813                if fmt.contains(',') {
2814                    let vals: Vec<String> = fmt
2815                        .split(',')
2816                        .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
2817                        .collect();
2818                    conditions.push(format!("format IN ({})", vals.join(",")));
2819                } else {
2820                    conditions.push(format!("format = ?{bind_idx}"));
2821                    bind_idx += 1;
2822                }
2823            }
2824        }
2825
2826        let where_clause = conditions.join(" AND ");
2827
2828        // Validate sort key
2829        let sort_col = match params.sort_key.as_str() {
2830            "name" => "name COLLATE NOCASE",
2831            "format" => "format",
2832            "size" => "size",
2833            "modified" => "modified",
2834            "directory" => "directory COLLATE NOCASE",
2835            "bpm" => "bpm",
2836            "key" => "key_name",
2837            "lufs" => "lufs",
2838            "duration" => "duration",
2839            "channels" => "channels",
2840            _ => "name COLLATE NOCASE",
2841        };
2842        let sort_dir = if params.sort_asc { "ASC" } else { "DESC" };
2843        let nulls = "NULLS LAST";
2844
2845        // Total unfiltered count
2846        let total_unfiltered: u64 = if single_scan {
2847            conn.query_row(
2848                "SELECT COUNT(*) FROM audio_samples WHERE scan_id = ?1",
2849                params![scan_id],
2850                |row| row.get::<_, i64>(0).map(|v| v as u64),
2851            )
2852            .map_err(|e| e.to_string())?
2853        } else {
2854            self.audio_library_total_rows(&conn)?
2855        };
2856
2857        // Filtered total: separate COUNT so the main SELECT can use LIMIT without
2858        // COUNT(*) OVER(), which SQLite evaluates before LIMIT (full scan / lockup at 200k+ rows).
2859        //
2860        // FTS: bounded COUNT (`FTS_INVENTORY_MATCH_COUNT_CAP`) — common substrings can match
2861        // hundreds of thousands of rows; exact `COUNT(*)` over that set dominated latency.
2862        let count_sql = format!("SELECT COUNT(*) FROM audio_samples WHERE {where_clause}");
2863        let (total_count, total_count_capped) = if fts_match.is_some() {
2864            let m = fts_match.as_ref().expect("fts");
2865            if single_scan {
2866                Self::audio_fts_bounded_count_scan(&conn, m, &scan_id)?
2867            } else {
2868                Self::audio_fts_bounded_count_library(&conn, m, params.format_filter.as_deref())?
2869            }
2870        } else {
2871            let mut count_stmt = conn.prepare(&count_sql).map_err(|e| e.to_string())?;
2872            let mut idx = 1;
2873            if single_scan {
2874                count_stmt
2875                    .raw_bind_parameter(idx, &scan_id)
2876                    .map_err(|e| e.to_string())?;
2877                idx += 1;
2878            }
2879            if let Some(ref r) = regex_pat {
2880                count_stmt
2881                    .raw_bind_parameter(idx, r)
2882                    .map_err(|e| e.to_string())?;
2883                idx += 1;
2884            } else if let Some(ref pat) = like_pat {
2885                count_stmt
2886                    .raw_bind_parameter(idx, pat)
2887                    .map_err(|e| e.to_string())?;
2888                idx += 1;
2889            }
2890            if let Some(ref fmt) = params.format_filter {
2891                if !fmt.is_empty() && fmt != "all" && !fmt.contains(',') {
2892                    count_stmt
2893                        .raw_bind_parameter(idx, fmt)
2894                        .map_err(|e| e.to_string())?;
2895                }
2896            }
2897            let mut count_rows = count_stmt.raw_query();
2898            let row = count_rows
2899                .next()
2900                .map_err(|e| e.to_string())?
2901                .ok_or_else(|| "COUNT returned no rows".to_string())?;
2902            let n = row.get::<_, i64>(0).map_err(|e| e.to_string())? as u64;
2903            (n, false)
2904        };
2905
2906        // FTS substring search: ORDER BY column sorts the entire match set before LIMIT (stalls).
2907        // Use bm25 + LIMIT like PDF/MIDI; frontend may re-rank with `searchScore`.
2908        let use_fts_bm25 = fts_match.is_some();
2909        let query_sql = if use_fts_bm25 {
2910            let (mut w, mut next_ph) = if single_scan {
2911                (
2912                    String::from(
2913                        "SELECT s.name, s.path, s.directory, s.format, s.size, s.size_formatted, s.modified,
2914                s.duration, s.channels, s.sample_rate, s.bits_per_sample, s.bpm, s.key_name, s.lufs, s.bpm_exhausted
2915                FROM audio_samples_fts
2916                INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2917                 WHERE audio_samples_fts MATCH ?1 AND s.scan_id = ?2",
2918                    ),
2919                    3usize,
2920                )
2921            } else {
2922                (
2923                    String::from(
2924                        "SELECT s.name, s.path, s.directory, s.format, s.size, s.size_formatted, s.modified,
2925                s.duration, s.channels, s.sample_rate, s.bits_per_sample, s.bpm, s.key_name, s.lufs, s.bpm_exhausted
2926                FROM audio_samples_fts
2927                INNER JOIN audio_samples s ON s.id = audio_samples_fts.rowid
2928                 INNER JOIN audio_library lib ON lib.sample_id = s.id
2929                 WHERE audio_samples_fts MATCH ?1",
2930                    ),
2931                    2usize,
2932                )
2933            };
2934            if let Some(fmt) = &params.format_filter {
2935                if !fmt.is_empty() && fmt != "all" {
2936                    if fmt.contains(',') {
2937                        let vals: Vec<String> = fmt
2938                            .split(',')
2939                            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
2940                            .collect();
2941                        w.push_str(&format!(" AND s.format IN ({})", vals.join(",")));
2942                    } else {
2943                        w.push_str(&format!(" AND s.format = ?{next_ph}"));
2944                        next_ph += 1;
2945                    }
2946                }
2947            }
2948            let plim = next_ph;
2949            let poff = next_ph + 1;
2950            w.push_str(&format!(
2951                " ORDER BY bm25(audio_samples_fts) LIMIT ?{plim} OFFSET ?{poff}"
2952            ));
2953            w
2954        } else {
2955            format!(
2956                "SELECT name, path, directory, format, size, size_formatted, modified,
2957                    duration, channels, sample_rate, bits_per_sample, bpm, key_name, lufs, bpm_exhausted
2958             FROM audio_samples
2959             WHERE {where_clause}
2960             ORDER BY {sort_col} {sort_dir} {nulls}
2961             LIMIT ?{limit_idx} OFFSET ?{offset_idx}",
2962                limit_idx = bind_idx,
2963                offset_idx = bind_idx + 1,
2964            )
2965        };
2966
2967        let mut stmt = conn.prepare(&query_sql).map_err(|e| e.to_string())?;
2968        let mut idx = 1;
2969        if use_fts_bm25 {
2970            let m = fts_match.as_ref().expect("fts");
2971            stmt.raw_bind_parameter(idx, m).map_err(|e| e.to_string())?;
2972            idx += 1;
2973            if single_scan {
2974                stmt.raw_bind_parameter(idx, &scan_id)
2975                    .map_err(|e| e.to_string())?;
2976                idx += 1;
2977            }
2978            if let Some(ref fmt) = params.format_filter {
2979                if !fmt.is_empty() && fmt != "all" && !fmt.contains(',') {
2980                    stmt.raw_bind_parameter(idx, fmt)
2981                        .map_err(|e| e.to_string())?;
2982                    idx += 1;
2983                }
2984            }
2985            stmt.raw_bind_parameter(idx, params.limit as i64)
2986                .map_err(|e| e.to_string())?;
2987            stmt.raw_bind_parameter(idx + 1, params.offset as i64)
2988                .map_err(|e| e.to_string())?;
2989        } else {
2990            if single_scan {
2991                stmt.raw_bind_parameter(idx, &scan_id)
2992                    .map_err(|e| e.to_string())?;
2993                idx += 1;
2994            }
2995            if let Some(ref r) = regex_pat {
2996                stmt.raw_bind_parameter(idx, r).map_err(|e| e.to_string())?;
2997                idx += 1;
2998            } else if let Some(ref pat) = like_pat {
2999                stmt.raw_bind_parameter(idx, pat)
3000                    .map_err(|e| e.to_string())?;
3001                idx += 1;
3002            }
3003            if let Some(ref fmt) = params.format_filter {
3004                if !fmt.is_empty() && fmt != "all" && !fmt.contains(',') {
3005                    stmt.raw_bind_parameter(idx, fmt)
3006                        .map_err(|e| e.to_string())?;
3007                    idx += 1;
3008                }
3009            }
3010            stmt.raw_bind_parameter(idx, params.limit as i64)
3011                .map_err(|e| e.to_string())?;
3012            stmt.raw_bind_parameter(idx + 1, params.offset as i64)
3013                .map_err(|e| e.to_string())?;
3014        }
3015
3016        let mut samples = Vec::new();
3017        let mut rows = stmt.raw_query();
3018        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
3019            samples.push(AudioSampleRow {
3020                name: row.get(0).unwrap_or_default(),
3021                path: row.get(1).unwrap_or_default(),
3022                directory: row.get(2).unwrap_or_default(),
3023                format: row.get(3).unwrap_or_default(),
3024                size: row.get::<_, i64>(4).unwrap_or(0) as u64,
3025                size_formatted: row.get(5).unwrap_or_default(),
3026                modified: row.get(6).unwrap_or_default(),
3027                duration: row.get(7).ok(),
3028                channels: row
3029                    .get::<_, Option<i32>>(8)
3030                    .ok()
3031                    .flatten()
3032                    .map(|v| v as u16),
3033                sample_rate: row
3034                    .get::<_, Option<i32>>(9)
3035                    .ok()
3036                    .flatten()
3037                    .map(|v| v as u32),
3038                bits_per_sample: row
3039                    .get::<_, Option<i32>>(10)
3040                    .ok()
3041                    .flatten()
3042                    .map(|v| v as u16),
3043                bpm: row.get(11).ok(),
3044                key: row.get(12).ok(),
3045                lufs: row.get(13).ok(),
3046                bpm_exhausted: row
3047                    .get::<_, i64>(14)
3048                    .ok()
3049                    .map(|v| v != 0)
3050                    .unwrap_or(false),
3051            });
3052        }
3053
3054        Ok(AudioQueryResult {
3055            samples,
3056            total_count,
3057            total_count_capped,
3058            total_unfiltered,
3059        })
3060    }
3061
3062    /// Get aggregate stats. `scan_id` None or empty → full library (deduped by path). Otherwise that scan only.
3063    pub fn audio_stats(&self, scan_id: Option<&str>) -> Result<AudioStatsResult, String> {
3064        let conn = self.read_conn();
3065
3066        let library = scan_id.map(|s| s.is_empty()).unwrap_or(true);
3067        if library {
3068            let sample_count: u64 = conn
3069                .query_row("SELECT COUNT(*) FROM audio_library", [], |row| {
3070                    row.get::<_, i64>(0).map(|v| v as u64)
3071                })
3072                .unwrap_or(0);
3073            let total_bytes: u64 = conn
3074                .query_row(
3075                    "SELECT COALESCE(SUM(s.size), 0) FROM audio_samples s INNER JOIN audio_library lib ON s.id = lib.sample_id",
3076                    [],
3077                    |row| row.get::<_, i64>(0).map(|v| v as u64),
3078                )
3079                .unwrap_or(0);
3080            let mut format_counts = HashMap::new();
3081            let mut stmt = conn
3082                .prepare(
3083                    "SELECT s.format, COUNT(*) FROM audio_samples s INNER JOIN audio_library lib ON s.id = lib.sample_id GROUP BY s.format",
3084                )
3085                .map_err(|e| e.to_string())?;
3086            let rows = stmt
3087                .query_map([], |row| {
3088                    Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3089                })
3090                .map_err(|e| e.to_string())?;
3091            for (fmt, count) in rows.flatten() {
3092                format_counts.insert(fmt, count);
3093            }
3094            let analyzed_count: u64 = conn
3095                .query_row(
3096                    "SELECT COUNT(*) FROM audio_samples s INNER JOIN audio_library lib ON s.id = lib.sample_id WHERE s.bpm IS NOT NULL",
3097                    [],
3098                    |row| row.get::<_, i64>(0).map(|v| v as u64),
3099                )
3100                .unwrap_or(0);
3101            return Ok(AudioStatsResult {
3102                sample_count,
3103                total_bytes,
3104                format_counts,
3105                analyzed_count,
3106            });
3107        }
3108
3109        let sid = scan_id.expect("scan_id").to_string();
3110        if sid.is_empty() {
3111            return Ok(AudioStatsResult {
3112                sample_count: 0,
3113                total_bytes: 0,
3114                format_counts: HashMap::new(),
3115                analyzed_count: 0,
3116            });
3117        }
3118
3119        let sample_count: u64 = conn
3120            .query_row(
3121                "SELECT COUNT(*) FROM audio_samples WHERE scan_id = ?1",
3122                params![sid],
3123                |row| row.get::<_, i64>(0).map(|v| v as u64),
3124            )
3125            .map_err(|e| e.to_string())?;
3126
3127        let total_bytes: u64 = conn
3128            .query_row(
3129                "SELECT COALESCE(SUM(size), 0) FROM audio_samples WHERE scan_id = ?1",
3130                params![sid],
3131                |row| row.get::<_, i64>(0).map(|v| v as u64),
3132            )
3133            .map_err(|e| e.to_string())?;
3134
3135        let mut format_counts = HashMap::new();
3136        let mut stmt = conn
3137            .prepare(
3138                "SELECT format, COUNT(*) FROM audio_samples WHERE scan_id = ?1 GROUP BY format",
3139            )
3140            .map_err(|e| e.to_string())?;
3141        let rows = stmt
3142            .query_map(params![sid], |row| {
3143                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3144            })
3145            .map_err(|e| e.to_string())?;
3146        for (fmt, count) in rows.flatten() {
3147            format_counts.insert(fmt, count);
3148        }
3149
3150        let analyzed_count: u64 = conn
3151            .query_row(
3152                "SELECT COUNT(*) FROM audio_samples WHERE scan_id = ?1 AND bpm IS NOT NULL",
3153                params![sid],
3154                |row| row.get::<_, i64>(0).map(|v| v as u64),
3155            )
3156            .map_err(|e| e.to_string())?;
3157
3158        Ok(AudioStatsResult {
3159            sample_count,
3160            total_bytes,
3161            format_counts,
3162            analyzed_count,
3163        })
3164    }
3165
3166    /// DAW aggregate stats. `scan_id` None or empty → full library (deduped by path).
3167    pub fn daw_stats(&self, scan_id: Option<&str>) -> Result<DawStatsResult, String> {
3168        let conn = self.read_conn();
3169        let library = scan_id.map(|s| s.is_empty()).unwrap_or(true);
3170        if library {
3171            let project_count: u64 = self.daw_library_total_rows(&conn)?;
3172            let total_bytes: u64 = conn
3173                .query_row(
3174                    "SELECT COALESCE(SUM(s.size), 0) FROM daw_projects s INNER JOIN daw_library lib ON s.id = lib.project_id",
3175                    [],
3176                    |row| row.get::<_, i64>(0).map(|v| v as u64),
3177                )
3178                .unwrap_or(0);
3179            let mut daw_counts = HashMap::new();
3180            let mut stmt = conn
3181                .prepare(
3182                    "SELECT s.daw, COUNT(*) FROM daw_projects s INNER JOIN daw_library lib ON s.id = lib.project_id GROUP BY s.daw",
3183                )
3184                .map_err(|e| e.to_string())?;
3185            let rows = stmt
3186                .query_map([], |row| {
3187                    Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3188                })
3189                .map_err(|e| e.to_string())?;
3190            for (daw, count) in rows.flatten() {
3191                daw_counts.insert(daw, count);
3192            }
3193            return Ok(DawStatsResult {
3194                project_count,
3195                total_bytes,
3196                daw_counts,
3197            });
3198        }
3199
3200        let sid = scan_id.expect("scan_id").to_string();
3201        if sid.is_empty() {
3202            return Ok(DawStatsResult {
3203                project_count: 0,
3204                total_bytes: 0,
3205                daw_counts: HashMap::new(),
3206            });
3207        }
3208        let project_count: u64 = conn
3209            .query_row(
3210                "SELECT COUNT(*) FROM daw_projects WHERE scan_id = ?1",
3211                params![sid],
3212                |row| row.get::<_, i64>(0).map(|v| v as u64),
3213            )
3214            .map_err(|e| e.to_string())?;
3215        let total_bytes: u64 = conn
3216            .query_row(
3217                "SELECT COALESCE(SUM(size), 0) FROM daw_projects WHERE scan_id = ?1",
3218                params![sid],
3219                |row| row.get::<_, i64>(0).map(|v| v as u64),
3220            )
3221            .map_err(|e| e.to_string())?;
3222        let mut daw_counts = HashMap::new();
3223        let mut stmt = conn
3224            .prepare("SELECT daw, COUNT(*) FROM daw_projects WHERE scan_id = ?1 GROUP BY daw")
3225            .map_err(|e| e.to_string())?;
3226        let rows = stmt
3227            .query_map(params![sid], |row| {
3228                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3229            })
3230            .map_err(|e| e.to_string())?;
3231        for (daw, count) in rows.flatten() {
3232            daw_counts.insert(daw, count);
3233        }
3234        Ok(DawStatsResult {
3235            project_count,
3236            total_bytes,
3237            daw_counts,
3238        })
3239    }
3240
3241    /// Preset aggregate stats. `scan_id` None or empty → full library (deduped by path). MIDI excluded.
3242    pub fn preset_stats(&self, scan_id: Option<&str>) -> Result<PresetStatsResult, String> {
3243        let conn = self.read_conn();
3244        let library = scan_id.map(|s| s.is_empty()).unwrap_or(true);
3245        if library {
3246            let preset_count: u64 = conn
3247                .query_row(
3248                    "SELECT COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI')",
3249                    [],
3250                    |row| row.get::<_, i64>(0).map(|v| v as u64),
3251                )
3252                .unwrap_or(0);
3253            let total_bytes: u64 = conn
3254                .query_row(
3255                    "SELECT COALESCE(SUM(size), 0) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI')",
3256                    [],
3257                    |row| row.get::<_, i64>(0).map(|v| v as u64),
3258                )
3259                .unwrap_or(0);
3260            let mut format_counts = HashMap::new();
3261            let mut stmt = conn
3262                .prepare(
3263                    "SELECT format, COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI') GROUP BY format",
3264                )
3265                .map_err(|e| e.to_string())?;
3266            let rows = stmt
3267                .query_map([], |row| {
3268                    Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3269                })
3270                .map_err(|e| e.to_string())?;
3271            for (fmt, count) in rows.flatten() {
3272                format_counts.insert(fmt, count);
3273            }
3274            return Ok(PresetStatsResult {
3275                preset_count,
3276                total_bytes,
3277                format_counts,
3278            });
3279        }
3280
3281        let sid = scan_id.expect("scan_id").to_string();
3282        if sid.is_empty() {
3283            return Ok(PresetStatsResult {
3284                preset_count: 0,
3285                total_bytes: 0,
3286                format_counts: HashMap::new(),
3287            });
3288        }
3289        let preset_count: u64 = conn
3290            .query_row(
3291                "SELECT COUNT(*) FROM presets WHERE scan_id = ?1 AND format NOT IN ('MID', 'MIDI')",
3292                params![sid],
3293                |row| row.get::<_, i64>(0).map(|v| v as u64),
3294            )
3295            .map_err(|e| e.to_string())?;
3296        let total_bytes: u64 = conn
3297            .query_row(
3298                "SELECT COALESCE(SUM(size), 0) FROM presets WHERE scan_id = ?1 AND format NOT IN ('MID', 'MIDI')",
3299                params![sid],
3300                |row| row.get::<_, i64>(0).map(|v| v as u64),
3301            )
3302            .map_err(|e| e.to_string())?;
3303        let mut format_counts = HashMap::new();
3304        let mut stmt = conn
3305            .prepare(
3306                "SELECT format, COUNT(*) FROM presets WHERE scan_id = ?1 AND format NOT IN ('MID', 'MIDI') GROUP BY format",
3307            )
3308            .map_err(|e| e.to_string())?;
3309        let rows = stmt
3310            .query_map(params![sid], |row| {
3311                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64))
3312            })
3313            .map_err(|e| e.to_string())?;
3314        for (fmt, count) in rows.flatten() {
3315            format_counts.insert(fmt, count);
3316        }
3317        Ok(PresetStatsResult {
3318            preset_count,
3319            total_bytes,
3320            format_counts,
3321        })
3322    }
3323
3324    /// Update BPM for a sample (all rows for that path).
3325    pub fn update_bpm(&self, path: &str, bpm: Option<f64>) -> Result<(), String> {
3326        let path = normalize_path_for_db(path);
3327        let conn = self.read_conn();
3328        conn.execute(
3329            "UPDATE audio_samples SET bpm = ?1, bpm_exhausted = 0 WHERE path = ?2",
3330            params![bpm, path],
3331        )
3332        .map_err(|e| e.to_string())?;
3333        Ok(())
3334    }
3335
3336    /// Update musical key for a sample.
3337    pub fn update_key(&self, path: &str, key: Option<&str>) -> Result<(), String> {
3338        let path = normalize_path_for_db(path);
3339        let conn = self.read_conn();
3340        conn.execute(
3341            "UPDATE audio_samples SET key_name = ?1 WHERE path = ?2",
3342            params![key, path],
3343        )
3344        .map_err(|e| e.to_string())?;
3345        Ok(())
3346    }
3347
3348    /// Update core audio metadata (duration, channels, sample_rate, bits_per_sample) for a sample.
3349    pub fn update_audio_meta(
3350        &self,
3351        path: &str,
3352        duration: Option<f64>,
3353        channels: Option<u16>,
3354        sample_rate: Option<u32>,
3355        bits_per_sample: Option<u16>,
3356    ) -> Result<(), String> {
3357        let path = normalize_path_for_db(path);
3358        let conn = self.read_conn();
3359        conn.execute(
3360            "UPDATE audio_samples SET duration = ?1, channels = ?2, sample_rate = ?3, bits_per_sample = ?4
3361             WHERE path = ?5",
3362            params![
3363                duration,
3364                channels.map(|v| v as i32),
3365                sample_rate.map(|v| v as i32),
3366                bits_per_sample.map(|v| v as i32),
3367                path
3368            ],
3369        )
3370        .map_err(|e| e.to_string())?;
3371        Ok(())
3372    }
3373
3374    /// Get paths that are missing duration metadata (among the given paths).
3375    pub fn paths_missing_audio_meta(&self, paths: &[String]) -> Result<Vec<String>, String> {
3376        let conn = self.read_conn();
3377        if paths.is_empty() {
3378            return Ok(Vec::new());
3379        }
3380        let placeholders: Vec<&str> = paths.iter().map(|_| "?").collect();
3381        let sql = format!(
3382            "SELECT path FROM audio_samples WHERE duration IS NULL AND path IN ({})",
3383            placeholders.join(",")
3384        );
3385        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3386        let mut idx = 1;
3387        for p in paths {
3388            let p = normalize_path_for_db(p);
3389            stmt.raw_bind_parameter(idx, p).map_err(|e| e.to_string())?;
3390            idx += 1;
3391        }
3392        let mut result = Vec::new();
3393        let mut rows = stmt.raw_query();
3394        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
3395            result.push(row.get::<_, String>(0).unwrap_or_default());
3396        }
3397        Ok(result)
3398    }
3399
3400    /// Update LUFS for a sample (all rows for that path).
3401    pub fn update_lufs(&self, path: &str, lufs: Option<f64>) -> Result<(), String> {
3402        let path = normalize_path_for_db(path);
3403        let conn = self.read_conn();
3404        conn.execute(
3405            "UPDATE audio_samples SET lufs = ?1 WHERE path = ?2",
3406            params![lufs, path],
3407        )
3408        .map_err(|e| e.to_string())?;
3409        Ok(())
3410    }
3411
3412    /// Get analysis data for a single sample.
3413    pub fn get_analysis(&self, path: &str) -> Result<serde_json::Value, String> {
3414        let path = normalize_path_for_db(path);
3415        let conn = self.read_conn();
3416        let result = conn
3417            .query_row(
3418                &format!(
3419                    "SELECT bpm, key_name, lufs, duration, channels, sample_rate, bits_per_sample
3420                 FROM audio_samples WHERE path = ?1 AND ({AUDIO_LIBRARY_IDS})"
3421                ),
3422                params![path],
3423                |row| {
3424                    Ok(serde_json::json!({
3425                        "bpm": row.get::<_, Option<f64>>(0)?,
3426                        "key": row.get::<_, Option<String>>(1)?,
3427                        "lufs": row.get::<_, Option<f64>>(2)?,
3428                        "duration": row.get::<_, Option<f64>>(3)?,
3429                        "channels": row.get::<_, Option<i32>>(4)?,
3430                        "sampleRate": row.get::<_, Option<i32>>(5)?,
3431                        "bitsPerSample": row.get::<_, Option<i32>>(6)?,
3432                    }))
3433                },
3434            )
3435            .optional()
3436            .map_err(|e| e.to_string())?;
3437        Ok(result.unwrap_or(serde_json::json!({})))
3438    }
3439
3440    /// Get paths of samples that still need BPM/Key/LUFS analysis (library rows).
3441    ///
3442    /// Rows where key and LUFS are filled but BPM is still NULL after a batch run are marked
3443    /// [`bpm_exhausted`](Self::batch_update_analysis) so we do not spin on the same paths forever
3444    /// when tempo detection fails. Clearing the BPM cache resets `bpm_exhausted` so analysis can retry.
3445    pub fn unanalyzed_paths(&self, limit: u64) -> Result<Vec<String>, String> {
3446        let conn = self.read_conn();
3447        let mut stmt = conn
3448            .prepare(&format!(
3449                "SELECT path FROM audio_samples
3450                 WHERE (
3451                     key_name IS NULL OR lufs IS NULL
3452                     OR (bpm IS NULL AND bpm_exhausted = 0)
3453                 )
3454                 AND ({AUDIO_LIBRARY_IDS})
3455                 LIMIT ?1"
3456            ))
3457            .map_err(|e| e.to_string())?;
3458        let rows = stmt
3459            .query_map(params![limit as i64], |row| row.get(0))
3460            .map_err(|e| e.to_string())?;
3461        rows.collect::<Result<Vec<String>, _>>()
3462            .map_err(|e| e.to_string())
3463    }
3464
3465    /// All canonical `path` values in the audio library (one row per path via `audio_library`).
3466    pub fn audio_library_paths(&self) -> Result<Vec<String>, String> {
3467        let conn = self.read_conn();
3468        let mut stmt = conn
3469            .prepare("SELECT path FROM audio_library ORDER BY path")
3470            .map_err(|e| e.to_string())?;
3471        let rows = stmt
3472            .query_map([], |row| row.get::<_, String>(0))
3473            .map_err(|e| e.to_string())?;
3474        rows.collect::<Result<Vec<String>, _>>()
3475            .map_err(|e| e.to_string())
3476    }
3477
3478    /// Delete a scan and its samples.
3479    pub fn delete_scan(&self, scan_id: &str) -> Result<(), String> {
3480        let conn = self.read_conn();
3481        conn.execute(
3482            "CREATE TEMP TABLE _al_refresh_paths (path TEXT PRIMARY KEY)",
3483            [],
3484        )
3485        .map_err(|e| e.to_string())?;
3486        conn.execute(
3487            "INSERT INTO _al_refresh_paths SELECT DISTINCT path FROM audio_samples WHERE scan_id = ?1",
3488            params![scan_id],
3489        )
3490        .map_err(|e| e.to_string())?;
3491        conn.execute(
3492            "DELETE FROM audio_samples WHERE scan_id = ?1",
3493            params![scan_id],
3494        )
3495        .map_err(|e| e.to_string())?;
3496        conn.execute(
3497            "DELETE FROM audio_samples_fts WHERE scan_id = ?1",
3498            params![scan_id],
3499        )
3500        .map_err(|e| e.to_string())?;
3501        Self::sync_audio_library_after_paths_refresh(&conn)?;
3502        self.invalidate_audio_library_total_cache();
3503        conn.execute("DELETE FROM audio_scans WHERE id = ?1", params![scan_id])
3504            .map_err(|e| e.to_string())?;
3505        Ok(())
3506    }
3507
3508    // ── Plugin scan CRUD ──
3509
3510    // ── Paginated plugin query ──
3511    pub fn query_plugins(
3512        &self,
3513        search: Option<&str>,
3514        type_filter: Option<&str>,
3515        status_filter: Option<&str>,
3516        sort_key: &str,
3517        sort_asc: bool,
3518        search_regex: bool,
3519        offset: u64,
3520        limit: u64,
3521    ) -> Result<PluginQueryResult, String> {
3522        let conn = self.read_conn();
3523        let total_unfiltered: u64 = self.plugin_library_total_rows(&conn)?;
3524        if total_unfiltered == 0 {
3525            return Ok(PluginQueryResult {
3526                plugins: vec![],
3527                total_count: 0,
3528                total_count_capped: false,
3529                total_unfiltered: 0,
3530            });
3531        }
3532
3533        let statuses = parse_plugin_status_filter(status_filter);
3534        let use_kvr_join = statuses.is_some();
3535        let q = if use_kvr_join { "plugins." } else { "" };
3536        let id_clause = if use_kvr_join {
3537            PLUGIN_LIBRARY_IDS_QUALIFIED
3538        } else {
3539            PLUGIN_LIBRARY_IDS
3540        };
3541        let from_sql = if use_kvr_join {
3542            "FROM plugins LEFT JOIN kvr_cache k ON k.plugin_key = (lower(coalesce(nullif(trim(plugins.manufacturer), ''), 'Unknown')) || '|||' || lower(plugins.name))"
3543        } else {
3544            "FROM plugins"
3545        };
3546        let select_cols = if use_kvr_join {
3547            "SELECT plugins.name, plugins.path, plugins.plugin_type, plugins.version, plugins.manufacturer, plugins.manufacturer_url, plugins.size, plugins.size_bytes, plugins.modified, plugins.architectures"
3548        } else {
3549            "SELECT name, path, plugin_type, version, manufacturer, manufacturer_url, size, size_bytes, modified, architectures"
3550        };
3551
3552        let mut where_parts = vec![id_clause.to_string()];
3553        let mut bind_idx = 1usize;
3554        let (regex_pat, like_pat) = classify_plugins_search(search, search_regex);
3555        if regex_pat.is_some() {
3556            where_parts.push(format!(
3557                "({q}name REGEXP ?{bind_idx} OR {q}manufacturer REGEXP ?{bind_idx} OR {q}path REGEXP ?{bind_idx})"
3558            ));
3559            bind_idx += 1;
3560        } else if like_pat.is_some() {
3561            where_parts.push(format!(
3562                "({q}name LIKE ?{bind_idx} ESCAPE '\\' OR {q}manufacturer LIKE ?{bind_idx} ESCAPE '\\' OR {q}path LIKE ?{bind_idx} ESCAPE '\\')"
3563            ));
3564            bind_idx += 1;
3565        }
3566        if let Some(tf) = type_filter {
3567            if !tf.is_empty() && tf != "all" {
3568                if tf.contains(',') {
3569                    let vals: Vec<String> = tf
3570                        .split(',')
3571                        .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
3572                        .collect();
3573                    where_parts.push(format!("{q}plugin_type IN ({})", vals.join(",")));
3574                } else {
3575                    where_parts.push(format!("{q}plugin_type = ?{bind_idx}"));
3576                }
3577            }
3578        }
3579        if let Some(ref st) = statuses {
3580            let parts: Vec<String> = st
3581                .iter()
3582                .map(|s| match *s {
3583                    "update" => "(k.has_update = 1)".to_string(),
3584                    "current" => "(k.plugin_key IS NOT NULL AND k.has_update = 0 AND COALESCE(k.source, '') != 'not-found')"
3585                        .to_string(),
3586                    "unknown" => "(k.plugin_key IS NULL OR (k.has_update = 0 AND k.source = 'not-found'))"
3587                        .to_string(),
3588                    _ => String::new(),
3589                })
3590                .filter(|s| !s.is_empty())
3591                .collect();
3592            if !parts.is_empty() {
3593                where_parts.push(format!("({})", parts.join(" OR ")));
3594            }
3595        }
3596        let where_cl = where_parts.join(" AND ");
3597
3598        let sort_col = match sort_key {
3599            "name" => format!("{q}name COLLATE NOCASE"),
3600            "type" => format!("{q}plugin_type"),
3601            "version" => format!("{q}version"),
3602            "manufacturer" => format!("{q}manufacturer COLLATE NOCASE"),
3603            "size" => format!("{q}size_bytes"),
3604            "modified" => format!("{q}modified"),
3605            _ => format!("{q}name COLLATE NOCASE"),
3606        };
3607        let dir = if sort_asc { "ASC" } else { "DESC" };
3608
3609        let (total_count, total_count_capped) = if regex_pat.is_some() || like_pat.is_some() {
3610            Self::plugin_search_bounded_count_library(
3611                &conn,
3612                from_sql,
3613                &where_cl,
3614                &regex_pat,
3615                &like_pat,
3616                type_filter,
3617            )?
3618        } else {
3619            let n: u64 = {
3620                let sql = format!("SELECT COUNT(*) {from_sql} WHERE {where_cl}");
3621                let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3622                let mut bi = 1;
3623                if let Some(ref p) = regex_pat.as_ref().or(like_pat.as_ref()) {
3624                    stmt.raw_bind_parameter(bi, p).map_err(|e| e.to_string())?;
3625                    bi += 1;
3626                }
3627                if let Some(tf) = type_filter {
3628                    if !tf.is_empty() && tf != "all" && !tf.contains(',') {
3629                        stmt.raw_bind_parameter(bi, tf).map_err(|e| e.to_string())?;
3630                    }
3631                }
3632                let _ = bi;
3633                let mut rows = stmt.raw_query();
3634                rows.next()
3635                    .map_err(|e| e.to_string())?
3636                    .map(|r| r.get::<_, i64>(0).unwrap_or(0) as u64)
3637                    .unwrap_or(0)
3638            };
3639            (n, false)
3640        };
3641
3642        let mut bind_offset = 1usize;
3643        if regex_pat.is_some() || like_pat.is_some() {
3644            bind_offset += 1;
3645        }
3646        if type_filter
3647            .map(|t| !t.is_empty() && t != "all" && !t.contains(','))
3648            .unwrap_or(false)
3649        {
3650            bind_offset += 1;
3651        }
3652        let sql = format!(
3653            "{select_cols} {from_sql} WHERE {where_cl} ORDER BY {sort_col} {dir} LIMIT ?{bind_offset} OFFSET ?{}",
3654            bind_offset + 1
3655        );
3656        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3657        let mut bi = 1;
3658        if let Some(ref p) = regex_pat.as_ref().or(like_pat.as_ref()) {
3659            stmt.raw_bind_parameter(bi, p).map_err(|e| e.to_string())?;
3660            bi += 1;
3661        }
3662        if let Some(tf) = type_filter {
3663            if !tf.is_empty() && tf != "all" && !tf.contains(',') {
3664                stmt.raw_bind_parameter(bi, tf).map_err(|e| e.to_string())?;
3665                bi += 1;
3666            }
3667        }
3668        stmt.raw_bind_parameter(bi, limit as i64)
3669            .map_err(|e| e.to_string())?;
3670        bi += 1;
3671        stmt.raw_bind_parameter(bi, offset as i64)
3672            .map_err(|e| e.to_string())?;
3673
3674        let mut plugins = Vec::new();
3675        let mut rows = stmt.raw_query();
3676        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
3677            let arch_str: String = row.get(9).unwrap_or_default();
3678            plugins.push(PluginRow {
3679                name: row.get(0).unwrap_or_default(),
3680                path: row.get(1).unwrap_or_default(),
3681                plugin_type: row.get(2).unwrap_or_default(),
3682                version: row.get(3).unwrap_or_default(),
3683                manufacturer: row.get(4).unwrap_or_default(),
3684                manufacturer_url: row.get(5).ok(),
3685                size: row.get(6).unwrap_or_default(),
3686                size_bytes: row.get::<_, i64>(7).unwrap_or(0) as u64,
3687                modified: row.get(8).unwrap_or_default(),
3688                architectures: serde_json::from_str(&arch_str).unwrap_or_default(),
3689            });
3690        }
3691        Ok(PluginQueryResult {
3692            plugins,
3693            total_count,
3694            total_count_capped,
3695            total_unfiltered,
3696        })
3697    }
3698
3699    // ── Paginated DAW query ──
3700    /// Full library (deduped by path). Same pattern as `query_audio` without `scan_id`.
3701    pub fn query_daw(
3702        &self,
3703        search: Option<&str>,
3704        daw_filter: Option<&str>,
3705        sort_key: &str,
3706        sort_asc: bool,
3707        search_regex: bool,
3708        offset: u64,
3709        limit: u64,
3710    ) -> Result<DawQueryResult, String> {
3711        let conn = self.read_conn();
3712        let total_unfiltered: u64 = self.daw_library_total_rows(&conn)?;
3713        if total_unfiltered == 0 {
3714            return Ok(DawQueryResult {
3715                projects: vec![],
3716                total_count: 0,
3717                total_count_capped: false,
3718                total_unfiltered: 0,
3719            });
3720        }
3721
3722        let mut where_parts = vec![DAW_LIBRARY_IDS.to_string()];
3723        let mut bind_idx = 1usize;
3724        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
3725        if fts_match.is_some() {
3726            // Library scope is already `DAW_LIBRARY_IDS`; do not nest a second
3727            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
3728            where_parts.push(format!(
3729                "id IN (SELECT rowid FROM daw_projects_fts WHERE daw_projects_fts MATCH ?{bind_idx})",
3730            ));
3731            bind_idx += 1;
3732        } else if regex_pat.is_some() {
3733            where_parts.push(format!(
3734                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
3735            ));
3736            bind_idx += 1;
3737        } else if like_pat.is_some() {
3738            where_parts.push(format!(
3739                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
3740            ));
3741            bind_idx += 1;
3742        }
3743        if let Some(f) = daw_filter {
3744            if !f.is_empty() && f != "all" {
3745                if f.contains(',') {
3746                    let vals: Vec<String> = f
3747                        .split(',')
3748                        .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
3749                        .collect();
3750                    where_parts.push(format!("daw IN ({})", vals.join(",")));
3751                } else {
3752                    where_parts.push(format!("daw = ?{bind_idx}"));
3753                    bind_idx += 1;
3754                }
3755            }
3756        }
3757        let where_cl = where_parts.join(" AND ");
3758
3759        let sort_col = match sort_key {
3760            "name" => "name COLLATE NOCASE",
3761            "daw" => "daw",
3762            "format" => "format",
3763            "size" => "size",
3764            "modified" => "modified",
3765            "directory" => "directory COLLATE NOCASE",
3766            _ => "name COLLATE NOCASE",
3767        };
3768        let dir = if sort_asc { "ASC" } else { "DESC" };
3769
3770        // FTS substring search: ORDER BY column sorts the entire match set before LIMIT (stalls).
3771        // Use bm25 + LIMIT like MIDI/PDF; frontend may re-rank with `searchScore`.
3772        let use_fts_rank_page = fts_match.is_some();
3773
3774        let (total_count, total_count_capped) = if fts_match.is_some() {
3775            let m = fts_match.as_ref().expect("fts");
3776            Self::daw_fts_bounded_count_library(&conn, m, daw_filter)?
3777        } else {
3778            let n: u64 = {
3779                let sql = format!("SELECT COUNT(*) FROM daw_projects WHERE {where_cl}");
3780                let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3781                let mut bi = 1;
3782                if let Some(ref r) = regex_pat {
3783                    stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
3784                    bi += 1;
3785                } else if let Some(ref pat) = like_pat {
3786                    stmt.raw_bind_parameter(bi, pat)
3787                        .map_err(|e| e.to_string())?;
3788                    bi += 1;
3789                }
3790                if let Some(f) = daw_filter {
3791                    if !f.is_empty() && f != "all" && !f.contains(',') {
3792                        stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
3793                    }
3794                }
3795                let mut rows = stmt.raw_query();
3796                rows.next()
3797                    .map_err(|e| e.to_string())?
3798                    .map(|r| r.get::<_, i64>(0).unwrap_or(0) as u64)
3799                    .unwrap_or(0)
3800            };
3801            (n, false)
3802        };
3803
3804        let sql = if use_fts_rank_page {
3805            let mut w = String::from(
3806                "SELECT d.name, d.path, d.directory, d.format, d.daw, d.size, d.size_formatted, d.modified
3807                 FROM daw_projects_fts
3808                 INNER JOIN daw_projects d ON d.id = daw_projects_fts.rowid
3809                 INNER JOIN daw_library lib ON lib.project_id = d.id
3810                 WHERE daw_projects_fts MATCH ?1",
3811            );
3812            let mut next_ph = 2usize;
3813            if let Some(f) = daw_filter {
3814                if !f.is_empty() && f != "all" {
3815                    if f.contains(',') {
3816                        let vals: Vec<String> = f
3817                            .split(',')
3818                            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
3819                            .collect();
3820                        w.push_str(&format!(" AND d.daw IN ({})", vals.join(",")));
3821                    } else {
3822                        w.push_str(&format!(" AND d.daw = ?{next_ph}"));
3823                        next_ph += 1;
3824                    }
3825                }
3826            }
3827            let li = next_ph;
3828            let oi = next_ph + 1;
3829            w.push_str(&format!(
3830                " ORDER BY bm25(daw_projects_fts) LIMIT ?{li} OFFSET ?{oi}"
3831            ));
3832            w
3833        } else {
3834            format!(
3835                "SELECT name, path, directory, format, daw, size, size_formatted, modified FROM daw_projects WHERE {where_cl} ORDER BY {sort_col} {dir} LIMIT ?{bind_idx} OFFSET ?{}",
3836                bind_idx + 1
3837            )
3838        };
3839        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3840        let mut bi = 1;
3841        if use_fts_rank_page {
3842            let m = fts_match.as_ref().expect("fts");
3843            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
3844            bi += 1;
3845            if let Some(f) = daw_filter {
3846                if !f.is_empty() && f != "all" && !f.contains(',') {
3847                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
3848                    bi += 1;
3849                }
3850            }
3851            stmt.raw_bind_parameter(bi, limit as i64)
3852                .map_err(|e| e.to_string())?;
3853            stmt.raw_bind_parameter(bi + 1, offset as i64)
3854                .map_err(|e| e.to_string())?;
3855        } else if let Some(ref r) = regex_pat {
3856            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
3857            bi += 1;
3858            if let Some(f) = daw_filter {
3859                if !f.is_empty() && f != "all" && !f.contains(',') {
3860                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
3861                    bi += 1;
3862                }
3863            }
3864            stmt.raw_bind_parameter(bi, limit as i64)
3865                .map_err(|e| e.to_string())?;
3866            stmt.raw_bind_parameter(bi + 1, offset as i64)
3867                .map_err(|e| e.to_string())?;
3868        } else if let Some(ref pat) = like_pat {
3869            stmt.raw_bind_parameter(bi, pat)
3870                .map_err(|e| e.to_string())?;
3871            bi += 1;
3872            if let Some(f) = daw_filter {
3873                if !f.is_empty() && f != "all" && !f.contains(',') {
3874                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
3875                    bi += 1;
3876                }
3877            }
3878            stmt.raw_bind_parameter(bi, limit as i64)
3879                .map_err(|e| e.to_string())?;
3880            stmt.raw_bind_parameter(bi + 1, offset as i64)
3881                .map_err(|e| e.to_string())?;
3882        } else {
3883            if let Some(f) = daw_filter {
3884                if !f.is_empty() && f != "all" && !f.contains(',') {
3885                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
3886                    bi += 1;
3887                }
3888            }
3889            stmt.raw_bind_parameter(bi, limit as i64)
3890                .map_err(|e| e.to_string())?;
3891            stmt.raw_bind_parameter(bi + 1, offset as i64)
3892                .map_err(|e| e.to_string())?;
3893        }
3894
3895        let mut projects = Vec::new();
3896        let mut rows = stmt.raw_query();
3897        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
3898            projects.push(DawRow {
3899                name: row.get(0).unwrap_or_default(),
3900                path: row.get(1).unwrap_or_default(),
3901                directory: row.get(2).unwrap_or_default(),
3902                format: row.get(3).unwrap_or_default(),
3903                daw: row.get(4).unwrap_or_default(),
3904                size: row.get::<_, i64>(5).unwrap_or(0) as u64,
3905                size_formatted: row.get(6).unwrap_or_default(),
3906                modified: row.get(7).unwrap_or_default(),
3907            });
3908        }
3909        Ok(DawQueryResult {
3910            projects,
3911            total_count,
3912            total_count_capped,
3913            total_unfiltered,
3914        })
3915    }
3916
3917    // ── Paginated preset query ──
3918    pub fn query_presets(
3919        &self,
3920        search: Option<&str>,
3921        format_filter: Option<&str>,
3922        sort_key: &str,
3923        sort_asc: bool,
3924        search_regex: bool,
3925        offset: u64,
3926        limit: u64,
3927    ) -> Result<PresetQueryResult, String> {
3928        let conn = self.read_conn();
3929        let total_unfiltered: u64 = self.preset_inventory_total_rows(&conn)?;
3930        if total_unfiltered == 0 {
3931            return Ok(PresetQueryResult {
3932                presets: vec![],
3933                total_count: 0,
3934                total_count_capped: false,
3935                total_unfiltered: 0,
3936            });
3937        }
3938
3939        let mut where_parts = vec![
3940            PRESET_LIBRARY_IDS.to_string(),
3941            "format NOT IN ('MID', 'MIDI')".to_string(),
3942        ];
3943        let mut bind_idx = 1usize;
3944        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
3945        if fts_match.is_some() {
3946            // Library scope is already `PRESET_LIBRARY_IDS`; do not nest a second
3947            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
3948            where_parts.push(format!(
3949                "id IN (SELECT rowid FROM presets_fts WHERE presets_fts MATCH ?{bind_idx})",
3950            ));
3951            bind_idx += 1;
3952        } else if regex_pat.is_some() {
3953            where_parts.push(format!(
3954                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
3955            ));
3956            bind_idx += 1;
3957        } else if like_pat.is_some() {
3958            where_parts.push(format!(
3959                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
3960            ));
3961            bind_idx += 1;
3962        }
3963        if let Some(f) = format_filter {
3964            if !f.is_empty() && f != "all" {
3965                if f.contains(',') {
3966                    let vals: Vec<String> = f
3967                        .split(',')
3968                        .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
3969                        .collect();
3970                    where_parts.push(format!("format IN ({})", vals.join(",")));
3971                } else {
3972                    where_parts.push(format!("format = ?{bind_idx}"));
3973                    bind_idx += 1;
3974                }
3975            }
3976        }
3977        let where_cl = where_parts.join(" AND ");
3978
3979        let sort_col = match sort_key {
3980            "name" => "name COLLATE NOCASE",
3981            "format" => "format",
3982            "size" => "size",
3983            "modified" => "modified",
3984            "directory" => "directory COLLATE NOCASE",
3985            _ => "name COLLATE NOCASE",
3986        };
3987        let dir = if sort_asc { "ASC" } else { "DESC" };
3988
3989        let (total_count, total_count_capped) = if fts_match.is_some() {
3990            let m = fts_match.as_ref().expect("fts");
3991            Self::preset_fts_bounded_count_library(&conn, m, format_filter)?
3992        } else {
3993            let sql = format!("SELECT COUNT(*) FROM presets WHERE {where_cl}");
3994            let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
3995            let mut bi = 1;
3996            if let Some(ref r) = regex_pat {
3997                stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
3998                bi += 1;
3999            } else if let Some(ref pat) = like_pat {
4000                stmt.raw_bind_parameter(bi, pat)
4001                    .map_err(|e| e.to_string())?;
4002                bi += 1;
4003            }
4004            if let Some(f) = format_filter {
4005                if !f.is_empty() && f != "all" && !f.contains(',') {
4006                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
4007                }
4008            }
4009            let mut rows = stmt.raw_query();
4010            let n = rows
4011                .next()
4012                .map_err(|e| e.to_string())?
4013                .map(|r| r.get::<_, i64>(0).unwrap_or(0) as u64)
4014                .unwrap_or(0);
4015            (n, false)
4016        };
4017
4018        // FTS substring search: ORDER BY column sorts the entire match set before LIMIT (stalls).
4019        // Use bm25 + LIMIT like PDF/MIDI; frontend may re-rank with `searchScore`.
4020        let use_fts_bm25 = fts_match.is_some();
4021        let sql = if use_fts_bm25 {
4022            let mut w = String::from(
4023                "SELECT p.name, p.path, p.directory, p.format, p.size, p.size_formatted, p.modified
4024                 FROM presets_fts
4025                 INNER JOIN presets p ON p.id = presets_fts.rowid
4026                 INNER JOIN preset_library lib ON lib.preset_id = p.id
4027                 WHERE presets_fts MATCH ?1
4028                 AND p.format NOT IN ('MID','MIDI')",
4029            );
4030            let mut next_ph = 2usize;
4031            if let Some(f) = format_filter {
4032                if !f.is_empty() && f != "all" {
4033                    if f.contains(',') {
4034                        let vals: Vec<String> = f
4035                            .split(',')
4036                            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
4037                            .collect();
4038                        w.push_str(&format!(" AND p.format IN ({})", vals.join(",")));
4039                    } else {
4040                        w.push_str(&format!(" AND p.format = ?{next_ph}"));
4041                        next_ph += 1;
4042                    }
4043                }
4044            }
4045            let plim = next_ph;
4046            let poff = next_ph + 1;
4047            w.push_str(&format!(
4048                " ORDER BY bm25(presets_fts) LIMIT ?{plim} OFFSET ?{poff}"
4049            ));
4050            w
4051        } else {
4052            format!(
4053                "SELECT name, path, directory, format, size, size_formatted, modified FROM presets WHERE {where_cl} ORDER BY {sort_col} {dir} LIMIT ?{bind_idx} OFFSET ?{}",
4054                bind_idx + 1
4055            )
4056        };
4057        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
4058        let mut bi = 1;
4059        if use_fts_bm25 {
4060            let m = fts_match.as_ref().expect("fts");
4061            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
4062            bi += 1;
4063            if let Some(f) = format_filter {
4064                if !f.is_empty() && f != "all" && !f.contains(',') {
4065                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
4066                    bi += 1;
4067                }
4068            }
4069            stmt.raw_bind_parameter(bi, limit as i64)
4070                .map_err(|e| e.to_string())?;
4071            stmt.raw_bind_parameter(bi + 1, offset as i64)
4072                .map_err(|e| e.to_string())?;
4073        } else if let Some(ref r) = regex_pat {
4074            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
4075            bi += 1;
4076            if let Some(f) = format_filter {
4077                if !f.is_empty() && f != "all" && !f.contains(',') {
4078                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
4079                    bi += 1;
4080                }
4081            }
4082            stmt.raw_bind_parameter(bi, limit as i64)
4083                .map_err(|e| e.to_string())?;
4084            stmt.raw_bind_parameter(bi + 1, offset as i64)
4085                .map_err(|e| e.to_string())?;
4086        } else if let Some(ref pat) = like_pat {
4087            stmt.raw_bind_parameter(bi, pat)
4088                .map_err(|e| e.to_string())?;
4089            bi += 1;
4090            if let Some(f) = format_filter {
4091                if !f.is_empty() && f != "all" && !f.contains(',') {
4092                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
4093                    bi += 1;
4094                }
4095            }
4096            stmt.raw_bind_parameter(bi, limit as i64)
4097                .map_err(|e| e.to_string())?;
4098            stmt.raw_bind_parameter(bi + 1, offset as i64)
4099                .map_err(|e| e.to_string())?;
4100        } else {
4101            if let Some(f) = format_filter {
4102                if !f.is_empty() && f != "all" && !f.contains(',') {
4103                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
4104                    bi += 1;
4105                }
4106            }
4107            stmt.raw_bind_parameter(bi, limit as i64)
4108                .map_err(|e| e.to_string())?;
4109            stmt.raw_bind_parameter(bi + 1, offset as i64)
4110                .map_err(|e| e.to_string())?;
4111        }
4112
4113        let mut presets = Vec::new();
4114        let mut rows = stmt.raw_query();
4115        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
4116            presets.push(PresetRow {
4117                name: row.get(0).unwrap_or_default(),
4118                path: row.get(1).unwrap_or_default(),
4119                directory: row.get(2).unwrap_or_default(),
4120                format: row.get(3).unwrap_or_default(),
4121                size: row.get::<_, i64>(4).unwrap_or(0) as u64,
4122                size_formatted: row.get(5).unwrap_or_default(),
4123                modified: row.get(6).unwrap_or_default(),
4124            });
4125        }
4126        Ok(PresetQueryResult {
4127            presets,
4128            total_count,
4129            total_count_capped,
4130            total_unfiltered,
4131        })
4132    }
4133
4134    pub fn save_plugin_scan(&self, snap: &ScanSnapshot) -> Result<(), String> {
4135        let conn = self.read_conn();
4136        let dirs_json = path_strings_json_normalized(&snap.directories);
4137        let roots_json = path_strings_json_normalized(&snap.roots);
4138        conn.execute(
4139            "INSERT OR REPLACE INTO plugin_scans (id, timestamp, plugin_count, directories, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,1)",
4140            params![snap.id, snap.timestamp, snap.plugin_count as i64, dirs_json, roots_json],
4141        ).map_err(|e| e.to_string())?;
4142        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
4143        {
4144            // Delete old plugins for this scan_id first
4145            tx.execute("DELETE FROM plugins WHERE scan_id = ?1", params![snap.id])
4146                .map_err(|e| e.to_string())?;
4147            let mut stmt = tx.prepare_cached(
4148                "INSERT OR REPLACE INTO plugins (name, path, plugin_type, version, manufacturer, manufacturer_url, size, size_bytes, modified, architectures, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"
4149            ).map_err(|e| e.to_string())?;
4150            for p in &snap.plugins {
4151                let arch_json = serde_json::to_string(&p.architectures).unwrap_or_default();
4152                let path = normalize_path_for_db(&p.path);
4153                stmt.execute(params![
4154                    p.name,
4155                    path,
4156                    p.plugin_type,
4157                    p.version,
4158                    p.manufacturer,
4159                    p.manufacturer_url,
4160                    p.size,
4161                    p.size_bytes as i64,
4162                    p.modified,
4163                    arch_json,
4164                    snap.id
4165                ])
4166                .map_err(|e| e.to_string())?;
4167            }
4168        }
4169        tx.commit().map_err(|e| e.to_string())?;
4170        Self::rebuild_plugin_library(&conn)?;
4171        self.invalidate_plugin_library_total_cache();
4172        Ok(())
4173    }
4174
4175    /// Begin a streaming plugin scan: parent row + clear prior rows for this id.
4176    pub fn plugin_scan_parent_create(
4177        &self,
4178        id: &str,
4179        timestamp: &str,
4180        roots: &[String],
4181    ) -> Result<(), String> {
4182        let conn = self.read_conn();
4183        let dirs_json = "[]".to_string();
4184        let roots_json = path_strings_json_normalized(roots);
4185        conn.execute(
4186            "INSERT OR REPLACE INTO plugin_scans (id, timestamp, plugin_count, directories, roots, scan_complete) VALUES (?1,?2,0,?3,?4,0)",
4187            params![id, timestamp, dirs_json, roots_json],
4188        )
4189        .map_err(|e| e.to_string())?;
4190        conn.execute(
4191            "CREATE TEMP TABLE _pl_refresh_paths (path TEXT PRIMARY KEY)",
4192            [],
4193        )
4194        .map_err(|e| e.to_string())?;
4195        conn.execute(
4196            "INSERT INTO _pl_refresh_paths SELECT DISTINCT path FROM plugins WHERE scan_id = ?1",
4197            params![id],
4198        )
4199        .map_err(|e| e.to_string())?;
4200        conn.execute("DELETE FROM plugins WHERE scan_id = ?1", params![id])
4201            .map_err(|e| e.to_string())?;
4202        Self::sync_plugin_library_after_paths_refresh(&conn)?;
4203        self.invalidate_plugin_library_total_cache();
4204        Ok(())
4205    }
4206
4207    /// Append plugins for a streaming scan; updates `plugin_scans.plugin_count` incrementally.
4208    pub fn insert_plugin_batch(&self, scan_id: &str, batch: &[PluginInfo]) -> Result<u64, String> {
4209        let conn = self.read_conn();
4210        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
4211        let mut inserted: u64 = 0;
4212        {
4213            let mut stmt = tx
4214                .prepare_cached(
4215                    "INSERT OR IGNORE INTO plugins (name, path, plugin_type, version, manufacturer, manufacturer_url, size, size_bytes, modified, architectures, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",
4216                )
4217                .map_err(|e| e.to_string())?;
4218            let mut lib_stmt = tx
4219                .prepare_cached(
4220                    "INSERT INTO plugin_library (path, plugin_id) VALUES (?1, ?2)
4221                     ON CONFLICT(path) DO UPDATE SET plugin_id = CASE
4222                       WHEN excluded.plugin_id > plugin_library.plugin_id THEN excluded.plugin_id
4223                       ELSE plugin_library.plugin_id END",
4224                )
4225                .map_err(|e| e.to_string())?;
4226            for p in batch {
4227                let arch_json = serde_json::to_string(&p.architectures).unwrap_or_default();
4228                let path = normalize_path_for_db(&p.path);
4229                let changed = stmt
4230                    .execute(params![
4231                        p.name,
4232                        path.clone(),
4233                        p.plugin_type,
4234                        p.version,
4235                        p.manufacturer,
4236                        p.manufacturer_url,
4237                        p.size,
4238                        p.size_bytes as i64,
4239                        p.modified,
4240                        arch_json,
4241                        scan_id
4242                    ])
4243                    .map_err(|e| e.to_string())?;
4244                if changed > 0 {
4245                    let row_id = tx.last_insert_rowid();
4246                    lib_stmt
4247                        .execute(params![path, row_id])
4248                        .map_err(|e| e.to_string())?;
4249                    inserted += 1;
4250                }
4251            }
4252        }
4253        if inserted > 0 {
4254            tx.execute(
4255                "UPDATE plugin_scans SET plugin_count = plugin_count + ?2 WHERE id = ?1",
4256                params![scan_id, inserted as i64],
4257            )
4258            .map_err(|e| e.to_string())?;
4259        }
4260        tx.commit().map_err(|e| e.to_string())?;
4261        if inserted > 0 {
4262            self.invalidate_plugin_library_total_cache();
4263        }
4264        Ok(inserted)
4265    }
4266
4267    /// Finalize directory list and counts after streaming inserts (matches non-streaming snapshot shape).
4268    pub fn plugin_scan_parent_finalize(
4269        &self,
4270        id: &str,
4271        _plugin_count: usize,
4272        directories: &[String],
4273        roots: &[String],
4274    ) -> Result<(), String> {
4275        let conn = self.read_conn();
4276        let plugin_count: i64 = self
4277            .plugin_library_total_rows(&conn)?
4278            .try_into()
4279            .unwrap_or(i64::MAX);
4280        let dirs_json = path_strings_json_normalized(directories);
4281        let roots_json = path_strings_json_normalized(roots);
4282        conn.execute(
4283            "UPDATE plugin_scans SET plugin_count = ?2, directories = ?3, roots = ?4 WHERE id = ?1",
4284            params![id, plugin_count, dirs_json, roots_json],
4285        )
4286        .map_err(|e| e.to_string())?;
4287        Ok(())
4288    }
4289
4290    pub fn get_plugin_scans(&self) -> Result<Vec<serde_json::Value>, String> {
4291        let conn = self.read_conn();
4292        let mut stmt = conn.prepare(
4293            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.plugin_count,0),(SELECT COUNT(*) FROM plugins WHERE scan_id = s.id)), s.roots FROM plugin_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
4294        )
4295        .map_err(|e| e.to_string())?;
4296        let rows = stmt
4297            .query_map([], |row| {
4298                let roots_str: String = row.get(3)?;
4299                Ok(serde_json::json!({
4300                    "id": row.get::<_,String>(0)?,
4301                    "timestamp": row.get::<_,String>(1)?,
4302                    "pluginCount": row.get::<_, i64>(2)? as u64,
4303                    "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
4304                }))
4305            })
4306            .map_err(|e| e.to_string())?;
4307        rows.collect::<Result<Vec<_>, _>>()
4308            .map_err(|e| e.to_string())
4309    }
4310
4311    pub fn get_plugin_scan_detail(&self, id: &str) -> Result<ScanSnapshot, String> {
4312        let conn = self.read_conn();
4313        let (ts, pc, dirs_json, roots_json): (String, usize, String, String) = conn.query_row(
4314            "SELECT timestamp, plugin_count, directories, roots FROM plugin_scans WHERE id = ?1",
4315            params![id],
4316            |row| {
4317                Ok((
4318                    row.get(0)?,
4319                    row.get::<_, i64>(1)? as usize,
4320                    row.get(2)?,
4321                    row.get(3)?,
4322                ))
4323            },
4324        )
4325        .map_err(|e| e.to_string())?;
4326        let mut stmt = conn.prepare("SELECT name, path, plugin_type, version, manufacturer, manufacturer_url, size, size_bytes, modified, architectures FROM plugins WHERE scan_id = ?1").map_err(|e| e.to_string())?;
4327        let plugins = stmt
4328            .query_map(params![id], |row| {
4329                let arch_str: String = row.get(9)?;
4330                Ok(PluginInfo {
4331                    name: row.get(0)?,
4332                    path: row.get(1)?,
4333                    plugin_type: row.get(2)?,
4334                    version: row.get(3)?,
4335                    manufacturer: row.get(4)?,
4336                    manufacturer_url: row.get(5)?,
4337                    size: row.get(6)?,
4338                    size_bytes: row.get::<_, i64>(7).unwrap_or(0) as u64,
4339                    modified: row.get(8)?,
4340                    architectures: serde_json::from_str(&arch_str).unwrap_or_default(),
4341                })
4342            })
4343            .map_err(|e| e.to_string())?
4344            .collect::<Result<Vec<_>, _>>()
4345            .map_err(|e| e.to_string())?;
4346        Ok(ScanSnapshot {
4347            id: id.to_string(),
4348            timestamp: ts,
4349            plugin_count: pc,
4350            plugins,
4351            directories: serde_json::from_str(&dirs_json).unwrap_or_default(),
4352            roots: serde_json::from_str(&roots_json).unwrap_or_default(),
4353        })
4354    }
4355
4356    pub fn get_latest_plugin_scan(&self) -> Result<Option<ScanSnapshot>, String> {
4357        let conn = self.read_conn();
4358        let id: Option<String> = conn
4359            .query_row(
4360                "SELECT id FROM plugin_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
4361                [],
4362                |r| r.get(0),
4363            )
4364            .optional()
4365            .map_err(|e| e.to_string())?;
4366        drop(conn);
4367        match id {
4368            Some(id) => self.get_plugin_scan_detail(&id).map(Some),
4369            None => Ok(None),
4370        }
4371    }
4372
4373    pub fn delete_plugin_scan(&self, id: &str) -> Result<(), String> {
4374        let conn = self.read_conn();
4375        conn.execute(
4376            "CREATE TEMP TABLE _pl_refresh_paths (path TEXT PRIMARY KEY)",
4377            [],
4378        )
4379        .map_err(|e| e.to_string())?;
4380        conn.execute(
4381            "INSERT INTO _pl_refresh_paths SELECT DISTINCT path FROM plugins WHERE scan_id = ?1",
4382            params![id],
4383        )
4384        .map_err(|e| e.to_string())?;
4385        conn.execute("DELETE FROM plugins WHERE scan_id = ?1", params![id])
4386            .map_err(|e| e.to_string())?;
4387        conn.execute("DELETE FROM plugin_scans WHERE id = ?1", params![id])
4388            .map_err(|e| e.to_string())?;
4389        Self::sync_plugin_library_after_paths_refresh(&conn)?;
4390        self.invalidate_plugin_library_total_cache();
4391        Ok(())
4392    }
4393
4394    pub fn clear_plugin_history(&self) -> Result<(), String> {
4395        let conn = self.read_conn();
4396        conn.execute_batch(
4397            "BEGIN IMMEDIATE;
4398             DELETE FROM plugin_library;
4399             DELETE FROM plugins;
4400             DELETE FROM plugin_scans;
4401             COMMIT;",
4402        )
4403        .map_err(|e| e.to_string())?;
4404        self.invalidate_plugin_library_total_cache();
4405        Ok(())
4406    }
4407
4408    // ── Audio scan full CRUD (using existing tables) ──
4409
4410    pub fn save_audio_scan_full(&self, snap: &AudioScanSnapshot) -> Result<(), String> {
4411        // Write parent with 0 counts — insert_audio_batch increments live.
4412        // Finalize afterwards to set the authoritative totals (including format_counts).
4413        self.save_scan(
4414            &snap.id,
4415            &snap.timestamp,
4416            0,
4417            0,
4418            &snap.format_counts,
4419            &snap.roots,
4420        )?;
4421        self.insert_audio_batch(&snap.id, &snap.samples)?;
4422        self.audio_scan_parent_finalize(
4423            &snap.id,
4424            snap.sample_count as u64,
4425            snap.total_bytes,
4426            &snap.format_counts,
4427        )
4428    }
4429
4430    pub fn get_audio_scans_list(&self) -> Result<Vec<serde_json::Value>, String> {
4431        let conn = self.read_conn();
4432        let mut stmt = conn.prepare(
4433            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.sample_count,0),(SELECT COUNT(*) FROM audio_samples WHERE scan_id = s.id)), COALESCE(NULLIF(s.total_bytes,0),(SELECT COALESCE(SUM(size),0) FROM audio_samples WHERE scan_id = s.id)), s.format_counts, s.roots FROM audio_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
4434        )
4435        .map_err(|e| e.to_string())?;
4436        let rows = stmt.query_map([], |row| {
4437            let fc_str: String = row.get(4)?;
4438            let roots_str: String = row.get(5)?;
4439            Ok(serde_json::json!({
4440                "id": row.get::<_,String>(0)?,
4441                "timestamp": row.get::<_,String>(1)?,
4442                "sampleCount": row.get::<_, i64>(2)? as u64,
4443                "totalBytes": row.get::<_, i64>(3)? as u64,
4444                "formatCounts": serde_json::from_str::<HashMap<String,usize>>(&fc_str).unwrap_or_default(),
4445                "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
4446            }))
4447        }).map_err(|e| e.to_string())?;
4448        rows.collect::<Result<Vec<_>, _>>()
4449            .map_err(|e| e.to_string())
4450    }
4451
4452    pub fn get_audio_scan_detail(&self, id: &str) -> Result<AudioScanSnapshot, String> {
4453        let conn = self.read_conn();
4454        let (ts, sc, tb, fc_str, roots_str): (String, usize, u64, String, String) = conn.query_row(
4455            "SELECT timestamp, sample_count, total_bytes, format_counts, roots FROM audio_scans WHERE id = ?1",
4456            params![id],
4457            |row| {
4458                Ok((
4459                    row.get(0)?,
4460                    row.get::<_, i64>(1)? as usize,
4461                    row.get::<_, i64>(2)? as u64,
4462                    row.get(3)?,
4463                    row.get(4)?,
4464                ))
4465            },
4466        )
4467        .map_err(|e| e.to_string())?;
4468        let mut stmt = conn.prepare("SELECT name, path, directory, format, size, size_formatted, modified, duration, channels, sample_rate, bits_per_sample FROM audio_samples WHERE scan_id = ?1").map_err(|e| e.to_string())?;
4469        let samples = stmt
4470            .query_map(params![id], |row| {
4471                Ok(AudioSample {
4472                    name: row.get(0)?,
4473                    path: row.get(1)?,
4474                    directory: row.get(2)?,
4475                    format: row.get(3)?,
4476                    size: row.get::<_, i64>(4).unwrap_or(0) as u64,
4477                    size_formatted: row.get(5)?,
4478                    modified: row.get(6)?,
4479                    duration: row.get(7).ok(),
4480                    channels: row
4481                        .get::<_, Option<i32>>(8)
4482                        .ok()
4483                        .flatten()
4484                        .map(|v| v as u16),
4485                    sample_rate: row
4486                        .get::<_, Option<i32>>(9)
4487                        .ok()
4488                        .flatten()
4489                        .map(|v| v as u32),
4490                    bits_per_sample: row
4491                        .get::<_, Option<i32>>(10)
4492                        .ok()
4493                        .flatten()
4494                        .map(|v| v as u16),
4495                })
4496            })
4497            .map_err(|e| e.to_string())?
4498            .collect::<Result<Vec<_>, _>>()
4499            .map_err(|e| e.to_string())?;
4500        // Derive count + total_bytes from child rows so the detail view is
4501        // correct even when parent_finalize never ran (streaming scan stopped
4502        // or finalize silently failed).
4503        let live_count = samples.len();
4504        let live_bytes: u64 = samples.iter().map(|s| s.size).sum();
4505        Ok(AudioScanSnapshot {
4506            id: id.to_string(),
4507            timestamp: ts,
4508            sample_count: if sc > 0 { sc } else { live_count },
4509            total_bytes: if tb > 0 { tb } else { live_bytes },
4510            format_counts: serde_json::from_str(&fc_str).unwrap_or_default(),
4511            samples,
4512            roots: serde_json::from_str(&roots_str).unwrap_or_default(),
4513        })
4514    }
4515
4516    pub fn get_latest_audio_scan(&self) -> Result<Option<AudioScanSnapshot>, String> {
4517        let conn = self.read_conn();
4518        let id: Option<String> = conn
4519            .query_row(
4520                "SELECT id FROM audio_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
4521                [],
4522                |r| r.get::<_, String>(0),
4523            )
4524            .optional()
4525            .map_err(|e| e.to_string())?;
4526        drop(conn);
4527        match id {
4528            Some(id) => self.get_audio_scan_detail(&id).map(Some),
4529            None => Ok(None),
4530        }
4531    }
4532
4533    pub fn delete_audio_scan(&self, id: &str) -> Result<(), String> {
4534        self.delete_scan(id)
4535    }
4536
4537    pub fn clear_audio_history(&self) -> Result<(), String> {
4538        let conn = self.read_conn();
4539        conn.execute_batch(
4540            "BEGIN IMMEDIATE;
4541             DELETE FROM audio_library;
4542             DELETE FROM audio_samples_fts;
4543             DELETE FROM audio_samples;
4544             DELETE FROM audio_scans;
4545             COMMIT;",
4546        )
4547        .map_err(|e| e.to_string())?;
4548        self.invalidate_audio_library_total_cache();
4549        Ok(())
4550    }
4551
4552    // ── DAW scan CRUD ──
4553
4554    /// Create (or re-create) a parent daw_scans row with zero counts. Used by
4555    /// streaming scans that don't know totals up front.
4556    pub fn daw_scan_parent_create(
4557        &self,
4558        id: &str,
4559        timestamp: &str,
4560        roots: &[String],
4561    ) -> Result<(), String> {
4562        let conn = self.read_conn();
4563        let roots_json = path_strings_json_normalized(roots);
4564        conn.execute(
4565            "INSERT OR REPLACE INTO daw_scans (id, timestamp, project_count, total_bytes, daw_counts, roots, scan_complete) VALUES (?1,?2,0,0,'{}',?3,0)",
4566            params![id, timestamp, roots_json],
4567        ).map_err(|e| e.to_string())?;
4568        conn.execute(
4569            "CREATE TEMP TABLE _dl_refresh_paths (path TEXT PRIMARY KEY)",
4570            [],
4571        )
4572        .map_err(|e| e.to_string())?;
4573        conn.execute(
4574            "INSERT INTO _dl_refresh_paths SELECT DISTINCT path FROM daw_projects WHERE scan_id = ?1",
4575            params![id],
4576        )
4577        .map_err(|e| e.to_string())?;
4578        conn.execute("DELETE FROM daw_projects WHERE scan_id = ?1", params![id])
4579            .map_err(|e| e.to_string())?;
4580        conn.execute(
4581            "DELETE FROM daw_projects_fts WHERE scan_id = ?1",
4582            params![id],
4583        )
4584        .map_err(|e| e.to_string())?;
4585        Self::sync_daw_library_after_paths_refresh(&conn)?;
4586        self.invalidate_daw_library_total_cache();
4587        Ok(())
4588    }
4589
4590    /// Finalize a parent daw_scans row with aggregate counts after streaming is complete.
4591    pub fn daw_scan_parent_finalize(
4592        &self,
4593        id: &str,
4594        _project_count: usize,
4595        _total_bytes: u64,
4596        _daw_counts: &HashMap<String, usize>,
4597    ) -> Result<(), String> {
4598        let conn = self.read_conn();
4599        let project_count: i64 = self
4600            .daw_library_total_rows(&conn)?
4601            .try_into()
4602            .unwrap_or(i64::MAX);
4603        let total_bytes: i64 = conn
4604            .query_row(
4605                "SELECT COALESCE(SUM(s.size), 0) FROM daw_projects s INNER JOIN daw_library lib ON s.id = lib.project_id",
4606                [],
4607                |r| r.get(0),
4608            )
4609            .unwrap_or(0);
4610        let mut daw_map: HashMap<String, usize> = HashMap::new();
4611        let mut stmt = conn
4612            .prepare(
4613                "SELECT s.daw, COUNT(*) FROM daw_projects s INNER JOIN daw_library lib ON s.id = lib.project_id GROUP BY s.daw",
4614            )
4615            .map_err(|e| e.to_string())?;
4616        let rows = stmt
4617            .query_map([], |row| {
4618                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
4619            })
4620            .map_err(|e| e.to_string())?;
4621        for (d, n) in rows.flatten() {
4622            daw_map.insert(d, n);
4623        }
4624        let dc_json = serde_json::to_string(&daw_map).unwrap_or_default();
4625        conn.execute(
4626            "UPDATE daw_scans SET project_count = ?2, total_bytes = ?3, daw_counts = ?4 WHERE id = ?1",
4627            params![id, project_count, total_bytes, dc_json],
4628        )
4629        .map_err(|e| e.to_string())?;
4630        Ok(())
4631    }
4632
4633    /// Stream-insert a batch of DawProject rows under an existing scan_id.
4634    pub fn insert_daw_batch(
4635        &self,
4636        scan_id: &str,
4637        projects: &[DawProject],
4638    ) -> Result<Vec<usize>, String> {
4639        let conn = self.read_conn();
4640        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
4641        let mut inserted_idx: Vec<usize> = Vec::new();
4642        let mut batch_bytes: u64 = 0;
4643        {
4644            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO daw_projects (name, path, directory, format, daw, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)").map_err(|e| e.to_string())?;
4645            let mut fts_stmt = tx.prepare_cached("INSERT INTO daw_projects_fts(rowid, name, path, daw, scan_id) VALUES (?1,?2,?3,?4,?5)").map_err(|e| e.to_string())?;
4646            let mut lib_stmt = tx
4647                .prepare_cached(
4648                    "INSERT INTO daw_library (path, project_id) VALUES (?1, ?2)
4649                     ON CONFLICT(path) DO UPDATE SET project_id = CASE
4650                       WHEN excluded.project_id > daw_library.project_id THEN excluded.project_id
4651                       ELSE daw_library.project_id END",
4652                )
4653                .map_err(|e| e.to_string())?;
4654            for (i, p) in projects.iter().enumerate() {
4655                let path = normalize_path_for_db(&p.path);
4656                let directory = normalize_path_for_db(&p.directory);
4657                let changed = stmt
4658                    .execute(params![
4659                        p.name,
4660                        path,
4661                        directory,
4662                        p.format,
4663                        p.daw,
4664                        p.size as i64,
4665                        p.size_formatted,
4666                        p.modified,
4667                        scan_id
4668                    ])
4669                    .map_err(|e| e.to_string())?;
4670                if changed > 0 {
4671                    let id = tx.last_insert_rowid();
4672                    fts_stmt
4673                        .execute(params![id, p.name, path, p.daw, scan_id])
4674                        .map_err(|e| e.to_string())?;
4675                    lib_stmt
4676                        .execute(params![path, id])
4677                        .map_err(|e| e.to_string())?;
4678                    inserted_idx.push(i);
4679                    batch_bytes += p.size;
4680                }
4681            }
4682        }
4683        let inserted = inserted_idx.len() as u64;
4684        if inserted > 0 {
4685            tx.execute(
4686                "UPDATE daw_scans SET project_count = project_count + ?2, total_bytes = total_bytes + ?3 WHERE id = ?1",
4687                params![scan_id, inserted as i64, batch_bytes as i64],
4688            ).map_err(|e| e.to_string())?;
4689        }
4690        tx.commit().map_err(|e| e.to_string())?;
4691        if !inserted_idx.is_empty() {
4692            self.invalidate_daw_library_total_cache();
4693        }
4694        Ok(inserted_idx)
4695    }
4696
4697    pub fn save_daw_scan(&self, snap: &DawScanSnapshot) -> Result<(), String> {
4698        let conn = self.read_conn();
4699        let daw_json = serde_json::to_string(&snap.daw_counts).unwrap_or_default();
4700        let roots_json = path_strings_json_normalized(&snap.roots);
4701        conn.execute(
4702            "INSERT OR REPLACE INTO daw_scans (id, timestamp, project_count, total_bytes, daw_counts, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,?6,1)",
4703            params![snap.id, snap.timestamp, snap.project_count as i64, snap.total_bytes as i64, daw_json, roots_json],
4704        ).map_err(|e| e.to_string())?;
4705        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
4706        {
4707            tx.execute(
4708                "DELETE FROM daw_projects WHERE scan_id = ?1",
4709                params![snap.id],
4710            )
4711            .map_err(|e| e.to_string())?;
4712            tx.execute(
4713                "DELETE FROM daw_projects_fts WHERE scan_id = ?1",
4714                params![snap.id],
4715            )
4716            .map_err(|e| e.to_string())?;
4717            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO daw_projects (name, path, directory, format, daw, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)").map_err(|e| e.to_string())?;
4718            let mut fts_stmt = tx.prepare_cached("INSERT INTO daw_projects_fts(rowid, name, path, daw, scan_id) VALUES (?1,?2,?3,?4,?5)").map_err(|e| e.to_string())?;
4719            for p in &snap.projects {
4720                let path = normalize_path_for_db(&p.path);
4721                let directory = normalize_path_for_db(&p.directory);
4722                let changed = stmt
4723                    .execute(params![
4724                        p.name,
4725                        path,
4726                        directory,
4727                        p.format,
4728                        p.daw,
4729                        p.size as i64,
4730                        p.size_formatted,
4731                        p.modified,
4732                        snap.id
4733                    ])
4734                    .map_err(|e| e.to_string())?;
4735                if changed > 0 {
4736                    let id = tx.last_insert_rowid();
4737                    fts_stmt
4738                        .execute(params![id, p.name, path, p.daw, snap.id])
4739                        .map_err(|e| e.to_string())?;
4740                }
4741            }
4742        }
4743        tx.commit().map_err(|e| e.to_string())?;
4744        Self::rebuild_daw_library(&conn)?;
4745        self.invalidate_daw_library_total_cache();
4746        Ok(())
4747    }
4748
4749    pub fn get_daw_scans(&self) -> Result<Vec<serde_json::Value>, String> {
4750        let conn = self.read_conn();
4751        // Count from child rows so the History tab stays correct even if parent totals
4752        // were never finalized (streaming scans) or finalize failed silently.
4753        let mut stmt = conn.prepare(
4754            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.project_count,0),(SELECT COUNT(*) FROM daw_projects WHERE scan_id = s.id)), COALESCE(NULLIF(s.total_bytes,0),(SELECT COALESCE(SUM(size),0) FROM daw_projects WHERE scan_id = s.id)), s.daw_counts, s.roots FROM daw_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
4755        )
4756        .map_err(|e| e.to_string())?;
4757        let rows = stmt.query_map([], |row| {
4758            let dc_str: String = row.get(4)?;
4759            let roots_str: String = row.get(5)?;
4760            Ok(serde_json::json!({
4761                "id": row.get::<_,String>(0)?,
4762                "timestamp": row.get::<_,String>(1)?,
4763                "projectCount": row.get::<_, i64>(2)? as u64,
4764                "totalBytes": row.get::<_, i64>(3)? as u64,
4765                "dawCounts": serde_json::from_str::<HashMap<String,usize>>(&dc_str).unwrap_or_default(),
4766                "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
4767            }))
4768        }).map_err(|e| e.to_string())?;
4769        rows.collect::<Result<Vec<_>, _>>()
4770            .map_err(|e| e.to_string())
4771    }
4772
4773    pub fn get_daw_scan_detail(&self, id: &str) -> Result<DawScanSnapshot, String> {
4774        let conn = self.read_conn();
4775        let (ts, pc, tb, dc_str, roots_str): (String, usize, u64, String, String) = conn.query_row(
4776            "SELECT timestamp, project_count, total_bytes, daw_counts, roots FROM daw_scans WHERE id = ?1",
4777            params![id],
4778            |row| {
4779                Ok((
4780                    row.get(0)?,
4781                    row.get::<_, i64>(1)? as usize,
4782                    row.get::<_, i64>(2)? as u64,
4783                    row.get(3)?,
4784                    row.get(4)?,
4785                ))
4786            },
4787        )
4788        .map_err(|e| e.to_string())?;
4789        let mut stmt = conn.prepare("SELECT name, path, directory, format, daw, size, size_formatted, modified FROM daw_projects WHERE scan_id = ?1").map_err(|e| e.to_string())?;
4790        let projects = stmt
4791            .query_map(params![id], |row| {
4792                Ok(DawProject {
4793                    name: row.get(0)?,
4794                    path: row.get(1)?,
4795                    directory: row.get(2)?,
4796                    format: row.get(3)?,
4797                    daw: row.get(4)?,
4798                    size: row.get::<_, i64>(5).unwrap_or(0) as u64,
4799                    size_formatted: row.get(6)?,
4800                    modified: row.get(7)?,
4801                })
4802            })
4803            .map_err(|e| e.to_string())?
4804            .collect::<Result<Vec<_>, _>>()
4805            .map_err(|e| e.to_string())?;
4806        let live_count = projects.len();
4807        let live_bytes: u64 = projects.iter().map(|p| p.size).sum();
4808        Ok(DawScanSnapshot {
4809            id: id.to_string(),
4810            timestamp: ts,
4811            project_count: if pc > 0 { pc } else { live_count },
4812            total_bytes: if tb > 0 { tb } else { live_bytes },
4813            daw_counts: serde_json::from_str(&dc_str).unwrap_or_default(),
4814            projects,
4815            roots: serde_json::from_str(&roots_str).unwrap_or_default(),
4816        })
4817    }
4818
4819    pub fn get_latest_daw_scan(&self) -> Result<Option<DawScanSnapshot>, String> {
4820        let conn = self.read_conn();
4821        let id: Option<String> = conn
4822            .query_row(LATEST_DAW_SCAN_ID_SQL, [], |r| r.get::<_, String>(0))
4823            .optional()
4824            .map_err(|e| e.to_string())?;
4825        drop(conn);
4826        match id {
4827            Some(id) => self.get_daw_scan_detail(&id).map(Some),
4828            None => Ok(None),
4829        }
4830    }
4831
4832    pub fn delete_daw_scan(&self, id: &str) -> Result<(), String> {
4833        let conn = self.read_conn();
4834        conn.execute(
4835            "CREATE TEMP TABLE _dl_refresh_paths (path TEXT PRIMARY KEY)",
4836            [],
4837        )
4838        .map_err(|e| e.to_string())?;
4839        conn.execute(
4840            "INSERT INTO _dl_refresh_paths SELECT DISTINCT path FROM daw_projects WHERE scan_id = ?1",
4841            params![id],
4842        )
4843        .map_err(|e| e.to_string())?;
4844        conn.execute(
4845            "DELETE FROM daw_projects_fts WHERE scan_id = ?1",
4846            params![id],
4847        )
4848        .map_err(|e| e.to_string())?;
4849        conn.execute("DELETE FROM daw_projects WHERE scan_id = ?1", params![id])
4850            .map_err(|e| e.to_string())?;
4851        conn.execute("DELETE FROM daw_scans WHERE id = ?1", params![id])
4852            .map_err(|e| e.to_string())?;
4853        Self::sync_daw_library_after_paths_refresh(&conn)?;
4854        self.invalidate_daw_library_total_cache();
4855        Ok(())
4856    }
4857
4858    pub fn clear_daw_history(&self) -> Result<(), String> {
4859        let conn = self.read_conn();
4860        conn.execute_batch(
4861            "BEGIN IMMEDIATE;
4862             DELETE FROM daw_library;
4863             DELETE FROM daw_projects_fts;
4864             DELETE FROM daw_projects;
4865             DELETE FROM daw_scans;
4866             COMMIT;",
4867        )
4868        .map_err(|e| e.to_string())?;
4869        self.invalidate_daw_library_total_cache();
4870        Ok(())
4871    }
4872
4873    // ── Preset scan CRUD ──
4874
4875    pub fn preset_scan_parent_create(
4876        &self,
4877        id: &str,
4878        timestamp: &str,
4879        roots: &[String],
4880    ) -> Result<(), String> {
4881        let conn = self.read_conn();
4882        let roots_json = path_strings_json_normalized(roots);
4883        conn.execute(
4884            "INSERT OR REPLACE INTO preset_scans (id, timestamp, preset_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,0,0,'{}',?3,0)",
4885            params![id, timestamp, roots_json],
4886        ).map_err(|e| e.to_string())?;
4887        conn.execute(
4888            "CREATE TEMP TABLE _preset_lib_refresh_paths (path TEXT PRIMARY KEY)",
4889            [],
4890        )
4891        .map_err(|e| e.to_string())?;
4892        conn.execute(
4893            "INSERT INTO _preset_lib_refresh_paths SELECT DISTINCT path FROM presets WHERE scan_id = ?1",
4894            params![id],
4895        )
4896        .map_err(|e| e.to_string())?;
4897        conn.execute("DELETE FROM presets WHERE scan_id = ?1", params![id])
4898            .map_err(|e| e.to_string())?;
4899        conn.execute("DELETE FROM presets_fts WHERE scan_id = ?1", params![id])
4900            .map_err(|e| e.to_string())?;
4901        Self::sync_preset_library_after_paths_refresh(&conn)?;
4902        self.invalidate_preset_inventory_total_cache();
4903        Ok(())
4904    }
4905
4906    pub fn preset_scan_parent_finalize(
4907        &self,
4908        id: &str,
4909        _preset_count: usize,
4910        _total_bytes: u64,
4911        _format_counts: &HashMap<String, usize>,
4912    ) -> Result<(), String> {
4913        let conn = self.read_conn();
4914        let preset_count: i64 = conn
4915            .query_row(
4916                "SELECT COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI')",
4917                [],
4918                |r| r.get(0),
4919            )
4920            .unwrap_or(0);
4921        let total_bytes: i64 = conn
4922            .query_row(
4923                "SELECT COALESCE(SUM(size), 0) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI')",
4924                [],
4925                |r| r.get(0),
4926            )
4927            .unwrap_or(0);
4928        let mut format_map: HashMap<String, usize> = HashMap::new();
4929        let mut stmt = conn
4930            .prepare(
4931                "SELECT format, COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID', 'MIDI') GROUP BY format",
4932            )
4933            .map_err(|e| e.to_string())?;
4934        let rows = stmt
4935            .query_map([], |row| {
4936                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
4937            })
4938            .map_err(|e| e.to_string())?;
4939        for (fmt, n) in rows.flatten() {
4940            format_map.insert(fmt, n);
4941        }
4942        let fc_json = serde_json::to_string(&format_map).unwrap_or_default();
4943        conn.execute(
4944            "UPDATE preset_scans SET preset_count = ?2, total_bytes = ?3, format_counts = ?4 WHERE id = ?1",
4945            params![id, preset_count, total_bytes, fc_json],
4946        )
4947        .map_err(|e| e.to_string())?;
4948        Ok(())
4949    }
4950
4951    pub fn insert_preset_batch(
4952        &self,
4953        scan_id: &str,
4954        presets: &[PresetFile],
4955    ) -> Result<u64, String> {
4956        let conn = self.read_conn();
4957        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
4958        let mut inserted: u64 = 0;
4959        let mut batch_bytes: u64 = 0;
4960        {
4961            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO presets (name, path, directory, format, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)").map_err(|e| e.to_string())?;
4962            let mut fts_stmt = tx.prepare_cached("INSERT INTO presets_fts(rowid, name, path, format, scan_id) VALUES (?1,?2,?3,?4,?5)").map_err(|e| e.to_string())?;
4963            let mut lib_stmt = tx
4964                .prepare_cached(
4965                    "INSERT INTO preset_library (path, preset_id) VALUES (?1, ?2)
4966                     ON CONFLICT(path) DO UPDATE SET preset_id = CASE
4967                       WHEN excluded.preset_id > preset_library.preset_id THEN excluded.preset_id
4968                       ELSE preset_library.preset_id END",
4969                )
4970                .map_err(|e| e.to_string())?;
4971            for p in presets {
4972                let path = normalize_path_for_db(&p.path);
4973                let directory = normalize_path_for_db(&p.directory);
4974                let changed = stmt
4975                    .execute(params![
4976                        p.name,
4977                        path,
4978                        directory,
4979                        p.format,
4980                        p.size as i64,
4981                        p.size_formatted,
4982                        p.modified,
4983                        scan_id
4984                    ])
4985                    .map_err(|e| e.to_string())?;
4986                if changed > 0 {
4987                    let id = tx.last_insert_rowid();
4988                    fts_stmt
4989                        .execute(params![id, p.name, path, p.format, scan_id])
4990                        .map_err(|e| e.to_string())?;
4991                    lib_stmt
4992                        .execute(params![path, id])
4993                        .map_err(|e| e.to_string())?;
4994                    inserted += 1;
4995                    batch_bytes += p.size;
4996                }
4997            }
4998        }
4999        if inserted > 0 {
5000            tx.execute(
5001                "UPDATE preset_scans SET preset_count = preset_count + ?2, total_bytes = total_bytes + ?3 WHERE id = ?1",
5002                params![scan_id, inserted as i64, batch_bytes as i64],
5003            ).map_err(|e| e.to_string())?;
5004        }
5005        tx.commit().map_err(|e| e.to_string())?;
5006        if inserted > 0 {
5007            self.invalidate_preset_inventory_total_cache();
5008        }
5009        Ok(inserted)
5010    }
5011
5012    pub fn save_preset_scan(&self, snap: &PresetScanSnapshot) -> Result<(), String> {
5013        let conn = self.read_conn();
5014        let fc_json = serde_json::to_string(&snap.format_counts).unwrap_or_default();
5015        let roots_json = path_strings_json_normalized(&snap.roots);
5016        conn.execute(
5017            "INSERT OR REPLACE INTO preset_scans (id, timestamp, preset_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,?6,1)",
5018            params![snap.id, snap.timestamp, snap.preset_count as i64, snap.total_bytes as i64, fc_json, roots_json],
5019        ).map_err(|e| e.to_string())?;
5020        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
5021        {
5022            tx.execute(
5023                "CREATE TEMP TABLE _preset_lib_refresh_paths (path TEXT PRIMARY KEY)",
5024                [],
5025            )
5026            .map_err(|e| e.to_string())?;
5027            tx.execute(
5028                "INSERT INTO _preset_lib_refresh_paths SELECT DISTINCT path FROM presets WHERE scan_id = ?1",
5029                params![snap.id],
5030            )
5031            .map_err(|e| e.to_string())?;
5032            for p in &snap.presets {
5033                let path = normalize_path_for_db(&p.path);
5034                tx.execute(
5035                    "INSERT OR IGNORE INTO _preset_lib_refresh_paths (path) VALUES (?1)",
5036                    params![path],
5037                )
5038                .map_err(|e| e.to_string())?;
5039            }
5040            tx.execute("DELETE FROM presets WHERE scan_id = ?1", params![snap.id])
5041                .map_err(|e| e.to_string())?;
5042            tx.execute(
5043                "DELETE FROM presets_fts WHERE scan_id = ?1",
5044                params![snap.id],
5045            )
5046            .map_err(|e| e.to_string())?;
5047            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO presets (name, path, directory, format, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)").map_err(|e| e.to_string())?;
5048            let mut fts_stmt = tx.prepare_cached("INSERT INTO presets_fts(rowid, name, path, format, scan_id) VALUES (?1,?2,?3,?4,?5)").map_err(|e| e.to_string())?;
5049            for p in &snap.presets {
5050                let path = normalize_path_for_db(&p.path);
5051                let directory = normalize_path_for_db(&p.directory);
5052                let changed = stmt
5053                    .execute(params![
5054                        p.name,
5055                        path,
5056                        directory,
5057                        p.format,
5058                        p.size as i64,
5059                        p.size_formatted,
5060                        p.modified,
5061                        snap.id
5062                    ])
5063                    .map_err(|e| e.to_string())?;
5064                if changed > 0 {
5065                    let id = tx.last_insert_rowid();
5066                    fts_stmt
5067                        .execute(params![id, p.name, path, p.format, snap.id])
5068                        .map_err(|e| e.to_string())?;
5069                }
5070            }
5071            Self::sync_preset_library_after_paths_refresh_tx(&tx)?;
5072        }
5073        tx.commit().map_err(|e| e.to_string())?;
5074        self.invalidate_preset_inventory_total_cache();
5075        Ok(())
5076    }
5077
5078    pub fn get_preset_scans(&self) -> Result<Vec<serde_json::Value>, String> {
5079        let conn = self.read_conn();
5080        let mut stmt = conn.prepare(
5081            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.preset_count,0),(SELECT COUNT(*) FROM presets WHERE scan_id = s.id)), COALESCE(NULLIF(s.total_bytes,0),(SELECT COALESCE(SUM(size),0) FROM presets WHERE scan_id = s.id)), s.format_counts, s.roots FROM preset_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
5082        )
5083        .map_err(|e| e.to_string())?;
5084        let rows = stmt.query_map([], |row| {
5085            let fc_str: String = row.get(4)?;
5086            let roots_str: String = row.get(5)?;
5087            Ok(serde_json::json!({
5088                "id": row.get::<_,String>(0)?,
5089                "timestamp": row.get::<_,String>(1)?,
5090                "presetCount": row.get::<_, i64>(2)? as u64,
5091                "totalBytes": row.get::<_, i64>(3)? as u64,
5092                "formatCounts": serde_json::from_str::<HashMap<String,usize>>(&fc_str).unwrap_or_default(),
5093                "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
5094            }))
5095        }).map_err(|e| e.to_string())?;
5096        rows.collect::<Result<Vec<_>, _>>()
5097            .map_err(|e| e.to_string())
5098    }
5099
5100    pub fn get_preset_scan_detail(&self, id: &str) -> Result<PresetScanSnapshot, String> {
5101        let conn = self.read_conn();
5102        let (ts, pc, tb, fc_str, roots_str): (String, usize, u64, String, String) = conn.query_row(
5103            "SELECT timestamp, preset_count, total_bytes, format_counts, roots FROM preset_scans WHERE id = ?1",
5104            params![id],
5105            |row| {
5106                Ok((
5107                    row.get(0)?,
5108                    row.get::<_, i64>(1)? as usize,
5109                    row.get::<_, i64>(2)? as u64,
5110                    row.get(3)?,
5111                    row.get(4)?,
5112                ))
5113            },
5114        )
5115        .map_err(|e| e.to_string())?;
5116        let mut stmt = conn.prepare("SELECT name, path, directory, format, size, size_formatted, modified FROM presets WHERE scan_id = ?1").map_err(|e| e.to_string())?;
5117        let presets = stmt
5118            .query_map(params![id], |row| {
5119                Ok(PresetFile {
5120                    name: row.get(0)?,
5121                    path: row.get(1)?,
5122                    directory: row.get(2)?,
5123                    format: row.get(3)?,
5124                    size: row.get::<_, i64>(4).unwrap_or(0) as u64,
5125                    size_formatted: row.get(5)?,
5126                    modified: row.get(6)?,
5127                })
5128            })
5129            .map_err(|e| e.to_string())?
5130            .collect::<Result<Vec<_>, _>>()
5131            .map_err(|e| e.to_string())?;
5132        let live_count = presets.len();
5133        let live_bytes: u64 = presets.iter().map(|p| p.size).sum();
5134        Ok(PresetScanSnapshot {
5135            id: id.to_string(),
5136            timestamp: ts,
5137            preset_count: if pc > 0 { pc } else { live_count },
5138            total_bytes: if tb > 0 { tb } else { live_bytes },
5139            format_counts: serde_json::from_str(&fc_str).unwrap_or_default(),
5140            presets,
5141            roots: serde_json::from_str(&roots_str).unwrap_or_default(),
5142        })
5143    }
5144
5145    pub fn get_latest_preset_scan(&self) -> Result<Option<PresetScanSnapshot>, String> {
5146        let conn = self.read_conn();
5147        let id: Option<String> = conn
5148            .query_row(
5149                "SELECT id FROM preset_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
5150                [],
5151                |r| r.get(0),
5152            )
5153            .optional()
5154            .map_err(|e| e.to_string())?;
5155        drop(conn);
5156        match id {
5157            Some(id) => self.get_preset_scan_detail(&id).map(Some),
5158            None => Ok(None),
5159        }
5160    }
5161
5162    pub fn delete_preset_scan(&self, id: &str) -> Result<(), String> {
5163        let conn = self.read_conn();
5164        conn.execute(
5165            "CREATE TEMP TABLE _preset_lib_refresh_paths (path TEXT PRIMARY KEY)",
5166            [],
5167        )
5168        .map_err(|e| e.to_string())?;
5169        conn.execute(
5170            "INSERT INTO _preset_lib_refresh_paths SELECT DISTINCT path FROM presets WHERE scan_id = ?1",
5171            params![id],
5172        )
5173        .map_err(|e| e.to_string())?;
5174        conn.execute("DELETE FROM presets WHERE scan_id = ?1", params![id])
5175            .map_err(|e| e.to_string())?;
5176        conn.execute("DELETE FROM presets_fts WHERE scan_id = ?1", params![id])
5177            .map_err(|e| e.to_string())?;
5178        Self::sync_preset_library_after_paths_refresh(&conn)?;
5179        self.invalidate_preset_inventory_total_cache();
5180        conn.execute("DELETE FROM preset_scans WHERE id = ?1", params![id])
5181            .map_err(|e| e.to_string())?;
5182        Ok(())
5183    }
5184
5185    pub fn clear_preset_history(&self) -> Result<(), String> {
5186        let conn = self.read_conn();
5187        conn.execute_batch(
5188            "BEGIN IMMEDIATE;
5189             DELETE FROM preset_library;
5190             DELETE FROM presets_fts;
5191             DELETE FROM presets;
5192             DELETE FROM preset_scans;
5193             COMMIT;",
5194        )
5195        .map_err(|e| e.to_string())?;
5196        self.invalidate_preset_inventory_total_cache();
5197        Ok(())
5198    }
5199
5200    // ── MIDI scan CRUD ──
5201
5202    pub fn midi_scan_parent_create(
5203        &self,
5204        id: &str,
5205        timestamp: &str,
5206        roots: &[String],
5207    ) -> Result<(), String> {
5208        let conn = self.read_conn();
5209        let roots_json = path_strings_json_normalized(roots);
5210        conn.execute(
5211            "INSERT OR REPLACE INTO midi_scans (id, timestamp, midi_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,0,0,'{}',?3,0)",
5212            params![id, timestamp, roots_json],
5213        ).map_err(|e| e.to_string())?;
5214        conn.execute(
5215            "CREATE TEMP TABLE _midi_lib_refresh_paths (path TEXT PRIMARY KEY)",
5216            [],
5217        )
5218        .map_err(|e| e.to_string())?;
5219        conn.execute(
5220            "INSERT INTO _midi_lib_refresh_paths SELECT DISTINCT path FROM midi_files WHERE scan_id = ?1",
5221            params![id],
5222        )
5223        .map_err(|e| e.to_string())?;
5224        conn.execute("DELETE FROM midi_files WHERE scan_id = ?1", params![id])
5225            .map_err(|e| e.to_string())?;
5226        conn.execute("DELETE FROM midi_files_fts WHERE scan_id = ?1", params![id])
5227            .map_err(|e| e.to_string())?;
5228        Self::sync_midi_library_after_paths_refresh(&conn)?;
5229        self.invalidate_midi_library_total_cache();
5230        Ok(())
5231    }
5232
5233    pub fn midi_scan_parent_finalize(
5234        &self,
5235        id: &str,
5236        _midi_count: usize,
5237        _total_bytes: u64,
5238        _format_counts: &HashMap<String, usize>,
5239    ) -> Result<(), String> {
5240        let conn = self.read_conn();
5241        let midi_count: i64 = conn
5242            .query_row("SELECT COUNT(DISTINCT path) FROM midi_files", [], |r| {
5243                r.get(0)
5244            })
5245            .unwrap_or(0);
5246        let total_bytes: i64 = conn
5247            .query_row(
5248                "SELECT COALESCE(SUM(size), 0) FROM midi_files WHERE id IN (SELECT midi_id FROM midi_library)",
5249                [],
5250                |r| r.get(0),
5251            )
5252            .unwrap_or(0);
5253        let mut format_map: HashMap<String, usize> = HashMap::new();
5254        let mut stmt = conn
5255            .prepare(
5256                "SELECT format, COUNT(*) FROM midi_files WHERE id IN (SELECT midi_id FROM midi_library) GROUP BY format",
5257            )
5258            .map_err(|e| e.to_string())?;
5259        let rows = stmt
5260            .query_map([], |row| {
5261                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
5262            })
5263            .map_err(|e| e.to_string())?;
5264        for (fmt, n) in rows.flatten() {
5265            format_map.insert(fmt, n);
5266        }
5267        let fc_json = serde_json::to_string(&format_map).unwrap_or_default();
5268        conn.execute(
5269            "UPDATE midi_scans SET midi_count = ?2, total_bytes = ?3, format_counts = ?4 WHERE id = ?1",
5270            params![id, midi_count, total_bytes, fc_json],
5271        )
5272        .map_err(|e| e.to_string())?;
5273        Ok(())
5274    }
5275
5276    pub fn insert_midi_batch(&self, scan_id: &str, midi_files: &[MidiFile]) -> Result<(), String> {
5277        let conn = self.read_conn();
5278        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
5279        let mut inserted: u64 = 0;
5280        let mut batch_bytes: u64 = 0;
5281        {
5282            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO midi_files (name, path, directory, format, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)").map_err(|e| e.to_string())?;
5283            let mut fts_stmt = tx
5284                .prepare_cached(
5285                    "INSERT INTO midi_files_fts(rowid, name, path, scan_id) VALUES (?1,?2,?3,?4)",
5286                )
5287                .map_err(|e| e.to_string())?;
5288            let mut lib_stmt = tx
5289                .prepare_cached(
5290                    "INSERT INTO midi_library (path, midi_id) VALUES (?1, ?2)
5291                     ON CONFLICT(path) DO UPDATE SET midi_id = CASE
5292                       WHEN excluded.midi_id > midi_library.midi_id THEN excluded.midi_id
5293                       ELSE midi_library.midi_id END",
5294                )
5295                .map_err(|e| e.to_string())?;
5296            for m in midi_files {
5297                let path = normalize_path_for_db(&m.path);
5298                let directory = normalize_path_for_db(&m.directory);
5299                let changed = stmt
5300                    .execute(params![
5301                        m.name,
5302                        path,
5303                        directory,
5304                        m.format,
5305                        m.size as i64,
5306                        m.size_formatted,
5307                        m.modified,
5308                        scan_id
5309                    ])
5310                    .map_err(|e| e.to_string())?;
5311                if changed > 0 {
5312                    let id = tx.last_insert_rowid();
5313                    fts_stmt
5314                        .execute(params![id, m.name, path, scan_id])
5315                        .map_err(|e| e.to_string())?;
5316                    lib_stmt
5317                        .execute(params![path, id])
5318                        .map_err(|e| e.to_string())?;
5319                    inserted += 1;
5320                    batch_bytes += m.size;
5321                }
5322            }
5323        }
5324        if inserted > 0 {
5325            tx.execute(
5326                "UPDATE midi_scans SET midi_count = midi_count + ?2, total_bytes = total_bytes + ?3 WHERE id = ?1",
5327                params![scan_id, inserted as i64, batch_bytes as i64],
5328            ).map_err(|e| e.to_string())?;
5329        }
5330        tx.commit().map_err(|e| e.to_string())?;
5331        if inserted > 0 {
5332            self.invalidate_midi_library_total_cache();
5333        }
5334        Ok(())
5335    }
5336
5337    pub fn save_midi_scan(&self, snap: &MidiScanSnapshot) -> Result<(), String> {
5338        let conn = self.read_conn();
5339        let fc_json = serde_json::to_string(&snap.format_counts).unwrap_or_default();
5340        let roots_json = path_strings_json_normalized(&snap.roots);
5341        conn.execute(
5342            "INSERT OR REPLACE INTO midi_scans (id, timestamp, midi_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,?6,1)",
5343            params![snap.id, snap.timestamp, snap.midi_count as i64, snap.total_bytes as i64, fc_json, roots_json],
5344        ).map_err(|e| e.to_string())?;
5345        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
5346        {
5347            tx.execute(
5348                "CREATE TEMP TABLE _midi_lib_refresh_paths (path TEXT PRIMARY KEY)",
5349                [],
5350            )
5351            .map_err(|e| e.to_string())?;
5352            tx.execute(
5353                "INSERT INTO _midi_lib_refresh_paths SELECT DISTINCT path FROM midi_files WHERE scan_id = ?1",
5354                params![snap.id],
5355            )
5356            .map_err(|e| e.to_string())?;
5357            for m in &snap.midi_files {
5358                let path = normalize_path_for_db(&m.path);
5359                tx.execute(
5360                    "INSERT OR IGNORE INTO _midi_lib_refresh_paths (path) VALUES (?1)",
5361                    params![path],
5362                )
5363                .map_err(|e| e.to_string())?;
5364            }
5365            tx.execute(
5366                "DELETE FROM midi_files WHERE scan_id = ?1",
5367                params![snap.id],
5368            )
5369            .map_err(|e| e.to_string())?;
5370            tx.execute(
5371                "DELETE FROM midi_files_fts WHERE scan_id = ?1",
5372                params![snap.id],
5373            )
5374            .map_err(|e| e.to_string())?;
5375            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO midi_files (name, path, directory, format, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)").map_err(|e| e.to_string())?;
5376            let mut fts_stmt = tx
5377                .prepare_cached(
5378                    "INSERT INTO midi_files_fts(rowid, name, path, scan_id) VALUES (?1,?2,?3,?4)",
5379                )
5380                .map_err(|e| e.to_string())?;
5381            for m in &snap.midi_files {
5382                let path = normalize_path_for_db(&m.path);
5383                let directory = normalize_path_for_db(&m.directory);
5384                let changed = stmt
5385                    .execute(params![
5386                        m.name,
5387                        path,
5388                        directory,
5389                        m.format,
5390                        m.size as i64,
5391                        m.size_formatted,
5392                        m.modified,
5393                        snap.id
5394                    ])
5395                    .map_err(|e| e.to_string())?;
5396                if changed > 0 {
5397                    let id = tx.last_insert_rowid();
5398                    fts_stmt
5399                        .execute(params![id, m.name, path, snap.id])
5400                        .map_err(|e| e.to_string())?;
5401                }
5402            }
5403            Self::sync_midi_library_after_paths_refresh_tx(&tx)?;
5404        }
5405        tx.commit().map_err(|e| e.to_string())?;
5406        self.invalidate_midi_library_total_cache();
5407        Ok(())
5408    }
5409
5410    pub fn get_midi_scans(&self) -> Result<Vec<serde_json::Value>, String> {
5411        let conn = self.read_conn();
5412        let mut stmt = conn.prepare(
5413            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.midi_count,0),(SELECT COUNT(*) FROM midi_files WHERE scan_id = s.id)), COALESCE(NULLIF(s.total_bytes,0),(SELECT COALESCE(SUM(size),0) FROM midi_files WHERE scan_id = s.id)), s.format_counts, s.roots FROM midi_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
5414        )
5415        .map_err(|e| e.to_string())?;
5416        let rows = stmt.query_map([], |row| {
5417            let fc_str: String = row.get(4)?;
5418            let roots_str: String = row.get(5)?;
5419            Ok(serde_json::json!({
5420                "id": row.get::<_,String>(0)?,
5421                "timestamp": row.get::<_,String>(1)?,
5422                "midiCount": row.get::<_, i64>(2)? as u64,
5423                "totalBytes": row.get::<_, i64>(3)? as u64,
5424                "formatCounts": serde_json::from_str::<HashMap<String,usize>>(&fc_str).unwrap_or_default(),
5425                "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
5426            }))
5427        }).map_err(|e| e.to_string())?;
5428        rows.collect::<Result<Vec<_>, _>>()
5429            .map_err(|e| e.to_string())
5430    }
5431
5432    pub fn get_midi_scan_detail(&self, id: &str) -> Result<MidiScanSnapshot, String> {
5433        let conn = self.read_conn();
5434        let (ts, mc, tb, fc_str, roots_str): (String, usize, u64, String, String) = conn.query_row(
5435            "SELECT timestamp, midi_count, total_bytes, format_counts, roots FROM midi_scans WHERE id = ?1",
5436            params![id],
5437            |row| {
5438                Ok((
5439                    row.get(0)?,
5440                    row.get::<_, i64>(1)? as usize,
5441                    row.get::<_, i64>(2)? as u64,
5442                    row.get(3)?,
5443                    row.get(4)?,
5444                ))
5445            },
5446        )
5447        .map_err(|e| e.to_string())?;
5448        let mut stmt = conn.prepare("SELECT name, path, directory, format, size, size_formatted, modified FROM midi_files WHERE scan_id = ?1").map_err(|e| e.to_string())?;
5449        let midi_files = stmt
5450            .query_map(params![id], |row| {
5451                Ok(MidiFile {
5452                    name: row.get(0)?,
5453                    path: row.get(1)?,
5454                    directory: row.get(2)?,
5455                    format: row.get(3)?,
5456                    size: row.get::<_, i64>(4).unwrap_or(0) as u64,
5457                    size_formatted: row.get(5)?,
5458                    modified: row.get(6)?,
5459                })
5460            })
5461            .map_err(|e| e.to_string())?
5462            .collect::<Result<Vec<_>, _>>()
5463            .map_err(|e| e.to_string())?;
5464        let live_count = midi_files.len();
5465        let live_bytes: u64 = midi_files.iter().map(|m| m.size).sum();
5466        Ok(MidiScanSnapshot {
5467            id: id.to_string(),
5468            timestamp: ts,
5469            midi_count: if mc > 0 { mc } else { live_count },
5470            total_bytes: if tb > 0 { tb } else { live_bytes },
5471            format_counts: serde_json::from_str(&fc_str).unwrap_or_default(),
5472            midi_files,
5473            roots: serde_json::from_str(&roots_str).unwrap_or_default(),
5474        })
5475    }
5476
5477    pub fn get_latest_midi_scan(&self) -> Result<Option<MidiScanSnapshot>, String> {
5478        let conn = self.read_conn();
5479        let id: Option<String> = conn
5480            .query_row(
5481                "SELECT id FROM midi_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
5482                [],
5483                |r| r.get(0),
5484            )
5485            .optional()
5486            .map_err(|e| e.to_string())?;
5487        drop(conn);
5488        match id {
5489            Some(id) => self.get_midi_scan_detail(&id).map(Some),
5490            None => Ok(None),
5491        }
5492    }
5493
5494    pub fn delete_midi_scan(&self, id: &str) -> Result<(), String> {
5495        let conn = self.read_conn();
5496        conn.execute(
5497            "CREATE TEMP TABLE _midi_lib_refresh_paths (path TEXT PRIMARY KEY)",
5498            [],
5499        )
5500        .map_err(|e| e.to_string())?;
5501        conn.execute(
5502            "INSERT INTO _midi_lib_refresh_paths SELECT DISTINCT path FROM midi_files WHERE scan_id = ?1",
5503            params![id],
5504        )
5505        .map_err(|e| e.to_string())?;
5506        conn.execute("DELETE FROM midi_files WHERE scan_id = ?1", params![id])
5507            .map_err(|e| e.to_string())?;
5508        conn.execute("DELETE FROM midi_files_fts WHERE scan_id = ?1", params![id])
5509            .map_err(|e| e.to_string())?;
5510        Self::sync_midi_library_after_paths_refresh(&conn)?;
5511        conn.execute("DELETE FROM midi_scans WHERE id = ?1", params![id])
5512            .map_err(|e| e.to_string())?;
5513        self.invalidate_midi_library_total_cache();
5514        Ok(())
5515    }
5516
5517    pub fn clear_midi_history(&self) -> Result<(), String> {
5518        let conn = self.read_conn();
5519        conn.execute_batch(
5520            "BEGIN IMMEDIATE;
5521             DELETE FROM midi_library;
5522             DELETE FROM midi_files_fts;
5523             DELETE FROM midi_files;
5524             DELETE FROM midi_scans;
5525             COMMIT;",
5526        )
5527        .map_err(|e| e.to_string())?;
5528        self.invalidate_midi_library_total_cache();
5529        Ok(())
5530    }
5531
5532    pub fn query_midi(
5533        &self,
5534        search: Option<&str>,
5535        format_filter: Option<&str>,
5536        sort_key: &str,
5537        sort_asc: bool,
5538        search_regex: bool,
5539        offset: u64,
5540        limit: u64,
5541    ) -> Result<MidiQueryResult, String> {
5542        let conn = self.read_conn();
5543        // One row per library entry (same semantics as Samples tab / `query_audio` COUNT).
5544        let total_unfiltered: u64 = self.midi_library_total_rows(&conn)?;
5545        if total_unfiltered == 0 {
5546            return Ok(MidiQueryResult {
5547                midi_files: vec![],
5548                total_count: 0,
5549                total_count_capped: false,
5550                total_unfiltered: 0,
5551            });
5552        }
5553
5554        let mut where_parts = vec![MIDI_LIBRARY_IDS.to_string()];
5555        let mut bind_idx = 1usize;
5556        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
5557        if fts_match.is_some() {
5558            // Library scope is already `MIDI_LIBRARY_IDS`; do not nest a second
5559            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
5560            where_parts.push(format!(
5561                "id IN (SELECT rowid FROM midi_files_fts WHERE midi_files_fts MATCH ?{bind_idx})",
5562            ));
5563            bind_idx += 1;
5564        } else if regex_pat.is_some() {
5565            where_parts.push(format!(
5566                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
5567            ));
5568            bind_idx += 1;
5569        } else if like_pat.is_some() {
5570            where_parts.push(format!(
5571                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
5572            ));
5573            bind_idx += 1;
5574        }
5575        if let Some(f) = format_filter {
5576            if !f.is_empty() && f != "all" {
5577                if f.contains(',') {
5578                    where_parts.push(format!(
5579                        "format IN ({})",
5580                        f.split(',')
5581                            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
5582                            .collect::<Vec<_>>()
5583                            .join(",")
5584                    ));
5585                } else {
5586                    where_parts.push(format!("format = ?{bind_idx}"));
5587                    bind_idx += 1;
5588                }
5589            }
5590        }
5591        let where_cl = where_parts.join(" AND ");
5592
5593        let sort_col = match sort_key {
5594            "name" => "name COLLATE NOCASE",
5595            "size" => "size",
5596            "modified" => "modified",
5597            "directory" => "directory COLLATE NOCASE",
5598            "format" => "format",
5599            _ => "name COLLATE NOCASE",
5600        };
5601        let dir = if sort_asc { "ASC" } else { "DESC" };
5602
5603        // FTS + ORDER BY name/size/… over millions of substring hits forces SQLite to sort the
5604        // full match set before LIMIT — multi‑minute stalls. Always use bm25 + LIMIT for FTS
5605        // (column sort is ignored in SQL; JS may re-rank the page with `searchScore`).
5606        let use_fts_rank_page = fts_match.is_some();
5607
5608        let (total_count, total_count_capped) = if fts_match.is_some() {
5609            let m = fts_match.as_ref().expect("fts");
5610            Self::midi_fts_bounded_count_library(&conn, m, format_filter)?
5611        } else {
5612            let sql = format!("SELECT COUNT(*) FROM midi_files WHERE {where_cl}");
5613            let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
5614            let mut bi = 1;
5615            if let Some(ref r) = regex_pat {
5616                stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
5617                bi += 1;
5618            } else if let Some(ref pat) = like_pat {
5619                stmt.raw_bind_parameter(bi, pat)
5620                    .map_err(|e| e.to_string())?;
5621                bi += 1;
5622            }
5623            if let Some(f) = format_filter {
5624                if !f.is_empty() && f != "all" && !f.contains(',') {
5625                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
5626                }
5627            }
5628            let mut rows = stmt.raw_query();
5629            let n = rows
5630                .next()
5631                .map_err(|e| e.to_string())?
5632                .map(|r| r.get::<_, i64>(0).unwrap_or(0) as u64)
5633                .unwrap_or(0);
5634            (n, false)
5635        };
5636
5637        let sql = if use_fts_rank_page {
5638            let mut w = String::from(
5639                "SELECT m.name, m.path, m.directory, m.format, m.size, m.size_formatted, m.modified
5640                 FROM midi_files_fts
5641                 INNER JOIN midi_files m ON m.id = midi_files_fts.rowid
5642                 INNER JOIN midi_library lib ON lib.midi_id = m.id
5643                 WHERE midi_files_fts MATCH ?1",
5644            );
5645            let mut next_ph = 2usize;
5646            if let Some(f) = format_filter {
5647                if !f.is_empty() && f != "all" {
5648                    if f.contains(',') {
5649                        let vals: Vec<String> = f
5650                            .split(',')
5651                            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
5652                            .collect();
5653                        w.push_str(&format!(" AND m.format IN ({})", vals.join(",")));
5654                    } else {
5655                        w.push_str(&format!(" AND m.format = ?{next_ph}"));
5656                        next_ph += 1;
5657                    }
5658                }
5659            }
5660            let li = next_ph;
5661            let oi = next_ph + 1;
5662            w.push_str(&format!(" ORDER BY bm25(midi_files_fts) LIMIT ?{li} OFFSET ?{oi}"));
5663            w
5664        } else {
5665            format!(
5666                "SELECT name, path, directory, format, size, size_formatted, modified
5667                 FROM midi_files WHERE {where_cl}
5668                 ORDER BY {sort_col} {dir} LIMIT ?{limit_idx} OFFSET ?{off_idx}",
5669                limit_idx = bind_idx,
5670                off_idx = bind_idx + 1
5671            )
5672        };
5673        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
5674        let mut bi = 1;
5675        if use_fts_rank_page {
5676            let m = fts_match.as_ref().expect("fts");
5677            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
5678            bi += 1;
5679            if let Some(f) = format_filter {
5680                if !f.is_empty() && f != "all" && !f.contains(',') {
5681                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
5682                    bi += 1;
5683                }
5684            }
5685            stmt.raw_bind_parameter(bi, limit as i64)
5686                .map_err(|e| e.to_string())?;
5687            stmt.raw_bind_parameter(bi + 1, offset as i64)
5688                .map_err(|e| e.to_string())?;
5689        } else if let Some(ref m) = fts_match {
5690            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
5691            bi += 1;
5692        } else if let Some(ref r) = regex_pat {
5693            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
5694            bi += 1;
5695        } else if let Some(ref pat) = like_pat {
5696            stmt.raw_bind_parameter(bi, pat)
5697                .map_err(|e| e.to_string())?;
5698            bi += 1;
5699        }
5700        if !use_fts_rank_page {
5701            if let Some(f) = format_filter {
5702                if !f.is_empty() && f != "all" && !f.contains(',') {
5703                    stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
5704                    bi += 1;
5705                }
5706            }
5707            stmt.raw_bind_parameter(bi, limit as i64)
5708                .map_err(|e| e.to_string())?;
5709            stmt.raw_bind_parameter(bi + 1, offset as i64)
5710                .map_err(|e| e.to_string())?;
5711        }
5712        let mut rows = stmt.raw_query();
5713        let mut out = Vec::new();
5714        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
5715            out.push(MidiFile {
5716                name: row.get(0).unwrap_or_default(),
5717                path: row.get(1).unwrap_or_default(),
5718                directory: row.get(2).unwrap_or_default(),
5719                format: row.get(3).unwrap_or_default(),
5720                size: row.get::<_, i64>(4).unwrap_or(0) as u64,
5721                size_formatted: row.get(5).unwrap_or_default(),
5722                modified: row.get(6).unwrap_or_default(),
5723            });
5724        }
5725        Ok(MidiQueryResult {
5726            midi_files: out,
5727            total_count,
5728            total_count_capped,
5729            total_unfiltered,
5730        })
5731    }
5732
5733    pub fn midi_filter_stats(
5734        &self,
5735        search: Option<&str>,
5736        format_filter: Option<&str>,
5737        search_regex: bool,
5738    ) -> Result<FilterStatsResult, String> {
5739        let conn = self.read_conn();
5740        let total_unfiltered: u64 = self.midi_library_total_rows(&conn)?;
5741        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
5742        let mut where_parts = vec![MIDI_LIBRARY_IDS.to_string()];
5743        let mut bind_idx = 1usize;
5744        if fts_match.is_some() {
5745            // Library scope is already `MIDI_LIBRARY_IDS`; do not nest a second
5746            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
5747            where_parts.push(format!(
5748                "id IN (SELECT rowid FROM midi_files_fts WHERE midi_files_fts MATCH ?{bind_idx})",
5749            ));
5750            bind_idx += 1;
5751        } else if regex_pat.is_some() {
5752            where_parts.push(format!(
5753                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
5754            ));
5755            bind_idx += 1;
5756        } else if like_pat.is_some() {
5757            where_parts.push(format!(
5758                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
5759            ));
5760            bind_idx += 1;
5761        }
5762        if let Some(f) = format_filter {
5763            if !f.is_empty() && f != "all" {
5764                if f.contains(',') {
5765                    where_parts.push(format!("format IN ({})", Self::in_list_sql(f)));
5766                } else {
5767                    where_parts.push(format!("format = ?{bind_idx}"));
5768                }
5769            }
5770        }
5771        let where_cl = where_parts.join(" AND ");
5772        if fts_match.is_some() {
5773            let m = fts_match.as_ref().expect("fts");
5774            let (bc, capped) = Self::midi_fts_bounded_count_library(&conn, m, format_filter)?;
5775            if bc == 0 {
5776                return Ok(FilterStatsResult {
5777                    count: 0,
5778                    count_capped: false,
5779                    total_bytes: 0,
5780                    by_type: HashMap::new(),
5781                    bytes_by_type: HashMap::new(),
5782                    total_unfiltered,
5783                    size_buckets: vec![],
5784                    ..Default::default()
5785                });
5786            }
5787            if capped {
5788                return Ok(FilterStatsResult {
5789                    count: bc,
5790                    count_capped: true,
5791                    total_bytes: 0,
5792                    by_type: HashMap::new(),
5793                    bytes_by_type: HashMap::new(),
5794                    total_unfiltered,
5795                    size_buckets: vec![],
5796                    ..Default::default()
5797                });
5798            }
5799        }
5800        let sql = format!(
5801            "SELECT format, COUNT(*), COALESCE(SUM(size),0) FROM midi_files WHERE {where_cl} GROUP BY format"
5802        );
5803        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
5804        let mut bi = 1;
5805        if let Some(ref m) = fts_match {
5806            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
5807            bi += 1;
5808        } else if let Some(ref r) = regex_pat {
5809            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
5810            bi += 1;
5811        } else if let Some(ref pat) = like_pat {
5812            stmt.raw_bind_parameter(bi, pat)
5813                .map_err(|e| e.to_string())?;
5814            bi += 1;
5815        }
5816        if let Some(f) = format_filter {
5817            if !f.is_empty() && f != "all" && !f.contains(',') {
5818                stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
5819            }
5820        }
5821        let _ = bi;
5822        let mut rows = stmt.raw_query();
5823        let mut count = 0u64;
5824        let mut total_bytes = 0u64;
5825        let mut by_type = HashMap::new();
5826        let mut bytes_by_type = HashMap::new();
5827        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
5828            let fmt: String = row.get(0).unwrap_or_default();
5829            let n: u64 = row.get::<_, i64>(1).unwrap_or(0) as u64;
5830            let sz: u64 = row.get::<_, i64>(2).unwrap_or(0) as u64;
5831            count += n;
5832            total_bytes += sz;
5833            by_type.insert(fmt.clone(), n);
5834            bytes_by_type.insert(fmt, sz);
5835        }
5836        Ok(FilterStatsResult {
5837            count,
5838            count_capped: false,
5839            total_bytes,
5840            by_type,
5841            bytes_by_type,
5842            total_unfiltered,
5843            size_buckets: vec![],
5844            ..Default::default()
5845        })
5846    }
5847
5848    // ── PDF scan CRUD ──
5849
5850    pub fn pdf_scan_parent_create(
5851        &self,
5852        id: &str,
5853        timestamp: &str,
5854        roots: &[String],
5855    ) -> Result<(), String> {
5856        let conn = self.read_conn();
5857        let roots_json = path_strings_json_normalized(roots);
5858        conn.execute(
5859            "INSERT OR REPLACE INTO pdf_scans (id, timestamp, pdf_count, total_bytes, roots, scan_complete) VALUES (?1,?2,0,0,?3,0)",
5860            params![id, timestamp, roots_json],
5861        ).map_err(|e| e.to_string())?;
5862        conn.execute(
5863            "CREATE TEMP TABLE _pdf_lib_refresh_paths (path TEXT PRIMARY KEY)",
5864            [],
5865        )
5866        .map_err(|e| e.to_string())?;
5867        conn.execute(
5868            "INSERT INTO _pdf_lib_refresh_paths SELECT DISTINCT path FROM pdfs WHERE scan_id = ?1",
5869            params![id],
5870        )
5871        .map_err(|e| e.to_string())?;
5872        conn.execute("DELETE FROM pdfs WHERE scan_id = ?1", params![id])
5873            .map_err(|e| e.to_string())?;
5874        conn.execute("DELETE FROM pdfs_fts WHERE scan_id = ?1", params![id])
5875            .map_err(|e| e.to_string())?;
5876        Self::sync_pdf_library_after_paths_refresh(&conn)?;
5877        self.invalidate_pdf_library_total_cache();
5878        Ok(())
5879    }
5880
5881    pub fn pdf_scan_parent_finalize(
5882        &self,
5883        id: &str,
5884        _pdf_count: usize,
5885        _total_bytes: u64,
5886    ) -> Result<(), String> {
5887        let conn = self.read_conn();
5888        let pdf_count: i64 = conn
5889            .query_row("SELECT COUNT(DISTINCT path) FROM pdfs", [], |r| r.get(0))
5890            .unwrap_or(0);
5891        let total_bytes: i64 = conn
5892            .query_row(
5893                "SELECT COALESCE(SUM(size), 0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
5894                [],
5895                |r| r.get(0),
5896            )
5897            .unwrap_or(0);
5898        conn.execute(
5899            "UPDATE pdf_scans SET pdf_count = ?2, total_bytes = ?3 WHERE id = ?1",
5900            params![id, pdf_count, total_bytes],
5901        )
5902        .map_err(|e| e.to_string())?;
5903        Ok(())
5904    }
5905
5906    pub fn insert_pdf_batch(&self, scan_id: &str, pdfs: &[PdfFile]) -> Result<u64, String> {
5907        let conn = self.read_conn();
5908        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
5909        let mut inserted: u64 = 0;
5910        let mut batch_bytes: u64 = 0;
5911        {
5912            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO pdfs (name, path, directory, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7)").map_err(|e| e.to_string())?;
5913            let mut fts_stmt = tx
5914                .prepare_cached(
5915                    "INSERT INTO pdfs_fts(rowid, name, path, scan_id) VALUES (?1,?2,?3,?4)",
5916                )
5917                .map_err(|e| e.to_string())?;
5918            let mut lib_stmt = tx
5919                .prepare_cached(
5920                    "INSERT INTO pdf_library (path, pdf_id) VALUES (?1, ?2)
5921                     ON CONFLICT(path) DO UPDATE SET pdf_id = CASE
5922                       WHEN excluded.pdf_id > pdf_library.pdf_id THEN excluded.pdf_id
5923                       ELSE pdf_library.pdf_id END",
5924                )
5925                .map_err(|e| e.to_string())?;
5926            for p in pdfs {
5927                let path = normalize_path_for_db(&p.path);
5928                let directory = normalize_path_for_db(&p.directory);
5929                let changed = stmt
5930                    .execute(params![
5931                        p.name,
5932                        path,
5933                        directory,
5934                        p.size as i64,
5935                        p.size_formatted,
5936                        p.modified,
5937                        scan_id
5938                    ])
5939                    .map_err(|e| e.to_string())?;
5940                if changed > 0 {
5941                    let id = tx.last_insert_rowid();
5942                    fts_stmt
5943                        .execute(params![id, p.name, path, scan_id])
5944                        .map_err(|e| e.to_string())?;
5945                    lib_stmt
5946                        .execute(params![path, id])
5947                        .map_err(|e| e.to_string())?;
5948                    inserted += 1;
5949                    batch_bytes += p.size;
5950                }
5951            }
5952        }
5953        if inserted > 0 {
5954            tx.execute(
5955                "UPDATE pdf_scans SET pdf_count = pdf_count + ?2, total_bytes = total_bytes + ?3 WHERE id = ?1",
5956                params![scan_id, inserted as i64, batch_bytes as i64],
5957            ).map_err(|e| e.to_string())?;
5958        }
5959        tx.commit().map_err(|e| e.to_string())?;
5960        if inserted > 0 {
5961            self.invalidate_pdf_library_total_cache();
5962        }
5963        Ok(inserted)
5964    }
5965
5966    // ── Directory scan state (incremental unified walker) ──
5967
5968    /// Load stored directory mtimes for a domain (e.g. `"unified"`).
5969    pub fn load_directory_scan_snapshot(
5970        &self,
5971        domain: &str,
5972    ) -> Result<HashMap<String, i64>, String> {
5973        let conn = self.read_conn();
5974        let mut stmt = conn
5975            .prepare("SELECT path, mtime_secs FROM directory_scan_state WHERE domain = ?1")
5976            .map_err(|e| e.to_string())?;
5977        let rows = stmt
5978            .query_map(params![domain], |row| {
5979                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
5980            })
5981            .map_err(|e| e.to_string())?;
5982        let mut out = HashMap::new();
5983        for r in rows {
5984            let (p, m) = r.map_err(|e| e.to_string())?;
5985            out.insert(p, m);
5986        }
5987        Ok(out)
5988    }
5989
5990    pub fn upsert_directory_scan_batch(
5991        &self,
5992        domain: &str,
5993        rows: &[(String, i64)],
5994        last_scan_id: Option<&str>,
5995    ) -> Result<(), String> {
5996        if rows.is_empty() {
5997            return Ok(());
5998        }
5999        let conn = self.read_conn();
6000        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
6001        {
6002            let mut stmt = tx
6003                .prepare_cached(
6004                    "INSERT OR REPLACE INTO directory_scan_state (domain, path, mtime_secs, last_scan_id) VALUES (?1, ?2, ?3, ?4)",
6005                )
6006                .map_err(|e| e.to_string())?;
6007            for (path, mtime_secs) in rows {
6008                let path = normalize_path_for_db(path);
6009                stmt.execute(params![domain, path, mtime_secs, last_scan_id])
6010                    .map_err(|e| e.to_string())?;
6011            }
6012        }
6013        tx.commit().map_err(|e| e.to_string())?;
6014        Ok(())
6015    }
6016
6017    /// Remove all incremental directory rows for a domain (e.g. `"unified"`).
6018    pub fn delete_directory_scan_state_domain(&self, domain: &str) -> Result<u64, String> {
6019        let conn = self.read_conn();
6020        let n = conn
6021            .execute(
6022                "DELETE FROM directory_scan_state WHERE domain = ?1",
6023                params![domain],
6024            )
6025            .map_err(|e| e.to_string())?;
6026        Ok(n as u64)
6027    }
6028
6029    /// True when the last persisted unified scan finished successfully; incremental mtime
6030    /// snapshots are only trusted in that case.
6031    pub fn unified_scan_incremental_snapshot_is_trusted(&self) -> Result<bool, String> {
6032        let conn = self.read_conn();
6033        let outcome: String = conn
6034            .query_row(
6035                "SELECT outcome FROM unified_scan_run WHERE id = 1",
6036                [],
6037                |row| row.get(0),
6038            )
6039            .map_err(|e| e.to_string())?;
6040        Ok(outcome == "complete")
6041    }
6042
6043    pub fn unified_scan_run_start(
6044        &self,
6045        run_id: &str,
6046        started_at: &str,
6047        audio_scan_id: &str,
6048        daw_scan_id: &str,
6049        preset_scan_id: &str,
6050        pdf_scan_id: &str,
6051        roots_json: &str,
6052    ) -> Result<(), String> {
6053        let conn = self.read_conn();
6054        conn.execute(
6055            "INSERT INTO unified_scan_run (id, run_id, started_at, finished_at, outcome, audio_scan_id, daw_scan_id, preset_scan_id, pdf_scan_id, roots_json, last_directory_path, error_message)
6056             VALUES (1, ?1, ?2, NULL, 'in_progress', ?3, ?4, ?5, ?6, ?7, NULL, NULL)
6057             ON CONFLICT(id) DO UPDATE SET
6058               run_id = excluded.run_id,
6059               started_at = excluded.started_at,
6060               finished_at = NULL,
6061               outcome = 'in_progress',
6062               audio_scan_id = excluded.audio_scan_id,
6063               daw_scan_id = excluded.daw_scan_id,
6064               preset_scan_id = excluded.preset_scan_id,
6065               pdf_scan_id = excluded.pdf_scan_id,
6066               roots_json = excluded.roots_json,
6067               last_directory_path = NULL,
6068               error_message = NULL",
6069            params![
6070                run_id,
6071                started_at,
6072                audio_scan_id,
6073                daw_scan_id,
6074                preset_scan_id,
6075                pdf_scan_id,
6076                roots_json,
6077            ],
6078        )
6079        .map_err(|e| e.to_string())?;
6080        Ok(())
6081    }
6082
6083    /// Persists final unified scan outcome. When `outcome` is not `complete`, clears incremental
6084    /// `directory_scan_state` rows for domain `"unified"` so partial walks are not reused.
6085    pub fn unified_scan_run_finish(
6086        &self,
6087        finished_at: &str,
6088        outcome: &str,
6089        error_message: Option<&str>,
6090        last_directory_path: Option<&str>,
6091    ) -> Result<(), String> {
6092        let conn = self.read_conn();
6093        conn.execute(
6094            "UPDATE unified_scan_run SET finished_at = ?1, outcome = ?2, error_message = ?3, last_directory_path = ?4 WHERE id = 1",
6095            params![
6096                finished_at,
6097                outcome,
6098                error_message,
6099                last_directory_path,
6100            ],
6101        )
6102        .map_err(|e| e.to_string())?;
6103        if outcome != "complete" {
6104            let _ = conn.execute(
6105                "DELETE FROM directory_scan_state WHERE domain = ?1",
6106                params![crate::DIRECTORY_SCAN_INCREMENTAL_DOMAIN],
6107            );
6108        }
6109        Ok(())
6110    }
6111
6112    pub fn get_unified_scan_run(&self) -> Result<UnifiedScanRunRow, String> {
6113        let conn = self.read_conn();
6114        conn.query_row(
6115            "SELECT run_id, started_at, finished_at, outcome, audio_scan_id, daw_scan_id, preset_scan_id, pdf_scan_id, roots_json, last_directory_path, error_message FROM unified_scan_run WHERE id = 1",
6116            [],
6117            |row| {
6118                Ok(UnifiedScanRunRow {
6119                    run_id: row.get(0)?,
6120                    started_at: row.get(1)?,
6121                    finished_at: row.get(2)?,
6122                    outcome: row.get(3)?,
6123                    audio_scan_id: row.get(4)?,
6124                    daw_scan_id: row.get(5)?,
6125                    preset_scan_id: row.get(6)?,
6126                    pdf_scan_id: row.get(7)?,
6127                    roots_json: row.get(8)?,
6128                    last_directory_path: row.get(9)?,
6129                    error_message: row.get(10)?,
6130                })
6131            },
6132        )
6133        .map_err(|e| e.to_string())
6134    }
6135
6136    pub fn save_pdf_scan(&self, snap: &PdfScanSnapshot) -> Result<(), String> {
6137        let conn = self.read_conn();
6138        let roots_json = path_strings_json_normalized(&snap.roots);
6139        conn.execute(
6140            "INSERT OR REPLACE INTO pdf_scans (id, timestamp, pdf_count, total_bytes, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,1)",
6141            params![snap.id, snap.timestamp, snap.pdf_count as i64, snap.total_bytes as i64, roots_json],
6142        ).map_err(|e| e.to_string())?;
6143        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
6144        {
6145            tx.execute(
6146                "CREATE TEMP TABLE _pdf_lib_refresh_paths (path TEXT PRIMARY KEY)",
6147                [],
6148            )
6149            .map_err(|e| e.to_string())?;
6150            tx.execute(
6151                "INSERT INTO _pdf_lib_refresh_paths SELECT DISTINCT path FROM pdfs WHERE scan_id = ?1",
6152                params![snap.id],
6153            )
6154            .map_err(|e| e.to_string())?;
6155            for p in &snap.pdfs {
6156                let path = normalize_path_for_db(&p.path);
6157                tx.execute(
6158                    "INSERT OR IGNORE INTO _pdf_lib_refresh_paths (path) VALUES (?1)",
6159                    params![path],
6160                )
6161                .map_err(|e| e.to_string())?;
6162            }
6163            tx.execute("DELETE FROM pdfs WHERE scan_id = ?1", params![snap.id])
6164                .map_err(|e| e.to_string())?;
6165            tx.execute("DELETE FROM pdfs_fts WHERE scan_id = ?1", params![snap.id])
6166                .map_err(|e| e.to_string())?;
6167            let mut stmt = tx.prepare_cached("INSERT OR IGNORE INTO pdfs (name, path, directory, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7)").map_err(|e| e.to_string())?;
6168            let mut fts_stmt = tx
6169                .prepare_cached(
6170                    "INSERT INTO pdfs_fts(rowid, name, path, scan_id) VALUES (?1,?2,?3,?4)",
6171                )
6172                .map_err(|e| e.to_string())?;
6173            for p in &snap.pdfs {
6174                let path = normalize_path_for_db(&p.path);
6175                let directory = normalize_path_for_db(&p.directory);
6176                let changed = stmt
6177                    .execute(params![
6178                        p.name,
6179                        path,
6180                        directory,
6181                        p.size as i64,
6182                        p.size_formatted,
6183                        p.modified,
6184                        snap.id
6185                    ])
6186                    .map_err(|e| e.to_string())?;
6187                if changed > 0 {
6188                    let id = tx.last_insert_rowid();
6189                    fts_stmt
6190                        .execute(params![id, p.name, path, snap.id])
6191                        .map_err(|e| e.to_string())?;
6192                }
6193            }
6194            Self::sync_pdf_library_after_paths_refresh_tx(&tx)?;
6195        }
6196        tx.commit().map_err(|e| e.to_string())?;
6197        self.invalidate_pdf_library_total_cache();
6198        Ok(())
6199    }
6200
6201    pub fn get_pdf_scans(&self) -> Result<Vec<serde_json::Value>, String> {
6202        let conn = self.read_conn();
6203        let mut stmt = conn.prepare(
6204            "SELECT s.id, s.timestamp, COALESCE(NULLIF(s.pdf_count,0),(SELECT COUNT(*) FROM pdfs WHERE scan_id = s.id)), COALESCE(NULLIF(s.total_bytes,0),(SELECT COALESCE(SUM(size),0) FROM pdfs WHERE scan_id = s.id)), s.roots FROM pdf_scans s WHERE s.scan_complete = 1 ORDER BY s.timestamp DESC",
6205        )
6206        .map_err(|e| e.to_string())?;
6207        let rows = stmt
6208            .query_map([], |row| {
6209                let roots_str: String = row.get(4)?;
6210                Ok(serde_json::json!({
6211                    "id": row.get::<_,String>(0)?,
6212                    "timestamp": row.get::<_,String>(1)?,
6213                    "pdfCount": row.get::<_, i64>(2)? as u64,
6214                    "totalBytes": row.get::<_, i64>(3)? as u64,
6215                    "roots": serde_json::from_str::<Vec<String>>(&roots_str).unwrap_or_default(),
6216                }))
6217            })
6218            .map_err(|e| e.to_string())?;
6219        rows.collect::<Result<Vec<_>, _>>()
6220            .map_err(|e| e.to_string())
6221    }
6222
6223    pub fn get_pdf_scan_detail(&self, id: &str) -> Result<PdfScanSnapshot, String> {
6224        let conn = self.read_conn();
6225        let (ts, pc, tb, roots_str): (String, usize, u64, String) = conn
6226            .query_row(
6227                "SELECT timestamp, pdf_count, total_bytes, roots FROM pdf_scans WHERE id = ?1",
6228                params![id],
6229                |row| {
6230                    Ok((
6231                        row.get(0)?,
6232                        row.get::<_, i64>(1)? as usize,
6233                        row.get::<_, i64>(2)? as u64,
6234                        row.get(3)?,
6235                    ))
6236                },
6237            )
6238            .map_err(|e| e.to_string())?;
6239        let mut stmt = conn
6240            .prepare("SELECT name, path, directory, size, size_formatted, modified FROM pdfs WHERE scan_id = ?1")
6241            .map_err(|e| e.to_string())?;
6242        let pdfs = stmt
6243            .query_map(params![id], |row| {
6244                Ok(PdfFile {
6245                    name: row.get(0)?,
6246                    path: row.get(1)?,
6247                    directory: row.get(2)?,
6248                    size: row.get::<_, i64>(3).unwrap_or(0) as u64,
6249                    size_formatted: row.get(4)?,
6250                    modified: row.get(5)?,
6251                })
6252            })
6253            .map_err(|e| e.to_string())?
6254            .collect::<Result<Vec<_>, _>>()
6255            .map_err(|e| e.to_string())?;
6256        let live_count = pdfs.len();
6257        let live_bytes: u64 = pdfs.iter().map(|p| p.size).sum();
6258        Ok(PdfScanSnapshot {
6259            id: id.to_string(),
6260            timestamp: ts,
6261            pdf_count: if pc > 0 { pc } else { live_count },
6262            total_bytes: if tb > 0 { tb } else { live_bytes },
6263            pdfs,
6264            roots: serde_json::from_str(&roots_str).unwrap_or_default(),
6265        })
6266    }
6267
6268    pub fn get_latest_pdf_scan(&self) -> Result<Option<PdfScanSnapshot>, String> {
6269        let conn = self.read_conn();
6270        let id: Option<String> = conn
6271            .query_row(
6272                "SELECT id FROM pdf_scans WHERE scan_complete = 1 ORDER BY timestamp DESC LIMIT 1",
6273                [],
6274                |r| r.get(0),
6275            )
6276            .optional()
6277            .map_err(|e| e.to_string())?;
6278        drop(conn);
6279        match id {
6280            Some(id) => self.get_pdf_scan_detail(&id).map(Some),
6281            None => Ok(None),
6282        }
6283    }
6284
6285    pub fn delete_pdf_scan(&self, id: &str) -> Result<(), String> {
6286        let conn = self.read_conn();
6287        conn.execute(
6288            "CREATE TEMP TABLE _pdf_lib_refresh_paths (path TEXT PRIMARY KEY)",
6289            [],
6290        )
6291        .map_err(|e| e.to_string())?;
6292        conn.execute(
6293            "INSERT INTO _pdf_lib_refresh_paths SELECT DISTINCT path FROM pdfs WHERE scan_id = ?1",
6294            params![id],
6295        )
6296        .map_err(|e| e.to_string())?;
6297        conn.execute("DELETE FROM pdfs WHERE scan_id = ?1", params![id])
6298            .map_err(|e| e.to_string())?;
6299        conn.execute("DELETE FROM pdfs_fts WHERE scan_id = ?1", params![id])
6300            .map_err(|e| e.to_string())?;
6301        Self::sync_pdf_library_after_paths_refresh(&conn)?;
6302        conn.execute("DELETE FROM pdf_scans WHERE id = ?1", params![id])
6303            .map_err(|e| e.to_string())?;
6304        self.invalidate_pdf_library_total_cache();
6305        Ok(())
6306    }
6307
6308    pub fn clear_pdf_history(&self) -> Result<(), String> {
6309        let conn = self.read_conn();
6310        conn.execute_batch(
6311            "BEGIN IMMEDIATE;
6312             DELETE FROM pdf_library;
6313             DELETE FROM pdfs_fts;
6314             DELETE FROM pdfs;
6315             DELETE FROM pdf_scans;
6316             COMMIT;",
6317        )
6318        .map_err(|e| e.to_string())?;
6319        self.invalidate_pdf_library_total_cache();
6320        Ok(())
6321    }
6322
6323    // ── PDF metadata (page count) ──
6324
6325    /// Paths in the materialized PDF **library** (newest row per filesystem path) that have no
6326    /// `pdf_metadata` row yet. Uses `pdf_library`, not “latest scan only”, so PDFs whose
6327    /// canonical `pdfs` row belongs to an older completed scan are still queued for page counts.
6328    pub fn unindexed_pdf_paths(&self, limit: u64) -> Result<Vec<String>, String> {
6329        let conn = self.read_conn();
6330        let mut stmt = conn
6331            .prepare(
6332                "SELECT p.path FROM pdfs p
6333             INNER JOIN pdf_library lib ON lib.pdf_id = p.id
6334             LEFT JOIN pdf_metadata m ON m.path = p.path
6335             WHERE m.path IS NULL
6336             LIMIT ?1",
6337            )
6338            .map_err(|e| e.to_string())?;
6339        let rows = stmt
6340            .query_map(params![limit as i64], |row| row.get::<_, String>(0))
6341            .map_err(|e| e.to_string())?;
6342        rows.collect::<Result<Vec<_>, _>>()
6343            .map_err(|e| e.to_string())
6344    }
6345
6346    /// Batch upsert PDF page counts. Entries with None page count are still
6347    /// inserted (as a negative marker) so we don't re-attempt broken files.
6348    pub fn save_pdf_metadata(&self, batch: &[(String, Option<u32>)]) -> Result<(), String> {
6349        if batch.is_empty() {
6350            return Ok(());
6351        }
6352        let conn = self.read_conn();
6353        let now = chrono::Utc::now()
6354            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
6355            .to_string();
6356        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
6357        {
6358            let mut stmt = tx
6359                .prepare_cached("INSERT OR REPLACE INTO pdf_metadata (path, pages, updated_at) VALUES (?1, ?2, ?3)")
6360                .map_err(|e| e.to_string())?;
6361            for (path, pages) in batch {
6362                let path = normalize_path_for_db(path);
6363                let pages_i: Option<i64> = pages.map(|n| n as i64);
6364                stmt.execute(params![path, pages_i, now])
6365                    .map_err(|e| e.to_string())?;
6366            }
6367        }
6368        tx.commit().map_err(|e| e.to_string())
6369    }
6370
6371    /// Get page counts for a set of paths (returns only entries that exist).
6372    pub fn get_pdf_metadata(
6373        &self,
6374        paths: &[String],
6375    ) -> Result<std::collections::HashMap<String, Option<u32>>, String> {
6376        if paths.is_empty() {
6377            return Ok(std::collections::HashMap::new());
6378        }
6379        let conn = self.read_conn();
6380        let mut out = std::collections::HashMap::new();
6381        // SQLite IN clause with ~999 param limit — chunk to be safe.
6382        for chunk in paths.chunks(500) {
6383            let placeholders: Vec<String> = (1..=chunk.len()).map(|i| format!("?{i}")).collect();
6384            let sql = format!(
6385                "SELECT path, pages FROM pdf_metadata WHERE path IN ({})",
6386                placeholders.join(",")
6387            );
6388            let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
6389            for (i, p) in chunk.iter().enumerate() {
6390                let p = normalize_path_for_db(p);
6391                stmt.raw_bind_parameter(i + 1, p)
6392                    .map_err(|e| e.to_string())?;
6393            }
6394            let mut rows = stmt.raw_query();
6395            while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6396                let path: String = row.get(0).unwrap_or_default();
6397                let pages: Option<i64> = row.get(1).ok();
6398                out.insert(
6399                    path,
6400                    pages.and_then(|n| if n >= 0 { Some(n as u32) } else { None }),
6401                );
6402            }
6403        }
6404        Ok(out)
6405    }
6406
6407    pub fn clear_pdf_metadata(&self) -> Result<(), String> {
6408        let conn = self.read_conn();
6409        conn.execute_batch("DELETE FROM pdf_metadata;")
6410            .map_err(|e| e.to_string())
6411    }
6412
6413    // ── Paginated PDF query ──
6414    pub fn query_pdfs(
6415        &self,
6416        search: Option<&str>,
6417        sort_key: &str,
6418        sort_asc: bool,
6419        search_regex: bool,
6420        offset: u64,
6421        limit: u64,
6422    ) -> Result<PdfQueryResult, String> {
6423        let conn = self.read_conn();
6424        let total_unfiltered: u64 = self.pdf_library_total_rows(&conn)?;
6425        if total_unfiltered == 0 {
6426            return Ok(PdfQueryResult {
6427                pdfs: vec![],
6428                total_count: 0,
6429                total_count_capped: false,
6430                total_unfiltered: 0,
6431            });
6432        }
6433
6434        let mut where_parts = vec![PDF_LIBRARY_IDS.to_string()];
6435        let mut bind_idx = 1usize;
6436        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
6437        if fts_match.is_some() {
6438            // Library scope is already `PDF_LIBRARY_IDS`; do not nest a second
6439            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
6440            where_parts.push(format!(
6441                "id IN (SELECT rowid FROM pdfs_fts WHERE pdfs_fts MATCH ?{bind_idx})",
6442            ));
6443            bind_idx += 1;
6444        } else if regex_pat.is_some() {
6445            where_parts.push(format!(
6446                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
6447            ));
6448            bind_idx += 1;
6449        } else if like_pat.is_some() {
6450            where_parts.push(format!(
6451                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
6452            ));
6453            bind_idx += 1;
6454        }
6455        let where_cl = where_parts.join(" AND ");
6456
6457        let sort_col = match sort_key {
6458            "name" => "name COLLATE NOCASE",
6459            "size" => "size",
6460            "modified" => "modified",
6461            "directory" => "directory COLLATE NOCASE",
6462            _ => "name COLLATE NOCASE",
6463        };
6464        let dir = if sort_asc { "ASC" } else { "DESC" };
6465
6466        let (total_count, total_count_capped) = if fts_match.is_some() {
6467            let m = fts_match.as_ref().expect("fts");
6468            Self::pdf_fts_bounded_count_library(&conn, m)?
6469        } else {
6470            let sql = format!("SELECT COUNT(*) FROM pdfs WHERE {where_cl}");
6471            let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
6472            let bi = 1;
6473            if let Some(ref r) = regex_pat {
6474                stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
6475            } else if let Some(ref pat) = like_pat {
6476                stmt.raw_bind_parameter(bi, pat)
6477                    .map_err(|e| e.to_string())?;
6478            }
6479            let _ = bi;
6480            let mut rows = stmt.raw_query();
6481            let n = rows
6482                .next()
6483                .map_err(|e| e.to_string())?
6484                .map(|r| r.get::<_, i64>(0).unwrap_or(0) as u64)
6485                .unwrap_or(0);
6486            (n, false)
6487        };
6488
6489        // FTS substring search: ORDER BY column sorts the entire match set before LIMIT (stalls).
6490        // Use bm25 + LIMIT like MIDI; frontend may re-rank with `searchScore`.
6491        let use_fts_bm25 = fts_match.is_some();
6492        let sql = if use_fts_bm25 {
6493            String::from(
6494                "SELECT p.name, p.path, p.directory, p.size, p.size_formatted, p.modified
6495                 FROM pdfs_fts
6496                 INNER JOIN pdfs p ON p.id = pdfs_fts.rowid
6497                 INNER JOIN pdf_library lib ON lib.pdf_id = p.id
6498                 WHERE pdfs_fts MATCH ?1
6499                 ORDER BY bm25(pdfs_fts) LIMIT ?2 OFFSET ?3",
6500            )
6501        } else {
6502            format!(
6503                "SELECT name, path, directory, size, size_formatted, modified FROM pdfs WHERE {where_cl} ORDER BY {sort_col} {dir} LIMIT ?{bind_idx} OFFSET ?{}",
6504                bind_idx + 1
6505            )
6506        };
6507        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
6508        let mut bi = 1;
6509        if use_fts_bm25 {
6510            let m = fts_match.as_ref().expect("fts");
6511            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
6512            stmt.raw_bind_parameter(bi + 1, limit as i64)
6513                .map_err(|e| e.to_string())?;
6514            stmt.raw_bind_parameter(bi + 2, offset as i64)
6515                .map_err(|e| e.to_string())?;
6516        } else if let Some(ref r) = regex_pat {
6517            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
6518            bi += 1;
6519        } else if let Some(ref pat) = like_pat {
6520            stmt.raw_bind_parameter(bi, pat)
6521                .map_err(|e| e.to_string())?;
6522            bi += 1;
6523        }
6524        if !use_fts_bm25 {
6525            stmt.raw_bind_parameter(bi, limit as i64)
6526                .map_err(|e| e.to_string())?;
6527            stmt.raw_bind_parameter(bi + 1, offset as i64)
6528                .map_err(|e| e.to_string())?;
6529        }
6530
6531        let mut pdfs = Vec::new();
6532        let mut rows = stmt.raw_query();
6533        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6534            pdfs.push(PdfRow {
6535                name: row.get(0).unwrap_or_default(),
6536                path: row.get(1).unwrap_or_default(),
6537                directory: row.get(2).unwrap_or_default(),
6538                size: row.get::<_, i64>(3).unwrap_or(0) as u64,
6539                size_formatted: row.get(4).unwrap_or_default(),
6540                modified: row.get(5).unwrap_or_default(),
6541            });
6542        }
6543        Ok(PdfQueryResult {
6544            pdfs,
6545            total_count,
6546            total_count_capped,
6547            total_unfiltered,
6548        })
6549    }
6550
6551    /// PDF aggregate stats. `scan_id` None or empty → full library (deduped by path).
6552    pub fn pdf_stats(&self, scan_id: Option<&str>) -> Result<PdfStatsResult, String> {
6553        let conn = self.read_conn();
6554        let library = scan_id.map(|s| s.is_empty()).unwrap_or(true);
6555        if library {
6556            let pdf_count: u64 = conn
6557                .query_row(
6558                    "SELECT COUNT(*) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
6559                    [],
6560                    |row| row.get::<_, i64>(0).map(|v| v as u64),
6561                )
6562                .unwrap_or(0);
6563            let total_bytes: u64 = conn
6564                .query_row(
6565                    "SELECT COALESCE(SUM(size), 0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
6566                    [],
6567                    |row| row.get::<_, i64>(0).map(|v| v as u64),
6568                )
6569                .unwrap_or(0);
6570            return Ok(PdfStatsResult {
6571                pdf_count,
6572                total_bytes,
6573            });
6574        }
6575
6576        let sid = scan_id.expect("scan_id").to_string();
6577        if sid.is_empty() {
6578            return Ok(PdfStatsResult {
6579                pdf_count: 0,
6580                total_bytes: 0,
6581            });
6582        }
6583        let pdf_count: u64 = conn
6584            .query_row(
6585                "SELECT COUNT(*) FROM pdfs WHERE scan_id = ?1",
6586                params![sid],
6587                |row| row.get::<_, i64>(0).map(|v| v as u64),
6588            )
6589            .map_err(|e| e.to_string())?;
6590        let total_bytes: u64 = conn
6591            .query_row(
6592                "SELECT COALESCE(SUM(size), 0) FROM pdfs WHERE scan_id = ?1",
6593                params![sid],
6594                |row| row.get::<_, i64>(0).map(|v| v as u64),
6595            )
6596            .map_err(|e| e.to_string())?;
6597        Ok(PdfStatsResult {
6598            pdf_count,
6599            total_bytes,
6600        })
6601    }
6602
6603    // ── Filter-aware aggregate stats ──
6604    // Each returns count + total_bytes + per-type breakdown reflecting the active
6605    // search/filter over library rows (deduped by path). Uses GROUP BY for the breakdown.
6606
6607    fn in_list_sql(values: &str) -> String {
6608        values
6609            .split(',')
6610            .map(|s| format!("'{}'", s.trim().replace('\'', "''")))
6611            .collect::<Vec<_>>()
6612            .join(",")
6613    }
6614
6615    /// First three path segments of `directory`, matching `heatmap-dashboard.js` `buildFolderCard`.
6616    fn heatmap_audio_folder_key(dir: &str) -> String {
6617        let parts: Vec<&str> = dir
6618            .split(|c| c == '/' || c == '\\')
6619            .filter(|s| !s.is_empty())
6620            .collect();
6621        let n = parts.len().min(3);
6622        if n == 0 {
6623            return "/".to_string();
6624        }
6625        format!("/{}", parts[..n].join("/"))
6626    }
6627
6628    /// BPM/key/folder aggregates for the heatmap dashboard (same `where_cl` as `audio_filter_stats`).
6629    fn audio_heatmap_aggregates(
6630        conn: &Connection,
6631        where_cl: &str,
6632        fts_match: &Option<String>,
6633        regex_pat: &Option<String>,
6634        like_pat: &Option<String>,
6635        format_filter: Option<&str>,
6636    ) -> Result<(Vec<u64>, u64, HashMap<String, u64>, u64, Vec<TopFolderRow>), String> {
6637        let mut bin_cases = Vec::with_capacity(34);
6638        for i in 0..34 {
6639            let lo = 50 + i * 5;
6640            let hi = lo + 5;
6641            bin_cases.push(format!(
6642                "COALESCE(SUM(CASE WHEN bpm IS NOT NULL AND bpm >= {lo} AND bpm < {hi} THEN 1 ELSE 0 END),0)"
6643            ));
6644        }
6645        let sql_bpm = format!(
6646            "SELECT COALESCE(SUM(CASE WHEN bpm IS NOT NULL THEN 1 ELSE 0 END),0), {} FROM audio_samples WHERE {}",
6647            bin_cases.join(", "),
6648            where_cl
6649        );
6650        let mut bpm_buckets = vec![0u64; 34];
6651        let mut bpm_analyzed_count = 0u64;
6652        let mut stmt = conn.prepare(&sql_bpm).map_err(|e| e.to_string())?;
6653        let mut bi = 1;
6654        if let Some(m) = fts_match {
6655            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
6656            bi += 1;
6657        } else if let Some(r) = regex_pat {
6658            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
6659            bi += 1;
6660        } else if let Some(pat) = like_pat {
6661            stmt.raw_bind_parameter(bi, pat)
6662                .map_err(|e| e.to_string())?;
6663            bi += 1;
6664        }
6665        if let Some(f) = format_filter {
6666            if !f.is_empty() && f != "all" && !f.contains(',') {
6667                stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
6668            }
6669        }
6670        let mut rows = stmt.raw_query();
6671        if let Some(row) = rows.next().map_err(|e| e.to_string())? {
6672            bpm_analyzed_count = row.get::<_, i64>(0).unwrap_or(0).max(0) as u64;
6673            for i in 0..34 {
6674                bpm_buckets[i] = row.get::<_, i64>(i + 1).unwrap_or(0).max(0) as u64;
6675            }
6676        }
6677
6678        let sql_key = format!(
6679            "SELECT key_name, COUNT(*) AS c FROM audio_samples WHERE {} AND key_name IS NOT NULL AND TRIM(key_name) != '' GROUP BY key_name ORDER BY c DESC LIMIT 32",
6680            where_cl
6681        );
6682        let mut key_counts: HashMap<String, u64> = HashMap::new();
6683        let mut key_analyzed_count = 0u64;
6684        let mut stmt_k = conn.prepare(&sql_key).map_err(|e| e.to_string())?;
6685        let mut bi_k = 1;
6686        if let Some(m) = fts_match {
6687            stmt_k.raw_bind_parameter(bi_k, m).map_err(|e| e.to_string())?;
6688            bi_k += 1;
6689        } else if let Some(r) = regex_pat {
6690            stmt_k.raw_bind_parameter(bi_k, r).map_err(|e| e.to_string())?;
6691            bi_k += 1;
6692        } else if let Some(pat) = like_pat {
6693            stmt_k
6694                .raw_bind_parameter(bi_k, pat)
6695                .map_err(|e| e.to_string())?;
6696            bi_k += 1;
6697        }
6698        if let Some(f) = format_filter {
6699            if !f.is_empty() && f != "all" && !f.contains(',') {
6700                stmt_k.raw_bind_parameter(bi_k, f).map_err(|e| e.to_string())?;
6701            }
6702        }
6703        let mut rows_k = stmt_k.raw_query();
6704        while let Some(row) = rows_k.next().map_err(|e| e.to_string())? {
6705            let k: String = row.get(0).unwrap_or_default();
6706            let c: u64 = row.get::<_, i64>(1).unwrap_or(0).max(0) as u64;
6707            if !k.is_empty() {
6708                key_counts.insert(k, c);
6709                key_analyzed_count += c;
6710            }
6711        }
6712
6713        let sql_dir = format!(
6714            "SELECT directory FROM audio_samples WHERE {} AND TRIM(directory) != ''",
6715            where_cl
6716        );
6717        let mut folder_acc: HashMap<String, u64> = HashMap::new();
6718        let mut stmt_d = conn.prepare(&sql_dir).map_err(|e| e.to_string())?;
6719        let mut bi_d = 1;
6720        if let Some(m) = fts_match {
6721            stmt_d.raw_bind_parameter(bi_d, m).map_err(|e| e.to_string())?;
6722            bi_d += 1;
6723        } else if let Some(r) = regex_pat {
6724            stmt_d.raw_bind_parameter(bi_d, r).map_err(|e| e.to_string())?;
6725            bi_d += 1;
6726        } else if let Some(pat) = like_pat {
6727            stmt_d
6728                .raw_bind_parameter(bi_d, pat)
6729                .map_err(|e| e.to_string())?;
6730            bi_d += 1;
6731        }
6732        if let Some(f) = format_filter {
6733            if !f.is_empty() && f != "all" && !f.contains(',') {
6734                stmt_d.raw_bind_parameter(bi_d, f).map_err(|e| e.to_string())?;
6735            }
6736        }
6737        let mut rows_d = stmt_d.raw_query();
6738        while let Some(row) = rows_d.next().map_err(|e| e.to_string())? {
6739            let dir: String = row.get(0).unwrap_or_default();
6740            let key = Self::heatmap_audio_folder_key(&dir);
6741            *folder_acc.entry(key).or_insert(0) += 1;
6742        }
6743        let mut top_pairs: Vec<(String, u64)> = folder_acc.into_iter().collect();
6744        top_pairs.sort_by(|a, b| b.1.cmp(&a.1));
6745        let top_folders: Vec<TopFolderRow> = top_pairs
6746            .into_iter()
6747            .take(12)
6748            .map(|(path, count)| TopFolderRow { path, count })
6749            .collect();
6750
6751        Ok((
6752            bpm_buckets,
6753            bpm_analyzed_count,
6754            key_counts,
6755            key_analyzed_count,
6756            top_folders,
6757        ))
6758    }
6759
6760    pub fn audio_filter_stats(
6761        &self,
6762        search: Option<&str>,
6763        format_filter: Option<&str>,
6764        search_regex: bool,
6765    ) -> Result<FilterStatsResult, String> {
6766        let conn = self.read_conn();
6767        let total_unfiltered: u64 = self.audio_library_total_rows(&conn)?;
6768        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
6769        let mut where_parts = vec![AUDIO_LIBRARY_IDS.to_string()];
6770        let mut bind_idx = 1usize;
6771        if fts_match.is_some() {
6772            where_parts.push(format!(
6773                "id IN (SELECT rowid FROM audio_samples_fts WHERE audio_samples_fts MATCH ?{bind_idx})",
6774            ));
6775            bind_idx += 1;
6776        } else if regex_pat.is_some() {
6777            where_parts.push(format!(
6778                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
6779            ));
6780            bind_idx += 1;
6781        } else if like_pat.is_some() {
6782            where_parts.push(format!(
6783                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
6784            ));
6785            bind_idx += 1;
6786        }
6787        if let Some(f) = format_filter {
6788            if !f.is_empty() && f != "all" {
6789                if f.contains(',') {
6790                    where_parts.push(format!("format IN ({})", Self::in_list_sql(f)));
6791                } else {
6792                    where_parts.push(format!("format = ?{bind_idx}"));
6793                }
6794            }
6795        }
6796        let where_cl = where_parts.join(" AND ");
6797        if fts_match.is_some() {
6798            let m = fts_match.as_ref().expect("fts");
6799            let (bc, capped) = Self::audio_fts_bounded_count_library(&conn, m, format_filter)?;
6800            if bc == 0 {
6801                return Ok(FilterStatsResult {
6802                    count: 0,
6803                    count_capped: false,
6804                    total_bytes: 0,
6805                    by_type: HashMap::new(),
6806                    bytes_by_type: HashMap::new(),
6807                    total_unfiltered,
6808                    size_buckets: vec![0; 6],
6809                    ..Default::default()
6810                });
6811            }
6812            if capped {
6813                return Ok(FilterStatsResult {
6814                    count: bc,
6815                    count_capped: true,
6816                    total_bytes: 0,
6817                    by_type: HashMap::new(),
6818                    bytes_by_type: HashMap::new(),
6819                    total_unfiltered,
6820                    size_buckets: vec![0; 6],
6821                    ..Default::default()
6822                });
6823            }
6824        }
6825        let sql = format!(
6826            "SELECT format, COUNT(*), COALESCE(SUM(size),0) FROM audio_samples WHERE {where_cl} GROUP BY format"
6827        );
6828        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
6829        let mut bi = 1;
6830        if let Some(ref m) = fts_match {
6831            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
6832            bi += 1;
6833        } else if let Some(ref r) = regex_pat {
6834            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
6835            bi += 1;
6836        } else if let Some(ref pat) = like_pat {
6837            stmt.raw_bind_parameter(bi, pat)
6838                .map_err(|e| e.to_string())?;
6839            bi += 1;
6840        }
6841        if let Some(f) = format_filter {
6842            if !f.is_empty() && f != "all" && !f.contains(',') {
6843                stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
6844            }
6845        }
6846        let _ = bi;
6847        let mut rows = stmt.raw_query();
6848        let mut count = 0u64;
6849        let mut total_bytes = 0u64;
6850        let mut by_type = HashMap::new();
6851        let mut bytes_by_type = HashMap::new();
6852        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6853            let fmt: String = row.get(0).unwrap_or_default();
6854            let n: u64 = row.get::<_, i64>(1).unwrap_or(0) as u64;
6855            let sz: u64 = row.get::<_, i64>(2).unwrap_or(0) as u64;
6856            count += n;
6857            total_bytes += sz;
6858            by_type.insert(fmt.clone(), n);
6859            bytes_by_type.insert(fmt, sz);
6860        }
6861        // Size histogram for heatmap (same 6 buckets as `heatmap-dashboard.js` — library scope + filters).
6862        let sql_buckets = format!(
6863            "SELECT \
6864             COALESCE(SUM(CASE WHEN COALESCE(size,0) < 102400 THEN 1 ELSE 0 END),0),\
6865             COALESCE(SUM(CASE WHEN COALESCE(size,0) >= 102400 AND COALESCE(size,0) < 1048576 THEN 1 ELSE 0 END),0),\
6866             COALESCE(SUM(CASE WHEN COALESCE(size,0) >= 1048576 AND COALESCE(size,0) < 10485760 THEN 1 ELSE 0 END),0),\
6867             COALESCE(SUM(CASE WHEN COALESCE(size,0) >= 10485760 AND COALESCE(size,0) < 52428800 THEN 1 ELSE 0 END),0),\
6868             COALESCE(SUM(CASE WHEN COALESCE(size,0) >= 52428800 AND COALESCE(size,0) < 104857600 THEN 1 ELSE 0 END),0),\
6869             COALESCE(SUM(CASE WHEN COALESCE(size,0) >= 104857600 THEN 1 ELSE 0 END),0)\
6870             FROM audio_samples WHERE {where_cl}"
6871        );
6872        let mut stmt_b = conn.prepare(&sql_buckets).map_err(|e| e.to_string())?;
6873        let mut bi_b = 1;
6874        if let Some(ref m) = fts_match {
6875            stmt_b.raw_bind_parameter(bi_b, m).map_err(|e| e.to_string())?;
6876            bi_b += 1;
6877        } else if let Some(ref r) = regex_pat {
6878            stmt_b.raw_bind_parameter(bi_b, r).map_err(|e| e.to_string())?;
6879            bi_b += 1;
6880        } else if let Some(ref pat) = like_pat {
6881            stmt_b
6882                .raw_bind_parameter(bi_b, pat)
6883                .map_err(|e| e.to_string())?;
6884            bi_b += 1;
6885        }
6886        if let Some(f) = format_filter {
6887            if !f.is_empty() && f != "all" && !f.contains(',') {
6888                stmt_b.raw_bind_parameter(bi_b, f).map_err(|e| e.to_string())?;
6889            }
6890        }
6891        let size_buckets: Vec<u64> = {
6892            let mut rows_b = stmt_b.raw_query();
6893            if let Some(row) = rows_b.next().map_err(|e| e.to_string())? {
6894                (0..6)
6895                    .map(|i| row.get::<_, i64>(i).unwrap_or(0).max(0) as u64)
6896                    .collect()
6897            } else {
6898                vec![0; 6]
6899            }
6900        };
6901        let (
6902            bpm_buckets,
6903            bpm_analyzed_count,
6904            key_counts,
6905            key_analyzed_count,
6906            top_folders,
6907        ) = Self::audio_heatmap_aggregates(
6908            &conn,
6909            &where_cl,
6910            &fts_match,
6911            &regex_pat,
6912            &like_pat,
6913            format_filter,
6914        )?;
6915        Ok(FilterStatsResult {
6916            count,
6917            count_capped: false,
6918            total_bytes,
6919            by_type,
6920            bytes_by_type,
6921            total_unfiltered,
6922            size_buckets,
6923            bpm_buckets,
6924            bpm_analyzed_count,
6925            key_counts,
6926            key_analyzed_count,
6927            top_folders,
6928        })
6929    }
6930
6931    pub fn daw_filter_stats(
6932        &self,
6933        search: Option<&str>,
6934        daw_filter: Option<&str>,
6935        search_regex: bool,
6936    ) -> Result<FilterStatsResult, String> {
6937        let conn = self.read_conn();
6938        let total_unfiltered: u64 = self.daw_library_total_rows(&conn)?;
6939        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
6940        let mut where_parts = vec![DAW_LIBRARY_IDS.to_string()];
6941        let mut bind_idx = 1usize;
6942        if fts_match.is_some() {
6943            // Library scope is already `DAW_LIBRARY_IDS`; do not nest a second
6944            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
6945            where_parts.push(format!(
6946                "id IN (SELECT rowid FROM daw_projects_fts WHERE daw_projects_fts MATCH ?{bind_idx})",
6947            ));
6948            bind_idx += 1;
6949        } else if regex_pat.is_some() {
6950            where_parts.push(format!(
6951                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
6952            ));
6953            bind_idx += 1;
6954        } else if like_pat.is_some() {
6955            where_parts.push(format!(
6956                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
6957            ));
6958            bind_idx += 1;
6959        }
6960        if let Some(f) = daw_filter {
6961            if !f.is_empty() && f != "all" {
6962                if f.contains(',') {
6963                    where_parts.push(format!("daw IN ({})", Self::in_list_sql(f)));
6964                } else {
6965                    where_parts.push(format!("daw = ?{bind_idx}"));
6966                }
6967            }
6968        }
6969        let where_cl = where_parts.join(" AND ");
6970        if fts_match.is_some() {
6971            let m = fts_match.as_ref().expect("fts");
6972            let (bc, capped) = Self::daw_fts_bounded_count_library(&conn, m, daw_filter)?;
6973            if bc == 0 {
6974                return Ok(FilterStatsResult {
6975                    count: 0,
6976                    count_capped: false,
6977                    total_bytes: 0,
6978                    by_type: HashMap::new(),
6979                    bytes_by_type: HashMap::new(),
6980                    total_unfiltered,
6981                    size_buckets: vec![],
6982                    ..Default::default()
6983                });
6984            }
6985            if capped {
6986                return Ok(FilterStatsResult {
6987                    count: bc,
6988                    count_capped: true,
6989                    total_bytes: 0,
6990                    by_type: HashMap::new(),
6991                    bytes_by_type: HashMap::new(),
6992                    total_unfiltered,
6993                    size_buckets: vec![],
6994                    ..Default::default()
6995                });
6996            }
6997        }
6998        let sql = format!(
6999            "SELECT daw, COUNT(*), COALESCE(SUM(size),0) FROM daw_projects WHERE {where_cl} GROUP BY daw"
7000        );
7001        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
7002        let mut bi = 1;
7003        if let Some(ref m) = fts_match {
7004            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
7005            bi += 1;
7006        } else if let Some(ref r) = regex_pat {
7007            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
7008            bi += 1;
7009        } else if let Some(ref pat) = like_pat {
7010            stmt.raw_bind_parameter(bi, pat)
7011                .map_err(|e| e.to_string())?;
7012            bi += 1;
7013        }
7014        if let Some(f) = daw_filter {
7015            if !f.is_empty() && f != "all" && !f.contains(',') {
7016                stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
7017            }
7018        }
7019        let _ = bi;
7020        let mut rows = stmt.raw_query();
7021        let mut count = 0u64;
7022        let mut total_bytes = 0u64;
7023        let mut by_type = HashMap::new();
7024        let mut bytes_by_type = HashMap::new();
7025        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7026            let daw: String = row.get(0).unwrap_or_default();
7027            let n: u64 = row.get::<_, i64>(1).unwrap_or(0) as u64;
7028            let sz: u64 = row.get::<_, i64>(2).unwrap_or(0) as u64;
7029            count += n;
7030            total_bytes += sz;
7031            by_type.insert(daw.clone(), n);
7032            bytes_by_type.insert(daw, sz);
7033        }
7034        Ok(FilterStatsResult {
7035            count,
7036            count_capped: false,
7037            total_bytes,
7038            by_type,
7039            bytes_by_type,
7040            total_unfiltered,
7041            size_buckets: vec![],
7042            ..Default::default()
7043        })
7044    }
7045
7046    pub fn preset_filter_stats(
7047        &self,
7048        search: Option<&str>,
7049        format_filter: Option<&str>,
7050        search_regex: bool,
7051    ) -> Result<FilterStatsResult, String> {
7052        let conn = self.read_conn();
7053        let total_unfiltered: u64 = self.preset_inventory_total_rows(&conn)?;
7054        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
7055        let mut where_parts = vec![
7056            PRESET_LIBRARY_IDS.to_string(),
7057            "format NOT IN ('MID','MIDI')".to_string(),
7058        ];
7059        let mut bind_idx = 1usize;
7060        if fts_match.is_some() {
7061            // Library scope is already `PRESET_LIBRARY_IDS`; do not nest a second
7062            // `MAX(id) GROUP BY path` inside the FTS subquery (same semantics, worse plan).
7063            where_parts.push(format!(
7064                "id IN (SELECT rowid FROM presets_fts WHERE presets_fts MATCH ?{bind_idx})",
7065            ));
7066            bind_idx += 1;
7067        } else if regex_pat.is_some() {
7068            where_parts.push(format!(
7069                "((name REGEXP ?{bind_idx}) OR (path REGEXP ?{bind_idx}))"
7070            ));
7071            bind_idx += 1;
7072        } else if like_pat.is_some() {
7073            where_parts.push(format!(
7074                "(name LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"
7075            ));
7076            bind_idx += 1;
7077        }
7078        if let Some(f) = format_filter {
7079            if !f.is_empty() && f != "all" {
7080                if f.contains(',') {
7081                    where_parts.push(format!("format IN ({})", Self::in_list_sql(f)));
7082                } else {
7083                    where_parts.push(format!("format = ?{bind_idx}"));
7084                }
7085            }
7086        }
7087        let where_cl = where_parts.join(" AND ");
7088        if fts_match.is_some() {
7089            let m = fts_match.as_ref().expect("fts");
7090            let (bc, capped) = Self::preset_fts_bounded_count_library(&conn, m, format_filter)?;
7091            if bc == 0 {
7092                return Ok(FilterStatsResult {
7093                    count: 0,
7094                    count_capped: false,
7095                    total_bytes: 0,
7096                    by_type: HashMap::new(),
7097                    bytes_by_type: HashMap::new(),
7098                    total_unfiltered,
7099                    size_buckets: vec![],
7100                    ..Default::default()
7101                });
7102            }
7103            if capped {
7104                return Ok(FilterStatsResult {
7105                    count: bc,
7106                    count_capped: true,
7107                    total_bytes: 0,
7108                    by_type: HashMap::new(),
7109                    bytes_by_type: HashMap::new(),
7110                    total_unfiltered,
7111                    size_buckets: vec![],
7112                    ..Default::default()
7113                });
7114            }
7115        }
7116        let sql = format!(
7117            "SELECT format, COUNT(*), COALESCE(SUM(size),0) FROM presets WHERE {where_cl} GROUP BY format"
7118        );
7119        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
7120        let mut bi = 1;
7121        if let Some(ref m) = fts_match {
7122            stmt.raw_bind_parameter(bi, m).map_err(|e| e.to_string())?;
7123            bi += 1;
7124        } else if let Some(ref r) = regex_pat {
7125            stmt.raw_bind_parameter(bi, r).map_err(|e| e.to_string())?;
7126            bi += 1;
7127        } else if let Some(ref pat) = like_pat {
7128            stmt.raw_bind_parameter(bi, pat)
7129                .map_err(|e| e.to_string())?;
7130            bi += 1;
7131        }
7132        if let Some(f) = format_filter {
7133            if !f.is_empty() && f != "all" && !f.contains(',') {
7134                stmt.raw_bind_parameter(bi, f).map_err(|e| e.to_string())?;
7135            }
7136        }
7137        let _ = bi;
7138        let mut rows = stmt.raw_query();
7139        let mut count = 0u64;
7140        let mut total_bytes = 0u64;
7141        let mut by_type = HashMap::new();
7142        let mut bytes_by_type = HashMap::new();
7143        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7144            let fmt: String = row.get(0).unwrap_or_default();
7145            let n: u64 = row.get::<_, i64>(1).unwrap_or(0) as u64;
7146            let sz: u64 = row.get::<_, i64>(2).unwrap_or(0) as u64;
7147            count += n;
7148            total_bytes += sz;
7149            by_type.insert(fmt.clone(), n);
7150            bytes_by_type.insert(fmt, sz);
7151        }
7152        Ok(FilterStatsResult {
7153            count,
7154            count_capped: false,
7155            total_bytes,
7156            by_type,
7157            bytes_by_type,
7158            total_unfiltered,
7159            size_buckets: vec![],
7160            ..Default::default()
7161        })
7162    }
7163
7164    pub fn plugin_filter_stats(
7165        &self,
7166        search: Option<&str>,
7167        type_filter: Option<&str>,
7168        search_regex: bool,
7169    ) -> Result<FilterStatsResult, String> {
7170        let conn = self.read_conn();
7171        let total_unfiltered: u64 = self.plugin_library_total_rows(&conn)?;
7172        let (regex_pat, like_pat) = classify_plugins_search(search, search_regex);
7173        let mut where_parts = vec![PLUGIN_LIBRARY_IDS.to_string()];
7174        let mut bind_idx = 1usize;
7175        if regex_pat.is_some() {
7176            where_parts.push(format!("(name REGEXP ?{bind_idx} OR manufacturer REGEXP ?{bind_idx} OR path REGEXP ?{bind_idx})"));
7177            bind_idx += 1;
7178        } else if like_pat.is_some() {
7179            where_parts.push(format!("(name LIKE ?{bind_idx} ESCAPE '\\' OR manufacturer LIKE ?{bind_idx} ESCAPE '\\' OR path LIKE ?{bind_idx} ESCAPE '\\')"));
7180            bind_idx += 1;
7181        }
7182        if let Some(tf) = type_filter {
7183            if !tf.is_empty() && tf != "all" {
7184                if tf.contains(',') {
7185                    where_parts.push(format!("plugin_type IN ({})", Self::in_list_sql(tf)));
7186                } else {
7187                    where_parts.push(format!("plugin_type = ?{bind_idx}"));
7188                }
7189            }
7190        }
7191        let where_cl = where_parts.join(" AND ");
7192        if regex_pat.is_some() || like_pat.is_some() {
7193            let (bc, capped) = Self::plugin_search_bounded_count_library(
7194                &conn,
7195                "FROM plugins",
7196                &where_cl,
7197                &regex_pat,
7198                &like_pat,
7199                type_filter,
7200            )?;
7201            if bc == 0 {
7202                return Ok(FilterStatsResult {
7203                    count: 0,
7204                    count_capped: false,
7205                    total_bytes: 0,
7206                    by_type: HashMap::new(),
7207                    bytes_by_type: HashMap::new(),
7208                    total_unfiltered,
7209                    size_buckets: vec![],
7210                    ..Default::default()
7211                });
7212            }
7213            if capped {
7214                return Ok(FilterStatsResult {
7215                    count: bc,
7216                    count_capped: true,
7217                    total_bytes: 0,
7218                    by_type: HashMap::new(),
7219                    bytes_by_type: HashMap::new(),
7220                    total_unfiltered,
7221                    size_buckets: vec![],
7222                    ..Default::default()
7223                });
7224            }
7225        }
7226        let sql = format!(
7227            "SELECT plugin_type, COUNT(*), COALESCE(SUM(size_bytes),0) FROM plugins WHERE {where_cl} GROUP BY plugin_type"
7228        );
7229        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
7230        let mut bi = 1;
7231        if let Some(ref p) = regex_pat.as_ref().or(like_pat.as_ref()) {
7232            stmt.raw_bind_parameter(bi, p).map_err(|e| e.to_string())?;
7233            bi += 1;
7234        }
7235        if let Some(tf) = type_filter {
7236            if !tf.is_empty() && tf != "all" && !tf.contains(',') {
7237                stmt.raw_bind_parameter(bi, tf).map_err(|e| e.to_string())?;
7238            }
7239        }
7240        let _ = bi;
7241        let mut rows = stmt.raw_query();
7242        let mut count = 0u64;
7243        let mut total_bytes = 0u64;
7244        let mut by_type = HashMap::new();
7245        let mut bytes_by_type = HashMap::new();
7246        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7247            let t: String = row.get(0).unwrap_or_default();
7248            let n: u64 = row.get::<_, i64>(1).unwrap_or(0) as u64;
7249            let sz: u64 = row.get::<_, i64>(2).unwrap_or(0) as u64;
7250            count += n;
7251            total_bytes += sz;
7252            by_type.insert(t.clone(), n);
7253            bytes_by_type.insert(t, sz);
7254        }
7255        Ok(FilterStatsResult {
7256            count,
7257            count_capped: false,
7258            total_bytes,
7259            by_type,
7260            bytes_by_type,
7261            total_unfiltered,
7262            size_buckets: vec![],
7263            ..Default::default()
7264        })
7265    }
7266
7267    pub fn pdf_filter_stats(
7268        &self,
7269        search: Option<&str>,
7270        search_regex: bool,
7271    ) -> Result<FilterStatsResult, String> {
7272        let conn = self.read_conn();
7273        let total_unfiltered: u64 = self.pdf_library_total_rows(&conn)?;
7274        let (fts_match, like_pat, regex_pat) = classify_fts_name_path_search(search, search_regex);
7275        if fts_match.is_some() {
7276            let m = fts_match.as_ref().expect("fts");
7277            let (bc, capped) = Self::pdf_fts_bounded_count_library(&conn, m)?;
7278            if bc == 0 {
7279                return Ok(FilterStatsResult {
7280                    count: 0,
7281                    count_capped: false,
7282                    total_bytes: 0,
7283                    by_type: HashMap::new(),
7284                    bytes_by_type: HashMap::new(),
7285                    total_unfiltered,
7286                    size_buckets: vec![],
7287                    ..Default::default()
7288                });
7289            }
7290            if capped {
7291                return Ok(FilterStatsResult {
7292                    count: bc,
7293                    count_capped: true,
7294                    total_bytes: 0,
7295                    by_type: HashMap::new(),
7296                    bytes_by_type: HashMap::new(),
7297                    total_unfiltered,
7298                    size_buckets: vec![],
7299                    ..Default::default()
7300                });
7301            }
7302        }
7303        let sql = if fts_match.is_some() {
7304            "SELECT COUNT(*), COALESCE(SUM(size),0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library) AND id IN (SELECT rowid FROM pdfs_fts WHERE pdfs_fts MATCH ?1)"
7305        } else if regex_pat.is_some() {
7306            "SELECT COUNT(*), COALESCE(SUM(size),0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library) AND ((name REGEXP ?1) OR (path REGEXP ?1))"
7307        } else if like_pat.is_some() {
7308            "SELECT COUNT(*), COALESCE(SUM(size),0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library) AND (name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\')"
7309        } else {
7310            "SELECT COUNT(*), COALESCE(SUM(size),0) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)"
7311        };
7312        let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
7313        if let Some(ref m) = fts_match {
7314            stmt.raw_bind_parameter(1, m).map_err(|e| e.to_string())?;
7315        } else if let Some(ref r) = regex_pat {
7316            stmt.raw_bind_parameter(1, r).map_err(|e| e.to_string())?;
7317        } else if let Some(ref pat) = like_pat {
7318            stmt.raw_bind_parameter(1, pat).map_err(|e| e.to_string())?;
7319        }
7320        let mut rows = stmt.raw_query();
7321        let (count, total_bytes) = if let Some(row) = rows.next().map_err(|e| e.to_string())? {
7322            (
7323                row.get::<_, i64>(0).unwrap_or(0) as u64,
7324                row.get::<_, i64>(1).unwrap_or(0) as u64,
7325            )
7326        } else {
7327            (0, 0)
7328        };
7329        Ok(FilterStatsResult {
7330            count,
7331            count_capped: false,
7332            total_bytes,
7333            by_type: HashMap::new(),
7334            bytes_by_type: HashMap::new(),
7335            total_unfiltered,
7336            size_buckets: vec![],
7337            ..Default::default()
7338        })
7339    }
7340
7341    // ── KVR cache ──
7342
7343    pub fn load_kvr_cache(&self) -> Result<HashMap<String, KvrCacheEntry>, String> {
7344        let conn = self.read_conn();
7345        let mut stmt = conn.prepare("SELECT plugin_key, kvr_url, update_url, latest_version, has_update, source, timestamp FROM kvr_cache").map_err(|e| e.to_string())?;
7346        let rows = stmt
7347            .query_map([], |row| {
7348                Ok((
7349                    row.get::<_, String>(0)?,
7350                    KvrCacheEntry {
7351                        kvr_url: row.get(1)?,
7352                        update_url: row.get(2)?,
7353                        latest_version: row.get(3)?,
7354                        has_update: row.get::<_, i32>(4).unwrap_or(0) != 0,
7355                        source: row.get(5)?,
7356                        timestamp: row.get(6)?,
7357                    },
7358                ))
7359            })
7360            .map_err(|e| e.to_string())?;
7361        let mut map = HashMap::new();
7362        for (k, v) in rows.flatten() {
7363            map.insert(k, v);
7364        }
7365        Ok(map)
7366    }
7367
7368    pub fn update_kvr_cache(
7369        &self,
7370        entries: &[crate::history::KvrCacheUpdateEntry],
7371    ) -> Result<(), String> {
7372        let conn = self.read_conn();
7373        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
7374        {
7375            let mut stmt = tx.prepare_cached(
7376                "INSERT OR REPLACE INTO kvr_cache (plugin_key, kvr_url, update_url, latest_version, has_update, source, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'))"
7377            ).map_err(|e| e.to_string())?;
7378            for e in entries {
7379                stmt.execute(params![
7380                    e.key,
7381                    e.kvr_url,
7382                    e.update_url,
7383                    e.latest_version,
7384                    e.has_update.unwrap_or(false) as i32,
7385                    e.source.as_deref().unwrap_or("")
7386                ])
7387                .map_err(|e| e.to_string())?;
7388            }
7389        }
7390        tx.commit().map_err(|e| e.to_string())
7391    }
7392
7393    // ── Generic cache read/write (replaces read_cache_file/write_cache_file) ──
7394
7395    pub fn read_cache(&self, name: &str) -> Result<serde_json::Value, String> {
7396        match name {
7397            "bpm-cache.json" => self.read_analysis_as_cache("bpm"),
7398            "key-cache.json" => self.read_analysis_as_cache("key"),
7399            "lufs-cache.json" => self.read_analysis_as_cache("lufs"),
7400            _ => self.read_kv_cache(name),
7401        }
7402    }
7403
7404    pub fn write_cache(&self, name: &str, data: &serde_json::Value) -> Result<(), String> {
7405        match name {
7406            "bpm-cache.json" => self.write_analysis_from_cache(data, "bpm"),
7407            "key-cache.json" => self.write_analysis_from_cache(data, "key"),
7408            "lufs-cache.json" => self.write_analysis_from_cache(data, "lufs"),
7409            _ => self.write_kv_cache(name, data),
7410        }
7411    }
7412
7413    fn read_analysis_as_cache(&self, field: &str) -> Result<serde_json::Value, String> {
7414        let conn = self.read_conn();
7415        let col = match field {
7416            "bpm" => "bpm",
7417            "key" => "key_name",
7418            "lufs" => "lufs",
7419            _ => return Ok(serde_json::json!({})),
7420        };
7421        let sql = format!(
7422            "SELECT path, {col} FROM audio_samples WHERE {col} IS NOT NULL AND ({AUDIO_LIBRARY_IDS})"
7423        );
7424        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
7425        let mut map = serde_json::Map::new();
7426        let mut rows = stmt.raw_query();
7427        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7428            let path: String = row.get(0).unwrap_or_default();
7429            let val: serde_json::Value = if field == "key" {
7430                serde_json::Value::String(row.get::<_, String>(1).unwrap_or_default())
7431            } else {
7432                serde_json::json!(row.get::<_, f64>(1).unwrap_or(0.0))
7433            };
7434            map.insert(path, val);
7435        }
7436        Ok(serde_json::Value::Object(map))
7437    }
7438
7439    fn write_analysis_from_cache(
7440        &self,
7441        data: &serde_json::Value,
7442        field: &str,
7443    ) -> Result<(), String> {
7444        let obj = data.as_object().ok_or("expected object")?;
7445        if obj.is_empty() {
7446            return Ok(());
7447        }
7448        let conn = self.write_conn();
7449        let col = match field {
7450            "bpm" => "bpm",
7451            "key" => "key_name",
7452            "lufs" => "lufs",
7453            _ => return Ok(()),
7454        };
7455        let sql = if field == "bpm" {
7456            "UPDATE audio_samples SET bpm = ?1, bpm_exhausted = 0 WHERE path = ?2".to_string()
7457        } else {
7458            format!("UPDATE audio_samples SET {col} = ?1 WHERE path = ?2")
7459        };
7460        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
7461        {
7462            let mut stmt = tx.prepare_cached(&sql).map_err(|e| e.to_string())?;
7463            for (path, val) in obj {
7464                let path = normalize_path_for_db(path);
7465                if field == "key" {
7466                    if let Some(s) = val.as_str() {
7467                        let _ = stmt.execute(params![s, path]);
7468                    }
7469                } else {
7470                    if let Some(v) = val.as_f64() {
7471                        let _ = stmt.execute(params![v, path]);
7472                    }
7473                }
7474            }
7475        }
7476        tx.commit().map_err(|e| e.to_string())
7477    }
7478
7479    fn read_kv_cache(&self, name: &str) -> Result<serde_json::Value, String> {
7480        let (table, key_col, val_col) = self.cache_table_for(name);
7481        let conn = self.read_conn();
7482        let sql = format!("SELECT {key_col}, {val_col} FROM {table}");
7483        let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
7484        let mut map = serde_json::Map::new();
7485        let mut rows = stmt.raw_query();
7486        while let Some(row) = rows.next().map_err(|e| e.to_string())? {
7487            let k: String = row.get(0).unwrap_or_default();
7488            let v: String = row.get(1).unwrap_or_default();
7489            // Try to parse as JSON, fall back to string
7490            let val = serde_json::from_str(&v).unwrap_or(serde_json::Value::String(v));
7491            map.insert(k, val);
7492        }
7493        Ok(serde_json::Value::Object(map))
7494    }
7495
7496    fn write_kv_cache(&self, name: &str, data: &serde_json::Value) -> Result<(), String> {
7497        let obj = data.as_object().ok_or("expected object")?;
7498        let (table, key_col, val_col) = self.cache_table_for(name);
7499        let conn = self.write_conn();
7500        let sql = format!("INSERT OR REPLACE INTO {table} ({key_col}, {val_col}) VALUES (?1, ?2)");
7501        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
7502        {
7503            let mut stmt = tx.prepare_cached(&sql).map_err(|e| e.to_string())?;
7504            for (k, v) in obj {
7505                let k = normalize_path_for_db(k);
7506                let val_str = if v.is_string() {
7507                    v.as_str().unwrap_or("").to_string()
7508                } else {
7509                    v.to_string()
7510                };
7511                let _ = stmt.execute(params![k, val_str]);
7512            }
7513        }
7514        tx.commit().map_err(|e| e.to_string())
7515    }
7516
7517    /// Get row counts for all tables.
7518    pub fn table_counts(&self) -> Result<serde_json::Value, String> {
7519        let conn = self.read_conn();
7520        let tables = [
7521            "audio_samples",
7522            "audio_scans",
7523            "plugins",
7524            "plugin_library",
7525            "plugin_scans",
7526            "daw_projects",
7527            "daw_library",
7528            "daw_scans",
7529            "presets",
7530            "preset_scans",
7531            "pdfs",
7532            "pdf_library",
7533            "midi_files",
7534            "midi_library",
7535            "pdf_scans",
7536            "pdf_metadata",
7537            "preset_library",
7538            "kvr_cache",
7539            "waveform_cache",
7540            "spectrogram_cache",
7541            "xref_cache",
7542            "fingerprint_cache",
7543        ];
7544        let mut map = serde_json::Map::new();
7545        for t in &tables {
7546            let count: u64 = conn
7547                .query_row(&format!("SELECT COUNT(*) FROM {t}"), [], |r| {
7548                    r.get::<_, i64>(0).map(|v| v as u64)
7549                })
7550                .unwrap_or(0);
7551            map.insert(t.to_string(), serde_json::json!(count));
7552        }
7553
7554        // Library counts: one canonical row per `path` (matches Samples tab / `query_audio`).
7555        // Raw `audio_samples` rows can exceed this when the same path appears in multiple scans.
7556        let audio_lib: u64 = self.audio_library_total_rows(&conn)?;
7557        let plugins_lib: u64 = self.plugin_library_total_rows(&conn)?;
7558        let daw_lib: u64 = self.daw_library_total_rows(&conn)?;
7559        let presets_lib: u64 = conn
7560            .query_row(
7561                "SELECT COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID','MIDI')",
7562                [],
7563                |r| r.get::<_, i64>(0).map(|v| v as u64),
7564            )
7565            .unwrap_or(0);
7566        let pdfs_lib: u64 = conn
7567            .query_row(
7568                "SELECT COUNT(*) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
7569                [],
7570                |r| r.get::<_, i64>(0).map(|v| v as u64),
7571            )
7572            .unwrap_or(0);
7573        let midi_lib: u64 = conn
7574            .query_row(
7575                "SELECT COUNT(*) FROM midi_files WHERE id IN (SELECT midi_id FROM midi_library)",
7576                [],
7577                |r| r.get::<_, i64>(0).map(|v| v as u64),
7578            )
7579            .unwrap_or(0);
7580
7581        map.insert("audio_samples_library".into(), serde_json::json!(audio_lib));
7582        map.insert("plugins_library".into(), serde_json::json!(plugins_lib));
7583        map.insert("daw_projects_library".into(), serde_json::json!(daw_lib));
7584        map.insert("presets_library".into(), serde_json::json!(presets_lib));
7585        map.insert("pdfs_library".into(), serde_json::json!(pdfs_lib));
7586        map.insert("midi_files_library".into(), serde_json::json!(midi_lib));
7587
7588        Ok(serde_json::Value::Object(map))
7589    }
7590
7591    /// Row counts per inventory category for the **library** view: one canonical row per `path`.
7592    /// Audio uses `audio_library` (v14); DAW uses `daw_library` (v16); PDF, MIDI, and presets use
7593    /// `pdf_library`, `midi_library`, and `preset_library` (v15); plugins use `plugin_library` (v17).
7594    /// Not scoped to a single `scan_id`. Presets exclude `MID`/`MIDI` (same tab rules as elsewhere).
7595    /// Matches default `scan_id` handling on paginated queries and `*_filter_stats`, not raw
7596    /// `COUNT(*)` on whole tables.
7597    pub fn active_scan_inventory_counts(&self) -> Result<serde_json::Value, String> {
7598        let conn = self.read_conn();
7599        let count_plugins: u64 = self.plugin_library_total_rows(&conn)?;
7600        let count_audio: u64 = self.audio_library_total_rows(&conn)?;
7601        let count_daw: u64 = self.daw_library_total_rows(&conn)?;
7602        let count_presets: u64 = conn
7603            .query_row(
7604                "SELECT COUNT(*) FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID','MIDI')",
7605                [],
7606                |r| r.get::<_, i64>(0).map(|v| v as u64),
7607            )
7608            .unwrap_or(0);
7609        let count_pdfs: u64 = conn
7610            .query_row(
7611                "SELECT COUNT(*) FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
7612                [],
7613                |r| r.get::<_, i64>(0).map(|v| v as u64),
7614            )
7615            .unwrap_or(0);
7616        let count_midi: u64 = conn
7617            .query_row(
7618                "SELECT COUNT(*) FROM midi_files WHERE id IN (SELECT midi_id FROM midi_library)",
7619                [],
7620                |r| r.get::<_, i64>(0).map(|v| v as u64),
7621            )
7622            .unwrap_or(0);
7623
7624        Ok(serde_json::json!({
7625            "plugins": count_plugins,
7626            "audio_samples": count_audio,
7627            "daw_projects": count_daw,
7628            "presets": count_presets,
7629            "pdfs": count_pdfs,
7630            "midi_files": count_midi,
7631        }))
7632    }
7633
7634    /// All library paths with byte sizes for content-hash duplicate detection.
7635    /// Uses the same per-domain library rules as [`Database::active_scan_inventory_counts`].
7636    pub fn library_paths_for_content_hash(&self) -> Result<Vec<(String, u64, String)>, String> {
7637        let conn = self.read_conn();
7638        let mut out: Vec<(String, u64, String)> = Vec::new();
7639
7640        let mut push_sql = |sql: &str, kind: &str| -> Result<(), String> {
7641            let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
7642            let rows = stmt
7643                .query_map([], |r| {
7644                    let path: String = r.get(0)?;
7645                    let sz: i64 = r.get(1)?;
7646                    Ok((path, sz.max(0) as u64, kind.to_string()))
7647                })
7648                .map_err(|e| e.to_string())?;
7649            for row in rows {
7650                out.push(row.map_err(|e| e.to_string())?);
7651            }
7652            Ok(())
7653        };
7654
7655        push_sql(
7656            &format!("SELECT path, size_bytes FROM plugins WHERE {PLUGIN_LIBRARY_IDS}"),
7657            "plugins",
7658        )?;
7659        push_sql(
7660            &format!("SELECT path, size FROM audio_samples WHERE {AUDIO_LIBRARY_IDS}"),
7661            "audio",
7662        )?;
7663        push_sql(
7664            &format!("SELECT path, size FROM daw_projects WHERE {DAW_LIBRARY_IDS}"),
7665            "daw",
7666        )?;
7667        push_sql(
7668            "SELECT path, size FROM presets WHERE id IN (SELECT preset_id FROM preset_library) AND format NOT IN ('MID','MIDI')",
7669            "presets",
7670        )?;
7671        push_sql(
7672            "SELECT path, size FROM pdfs WHERE id IN (SELECT pdf_id FROM pdf_library)",
7673            "pdf",
7674        )?;
7675        push_sql(
7676            "SELECT path, size FROM midi_files WHERE id IN (SELECT midi_id FROM midi_library)",
7677            "midi",
7678        )?;
7679
7680        Ok(out)
7681    }
7682
7683    /// Get stats for all caches: item count and estimated size.
7684    ///
7685    /// Uses the primary **write** connection (not the read pool). Read-pool handles install a
7686    /// [`SQLITE_QUERY_TIMEOUT_SECS`] progress-handler budget per acquisition; `cache_stats` runs
7687    /// many sequential full-table `COUNT`s and `dbstat` passes — cumulative time exceeded 30s on
7688    /// large libraries and surfaced as an empty/error cache table in Settings.
7689    pub fn cache_stats(&self) -> Result<Vec<CacheStat>, String> {
7690        let conn = self.write_conn();
7691        let mut stats = Vec::new();
7692
7693        // Analysis caches (columns on audio_samples — library rows only).
7694        // `total` stays 0 so Settings → Database Caches shows a plain row count (like KV caches),
7695        // not "cached / entire library", which misread the Items column as cache capacity.
7696        // Size: Key uses UTF-8 payload length; BPM/LUFS use an 8-byte float payload estimate.
7697        for (label, col, key) in [
7698            ("BPM", "bpm", "bpm"),
7699            ("Key", "key_name", "key"),
7700            ("LUFS", "lufs", "lufs"),
7701        ] {
7702            let count: u64 = conn.query_row(
7703                &format!("SELECT COUNT(*) FROM audio_samples WHERE {col} IS NOT NULL AND ({AUDIO_LIBRARY_IDS})"),
7704                [],
7705                |r| r.get::<_, i64>(0).map(|v| v as u64),
7706            )
7707            .unwrap_or(0);
7708            let size_bytes: u64 = if key == "key" {
7709                conn.query_row(
7710                    &format!(
7711                        "SELECT COALESCE(SUM(LENGTH(key_name)), 0) FROM audio_samples WHERE key_name IS NOT NULL AND ({AUDIO_LIBRARY_IDS})"
7712                    ),
7713                    [],
7714                    |r| r.get::<_, i64>(0).map(|v| v as u64),
7715                )
7716                .unwrap_or(0)
7717            } else {
7718                count.saturating_mul(8)
7719            };
7720            stats.push(CacheStat {
7721                key: key.into(),
7722                label: label.into(),
7723                count,
7724                total: 0,
7725                size_bytes,
7726            });
7727        }
7728
7729        // KV caches — count rows and estimate size from data length
7730        for (label, table, _key_col, val_col, key) in [
7731            ("Waveform", "waveform_cache", "path", "data", "waveform"),
7732            (
7733                "Spectrogram",
7734                "spectrogram_cache",
7735                "path",
7736                "data",
7737                "spectrogram",
7738            ),
7739            ("Xref", "xref_cache", "project_path", "plugins_json", "xref"),
7740            (
7741                "Fingerprint",
7742                "fingerprint_cache",
7743                "path",
7744                "fingerprint",
7745                "fingerprint",
7746            ),
7747            ("KVR", "kvr_cache", "plugin_key", "kvr_url", "kvr"),
7748        ] {
7749            let (count, size): (u64, u64) = conn
7750                .query_row(
7751                    &format!("SELECT COUNT(*), COALESCE(SUM(LENGTH({val_col})), 0) FROM {table}"),
7752                    [],
7753                    |r| Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as u64)),
7754                )
7755                .unwrap_or((0, 0));
7756            stats.push(CacheStat {
7757                key: key.into(),
7758                label: label.into(),
7759                count,
7760                total: 0,
7761                size_bytes: size,
7762            });
7763        }
7764
7765        // Scan histories — per-group on-disk size via `dbstat` (table + indexes), not
7766        // `(whole DB pages) / row_count` (that made every category ≈ full file size).
7767        let db_path = history::get_data_dir().join("audio_haxor.db");
7768        let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
7769        let total_inv_rows: u64 = [
7770            "plugins",
7771            "audio_samples",
7772            "daw_projects",
7773            "presets",
7774            "midi_files",
7775            "pdfs",
7776        ]
7777        .iter()
7778        .map(|t| {
7779            conn.query_row(&format!("SELECT COUNT(*) FROM {t}"), [], |r| {
7780                r.get::<_, i64>(0).map(|v| v as u64)
7781            })
7782            .unwrap_or(0)
7783        })
7784        .sum();
7785
7786        for (label, scan_table, item_table, key) in [
7787            ("Plugin Scans", "plugin_scans", "plugins", "plugin_scans"),
7788            ("Audio Scans", "audio_scans", "audio_samples", "audio_scans"),
7789            ("DAW Scans", "daw_scans", "daw_projects", "daw_scans"),
7790            ("Preset Scans", "preset_scans", "presets", "preset_scans"),
7791            ("MIDI Scans", "midi_scans", "midi_files", "midi_scans"),
7792            ("PDF Scans", "pdf_scans", "pdfs", "pdf_scans"),
7793        ] {
7794            let scan_count: u64 = conn
7795                .query_row(&format!("SELECT COUNT(*) FROM {scan_table}"), [], |r| {
7796                    r.get::<_, i64>(0).map(|v| v as u64)
7797                })
7798                .unwrap_or(0);
7799            let item_count: u64 = conn
7800                .query_row(&format!("SELECT COUNT(*) FROM {item_table}"), [], |r| {
7801                    r.get::<_, i64>(0).map(|v| v as u64)
7802                })
7803                .unwrap_or(0);
7804            let size_bytes = if let Some(b) =
7805                dbstat_bytes_for_scan_group(&conn, scan_table, item_table)
7806            {
7807                b
7808            } else if total_inv_rows > 0 {
7809                db_size.saturating_mul(item_count) / total_inv_rows.max(1)
7810            } else {
7811                0
7812            };
7813            stats.push(CacheStat {
7814                key: key.into(),
7815                label: label.into(),
7816                count: item_count,
7817                total: scan_count,
7818                size_bytes,
7819            });
7820        }
7821
7822        // Total DB file size
7823        stats.push(CacheStat {
7824            key: "database".into(),
7825            label: "Total Database".into(),
7826            count: 0,
7827            total: 0,
7828            size_bytes: db_size,
7829        });
7830
7831        Ok(stats)
7832    }
7833
7834    /// Batch update BPM/Key/LUFS for multiple files in a single transaction.
7835    ///
7836    /// Returns the **sum of `sqlite3_changes()`** for each `UPDATE` (rows modified), not the
7837    /// number of input paths — a path matching multiple `audio_samples` rows updates all of them.
7838    pub fn batch_update_analysis(&self, results: &[AnalysisBatchRow]) -> Result<u32, String> {
7839        let conn = self.read_conn();
7840        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
7841        let mut rows_changed: u32 = 0;
7842        {
7843            let mut stmt = tx
7844                .prepare_cached(
7845                    "UPDATE audio_samples SET bpm = ?1, key_name = ?2, lufs = ?3, bpm_exhausted = ?4 WHERE path = ?5",
7846                )
7847                .map_err(|e| e.to_string())?;
7848            for (path, bpm, key, lufs) in results {
7849                let path = normalize_path_for_db(path);
7850                let exhausted: i32 = if bpm.is_none() && key.is_some() && lufs.is_some() {
7851                    1
7852                } else {
7853                    0
7854                };
7855                let n = stmt
7856                    .execute(params![bpm, key, lufs, exhausted, path])
7857                    .map_err(|e| e.to_string())?;
7858                rows_changed = rows_changed.saturating_add(n as u32);
7859            }
7860        }
7861        tx.commit().map_err(|e| e.to_string())?;
7862        Ok(rows_changed)
7863    }
7864
7865    /// Clear a specific cache table.
7866    pub fn clear_cache_table(&self, table: &str) -> Result<(), String> {
7867        let conn = self.read_conn();
7868        let sql = match table {
7869            "bpm" => "UPDATE audio_samples SET bpm = NULL, bpm_exhausted = 0",
7870            "key" => "UPDATE audio_samples SET key_name = NULL",
7871            "lufs" => "UPDATE audio_samples SET lufs = NULL",
7872            "waveform" => "DELETE FROM waveform_cache",
7873            "spectrogram" => "DELETE FROM spectrogram_cache",
7874            "xref" => "DELETE FROM xref_cache",
7875            "fingerprint" => "DELETE FROM fingerprint_cache",
7876            "kvr" => "DELETE FROM kvr_cache",
7877            _ => return Err(format!("Unknown cache: {table}")),
7878        };
7879        conn.execute_batch(sql).map_err(|e| e.to_string())
7880    }
7881
7882    /// Clear all analysis and cache data from SQLite.
7883    pub fn clear_all_caches(&self) -> Result<(), String> {
7884        let conn = self.read_conn();
7885        conn.execute_batch(
7886            "UPDATE audio_samples SET bpm = NULL, key_name = NULL, lufs = NULL, bpm_exhausted = 0;
7887             DELETE FROM waveform_cache;
7888             DELETE FROM spectrogram_cache;
7889             DELETE FROM xref_cache;
7890             DELETE FROM fingerprint_cache;
7891             DELETE FROM kvr_cache;",
7892        )
7893        .map_err(|e| e.to_string())
7894    }
7895
7896    fn cache_table_for(&self, name: &str) -> (&str, &str, &str) {
7897        match name {
7898            "waveform-cache.json" => ("waveform_cache", "path", "data"),
7899            "spectrogram-cache.json" => ("spectrogram_cache", "path", "data"),
7900            "xref-cache.json" => ("xref_cache", "project_path", "plugins_json"),
7901            "fingerprint-cache.json" => ("fingerprint_cache", "path", "fingerprint"),
7902            _ => ("waveform_cache", "path", "data"), // fallback
7903        }
7904    }
7905
7906    /// One-time migration of ALL JSON history/cache files to SQLite.
7907    pub fn migrate_from_json(&self) -> Result<usize, String> {
7908        let data_dir = history::get_data_dir();
7909        let mut total = 0;
7910
7911        // Check if already migrated (any scan table has data)
7912        {
7913            let conn = self.read_conn();
7914            let count: u64 = conn
7915                .query_row(
7916                    "SELECT (SELECT COUNT(*) FROM audio_scans) +
7917                            (SELECT COUNT(*) FROM plugin_scans) +
7918                            (SELECT COUNT(*) FROM daw_scans) +
7919                            (SELECT COUNT(*) FROM preset_scans)",
7920                    [],
7921                    |row| row.get::<_, i64>(0).map(|v| v as u64),
7922                )
7923                .unwrap_or(0);
7924            if count > 0 {
7925                return Ok(0);
7926            }
7927        }
7928
7929        // ── Audio samples ──
7930        total += self.migrate_audio_json(&data_dir)?;
7931
7932        // ── Plugin scans ──
7933        total += self.migrate_plugin_json(&data_dir)?;
7934
7935        // ── DAW projects ──
7936        total += self.migrate_daw_json(&data_dir)?;
7937
7938        // ── Presets ──
7939        total += self.migrate_preset_json(&data_dir)?;
7940
7941        // ── KVR cache ──
7942        total += self.migrate_kvr_json(&data_dir)?;
7943
7944        // ── Frontend caches (xref, waveform, spectrogram, fingerprint) ──
7945        total += self.migrate_kv_cache(
7946            &data_dir,
7947            "xref-cache.json",
7948            "xref_cache",
7949            "project_path",
7950            "plugins_json",
7951        )?;
7952        total += self.migrate_kv_cache(
7953            &data_dir,
7954            "waveform-cache.json",
7955            "waveform_cache",
7956            "path",
7957            "data",
7958        )?;
7959        total += self.migrate_kv_cache(
7960            &data_dir,
7961            "spectrogram-cache.json",
7962            "spectrogram_cache",
7963            "path",
7964            "data",
7965        )?;
7966        total += self.migrate_kv_cache(
7967            &data_dir,
7968            "fingerprint-cache.json",
7969            "fingerprint_cache",
7970            "path",
7971            "fingerprint",
7972        )?;
7973
7974        // Rename all migrated JSON files to .bak
7975        for name in &[
7976            "audio-scan-history.json",
7977            "bpm-cache.json",
7978            "key-cache.json",
7979            "lufs-cache.json",
7980            "scan-history.json",
7981            "daw-scan-history.json",
7982            "preset-scan-history.json",
7983            "kvr-cache.json",
7984            "xref-cache.json",
7985            "waveform-cache.json",
7986            "spectrogram-cache.json",
7987            "fingerprint-cache.json",
7988        ] {
7989            let p = data_dir.join(name);
7990            if p.exists() {
7991                let _ = std::fs::rename(&p, data_dir.join(format!("{name}.bak")));
7992            }
7993        }
7994
7995        Ok(total)
7996    }
7997
7998    fn migrate_audio_json(&self, data_dir: &std::path::Path) -> Result<usize, String> {
7999        let path = data_dir.join("audio-scan-history.json");
8000        if !path.exists() {
8001            return Ok(0);
8002        }
8003        let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
8004        let history: AudioHistory =
8005            serde_json::from_str(&data).map_err(|e| format!("audio JSON: {e}"))?;
8006        let mut count = 0;
8007        for snap in &history.scans {
8008            self.save_scan(
8009                &snap.id,
8010                &snap.timestamp,
8011                snap.sample_count as u64,
8012                snap.total_bytes,
8013                &snap.format_counts,
8014                &snap.roots,
8015            )?;
8016            self.insert_audio_batch(&snap.id, &snap.samples)?;
8017            count += snap.samples.len();
8018        }
8019        self.migrate_analysis_cache(data_dir, "bpm-cache.json", "bpm")?;
8020        self.migrate_analysis_cache(data_dir, "key-cache.json", "key")?;
8021        self.migrate_analysis_cache(data_dir, "lufs-cache.json", "lufs")?;
8022        Ok(count)
8023    }
8024
8025    fn migrate_plugin_json(&self, data_dir: &std::path::Path) -> Result<usize, String> {
8026        let path = data_dir.join("scan-history.json");
8027        if !path.exists() {
8028            return Ok(0);
8029        }
8030        let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
8031        let history: ScanHistory =
8032            serde_json::from_str(&data).map_err(|e| format!("plugin JSON: {e}"))?;
8033        let conn = self.read_conn();
8034        let mut count = 0;
8035        for snap in &history.scans {
8036            let dirs_json = path_strings_json_normalized(&snap.directories);
8037            let roots_json = path_strings_json_normalized(&snap.roots);
8038            conn.execute(
8039                "INSERT OR REPLACE INTO plugin_scans (id, timestamp, plugin_count, directories, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,1)",
8040                params![snap.id, snap.timestamp, snap.plugin_count as i64, dirs_json, roots_json],
8041            ).map_err(|e| e.to_string())?;
8042
8043            let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8044            {
8045                let mut stmt = tx.prepare_cached(
8046                    "INSERT OR REPLACE INTO plugins (name, path, plugin_type, version, manufacturer, manufacturer_url, size, size_bytes, modified, architectures, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)"
8047                ).map_err(|e| e.to_string())?;
8048                for p in &snap.plugins {
8049                    let arch_json = serde_json::to_string(&p.architectures).unwrap_or_default();
8050                    let path = normalize_path_for_db(&p.path);
8051                    stmt.execute(params![
8052                        p.name,
8053                        path,
8054                        p.plugin_type,
8055                        p.version,
8056                        p.manufacturer,
8057                        p.manufacturer_url,
8058                        p.size,
8059                        p.size_bytes as i64,
8060                        p.modified,
8061                        arch_json,
8062                        snap.id
8063                    ])
8064                    .map_err(|e| e.to_string())?;
8065                }
8066            }
8067            tx.commit().map_err(|e| e.to_string())?;
8068            count += snap.plugins.len();
8069        }
8070        Self::rebuild_plugin_library(&conn)?;
8071        self.invalidate_plugin_library_total_cache();
8072        Ok(count)
8073    }
8074
8075    fn migrate_daw_json(&self, data_dir: &std::path::Path) -> Result<usize, String> {
8076        let path = data_dir.join("daw-scan-history.json");
8077        if !path.exists() {
8078            return Ok(0);
8079        }
8080        let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
8081        let history: DawHistory =
8082            serde_json::from_str(&data).map_err(|e| format!("daw JSON: {e}"))?;
8083        let conn = self.read_conn();
8084        let mut count = 0;
8085        for snap in &history.scans {
8086            let daw_json = serde_json::to_string(&snap.daw_counts).unwrap_or_default();
8087            let roots_json = path_strings_json_normalized(&snap.roots);
8088            conn.execute(
8089                "INSERT OR REPLACE INTO daw_scans (id, timestamp, project_count, total_bytes, daw_counts, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,?6,1)",
8090                params![snap.id, snap.timestamp, snap.project_count as i64, snap.total_bytes as i64, daw_json, roots_json],
8091            ).map_err(|e| e.to_string())?;
8092
8093            let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8094            {
8095                let mut stmt = tx.prepare_cached(
8096                    "INSERT OR REPLACE INTO daw_projects (name, path, directory, format, daw, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"
8097                ).map_err(|e| e.to_string())?;
8098                for p in &snap.projects {
8099                    let path = normalize_path_for_db(&p.path);
8100                    let directory = normalize_path_for_db(&p.directory);
8101                    stmt.execute(params![
8102                        p.name,
8103                        path,
8104                        directory,
8105                        p.format,
8106                        p.daw,
8107                        p.size as i64,
8108                        p.size_formatted,
8109                        p.modified,
8110                        snap.id
8111                    ])
8112                    .map_err(|e| e.to_string())?;
8113                }
8114            }
8115            tx.commit().map_err(|e| e.to_string())?;
8116            count += snap.projects.len();
8117        }
8118        Self::rebuild_daw_library(&conn)?;
8119        self.invalidate_daw_library_total_cache();
8120        Ok(count)
8121    }
8122
8123    fn migrate_preset_json(&self, data_dir: &std::path::Path) -> Result<usize, String> {
8124        let path = data_dir.join("preset-scan-history.json");
8125        if !path.exists() {
8126            return Ok(0);
8127        }
8128        let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
8129        let history: PresetHistory =
8130            serde_json::from_str(&data).map_err(|e| format!("preset JSON: {e}"))?;
8131        let conn = self.read_conn();
8132        let mut count = 0;
8133        for snap in &history.scans {
8134            let fc_json = serde_json::to_string(&snap.format_counts).unwrap_or_default();
8135            let roots_json = path_strings_json_normalized(&snap.roots);
8136            conn.execute(
8137                "INSERT OR REPLACE INTO preset_scans (id, timestamp, preset_count, total_bytes, format_counts, roots, scan_complete) VALUES (?1,?2,?3,?4,?5,?6,1)",
8138                params![snap.id, snap.timestamp, snap.preset_count as i64, snap.total_bytes as i64, fc_json, roots_json],
8139            ).map_err(|e| e.to_string())?;
8140
8141            let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8142            {
8143                let mut stmt = tx.prepare_cached(
8144                    "INSERT OR REPLACE INTO presets (name, path, directory, format, size, size_formatted, modified, scan_id) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)"
8145                ).map_err(|e| e.to_string())?;
8146                for p in &snap.presets {
8147                    let path = normalize_path_for_db(&p.path);
8148                    let directory = normalize_path_for_db(&p.directory);
8149                    stmt.execute(params![
8150                        p.name,
8151                        path,
8152                        directory,
8153                        p.format,
8154                        p.size as i64,
8155                        p.size_formatted,
8156                        p.modified,
8157                        snap.id
8158                    ])
8159                    .map_err(|e| e.to_string())?;
8160                }
8161            }
8162            tx.commit().map_err(|e| e.to_string())?;
8163            count += snap.presets.len();
8164        }
8165        Ok(count)
8166    }
8167
8168    fn migrate_kvr_json(&self, data_dir: &std::path::Path) -> Result<usize, String> {
8169        let path = data_dir.join("kvr-cache.json");
8170        if !path.exists() {
8171            return Ok(0);
8172        }
8173        let data = std::fs::read_to_string(&path).unwrap_or_default();
8174        let cache: HashMap<String, KvrCacheEntry> = serde_json::from_str(&data).unwrap_or_default();
8175        if cache.is_empty() {
8176            return Ok(0);
8177        }
8178        let conn = self.read_conn();
8179        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8180        let count = cache.len();
8181        {
8182            let mut stmt = tx.prepare_cached(
8183                "INSERT OR REPLACE INTO kvr_cache (plugin_key, kvr_url, update_url, latest_version, has_update, source, timestamp) VALUES (?1,?2,?3,?4,?5,?6,?7)"
8184            ).map_err(|e| e.to_string())?;
8185            for (key, entry) in &cache {
8186                stmt.execute(params![
8187                    key,
8188                    entry.kvr_url,
8189                    entry.update_url,
8190                    entry.latest_version,
8191                    entry.has_update as i32,
8192                    entry.source,
8193                    entry.timestamp
8194                ])
8195                .map_err(|e| e.to_string())?;
8196            }
8197        }
8198        tx.commit().map_err(|e| e.to_string())?;
8199        Ok(count)
8200    }
8201
8202    /// Generic key→value JSON cache migration (xref, waveform, spectrogram, fingerprint).
8203    fn migrate_kv_cache(
8204        &self,
8205        data_dir: &std::path::Path,
8206        filename: &str,
8207        table: &str,
8208        key_col: &str,
8209        val_col: &str,
8210    ) -> Result<usize, String> {
8211        let path = data_dir.join(filename);
8212        if !path.exists() {
8213            return Ok(0);
8214        }
8215        let data = std::fs::read_to_string(&path).unwrap_or_default();
8216        let cache: HashMap<String, serde_json::Value> =
8217            serde_json::from_str(&data).unwrap_or_default();
8218        if cache.is_empty() {
8219            return Ok(0);
8220        }
8221        let conn = self.read_conn();
8222        let sql = format!("INSERT OR REPLACE INTO {table} ({key_col}, {val_col}) VALUES (?1, ?2)");
8223        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8224        let count = cache.len();
8225        {
8226            let mut stmt = tx.prepare_cached(&sql).map_err(|e| e.to_string())?;
8227            for (k, v) in &cache {
8228                let k = normalize_path_for_db(k);
8229                let val_str = if v.is_string() {
8230                    v.as_str().unwrap_or("").to_string()
8231                } else {
8232                    v.to_string()
8233                };
8234                stmt.execute(params![k, val_str])
8235                    .map_err(|e| e.to_string())?;
8236            }
8237        }
8238        tx.commit().map_err(|e| e.to_string())?;
8239        Ok(count)
8240    }
8241
8242    fn migrate_analysis_cache(
8243        &self,
8244        data_dir: &std::path::Path,
8245        filename: &str,
8246        field: &str,
8247    ) -> Result<(), String> {
8248        let path = data_dir.join(filename);
8249        if !path.exists() {
8250            return Ok(());
8251        }
8252        let data = std::fs::read_to_string(&path).unwrap_or_default();
8253        let cache: HashMap<String, serde_json::Value> =
8254            serde_json::from_str(&data).unwrap_or_default();
8255        if cache.is_empty() {
8256            return Ok(());
8257        }
8258
8259        let conn = self.read_conn();
8260        let sql = match field {
8261            "bpm" => "UPDATE audio_samples SET bpm = ?1 WHERE path = ?2",
8262            "key" => "UPDATE audio_samples SET key_name = ?1 WHERE path = ?2",
8263            "lufs" => "UPDATE audio_samples SET lufs = ?1 WHERE path = ?2",
8264            _ => return Ok(()),
8265        };
8266        let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?;
8267        {
8268            let mut stmt = tx.prepare_cached(sql).map_err(|e| e.to_string())?;
8269            for (sample_path, value) in &cache {
8270                let sample_path = normalize_path_for_db(sample_path);
8271                match field {
8272                    "bpm" | "lufs" => {
8273                        if let Some(v) = value.as_f64() {
8274                            let _ = stmt.execute(params![v, sample_path]);
8275                        }
8276                    }
8277                    "key" => {
8278                        if let Some(v) = value.as_str() {
8279                            let _ = stmt.execute(params![v, sample_path]);
8280                        }
8281                    }
8282                    _ => {}
8283                }
8284            }
8285        }
8286        tx.commit().map_err(|e| e.to_string())?;
8287        Ok(())
8288    }
8289}
8290
8291#[cfg(test)]
8292mod tests {
8293    use super::*;
8294    use std::collections::HashSet;
8295
8296    /// `history::set_test_data_dir_path` is process-global; serialize migrate JSON tests.
8297    static MIGRATE_JSON_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
8298
8299    #[test]
8300    fn read_pool_cache_splits_budget_when_extra_is_32_readers() {
8301        let one = read_pool_cache_kib(1);
8302        let thirty_three = read_pool_cache_kib(33);
8303        assert_eq!(one, -32_768, "single reader capped at 32 MiB");
8304        assert!(
8305            thirty_three.abs() < one.abs(),
8306            "33 readers must use smaller per-connection cache than a lone reader"
8307        );
8308        assert_eq!(thirty_three, -i32::try_from((512_i64 * 1024) / 33).unwrap());
8309    }
8310
8311    #[test]
8312    fn read_pool_mmap_splits_budget() {
8313        let m = read_pool_mmap_bytes(33);
8314        assert_eq!(m, 536_870_912_i64 / 33);
8315    }
8316
8317    #[test]
8318    fn test_audio_query_params_json_empty_object_uses_defaults() {
8319        let v = serde_json::json!({});
8320        let p: AudioQueryParams = serde_json::from_value(v).expect("deserialize");
8321        assert_eq!(p.sort_key, "name");
8322        assert!(p.sort_asc);
8323        assert_eq!(p.limit, 200);
8324        assert_eq!(p.offset, 0);
8325        assert!(p.scan_id.is_none());
8326        assert!(p.search.is_none());
8327        assert!(p.format_filter.is_none());
8328    }
8329
8330    #[test]
8331    fn test_audio_query_params_json_partial_snake_case_overrides() {
8332        let v = serde_json::json!({
8333            "sort_key": "modified",
8334            "sort_asc": false,
8335            "limit": 50,
8336            "offset": 100,
8337            "search": "kick"
8338        });
8339        let p: AudioQueryParams = serde_json::from_value(v).expect("deserialize");
8340        assert_eq!(p.sort_key, "modified");
8341        assert!(!p.sort_asc);
8342        assert_eq!(p.limit, 50);
8343        assert_eq!(p.offset, 100);
8344        assert_eq!(p.search.as_deref(), Some("kick"));
8345        assert!(p.scan_id.is_none());
8346        assert!(p.format_filter.is_none());
8347    }
8348
8349    #[test]
8350    fn test_audio_query_params_json_scan_id_and_format_filter() {
8351        let v = serde_json::json!({
8352            "scan_id": "scan-abc-123",
8353            "format_filter": "WAV,AIFF"
8354        });
8355        let p: AudioQueryParams = serde_json::from_value(v).expect("deserialize");
8356        assert_eq!(p.scan_id.as_deref(), Some("scan-abc-123"));
8357        assert_eq!(p.format_filter.as_deref(), Some("WAV,AIFF"));
8358        assert_eq!(p.sort_key, "name");
8359        assert!(p.sort_asc);
8360        assert_eq!(p.limit, 200);
8361    }
8362
8363    #[test]
8364    fn test_audio_query_params_explicit_zero_offset_keeps_default_limit() {
8365        let v = serde_json::json!({ "offset": 0, "limit": 25 });
8366        let p: AudioQueryParams = serde_json::from_value(v).expect("deserialize");
8367        assert_eq!(p.offset, 0);
8368        assert_eq!(p.limit, 25);
8369        assert_eq!(p.sort_key, "name");
8370    }
8371
8372    #[test]
8373    fn cache_stats_analysis_rows_use_count_only_not_library_total() {
8374        let db = test_db();
8375        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
8376        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
8377            .unwrap();
8378        db.insert_audio_batch("s1", &samples).unwrap();
8379        db.update_lufs("/a.wav", Some(-12.0)).unwrap();
8380        let stats = db.cache_stats().unwrap();
8381        let bpm = stats.iter().find(|s| s.key == "bpm").unwrap();
8382        let key = stats.iter().find(|s| s.key == "key").unwrap();
8383        let lufs = stats.iter().find(|s| s.key == "lufs").unwrap();
8384        assert_eq!(bpm.total, 0, "BPM row must not use library row count as Items denominator");
8385        assert_eq!(key.total, 0);
8386        assert_eq!(lufs.total, 0);
8387        assert_eq!(lufs.count, 1);
8388        assert_eq!(lufs.size_bytes, 8);
8389    }
8390
8391    fn test_db() -> Database {
8392        // Shared-cache in-memory DB so writer + read pool see the same tables (matches on-disk open).
8393        let uri = format!(
8394            "file:memdb_{}?mode=memory&cache=shared",
8395            rand::random::<u128>()
8396        );
8397        let write = Connection::open(uri.as_str()).unwrap();
8398        write
8399            .execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
8400            .unwrap();
8401        install_regexp_function(&write).unwrap();
8402        let mut db = Database {
8403            write: Mutex::new(write),
8404            read: Vec::new(),
8405            read_deadlines: Vec::new(),
8406            read_idx: AtomicUsize::new(0),
8407            midi_library_total_cache: Mutex::new(None),
8408            pdf_library_total_cache: Mutex::new(None),
8409            audio_library_total_cache: Mutex::new(None),
8410            preset_inventory_total_cache: Mutex::new(None),
8411            daw_library_total_cache: Mutex::new(None),
8412            plugin_library_total_cache: Mutex::new(None),
8413        };
8414        db.migrate().unwrap();
8415        let read = Connection::open(uri.as_str()).unwrap();
8416        read.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
8417            .unwrap();
8418        install_regexp_function(&read).unwrap();
8419        let deadline = Arc::new(AtomicU64::new(now_epoch_ms()));
8420        db.read.push(Mutex::new(read));
8421        db.read_deadlines.push(deadline);
8422        db
8423    }
8424
8425    fn sample(name: &str, path: &str, fmt: &str, size: u64) -> AudioSample {
8426        AudioSample {
8427            name: name.into(),
8428            path: path.into(),
8429            directory: "/test".into(),
8430            format: fmt.into(),
8431            size,
8432            size_formatted: crate::format_size(size),
8433            modified: "2024-01-01".into(),
8434            duration: None,
8435            channels: None,
8436            sample_rate: None,
8437            bits_per_sample: None,
8438        }
8439    }
8440
8441    #[test]
8442    fn test_insert_and_query() {
8443        let db = test_db();
8444        let samples = vec![
8445            sample("kick.wav", "/test/kick.wav", "WAV", 1000),
8446            sample("snare.wav", "/test/snare.wav", "WAV", 2000),
8447            sample("hat.mp3", "/test/hat.mp3", "MP3", 500),
8448        ];
8449        db.save_scan(
8450            "scan1",
8451            "2024-01-01T00:00:00",
8452            3,
8453            3500,
8454            &HashMap::new(),
8455            &[],
8456        )
8457        .unwrap();
8458        db.insert_audio_batch("scan1", &samples).unwrap();
8459
8460        let result = db
8461            .query_audio(&AudioQueryParams {
8462                scan_id: Some("scan1".into()),
8463                search: None,
8464                search_regex: false,
8465                format_filter: None,
8466                sort_key: "name".into(),
8467                sort_asc: true,
8468                offset: 0,
8469                limit: 100,
8470            })
8471            .unwrap();
8472
8473        assert_eq!(result.total_count, 3);
8474        assert_eq!(result.samples.len(), 3);
8475        assert_eq!(result.samples[0].name, "hat.mp3");
8476    }
8477
8478    #[test]
8479    fn test_search_subsequence() {
8480        let db = test_db();
8481        let samples = vec![
8482            sample("kick_hard.wav", "/test/kick_hard.wav", "WAV", 1000),
8483            sample("snare_soft.wav", "/test/snare_soft.wav", "WAV", 2000),
8484            sample("kick_808.wav", "/test/kick_808.wav", "WAV", 1500),
8485        ];
8486        db.save_scan("s1", "2024-01-01T00:00:00", 3, 4500, &HashMap::new(), &[])
8487            .unwrap();
8488        db.insert_audio_batch("s1", &samples).unwrap();
8489
8490        // "kick" should match both "kick_hard.wav" and "kick_808.wav" via FTS5 substring.
8491        let result = db
8492            .query_audio(&AudioQueryParams {
8493                scan_id: Some("s1".into()),
8494                search: Some("kick".into()),
8495                search_regex: false,
8496                format_filter: None,
8497                sort_key: "name".into(),
8498                sort_asc: true,
8499                offset: 0,
8500                limit: 100,
8501            })
8502            .unwrap();
8503
8504        assert_eq!(result.total_count, 2);
8505    }
8506
8507    /// Regex mode (`search_regex`) uses SQLite `REGEXP` + Rust `regex` (case-insensitive), not FTS5
8508    /// phrase search — so `F[a][n]` matches `Fan` (JS `RegExp` semantics), not a literal `[` substring.
8509    #[test]
8510    fn test_query_audio_regex_mode_bracket_class_matches_name() {
8511        let db = test_db();
8512        let samples = vec![
8513            sample("Fan.wav", "/test/Fan.wav", "WAV", 1000),
8514            sample("Fox.wav", "/test/Fox.wav", "WAV", 2000),
8515        ];
8516        db.save_scan("s1", "2024-01-01T00:00:00", 2, 3000, &HashMap::new(), &[])
8517            .unwrap();
8518        db.insert_audio_batch("s1", &samples).unwrap();
8519
8520        let result = db
8521            .query_audio(&AudioQueryParams {
8522                scan_id: Some("s1".into()),
8523                search: Some("F[a][n]".into()),
8524                search_regex: true,
8525                format_filter: None,
8526                sort_key: "name".into(),
8527                sort_asc: true,
8528                offset: 0,
8529                limit: 100,
8530            })
8531            .unwrap();
8532
8533        assert_eq!(result.total_count, 1);
8534        assert_eq!(result.samples[0].name, "Fan.wav");
8535    }
8536
8537    /// v13 backfill: rows in `audio_samples` without FTS shadow rows produced zero `MATCH` hits.
8538    #[test]
8539    fn test_backfill_contentless_fts_restores_audio_match() {
8540        let db = test_db();
8541        let samples = vec![sample("kick.wav", "/test/kick.wav", "WAV", 1000)];
8542        db.save_scan("s1", "2024-01-01T00:00:00", 1, 1000, &HashMap::new(), &[])
8543            .unwrap();
8544        db.insert_audio_batch("s1", &samples).unwrap();
8545
8546        {
8547            let conn = db.read_conn();
8548            conn.execute("DELETE FROM audio_samples_fts", [])
8549                .expect("clear FTS");
8550        }
8551
8552        let empty = db
8553            .query_audio(&AudioQueryParams {
8554                scan_id: Some("s1".into()),
8555                search: Some("kick".into()),
8556                search_regex: false,
8557                format_filter: None,
8558                sort_key: "name".into(),
8559                sort_asc: true,
8560                offset: 0,
8561                limit: 100,
8562            })
8563            .unwrap();
8564        assert_eq!(empty.total_count, 0);
8565
8566        {
8567            let conn = db.read_conn();
8568            backfill_contentless_fts(&conn).expect("backfill");
8569        }
8570
8571        let restored = db
8572            .query_audio(&AudioQueryParams {
8573                scan_id: Some("s1".into()),
8574                search: Some("kick".into()),
8575                search_regex: false,
8576                format_filter: None,
8577                sort_key: "name".into(),
8578                sort_asc: true,
8579                offset: 0,
8580                limit: 100,
8581            })
8582            .unwrap();
8583        assert_eq!(restored.total_count, 1);
8584    }
8585
8586    /// Search (name subsequence) + pagination: verifies full query_audio path.
8587    /// With FTS active, row order is bm25 relevance (not column sort); UI may re-rank client-side.
8588    #[test]
8589    fn test_query_audio_search_subsequence_and_sort_size_desc() {
8590        use std::collections::HashSet;
8591        let db = test_db();
8592        let samples = vec![
8593            sample("small_kick.wav", "/test/small_kick.wav", "WAV", 100),
8594            sample("big_kick.wav", "/test/big_kick.wav", "WAV", 9_999),
8595            sample("snare.wav", "/test/snare.wav", "WAV", 500),
8596        ];
8597        db.save_scan("s1", "2024-01-01T00:00:00", 3, 10_599, &HashMap::new(), &[])
8598            .unwrap();
8599        db.insert_audio_batch("s1", &samples).unwrap();
8600
8601        let result = db
8602            .query_audio(&AudioQueryParams {
8603                scan_id: Some("s1".into()),
8604                search: Some("kick".into()),
8605                search_regex: false,
8606                format_filter: None,
8607                sort_key: "size".into(),
8608                sort_asc: false,
8609                offset: 0,
8610                limit: 10,
8611            })
8612            .unwrap();
8613
8614        assert_eq!(result.total_count, 2);
8615        let names: HashSet<&str> = result.samples.iter().map(|s| s.name.as_str()).collect();
8616        assert!(names.contains("big_kick.wav") && names.contains("small_kick.wav"));
8617
8618        let by_size = db
8619            .query_audio(&AudioQueryParams {
8620                scan_id: Some("s1".into()),
8621                search: None,
8622                search_regex: false,
8623                format_filter: None,
8624                sort_key: "size".into(),
8625                sort_asc: false,
8626                offset: 0,
8627                limit: 10,
8628            })
8629            .unwrap();
8630        assert_eq!(by_size.samples[0].name, "big_kick.wav");
8631        assert_eq!(by_size.samples[0].size, 9_999);
8632    }
8633
8634    #[test]
8635    fn test_format_filter() {
8636        let db = test_db();
8637        let samples = vec![
8638            sample("a.wav", "/a.wav", "WAV", 100),
8639            sample("b.mp3", "/b.mp3", "MP3", 200),
8640            sample("c.wav", "/c.wav", "WAV", 300),
8641        ];
8642        db.save_scan("s1", "2024-01-01T00:00:00", 3, 600, &HashMap::new(), &[])
8643            .unwrap();
8644        db.insert_audio_batch("s1", &samples).unwrap();
8645
8646        let result = db
8647            .query_audio(&AudioQueryParams {
8648                scan_id: Some("s1".into()),
8649                search: None,
8650                search_regex: false,
8651                format_filter: Some("WAV".into()),
8652                sort_key: "name".into(),
8653                sort_asc: true,
8654                offset: 0,
8655                limit: 100,
8656            })
8657            .unwrap();
8658
8659        assert_eq!(result.total_count, 2);
8660        assert!(result.samples.iter().all(|s| s.format == "WAV"));
8661    }
8662
8663    /// User search uses SQL LIKE: `%` and `_` in the query string must be escaped (not wildcards).
8664    #[test]
8665    fn test_query_audio_search_escapes_percent_in_user_query() {
8666        let db = test_db();
8667        let samples = vec![
8668            sample("kick.wav", "/kick.wav", "WAV", 100),
8669            sample("100%_wet.wav", "/w.wav", "WAV", 200),
8670        ];
8671        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
8672            .unwrap();
8673        db.insert_audio_batch("s1", &samples).unwrap();
8674
8675        let result = db
8676            .query_audio(&AudioQueryParams {
8677                scan_id: Some("s1".into()),
8678                search: Some("100%".into()),
8679                search_regex: false,
8680                format_filter: None,
8681                sort_key: "name".into(),
8682                sort_asc: true,
8683                offset: 0,
8684                limit: 100,
8685            })
8686            .unwrap();
8687
8688        assert_eq!(result.total_count, 1);
8689        assert_eq!(result.samples[0].name, "100%_wet.wav");
8690    }
8691
8692    #[test]
8693    fn test_query_audio_search_escapes_underscore_in_user_query() {
8694        let db = test_db();
8695        let samples = vec![
8696            sample("ab.wav", "/ab.wav", "WAV", 100),
8697            sample("a_b.wav", "/a_b.wav", "WAV", 200),
8698        ];
8699        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
8700            .unwrap();
8701        db.insert_audio_batch("s1", &samples).unwrap();
8702
8703        let result = db
8704            .query_audio(&AudioQueryParams {
8705                scan_id: Some("s1".into()),
8706                search: Some("a_b".into()),
8707                search_regex: false,
8708                format_filter: None,
8709                sort_key: "name".into(),
8710                sort_asc: true,
8711                offset: 0,
8712                limit: 100,
8713            })
8714            .unwrap();
8715
8716        assert_eq!(result.total_count, 1);
8717        assert_eq!(result.samples[0].name, "a_b.wav");
8718    }
8719
8720    /// Unknown `sort_key` falls back to name (NOCASE), same as the default branch in `query_audio`.
8721    #[test]
8722    fn test_query_audio_unknown_sort_key_defaults_to_name() {
8723        let db = test_db();
8724        let samples = vec![
8725            sample("zebra.wav", "/z.wav", "WAV", 100),
8726            sample("Alpha.wav", "/a.wav", "WAV", 200),
8727        ];
8728        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
8729            .unwrap();
8730        db.insert_audio_batch("s1", &samples).unwrap();
8731
8732        let result = db
8733            .query_audio(&AudioQueryParams {
8734                scan_id: Some("s1".into()),
8735                search: None,
8736                search_regex: false,
8737                format_filter: None,
8738                sort_key: "not_a_supported_column".into(),
8739                sort_asc: true,
8740                offset: 0,
8741                limit: 100,
8742            })
8743            .unwrap();
8744
8745        assert_eq!(result.samples[0].name, "Alpha.wav");
8746        assert_eq!(result.samples[1].name, "zebra.wav");
8747    }
8748
8749    #[test]
8750    fn test_format_filter_all_does_not_restrict() {
8751        let db = test_db();
8752        let samples = vec![
8753            sample("a.wav", "/a.wav", "WAV", 100),
8754            sample("b.mp3", "/b.mp3", "MP3", 200),
8755        ];
8756        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
8757            .unwrap();
8758        db.insert_audio_batch("s1", &samples).unwrap();
8759
8760        let result = db
8761            .query_audio(&AudioQueryParams {
8762                scan_id: Some("s1".into()),
8763                search: None,
8764                search_regex: false,
8765                format_filter: Some("all".into()),
8766                sort_key: "name".into(),
8767                sort_asc: true,
8768                offset: 0,
8769                limit: 100,
8770            })
8771            .unwrap();
8772
8773        assert_eq!(result.total_count, 2);
8774        assert_eq!(result.total_unfiltered, 2);
8775    }
8776
8777    // ── Filter-aware aggregate stats (`*filter_stats` — disk bar / breakdown) ──
8778
8779    #[test]
8780    fn test_audio_filter_stats_empty_db() {
8781        let db = test_db();
8782        let st = db.audio_filter_stats(None, None, false).unwrap();
8783        assert_eq!(st.count, 0);
8784        assert_eq!(st.total_bytes, 0);
8785        assert_eq!(st.total_unfiltered, 0);
8786        assert!(st.by_type.is_empty());
8787        assert_eq!(st.size_buckets, vec![0u64; 6]);
8788    }
8789
8790    #[test]
8791    fn test_audio_filter_stats_unfiltered_breakdown() {
8792        let db = test_db();
8793        let samples = vec![
8794            sample("kick.wav", "/kick.wav", "WAV", 100),
8795            sample("snare.wav", "/snare.wav", "WAV", 200),
8796            sample("loop.mp3", "/loop.mp3", "MP3", 400),
8797        ];
8798        db.save_scan("s1", "2024-01-01T00:00:00", 3, 700, &HashMap::new(), &[])
8799            .unwrap();
8800        db.insert_audio_batch("s1", &samples).unwrap();
8801
8802        let st = db.audio_filter_stats(None, None, false).unwrap();
8803        assert_eq!(st.total_unfiltered, 3);
8804        assert_eq!(st.count, 3);
8805        assert_eq!(st.total_bytes, 700);
8806        assert_eq!(st.by_type.get("WAV").copied().unwrap_or(0), 2);
8807        assert_eq!(st.by_type.get("MP3").copied().unwrap_or(0), 1);
8808        assert_eq!(st.bytes_by_type.get("WAV").copied().unwrap_or(0), 300);
8809        assert_eq!(st.bytes_by_type.get("MP3").copied().unwrap_or(0), 400);
8810        assert_eq!(st.size_buckets.len(), 6);
8811        assert_eq!(st.size_buckets.iter().sum::<u64>(), st.count);
8812        assert_eq!(st.size_buckets[0], 3); // all < 100 KiB
8813    }
8814
8815    #[test]
8816    fn test_audio_filter_stats_search_subsequence() {
8817        let db = test_db();
8818        let samples = vec![
8819            sample("kick_hard.wav", "/k.wav", "WAV", 100),
8820            sample("snare.wav", "/s.wav", "WAV", 200),
8821            sample("kick_soft.wav", "/ks.wav", "WAV", 300),
8822        ];
8823        db.save_scan("s1", "2024-01-01T00:00:00", 3, 600, &HashMap::new(), &[])
8824            .unwrap();
8825        db.insert_audio_batch("s1", &samples).unwrap();
8826
8827        let st = db.audio_filter_stats(Some("kick"), None, false).unwrap();
8828        assert_eq!(st.total_unfiltered, 3);
8829        assert_eq!(st.count, 2);
8830        assert_eq!(st.total_bytes, 400);
8831    }
8832
8833    #[test]
8834    fn test_audio_filter_stats_format_single_and_multi() {
8835        let db = test_db();
8836        let samples = vec![
8837            sample("a.wav", "/a.wav", "WAV", 100),
8838            sample("b.aiff", "/b.aiff", "AIFF", 200),
8839            sample("c.mp3", "/c.mp3", "MP3", 400),
8840        ];
8841        db.save_scan("s1", "2024-01-01T00:00:00", 3, 700, &HashMap::new(), &[])
8842            .unwrap();
8843        db.insert_audio_batch("s1", &samples).unwrap();
8844
8845        let w = db.audio_filter_stats(None, Some("WAV"), false).unwrap();
8846        assert_eq!(w.count, 1);
8847        assert_eq!(w.total_unfiltered, 3);
8848        assert_eq!(w.by_type.len(), 1);
8849
8850        let wm = db
8851            .audio_filter_stats(None, Some("WAV,AIFF"), false)
8852            .unwrap();
8853        assert_eq!(wm.count, 2);
8854        assert_eq!(wm.total_bytes, 300);
8855    }
8856
8857    #[test]
8858    fn test_audio_filter_stats_format_all_noop() {
8859        let db = test_db();
8860        let samples = vec![sample("x.wav", "/x.wav", "WAV", 10)];
8861        db.save_scan("s1", "2024-01-01T00:00:00", 1, 10, &HashMap::new(), &[])
8862            .unwrap();
8863        db.insert_audio_batch("s1", &samples).unwrap();
8864
8865        let st = db.audio_filter_stats(None, Some("all"), false).unwrap();
8866        assert_eq!(st.count, 1);
8867        assert_eq!(st.total_unfiltered, 1);
8868    }
8869
8870    #[test]
8871    fn test_daw_filter_stats_daw_filter_and_search() {
8872        let db = test_db();
8873        db.save_daw_scan(&daw_snap(
8874            "ds-fs1",
8875            "2024-06-01T00:00:00",
8876            vec![
8877                daw_project("a.als", "Ableton Live"),
8878                daw_project("b.als", "Ableton Live"),
8879                daw_project("c.logicx", "Logic Pro"),
8880            ],
8881        ))
8882        .unwrap();
8883
8884        let unfiltered = db.daw_filter_stats(None, None, false).unwrap();
8885        assert_eq!(unfiltered.total_unfiltered, 3);
8886        assert_eq!(unfiltered.count, 3);
8887        assert_eq!(
8888            unfiltered.by_type.get("Ableton Live").copied().unwrap_or(0),
8889            2
8890        );
8891        assert_eq!(unfiltered.by_type.get("Logic Pro").copied().unwrap_or(0), 1);
8892
8893        let abl = db
8894            .daw_filter_stats(None, Some("Ableton Live"), false)
8895            .unwrap();
8896        assert_eq!(abl.count, 2);
8897        assert_eq!(abl.total_unfiltered, 3);
8898        assert_eq!(abl.total_bytes, 2000);
8899
8900        let search = db.daw_filter_stats(Some("a.als"), None, false).unwrap();
8901        assert_eq!(search.count, 1);
8902        assert_eq!(search.total_unfiltered, 3);
8903    }
8904
8905    #[test]
8906    fn test_daw_filter_stats_empty_db() {
8907        let db = test_db();
8908        let st = db.daw_filter_stats(None, None, false).unwrap();
8909        assert_eq!(st.count, 0);
8910        assert_eq!(st.total_unfiltered, 0);
8911    }
8912
8913    #[test]
8914    fn test_preset_filter_stats_respects_midi_exclusion() {
8915        let db = test_db();
8916        db.save_preset_scan(&preset_snap(
8917            "pr-fs",
8918            "2024-06-01T00:00:00",
8919            vec![
8920                preset_file("a.fxp", "FXP"),
8921                preset_file("b.fxp", "FXP"),
8922                preset_file("c.mid", "MID"),
8923            ],
8924        ))
8925        .unwrap();
8926
8927        let st = db.preset_filter_stats(None, None, false).unwrap();
8928        assert_eq!(st.total_unfiltered, 2);
8929        assert_eq!(st.count, 2);
8930        assert_eq!(st.by_type.get("FXP").copied().unwrap_or(0), 2);
8931
8932        let fx = db.preset_filter_stats(None, Some("FXP"), false).unwrap();
8933        assert_eq!(fx.count, 2);
8934        assert_eq!(fx.total_unfiltered, 2);
8935    }
8936
8937    #[test]
8938    fn test_preset_filter_stats_search_subsequence() {
8939        let db = test_db();
8940        db.save_preset_scan(&preset_snap(
8941            "pr-fs2",
8942            "2024-06-01T00:00:00",
8943            vec![
8944                preset_file("lead_brass.fxp", "FXP"),
8945                preset_file("kick.wav", "WAV"),
8946            ],
8947        ))
8948        .unwrap();
8949
8950        let st = db.preset_filter_stats(Some("brass"), None, false).unwrap();
8951        assert_eq!(st.count, 1);
8952        assert_eq!(st.total_unfiltered, 2);
8953    }
8954
8955    #[test]
8956    fn test_plugin_filter_stats_type_and_search() {
8957        let db = test_db();
8958        db.save_plugin_scan(&plugin_snap(
8959            "ps-fs",
8960            "2024-06-01T00:00:00",
8961            vec![
8962                plugin_info("Serum", "VST3", "Xfer"),
8963                plugin_info("Diva", "AU", "u-he"),
8964                plugin_info("Vital", "VST3", "Matt"),
8965            ],
8966        ))
8967        .unwrap();
8968
8969        let st = db.plugin_filter_stats(None, None, false).unwrap();
8970        assert_eq!(st.total_unfiltered, 3);
8971        assert_eq!(st.count, 3);
8972        assert_eq!(st.by_type.get("VST3").copied().unwrap_or(0), 2);
8973
8974        let vst = db.plugin_filter_stats(None, Some("VST3"), false).unwrap();
8975        assert_eq!(vst.count, 2);
8976        assert_eq!(vst.total_bytes, 2_000_000);
8977
8978        let xfer = db.plugin_filter_stats(Some("Xfer"), None, false).unwrap();
8979        assert_eq!(xfer.count, 1);
8980        assert_eq!(xfer.total_unfiltered, 3);
8981    }
8982
8983    #[test]
8984    fn test_plugin_filter_stats_multi_type() {
8985        let db = test_db();
8986        db.save_plugin_scan(&plugin_snap(
8987            "ps-fs2",
8988            "2024-06-01T00:00:00",
8989            vec![plugin_info("A", "VST3", "X"), plugin_info("B", "AU", "X")],
8990        ))
8991        .unwrap();
8992
8993        let st = db
8994            .plugin_filter_stats(None, Some("VST3,AU"), false)
8995            .unwrap();
8996        assert_eq!(st.count, 2);
8997        assert_eq!(st.total_unfiltered, 2);
8998    }
8999
9000    #[test]
9001    fn test_plugin_filter_stats_empty_db() {
9002        let db = test_db();
9003        let st = db.plugin_filter_stats(None, None, false).unwrap();
9004        assert_eq!(st.count, 0);
9005        assert_eq!(st.total_unfiltered, 0);
9006    }
9007
9008    #[test]
9009    fn test_pdf_filter_stats_search_and_totals() {
9010        let db = test_db();
9011        let snap = PdfScanSnapshot {
9012            id: "pdf-fs".into(),
9013            timestamp: "2024-07-01T00:00:00".into(),
9014            pdf_count: 2,
9015            total_bytes: 300,
9016            pdfs: vec![
9017                PdfFile {
9018                    name: "manual".into(),
9019                    path: "/docs/manual.pdf".into(),
9020                    directory: "/docs".into(),
9021                    size: 100,
9022                    size_formatted: "100 B".into(),
9023                    modified: "2024-06-01".into(),
9024                },
9025                PdfFile {
9026                    name: "readme_extra".into(),
9027                    path: "/docs/readme_extra.pdf".into(),
9028                    directory: "/docs".into(),
9029                    size: 200,
9030                    size_formatted: "200 B".into(),
9031                    modified: "2024-06-02".into(),
9032                },
9033            ],
9034            roots: vec!["/docs".into()],
9035        };
9036        db.save_pdf_scan(&snap).unwrap();
9037
9038        let all = db.pdf_filter_stats(None, false).unwrap();
9039        assert_eq!(all.total_unfiltered, 2);
9040        assert_eq!(all.count, 2);
9041        assert_eq!(all.total_bytes, 300);
9042        assert!(all.by_type.is_empty());
9043
9044        let sub = db.pdf_filter_stats(Some("readme"), false).unwrap();
9045        assert_eq!(sub.count, 1);
9046        assert_eq!(sub.total_bytes, 200);
9047        assert_eq!(sub.total_unfiltered, 2);
9048    }
9049
9050    #[test]
9051    fn test_pdf_filter_stats_empty_db() {
9052        let db = test_db();
9053        let st = db.pdf_filter_stats(None, false).unwrap();
9054        assert_eq!(st.count, 0);
9055        assert_eq!(st.total_unfiltered, 0);
9056    }
9057
9058    /// Whitespace-only `search` is treated as no search (same row set as `search: None`).
9059    #[test]
9060    fn test_query_audio_whitespace_only_search_is_noop() {
9061        let db = test_db();
9062        let samples = vec![
9063            sample("first.wav", "/first.wav", "WAV", 100),
9064            sample("second.wav", "/second.wav", "WAV", 200),
9065        ];
9066        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
9067            .unwrap();
9068        db.insert_audio_batch("s1", &samples).unwrap();
9069
9070        let with_spaces = db
9071            .query_audio(&AudioQueryParams {
9072                scan_id: Some("s1".into()),
9073                search: Some("   \t  ".into()),
9074                search_regex: false,
9075                format_filter: None,
9076                sort_key: "name".into(),
9077                sort_asc: true,
9078                offset: 0,
9079                limit: 100,
9080            })
9081            .unwrap();
9082        let no_search = db
9083            .query_audio(&AudioQueryParams {
9084                scan_id: Some("s1".into()),
9085                search: None,
9086                search_regex: false,
9087                format_filter: None,
9088                sort_key: "name".into(),
9089                sort_asc: true,
9090                offset: 0,
9091                limit: 100,
9092            })
9093            .unwrap();
9094        assert_eq!(with_spaces.total_count, no_search.total_count);
9095        assert_eq!(with_spaces.total_count, 2);
9096    }
9097
9098    #[test]
9099    fn test_batch_update_analysis_empty_batch_returns_zero() {
9100        let db = test_db();
9101        assert_eq!(db.batch_update_analysis(&[]).unwrap(), 0);
9102    }
9103
9104    #[test]
9105    fn test_get_analysis_unknown_path_returns_empty_object() {
9106        let db = test_db();
9107        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
9108        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
9109            .unwrap();
9110        db.insert_audio_batch("s1", &samples).unwrap();
9111
9112        let j = db.get_analysis("/no/such/file.wav").unwrap();
9113        assert!(j.is_object());
9114        assert!(j.as_object().unwrap().is_empty());
9115    }
9116
9117    #[test]
9118    fn test_pagination() {
9119        let db = test_db();
9120        let samples: Vec<_> = (0..50)
9121            .map(|i| {
9122                sample(
9123                    &format!("s{i:03}.wav"),
9124                    &format!("/s{i:03}.wav"),
9125                    "WAV",
9126                    100,
9127                )
9128            })
9129            .collect();
9130        db.save_scan("s1", "2024-01-01T00:00:00", 50, 5000, &HashMap::new(), &[])
9131            .unwrap();
9132        db.insert_audio_batch("s1", &samples).unwrap();
9133
9134        let page1 = db
9135            .query_audio(&AudioQueryParams {
9136                scan_id: Some("s1".into()),
9137                search: None,
9138                search_regex: false,
9139                format_filter: None,
9140                sort_key: "name".into(),
9141                sort_asc: true,
9142                offset: 0,
9143                limit: 10,
9144            })
9145            .unwrap();
9146
9147        assert_eq!(page1.total_count, 50);
9148        assert_eq!(page1.samples.len(), 10);
9149        assert_eq!(page1.samples[0].name, "s000.wav");
9150
9151        let page2 = db
9152            .query_audio(&AudioQueryParams {
9153                scan_id: Some("s1".into()),
9154                search: None,
9155                search_regex: false,
9156                format_filter: None,
9157                sort_key: "name".into(),
9158                sort_asc: true,
9159                offset: 10,
9160                limit: 10,
9161            })
9162            .unwrap();
9163
9164        assert_eq!(page2.samples[0].name, "s010.wav");
9165    }
9166
9167    #[test]
9168    fn test_update_analysis() {
9169        let db = test_db();
9170        let samples = vec![sample("kick.wav", "/kick.wav", "WAV", 1000)];
9171        db.save_scan("s1", "2024-01-01T00:00:00", 1, 1000, &HashMap::new(), &[])
9172            .unwrap();
9173        db.insert_audio_batch("s1", &samples).unwrap();
9174
9175        db.update_bpm("/kick.wav", Some(120.0)).unwrap();
9176        db.update_key("/kick.wav", Some("C minor")).unwrap();
9177        db.update_lufs("/kick.wav", Some(-14.5)).unwrap();
9178
9179        let analysis = db.get_analysis("/kick.wav").unwrap();
9180        assert_eq!(analysis["bpm"], 120.0);
9181        assert_eq!(analysis["key"], "C minor");
9182        assert_eq!(analysis["lufs"], -14.5);
9183    }
9184
9185    #[test]
9186    fn test_batch_update_analysis_and_audio_stats() {
9187        let db = test_db();
9188        let samples = vec![
9189            sample("a.wav", "/a.wav", "WAV", 100),
9190            sample("b.wav", "/b.wav", "WAV", 200),
9191        ];
9192        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
9193            .unwrap();
9194        db.insert_audio_batch("s1", &samples).unwrap();
9195
9196        assert_eq!(db.audio_stats(Some("s1")).unwrap().analyzed_count, 0);
9197
9198        let rows: Vec<AnalysisBatchRow> = vec![
9199            ("/a.wav".into(), Some(128.0), Some("D".into()), Some(-12.0)),
9200            (
9201                "/b.wav".into(),
9202                Some(90.0),
9203                Some("A minor".into()),
9204                Some(-15.5),
9205            ),
9206        ];
9207        assert_eq!(db.batch_update_analysis(&rows).unwrap(), 2);
9208
9209        let stats = db.audio_stats(Some("s1")).unwrap();
9210        assert_eq!(stats.analyzed_count, 2);
9211        assert_eq!(stats.sample_count, 2);
9212        assert_eq!(stats.total_bytes, 300);
9213
9214        let ja = db.get_analysis("/a.wav").unwrap();
9215        assert_eq!(ja["bpm"], 128.0);
9216        assert_eq!(ja["key"], "D");
9217        assert_eq!(ja["lufs"], -12.0);
9218
9219        let jb = db.get_analysis("/b.wav").unwrap();
9220        assert_eq!(jb["bpm"], 90.0);
9221        assert_eq!(jb["key"], "A minor");
9222        assert_eq!(jb["lufs"], -15.5);
9223    }
9224
9225    #[test]
9226    fn test_unanalyzed_paths() {
9227        let db = test_db();
9228        let samples = vec![
9229            sample("a.wav", "/a.wav", "WAV", 100),
9230            sample("b.wav", "/b.wav", "WAV", 200),
9231        ];
9232        db.save_scan("s1", "2024-01-01T00:00:00", 2, 300, &HashMap::new(), &[])
9233            .unwrap();
9234        db.insert_audio_batch("s1", &samples).unwrap();
9235        db.update_bpm("/a.wav", Some(120.0)).unwrap();
9236        db.update_key("/a.wav", Some("C")).unwrap();
9237        db.update_lufs("/a.wav", Some(-12.0)).unwrap();
9238
9239        let unanalyzed = db.unanalyzed_paths(100).unwrap();
9240        assert_eq!(unanalyzed.len(), 1);
9241        assert_eq!(unanalyzed[0], "/b.wav");
9242    }
9243
9244    #[test]
9245    fn test_unanalyzed_paths_skips_when_bpm_exhausted() {
9246        let db = test_db();
9247        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
9248        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
9249            .unwrap();
9250        db.insert_audio_batch("s1", &samples).unwrap();
9251        let rows: Vec<AnalysisBatchRow> = vec![(
9252            "/a.wav".into(),
9253            None,
9254            Some("D".into()),
9255            Some(-12.0),
9256        )];
9257        assert_eq!(db.batch_update_analysis(&rows).unwrap(), 1);
9258        let unanalyzed = db.unanalyzed_paths(100).unwrap();
9259        assert!(
9260            unanalyzed.is_empty(),
9261            "key+lufs present but BPM None should set bpm_exhausted and leave the queue"
9262        );
9263    }
9264
9265    #[test]
9266    fn test_audio_stats() {
9267        let db = test_db();
9268        let samples = vec![
9269            sample("a.wav", "/a.wav", "WAV", 100),
9270            sample("b.mp3", "/b.mp3", "MP3", 200),
9271            sample("c.wav", "/c.wav", "WAV", 300),
9272        ];
9273        db.save_scan("s1", "2024-01-01T00:00:00", 3, 600, &HashMap::new(), &[])
9274            .unwrap();
9275        db.insert_audio_batch("s1", &samples).unwrap();
9276
9277        let stats = db.audio_stats(Some("s1")).unwrap();
9278        assert_eq!(stats.sample_count, 3);
9279        assert_eq!(stats.total_bytes, 600);
9280        assert_eq!(stats.format_counts["WAV"], 2);
9281        assert_eq!(stats.format_counts["MP3"], 1);
9282    }
9283
9284    #[test]
9285    fn test_delete_scan() {
9286        let db = test_db();
9287        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
9288        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
9289            .unwrap();
9290        db.insert_audio_batch("s1", &samples).unwrap();
9291
9292        db.delete_scan("s1").unwrap();
9293
9294        let scans = db.list_scans().unwrap();
9295        assert!(scans.is_empty());
9296
9297        let stats = db.audio_stats(Some("s1")).unwrap();
9298        assert_eq!(stats.sample_count, 0);
9299    }
9300
9301    #[test]
9302    fn test_sort_directions() {
9303        let db = test_db();
9304        let samples = vec![
9305            sample("z.wav", "/z.wav", "WAV", 300),
9306            sample("a.wav", "/a.wav", "WAV", 100),
9307            sample("m.wav", "/m.wav", "WAV", 200),
9308        ];
9309        db.save_scan("s1", "2024-01-01T00:00:00", 3, 600, &HashMap::new(), &[])
9310            .unwrap();
9311        db.insert_audio_batch("s1", &samples).unwrap();
9312
9313        let asc = db
9314            .query_audio(&AudioQueryParams {
9315                scan_id: Some("s1".into()),
9316                search: None,
9317                search_regex: false,
9318                format_filter: None,
9319                sort_key: "size".into(),
9320                sort_asc: true,
9321                offset: 0,
9322                limit: 100,
9323            })
9324            .unwrap();
9325        assert_eq!(asc.samples[0].size, 100);
9326        assert_eq!(asc.samples[2].size, 300);
9327
9328        let desc = db
9329            .query_audio(&AudioQueryParams {
9330                scan_id: Some("s1".into()),
9331                search: None,
9332                search_regex: false,
9333                format_filter: None,
9334                sort_key: "size".into(),
9335                sort_asc: false,
9336                offset: 0,
9337                limit: 100,
9338            })
9339            .unwrap();
9340        assert_eq!(desc.samples[0].size, 300);
9341    }
9342
9343    #[test]
9344    fn test_plugin_scan_roundtrip() {
9345        let db = test_db();
9346        let snap = ScanSnapshot {
9347            id: "ps1".into(),
9348            timestamp: "2024-06-01T00:00:00".into(),
9349            plugin_count: 2,
9350            plugins: vec![
9351                PluginInfo {
9352                    name: "Serum".into(),
9353                    path: "/vst/Serum.vst3".into(),
9354                    plugin_type: "VST3".into(),
9355                    version: "1.3".into(),
9356                    manufacturer: "Xfer".into(),
9357                    manufacturer_url: None,
9358                    size: "10 MB".into(),
9359                    size_bytes: 10_000_000,
9360                    modified: "2024-01-01".into(),
9361                    architectures: vec!["arm64".into()],
9362                },
9363                PluginInfo {
9364                    name: "Vital".into(),
9365                    path: "/vst/Vital.vst3".into(),
9366                    plugin_type: "VST3".into(),
9367                    version: "1.5".into(),
9368                    manufacturer: "Matt Tytel".into(),
9369                    manufacturer_url: Some("https://vital.audio".into()),
9370                    size: "5 MB".into(),
9371                    size_bytes: 5_000_000,
9372                    modified: "2024-02-01".into(),
9373                    architectures: vec!["arm64".into(), "x86_64".into()],
9374                },
9375            ],
9376            directories: vec!["/vst".into()],
9377            roots: vec!["/vst".into()],
9378        };
9379        db.save_plugin_scan(&snap).unwrap();
9380
9381        let scans = db.get_plugin_scans().unwrap();
9382        assert_eq!(scans.len(), 1);
9383        assert_eq!(scans[0]["id"], "ps1");
9384        assert_eq!(scans[0]["pluginCount"], 2);
9385
9386        let detail = db.get_plugin_scan_detail("ps1").unwrap();
9387        assert_eq!(detail.plugins.len(), 2);
9388        assert_eq!(detail.plugins[0].name, "Serum");
9389        assert_eq!(detail.plugins[1].manufacturer, "Matt Tytel");
9390        assert_eq!(detail.plugins[1].architectures, vec!["arm64", "x86_64"]);
9391    }
9392
9393    /// Subsequence search (name/manufacturer/path) + sort by `size_bytes` DESC.
9394    #[test]
9395    fn test_query_plugins_search_subsequence_and_sort_size_desc() {
9396        let db = test_db();
9397        let snap = ScanSnapshot {
9398            id: "pg-sort-1".into(),
9399            timestamp: "2024-06-01T00:00:00".into(),
9400            plugin_count: 3,
9401            plugins: vec![
9402                PluginInfo {
9403                    name: "small_serum_label".into(),
9404                    path: "/vst/small.vst3".into(),
9405                    plugin_type: "VST3".into(),
9406                    version: "1.0".into(),
9407                    manufacturer: "Xfer".into(),
9408                    manufacturer_url: None,
9409                    size: "100 B".into(),
9410                    size_bytes: 100,
9411                    modified: "2024-01-01".into(),
9412                    architectures: vec![],
9413                },
9414                PluginInfo {
9415                    name: "big_serum_bank".into(),
9416                    path: "/vst/big.vst3".into(),
9417                    plugin_type: "VST3".into(),
9418                    version: "1.0".into(),
9419                    manufacturer: "Xfer".into(),
9420                    manufacturer_url: None,
9421                    size: "10 KB".into(),
9422                    size_bytes: 10_000,
9423                    modified: "2024-01-01".into(),
9424                    architectures: vec![],
9425                },
9426                PluginInfo {
9427                    name: "Other".into(),
9428                    // Path must not contain "s…e…r" subsequence (e.g. `/vst/…` matches "ser").
9429                    path: "/plugin/other.clap".into(),
9430                    plugin_type: "CLAP".into(),
9431                    version: "1.0".into(),
9432                    manufacturer: "ACME".into(),
9433                    manufacturer_url: None,
9434                    size: "5 MB".into(),
9435                    size_bytes: 5_000_000,
9436                    modified: "2024-01-01".into(),
9437                    architectures: vec![],
9438                },
9439            ],
9440            directories: vec!["/vst".into()],
9441            roots: vec!["/vst".into()],
9442        };
9443        db.save_plugin_scan(&snap).unwrap();
9444
9445        let res = db
9446            .query_plugins(Some("ser"), None, None, "size", false, false, 0, 100)
9447            .unwrap();
9448        assert_eq!(res.total_count, 2);
9449        assert_eq!(res.plugins[0].name, "big_serum_bank");
9450        assert_eq!(res.plugins[0].size_bytes, 10_000);
9451        assert_eq!(res.plugins[1].name, "small_serum_label");
9452    }
9453
9454    #[test]
9455    fn test_daw_scan_roundtrip() {
9456        let db = test_db();
9457        let mut daw_counts = HashMap::new();
9458        daw_counts.insert("Ableton".into(), 2);
9459        let snap = DawScanSnapshot {
9460            id: "ds1".into(),
9461            timestamp: "2024-06-01T00:00:00".into(),
9462            project_count: 2,
9463            total_bytes: 50_000,
9464            daw_counts,
9465            projects: vec![
9466                DawProject {
9467                    name: "track1.als".into(),
9468                    path: "/music/track1.als".into(),
9469                    directory: "/music".into(),
9470                    format: "ALS".into(),
9471                    daw: "Ableton".into(),
9472                    size: 30_000,
9473                    size_formatted: "30 KB".into(),
9474                    modified: "2024-03-01".into(),
9475                },
9476                DawProject {
9477                    name: "track2.als".into(),
9478                    path: "/music/track2.als".into(),
9479                    directory: "/music".into(),
9480                    format: "ALS".into(),
9481                    daw: "Ableton".into(),
9482                    size: 20_000,
9483                    size_formatted: "20 KB".into(),
9484                    modified: "2024-04-01".into(),
9485                },
9486            ],
9487            roots: vec!["/music".into()],
9488        };
9489        db.save_daw_scan(&snap).unwrap();
9490
9491        let scans = db.get_daw_scans().unwrap();
9492        assert_eq!(scans.len(), 1);
9493        assert_eq!(scans[0]["projectCount"], 2);
9494        assert_eq!(scans[0]["totalBytes"], 50_000);
9495
9496        let detail = db.get_daw_scan_detail("ds1").unwrap();
9497        assert_eq!(detail.projects.len(), 2);
9498        assert_eq!(detail.projects[0].daw, "Ableton");
9499    }
9500
9501    /// History list must show live row counts even if `daw_scan_parent_finalize` never ran
9502    /// (parent row still has project_count = 0).
9503    #[test]
9504    fn test_get_daw_scans_project_count_from_child_table() {
9505        let db = test_db();
9506        let id = "daw-unfinalized";
9507        let ts = "2024-06-01T00:00:00";
9508        db.daw_scan_parent_create(id, ts, &["/roots".into()])
9509            .unwrap();
9510        let p = DawProject {
9511            name: "track.als".into(),
9512            path: "/music/track.als".into(),
9513            directory: "/music".into(),
9514            format: "ALS".into(),
9515            daw: "Ableton".into(),
9516            size: 100,
9517            size_formatted: "100 B".into(),
9518            modified: "2024-01-01".into(),
9519        };
9520        db.insert_daw_batch(id, &[p]).unwrap();
9521        db.set_daw_scan_complete(id, true)
9522            .expect("mark scan complete so history lists it");
9523        let scans = db.get_daw_scans().unwrap();
9524        assert_eq!(scans.len(), 1);
9525        assert_eq!(scans[0]["projectCount"], 1);
9526    }
9527
9528    /// User-stopped (or unfinalized-incomplete) scans stay in the DB for library aggregation but
9529    /// must not appear in deletable history lists.
9530    #[test]
9531    fn test_incomplete_scan_hidden_from_plugin_history() {
9532        let db = test_db();
9533        db.plugin_scan_parent_create("ps-partial", "2024-01-01T00:00:00", &["/vst".into()])
9534            .unwrap();
9535        let p = PluginInfo {
9536            name: "X".into(),
9537            path: "/x.vst3".into(),
9538            plugin_type: "VST3".into(),
9539            version: "1".into(),
9540            manufacturer: "M".into(),
9541            manufacturer_url: None,
9542            size: "1 B".into(),
9543            size_bytes: 1,
9544            modified: "2024-01-01".into(),
9545            architectures: vec![],
9546        };
9547        db.insert_plugin_batch("ps-partial", &[p]).unwrap();
9548        db.plugin_scan_parent_finalize("ps-partial", 1, &["/vst".into()], &["/vst".into()])
9549            .unwrap();
9550        assert!(db.get_plugin_scans().unwrap().is_empty());
9551        db.set_plugin_scan_complete("ps-partial", true).unwrap();
9552        assert_eq!(db.get_plugin_scans().unwrap().len(), 1);
9553    }
9554
9555    /// Subsequence search on name/path + sort by file size DESC.
9556    #[test]
9557    fn test_query_daw_search_subsequence_and_sort_size_desc() {
9558        let db = test_db();
9559        let mut daw_counts = HashMap::new();
9560        daw_counts.insert("Ableton".into(), 3);
9561        let snap = DawScanSnapshot {
9562            id: "ds-sort-1".into(),
9563            timestamp: "2024-06-01T00:00:00".into(),
9564            project_count: 3,
9565            total_bytes: 10_600,
9566            daw_counts,
9567            projects: vec![
9568                DawProject {
9569                    name: "small_mix_down.als".into(),
9570                    path: "/music/small_mix_down.als".into(),
9571                    directory: "/music".into(),
9572                    format: "ALS".into(),
9573                    daw: "Ableton".into(),
9574                    size: 100,
9575                    size_formatted: "100 B".into(),
9576                    modified: "2024-01-01".into(),
9577                },
9578                DawProject {
9579                    name: "big_mix_master.als".into(),
9580                    path: "/music/big_mix_master.als".into(),
9581                    directory: "/music".into(),
9582                    format: "ALS".into(),
9583                    daw: "Ableton".into(),
9584                    size: 10_000,
9585                    size_formatted: "10 KB".into(),
9586                    modified: "2024-01-01".into(),
9587                },
9588                DawProject {
9589                    name: "vocal_take.als".into(),
9590                    path: "/music/vocal_take.als".into(),
9591                    directory: "/music".into(),
9592                    format: "ALS".into(),
9593                    daw: "Ableton".into(),
9594                    size: 500,
9595                    size_formatted: "500 B".into(),
9596                    modified: "2024-01-01".into(),
9597                },
9598            ],
9599            roots: vec!["/music".into()],
9600        };
9601        db.save_daw_scan(&snap).unwrap();
9602
9603        let res = db
9604            .query_daw(Some("mix"), None, "size", false, false, 0, 100)
9605            .unwrap();
9606        assert_eq!(res.total_count, 2);
9607        let names: HashSet<_> = res.projects.iter().map(|p| p.name.as_str()).collect();
9608        assert_eq!(
9609            names,
9610            HashSet::from(["big_mix_master.als", "small_mix_down.als"])
9611        );
9612
9613        let by_size = db
9614            .query_daw(None, None, "size", false, false, 0, 100)
9615            .unwrap();
9616        assert_eq!(by_size.projects.len(), 3);
9617        assert_eq!(by_size.projects[0].name, "big_mix_master.als");
9618        assert_eq!(by_size.projects[0].size, 10_000);
9619        assert_eq!(by_size.projects[1].name, "vocal_take.als");
9620        assert_eq!(by_size.projects[2].name, "small_mix_down.als");
9621    }
9622
9623    #[test]
9624    fn test_preset_scan_roundtrip() {
9625        let db = test_db();
9626        let mut format_counts = HashMap::new();
9627        format_counts.insert("FXP".into(), 1);
9628        let snap = PresetScanSnapshot {
9629            id: "pr1".into(),
9630            timestamp: "2024-06-01T00:00:00".into(),
9631            preset_count: 1,
9632            total_bytes: 8000,
9633            format_counts,
9634            presets: vec![PresetFile {
9635                name: "bass.fxp".into(),
9636                path: "/presets/bass.fxp".into(),
9637                directory: "/presets".into(),
9638                format: "FXP".into(),
9639                size: 8000,
9640                size_formatted: "8 KB".into(),
9641                modified: "2024-05-01".into(),
9642            }],
9643            roots: vec!["/presets".into()],
9644        };
9645        db.save_preset_scan(&snap).unwrap();
9646
9647        let scans = db.get_preset_scans().unwrap();
9648        assert_eq!(scans.len(), 1);
9649        assert_eq!(scans[0]["presetCount"], 1);
9650
9651        let detail = db.get_preset_scan_detail("pr1").unwrap();
9652        assert_eq!(detail.presets.len(), 1);
9653        assert_eq!(detail.presets[0].name, "bass.fxp");
9654    }
9655
9656    #[test]
9657    fn test_pdf_scan_roundtrip() {
9658        let db = test_db();
9659        let snap = PdfScanSnapshot {
9660            id: "pdf1".into(),
9661            timestamp: "2024-07-01T00:00:00".into(),
9662            pdf_count: 1,
9663            total_bytes: 1024,
9664            pdfs: vec![PdfFile {
9665                name: "readme".into(),
9666                path: "/docs/readme.pdf".into(),
9667                directory: "/docs".into(),
9668                size: 1024,
9669                size_formatted: "1.0 KB".into(),
9670                modified: "2024-06-01".into(),
9671            }],
9672            roots: vec!["/docs".into()],
9673        };
9674        db.save_pdf_scan(&snap).unwrap();
9675        let scans = db.get_pdf_scans().unwrap();
9676        assert_eq!(scans.len(), 1);
9677        assert_eq!(scans[0]["pdfCount"], 1);
9678        let detail = db.get_pdf_scan_detail("pdf1").unwrap();
9679        assert_eq!(detail.pdfs.len(), 1);
9680        assert_eq!(detail.pdfs[0].name, "readme");
9681    }
9682
9683    #[test]
9684    fn test_query_pdfs_empty_without_scan() {
9685        let db = test_db();
9686        let res = db.query_pdfs(None, "name", true, false, 0, 100).unwrap();
9687        assert_eq!(res.total_count, 0);
9688        assert_eq!(res.total_unfiltered, 0);
9689        assert!(res.pdfs.is_empty());
9690    }
9691
9692    #[test]
9693    fn test_query_pdfs_search_sort_and_pagination() {
9694        let db = test_db();
9695        let pdfs = vec![
9696            PdfFile {
9697                name: "zebra".into(),
9698                path: "/a/z.pdf".into(),
9699                directory: "/a".into(),
9700                size: 100,
9701                size_formatted: "100 B".into(),
9702                modified: "2024-01-03".into(),
9703            },
9704            PdfFile {
9705                name: "alpha".into(),
9706                path: "/a/a.pdf".into(),
9707                directory: "/b".into(),
9708                size: 50,
9709                size_formatted: "50 B".into(),
9710                modified: "2024-01-01".into(),
9711            },
9712            PdfFile {
9713                name: "alpha_notes".into(),
9714                path: "/a/notes.pdf".into(),
9715                directory: "/c".into(),
9716                size: 50,
9717                size_formatted: "50 B".into(),
9718                modified: "2024-01-02".into(),
9719            },
9720        ];
9721        let total_bytes: u64 = pdfs.iter().map(|p| p.size).sum();
9722        let snap = PdfScanSnapshot {
9723            id: "pdfq".into(),
9724            timestamp: "2024-08-01T00:00:00".into(),
9725            pdf_count: pdfs.len(),
9726            total_bytes,
9727            pdfs,
9728            roots: vec![],
9729        };
9730        db.save_pdf_scan(&snap).unwrap();
9731
9732        let filtered = db
9733            .query_pdfs(Some("alp"), "name", true, false, 0, 100)
9734            .unwrap();
9735        assert_eq!(filtered.total_unfiltered, 3);
9736        assert_eq!(filtered.total_count, 2);
9737        assert_eq!(filtered.pdfs.len(), 2);
9738        let mut got: Vec<&str> = filtered.pdfs.iter().map(|p| p.name.as_str()).collect();
9739        got.sort();
9740        assert_eq!(got, vec!["alpha", "alpha_notes"]);
9741
9742        let by_size = db.query_pdfs(None, "size", false, false, 0, 10).unwrap();
9743        assert_eq!(by_size.pdfs[0].name, "zebra");
9744
9745        let page = db.query_pdfs(None, "name", true, false, 1, 1).unwrap();
9746        assert_eq!(page.pdfs.len(), 1);
9747        assert_eq!(page.total_count, 3);
9748        assert_eq!(page.pdfs[0].name, "alpha_notes");
9749    }
9750
9751    #[test]
9752    fn test_query_pdfs_library_unions_distinct_paths_across_scans() {
9753        let db = test_db();
9754        db.save_pdf_scan(&PdfScanSnapshot {
9755            id: "old-pdf".into(),
9756            timestamp: "2024-01-01T00:00:00".into(),
9757            pdf_count: 1,
9758            total_bytes: 10,
9759            pdfs: vec![PdfFile {
9760                name: "old".into(),
9761                path: "/a/old.pdf".into(),
9762                directory: "/a".into(),
9763                size: 10,
9764                size_formatted: "10 B".into(),
9765                modified: "d".into(),
9766            }],
9767            roots: vec![],
9768        })
9769        .unwrap();
9770        db.save_pdf_scan(&PdfScanSnapshot {
9771            id: "new-pdf".into(),
9772            timestamp: "2024-02-01T00:00:00".into(),
9773            pdf_count: 1,
9774            total_bytes: 20,
9775            pdfs: vec![PdfFile {
9776                name: "new".into(),
9777                path: "/b/new.pdf".into(),
9778                directory: "/b".into(),
9779                size: 20,
9780                size_formatted: "20 B".into(),
9781                modified: "d".into(),
9782            }],
9783            roots: vec![],
9784        })
9785        .unwrap();
9786        let res = db.query_pdfs(None, "name", true, false, 0, 100).unwrap();
9787        assert_eq!(res.total_unfiltered, 2);
9788        assert_eq!(res.pdfs.len(), 2);
9789        assert_eq!(res.pdfs[0].name, "new");
9790        assert_eq!(res.pdfs[1].name, "old");
9791
9792        // Regression: unindexed paths must include every library path missing metadata, not only
9793        // PDFs whose `pdfs.scan_id` is the latest completed scan (otherwise /a/old.pdf would
9794        // never be queued for page-count extraction).
9795        let mut unindexed = db.unindexed_pdf_paths(100).unwrap();
9796        unindexed.sort();
9797        assert_eq!(unindexed, vec!["/a/old.pdf".to_string(), "/b/new.pdf".to_string()]);
9798    }
9799
9800    #[test]
9801    fn test_pdf_stats_matches_rows() {
9802        let db = test_db();
9803        let snap = PdfScanSnapshot {
9804            id: "pdf-stat".into(),
9805            timestamp: "2024-09-01T00:00:00".into(),
9806            pdf_count: 2,
9807            total_bytes: 300,
9808            pdfs: vec![
9809                PdfFile {
9810                    name: "a".into(),
9811                    path: "/p/a.pdf".into(),
9812                    directory: "/p".into(),
9813                    size: 100,
9814                    size_formatted: "100 B".into(),
9815                    modified: "d".into(),
9816                },
9817                PdfFile {
9818                    name: "b".into(),
9819                    path: "/p/b.pdf".into(),
9820                    directory: "/p".into(),
9821                    size: 200,
9822                    size_formatted: "200 B".into(),
9823                    modified: "d".into(),
9824                },
9825            ],
9826            roots: vec![],
9827        };
9828        db.save_pdf_scan(&snap).unwrap();
9829        let st = db.pdf_stats(None).unwrap();
9830        assert_eq!(st.pdf_count, 2);
9831        assert_eq!(st.total_bytes, 300);
9832        let st2 = db.pdf_stats(Some("pdf-stat")).unwrap();
9833        assert_eq!(st2.pdf_count, 2);
9834        assert_eq!(st2.total_bytes, 300);
9835    }
9836
9837    // ── Header-count regression tests ──
9838    //
9839    // These verify that query_plugins/query_daw/query_presets return a
9840    // `total_unfiltered` that reflects the *library* (one row per path) and is
9841    // independent of any search/filter arguments. This is what drives the
9842    // header counters and must NOT drop to 0 when a filter excludes all rows.
9843
9844    fn plugin_info(name: &str, ptype: &str, manufacturer: &str) -> PluginInfo {
9845        PluginInfo {
9846            name: name.into(),
9847            path: format!("/vst/{name}.vst3"),
9848            plugin_type: ptype.into(),
9849            version: "1.0".into(),
9850            manufacturer: manufacturer.into(),
9851            manufacturer_url: None,
9852            size: "1 MB".into(),
9853            size_bytes: 1_000_000,
9854            modified: "2024-01-01".into(),
9855            architectures: vec!["arm64".into()],
9856        }
9857    }
9858
9859    #[test]
9860    fn test_delete_plugin_scan_removes_rows_and_get_latest_falls_back() {
9861        let db = test_db();
9862        db.save_plugin_scan(&ScanSnapshot {
9863            id: "pl-old".into(),
9864            timestamp: "2024-01-01T00:00:00".into(),
9865            plugin_count: 1,
9866            plugins: vec![plugin_info("Old", "VST3", "Xfer")],
9867            directories: vec!["/vst".into()],
9868            roots: vec!["/vst".into()],
9869        })
9870        .unwrap();
9871        db.save_plugin_scan(&ScanSnapshot {
9872            id: "pl-new".into(),
9873            timestamp: "2024-06-01T00:00:00".into(),
9874            plugin_count: 1,
9875            plugins: vec![plugin_info("New", "VST3", "Y")],
9876            directories: vec!["/vst".into()],
9877            roots: vec!["/vst".into()],
9878        })
9879        .unwrap();
9880        assert_eq!(db.get_latest_plugin_scan().unwrap().unwrap().id, "pl-new");
9881
9882        db.delete_plugin_scan("pl-new").unwrap();
9883
9884        assert!(db.get_plugin_scan_detail("pl-new").is_err());
9885        let latest = db.get_latest_plugin_scan().unwrap().unwrap();
9886        assert_eq!(latest.id, "pl-old");
9887        assert_eq!(latest.plugins[0].name, "Old");
9888    }
9889
9890    #[test]
9891    fn test_clear_plugin_history_removes_all_plugin_scans() {
9892        let db = test_db();
9893        db.save_plugin_scan(&ScanSnapshot {
9894            id: "pc1".into(),
9895            timestamp: "2024-06-01T00:00:00".into(),
9896            plugin_count: 1,
9897            plugins: vec![plugin_info("One", "VST3", "Z")],
9898            directories: vec![],
9899            roots: vec![],
9900        })
9901        .unwrap();
9902        db.clear_plugin_history().unwrap();
9903        assert!(db.get_latest_plugin_scan().unwrap().is_none());
9904        assert!(db.get_plugin_scans().unwrap().is_empty());
9905    }
9906
9907    #[test]
9908    fn test_query_plugins_total_unfiltered_with_filter_match_none() {
9909        let db = test_db();
9910        let snap = ScanSnapshot {
9911            id: "ps-hdr-1".into(),
9912            timestamp: "2024-06-01T00:00:00".into(),
9913            plugin_count: 3,
9914            plugins: vec![
9915                plugin_info("Serum", "VST3", "Xfer"),
9916                plugin_info("Vital", "VST3", "Matt Tytel"),
9917                plugin_info("Massive", "VST3", "NI"),
9918            ],
9919            directories: vec!["/vst".into()],
9920            roots: vec!["/vst".into()],
9921        };
9922        db.save_plugin_scan(&snap).unwrap();
9923
9924        // Filter that matches nothing → filtered count 0, unfiltered stays 3
9925        let res = db
9926            .query_plugins(
9927                Some("nonexistent_xyz"),
9928                None,
9929                None,
9930                "name",
9931                true,
9932                false,
9933                0,
9934                100,
9935            )
9936            .unwrap();
9937        assert_eq!(res.total_count, 0, "filtered count should be 0");
9938        assert_eq!(
9939            res.total_unfiltered, 3,
9940            "unfiltered header count must reflect full scan, not filter"
9941        );
9942        assert!(res.plugins.is_empty());
9943    }
9944
9945    #[test]
9946    fn test_query_plugins_total_unfiltered_matches_total_count_no_filter() {
9947        let db = test_db();
9948        let snap = ScanSnapshot {
9949            id: "ps-hdr-2".into(),
9950            timestamp: "2024-06-01T00:00:00".into(),
9951            plugin_count: 2,
9952            plugins: vec![
9953                plugin_info("Serum", "VST3", "Xfer"),
9954                plugin_info("Vital", "VST3", "Matt Tytel"),
9955            ],
9956            directories: vec!["/vst".into()],
9957            roots: vec!["/vst".into()],
9958        };
9959        db.save_plugin_scan(&snap).unwrap();
9960
9961        let res = db
9962            .query_plugins(None, None, None, "name", true, false, 0, 100)
9963            .unwrap();
9964        assert_eq!(res.total_count, 2);
9965        assert_eq!(res.total_unfiltered, 2);
9966    }
9967
9968    #[test]
9969    fn test_query_plugins_total_unfiltered_empty_db() {
9970        let db = test_db();
9971        let res = db
9972            .query_plugins(None, None, None, "name", true, false, 0, 100)
9973            .unwrap();
9974        assert_eq!(res.total_count, 0);
9975        assert_eq!(res.total_unfiltered, 0);
9976        assert!(res.plugins.is_empty());
9977    }
9978
9979    fn daw_project(name: &str, daw: &str) -> DawProject {
9980        DawProject {
9981            name: name.into(),
9982            path: format!("/music/{name}"),
9983            directory: "/music".into(),
9984            format: "ALS".into(),
9985            daw: daw.into(),
9986            size: 1000,
9987            size_formatted: "1 KB".into(),
9988            modified: "2024-01-01".into(),
9989        }
9990    }
9991
9992    #[test]
9993    fn test_query_daw_total_unfiltered_with_filter_match_none() {
9994        let db = test_db();
9995        let mut daw_counts = HashMap::new();
9996        daw_counts.insert("Ableton".into(), 2);
9997        daw_counts.insert("Logic".into(), 1);
9998        let snap = DawScanSnapshot {
9999            id: "ds-hdr-1".into(),
10000            timestamp: "2024-06-01T00:00:00".into(),
10001            project_count: 3,
10002            total_bytes: 3000,
10003            daw_counts,
10004            projects: vec![
10005                daw_project("t1.als", "Ableton"),
10006                daw_project("t2.als", "Ableton"),
10007                daw_project("t3.logicx", "Logic"),
10008            ],
10009            roots: vec!["/music".into()],
10010        };
10011        db.save_daw_scan(&snap).unwrap();
10012
10013        // daw_filter that doesn't match any existing daw — filtered=0, unfiltered=3
10014        let res = db
10015            .query_daw(None, Some("FL Studio"), "name", true, false, 0, 100)
10016            .unwrap();
10017        assert_eq!(res.total_count, 0);
10018        assert_eq!(
10019            res.total_unfiltered, 3,
10020            "unfiltered count must include all 3 projects in the library scope"
10021        );
10022    }
10023
10024    #[test]
10025    fn test_query_daw_total_unfiltered_with_search_filter() {
10026        let db = test_db();
10027        let mut daw_counts = HashMap::new();
10028        daw_counts.insert("Ableton".into(), 2);
10029        let snap = DawScanSnapshot {
10030            id: "ds-hdr-2".into(),
10031            timestamp: "2024-06-01T00:00:00".into(),
10032            project_count: 2,
10033            total_bytes: 2000,
10034            daw_counts,
10035            projects: vec![
10036                daw_project("bassline.als", "Ableton"),
10037                daw_project("drums.als", "Ableton"),
10038            ],
10039            roots: vec!["/music".into()],
10040        };
10041        db.save_daw_scan(&snap).unwrap();
10042
10043        // Search that only matches 1 — filtered=1, unfiltered=2
10044        let res = db
10045            .query_daw(Some("bass"), None, "name", true, false, 0, 100)
10046            .unwrap();
10047        assert_eq!(res.total_count, 1);
10048        assert_eq!(res.total_unfiltered, 2);
10049    }
10050
10051    #[test]
10052    fn test_query_daw_total_unfiltered_empty_db() {
10053        let db = test_db();
10054        let res = db
10055            .query_daw(None, None, "name", true, false, 0, 100)
10056            .unwrap();
10057        assert_eq!(res.total_count, 0);
10058        assert_eq!(res.total_unfiltered, 0);
10059    }
10060
10061    fn preset_file(name: &str, fmt: &str) -> PresetFile {
10062        PresetFile {
10063            name: name.into(),
10064            path: format!("/presets/{name}"),
10065            directory: "/presets".into(),
10066            format: fmt.into(),
10067            size: 1000,
10068            size_formatted: "1 KB".into(),
10069            modified: "2024-01-01".into(),
10070        }
10071    }
10072
10073    #[test]
10074    fn test_delete_preset_scan_removes_rows_and_get_latest_falls_back() {
10075        let db = test_db();
10076        let mut fc = HashMap::new();
10077        fc.insert("FXP".into(), 1);
10078        db.save_preset_scan(&PresetScanSnapshot {
10079            id: "pr-old".into(),
10080            timestamp: "2024-01-01T00:00:00".into(),
10081            preset_count: 1,
10082            total_bytes: 1000,
10083            format_counts: fc.clone(),
10084            presets: vec![preset_file("old.fxp", "FXP")],
10085            roots: vec![],
10086        })
10087        .unwrap();
10088        db.save_preset_scan(&PresetScanSnapshot {
10089            id: "pr-new".into(),
10090            timestamp: "2024-06-01T00:00:00".into(),
10091            preset_count: 1,
10092            total_bytes: 2000,
10093            format_counts: fc,
10094            presets: vec![preset_file("new.fxp", "FXP")],
10095            roots: vec![],
10096        })
10097        .unwrap();
10098        assert_eq!(db.get_latest_preset_scan().unwrap().unwrap().id, "pr-new");
10099
10100        db.delete_preset_scan("pr-new").unwrap();
10101
10102        assert!(db.get_preset_scan_detail("pr-new").is_err());
10103        let latest = db.get_latest_preset_scan().unwrap().unwrap();
10104        assert_eq!(latest.id, "pr-old");
10105        assert_eq!(latest.presets[0].name, "old.fxp");
10106    }
10107
10108    #[test]
10109    fn test_query_presets_total_unfiltered_with_filter_match_none() {
10110        let db = test_db();
10111        let mut format_counts = HashMap::new();
10112        format_counts.insert("FXP".into(), 2);
10113        let snap = PresetScanSnapshot {
10114            id: "pr-hdr-1".into(),
10115            timestamp: "2024-06-01T00:00:00".into(),
10116            preset_count: 2,
10117            total_bytes: 2000,
10118            format_counts,
10119            presets: vec![
10120                preset_file("lead.fxp", "FXP"),
10121                preset_file("pad.fxp", "FXP"),
10122            ],
10123            roots: vec!["/presets".into()],
10124        };
10125        db.save_preset_scan(&snap).unwrap();
10126
10127        let res = db
10128            .query_presets(None, Some("H2P"), "name", true, false, 0, 100)
10129            .unwrap();
10130        assert_eq!(res.total_count, 0);
10131        assert_eq!(res.total_unfiltered, 2);
10132    }
10133
10134    #[test]
10135    fn test_query_presets_total_unfiltered_excludes_midi() {
10136        // MIDI files live in the presets table but are shown in their own tab.
10137        // `total_unfiltered` for presets must exclude MID/MIDI so the preset
10138        // header count matches what the preset view actually shows.
10139        let db = test_db();
10140        let mut format_counts = HashMap::new();
10141        format_counts.insert("FXP".into(), 1);
10142        format_counts.insert("MID".into(), 2);
10143        let snap = PresetScanSnapshot {
10144            id: "pr-hdr-2".into(),
10145            timestamp: "2024-06-01T00:00:00".into(),
10146            preset_count: 3,
10147            total_bytes: 3000,
10148            format_counts,
10149            presets: vec![
10150                preset_file("lead.fxp", "FXP"),
10151                preset_file("song.mid", "MID"),
10152                preset_file("beat.midi", "MIDI"),
10153            ],
10154            roots: vec!["/presets".into()],
10155        };
10156        db.save_preset_scan(&snap).unwrap();
10157
10158        let res = db
10159            .query_presets(None, None, "name", true, false, 0, 100)
10160            .unwrap();
10161        assert_eq!(
10162            res.total_unfiltered, 1,
10163            "MIDI files must be excluded from preset header count"
10164        );
10165        assert_eq!(res.total_count, 1);
10166        assert!(
10167            res.presets
10168                .iter()
10169                .all(|p| p.format != "MID" && p.format != "MIDI")
10170        );
10171    }
10172
10173    #[test]
10174    fn test_query_presets_total_unfiltered_with_search() {
10175        let db = test_db();
10176        let mut format_counts = HashMap::new();
10177        format_counts.insert("FXP".into(), 3);
10178        let snap = PresetScanSnapshot {
10179            id: "pr-hdr-3".into(),
10180            timestamp: "2024-06-01T00:00:00".into(),
10181            preset_count: 3,
10182            total_bytes: 3000,
10183            format_counts,
10184            presets: vec![
10185                preset_file("bass_sub.fxp", "FXP"),
10186                preset_file("bass_808.fxp", "FXP"),
10187                preset_file("lead_saw.fxp", "FXP"),
10188            ],
10189            roots: vec!["/presets".into()],
10190        };
10191        db.save_preset_scan(&snap).unwrap();
10192
10193        let res = db
10194            .query_presets(Some("bass"), None, "name", true, false, 0, 100)
10195            .unwrap();
10196        assert_eq!(res.total_count, 2);
10197        assert_eq!(res.total_unfiltered, 3);
10198    }
10199
10200    /// Subsequence search on name + pagination (full `query_presets` path).
10201    /// With FTS active, row order is bm25 relevance (not column sort); UI may re-rank client-side.
10202    #[test]
10203    fn test_query_presets_search_subsequence_and_sort_size_desc() {
10204        use std::collections::HashSet;
10205        let db = test_db();
10206        let mut format_counts = HashMap::new();
10207        format_counts.insert("FXP".into(), 3);
10208        let snap = PresetScanSnapshot {
10209            id: "pr-sort-1".into(),
10210            timestamp: "2024-06-01T00:00:00".into(),
10211            preset_count: 3,
10212            total_bytes: 10_200,
10213            format_counts,
10214            presets: vec![
10215                PresetFile {
10216                    name: "small_lead.fxp".into(),
10217                    path: "/p/small_lead.fxp".into(),
10218                    directory: "/p".into(),
10219                    format: "FXP".into(),
10220                    size: 100,
10221                    size_formatted: "100 B".into(),
10222                    modified: "2024-01-01".into(),
10223                },
10224                PresetFile {
10225                    name: "big_lead.fxp".into(),
10226                    path: "/p/big_lead.fxp".into(),
10227                    directory: "/p".into(),
10228                    format: "FXP".into(),
10229                    size: 10_000,
10230                    size_formatted: "10 KB".into(),
10231                    modified: "2024-01-01".into(),
10232                },
10233                PresetFile {
10234                    name: "snare.fxp".into(),
10235                    path: "/p/snare.fxp".into(),
10236                    directory: "/p".into(),
10237                    format: "FXP".into(),
10238                    size: 5000,
10239                    size_formatted: "5 KB".into(),
10240                    modified: "2024-01-01".into(),
10241                },
10242            ],
10243            roots: vec!["/p".into()],
10244        };
10245        db.save_preset_scan(&snap).unwrap();
10246
10247        let res = db
10248            .query_presets(Some("lead"), None, "size", false, false, 0, 100)
10249            .unwrap();
10250        assert_eq!(res.total_count, 2);
10251        let names: HashSet<&str> = res.presets.iter().map(|p| p.name.as_str()).collect();
10252        assert!(names.contains("big_lead.fxp") && names.contains("small_lead.fxp"));
10253
10254        let by_size = db
10255            .query_presets(None, None, "size", false, false, 0, 100)
10256            .unwrap();
10257        assert_eq!(by_size.presets[0].name, "big_lead.fxp");
10258        assert_eq!(by_size.presets[0].size, 10_000);
10259    }
10260
10261    #[test]
10262    fn test_query_presets_total_unfiltered_empty_db() {
10263        let db = test_db();
10264        let res = db
10265            .query_presets(None, None, "name", true, false, 0, 100)
10266            .unwrap();
10267        assert_eq!(res.total_count, 0);
10268        assert_eq!(res.total_unfiltered, 0);
10269    }
10270
10271    // ── Multi-scan semantics ──
10272    //
10273    // Each new scan inserts rows with a fresh scan_id (tables accumulate rows across history).
10274    // Default UI queries use the **library** (one row per `path` — audio: `audio_library`; DAW:
10275    // `daw_library`; PDF/MIDI/presets: `pdf_library` / `midi_library` / `preset_library`; plugins:
10276    // `plugin_library`),
10277    // not “latest scan only” and not raw `COUNT(*)` of all rows.
10278
10279    fn daw_snap(id: &str, ts: &str, projects: Vec<DawProject>) -> DawScanSnapshot {
10280        let mut daw_counts = HashMap::new();
10281        for p in &projects {
10282            *daw_counts.entry(p.daw.clone()).or_insert(0usize) += 1;
10283        }
10284        let total_bytes = projects.iter().map(|p| p.size).sum();
10285        DawScanSnapshot {
10286            id: id.into(),
10287            timestamp: ts.into(),
10288            project_count: projects.len(),
10289            total_bytes,
10290            daw_counts,
10291            projects,
10292            roots: vec!["/music".into()],
10293        }
10294    }
10295
10296    /// `get_latest_*_scan` each run `ORDER BY timestamp DESC` then hydrate via `get_*_detail`.
10297    #[test]
10298    fn test_get_latest_plugin_audio_daw_preset_scan_return_newest_timestamp() {
10299        let db = test_db();
10300
10301        db.save_plugin_scan(&ScanSnapshot {
10302            id: "pl-old".into(),
10303            timestamp: "2024-01-01T00:00:00".into(),
10304            plugin_count: 1,
10305            plugins: vec![plugin_info("OldPlug", "VST3", "Xfer")],
10306            directories: vec!["/vst".into()],
10307            roots: vec!["/vst".into()],
10308        })
10309        .unwrap();
10310        db.save_plugin_scan(&ScanSnapshot {
10311            id: "pl-new".into(),
10312            timestamp: "2024-06-01T00:00:00".into(),
10313            plugin_count: 1,
10314            plugins: vec![plugin_info("NewPlug", "VST3", "Xfer")],
10315            directories: vec!["/vst".into()],
10316            roots: vec!["/vst".into()],
10317        })
10318        .unwrap();
10319        assert_eq!(db.get_latest_plugin_scan().unwrap().unwrap().id, "pl-new");
10320
10321        let mut fc = HashMap::new();
10322        fc.insert("WAV".into(), 1);
10323        db.save_scan("au-old", "2024-01-01T00:00:00", 1, 100, &fc, &[])
10324            .unwrap();
10325        db.insert_audio_batch("au-old", &[sample("a.wav", "/a.wav", "WAV", 100)])
10326            .unwrap();
10327        db.save_scan("au-new", "2024-06-01T00:00:00", 1, 200, &fc, &[])
10328            .unwrap();
10329        db.insert_audio_batch("au-new", &[sample("b.wav", "/b.wav", "WAV", 200)])
10330            .unwrap();
10331        assert_eq!(db.get_latest_audio_scan().unwrap().unwrap().id, "au-new");
10332
10333        db.save_daw_scan(&daw_snap(
10334            "daw-old",
10335            "2024-01-01T00:00:00",
10336            vec![daw_project("old.als", "Ableton")],
10337        ))
10338        .unwrap();
10339        db.save_daw_scan(&daw_snap(
10340            "daw-new",
10341            "2024-06-01T00:00:00",
10342            vec![daw_project("new.als", "Ableton")],
10343        ))
10344        .unwrap();
10345        assert_eq!(db.get_latest_daw_scan().unwrap().unwrap().id, "daw-new");
10346
10347        let mut pfc = HashMap::new();
10348        pfc.insert("FXP".into(), 1);
10349        db.save_preset_scan(&PresetScanSnapshot {
10350            id: "pr-old".into(),
10351            timestamp: "2024-01-01T00:00:00".into(),
10352            preset_count: 1,
10353            total_bytes: 10,
10354            format_counts: pfc.clone(),
10355            presets: vec![preset_file("old.fxp", "FXP")],
10356            roots: vec![],
10357        })
10358        .unwrap();
10359        db.save_preset_scan(&PresetScanSnapshot {
10360            id: "pr-new".into(),
10361            timestamp: "2024-06-01T00:00:00".into(),
10362            preset_count: 1,
10363            total_bytes: 20,
10364            format_counts: pfc,
10365            presets: vec![preset_file("new.fxp", "FXP")],
10366            roots: vec![],
10367        })
10368        .unwrap();
10369        assert_eq!(db.get_latest_preset_scan().unwrap().unwrap().id, "pr-new");
10370    }
10371
10372    #[test]
10373    fn test_delete_daw_scan_removes_rows_and_get_latest_falls_back() {
10374        let db = test_db();
10375        db.save_daw_scan(&daw_snap(
10376            "daw-old",
10377            "2024-01-01T00:00:00",
10378            vec![daw_project("old.als", "Ableton")],
10379        ))
10380        .unwrap();
10381        db.save_daw_scan(&daw_snap(
10382            "daw-new",
10383            "2024-06-01T00:00:00",
10384            vec![daw_project("new.als", "Ableton")],
10385        ))
10386        .unwrap();
10387        assert_eq!(db.get_latest_daw_scan().unwrap().unwrap().id, "daw-new");
10388
10389        db.delete_daw_scan("daw-new").unwrap();
10390
10391        assert!(db.get_daw_scan_detail("daw-new").is_err());
10392        let latest = db.get_latest_daw_scan().unwrap().unwrap();
10393        assert_eq!(latest.id, "daw-old");
10394        assert_eq!(latest.projects[0].name, "old.als");
10395    }
10396
10397    #[test]
10398    fn test_clear_daw_history_removes_all_daw_scans() {
10399        let db = test_db();
10400        db.save_daw_scan(&daw_snap(
10401            "daw1",
10402            "2024-06-01T00:00:00",
10403            vec![daw_project("x.als", "Ableton")],
10404        ))
10405        .unwrap();
10406        db.clear_daw_history().unwrap();
10407        assert!(db.get_latest_daw_scan().unwrap().is_none());
10408        assert!(db.get_daw_scans().unwrap().is_empty());
10409    }
10410
10411    #[test]
10412    fn test_clear_preset_history_removes_all_preset_scans() {
10413        let db = test_db();
10414        let mut fc = HashMap::new();
10415        fc.insert("FXP".into(), 1);
10416        db.save_preset_scan(&PresetScanSnapshot {
10417            id: "pr-clear-1".into(),
10418            timestamp: "2024-06-01T00:00:00".into(),
10419            preset_count: 1,
10420            total_bytes: 1000,
10421            format_counts: fc,
10422            presets: vec![preset_file("x.fxp", "FXP")],
10423            roots: vec![],
10424        })
10425        .unwrap();
10426        db.clear_preset_history().unwrap();
10427        assert!(db.get_latest_preset_scan().unwrap().is_none());
10428        assert!(db.get_preset_scans().unwrap().is_empty());
10429    }
10430
10431    #[test]
10432    fn test_delete_audio_scan_removes_samples_and_get_latest_falls_back() {
10433        let db = test_db();
10434        let mut fc = HashMap::new();
10435        fc.insert("WAV".into(), 1);
10436        db.save_scan("au-old", "2024-01-01T00:00:00", 1, 100, &fc, &[])
10437            .unwrap();
10438        db.insert_audio_batch("au-old", &[sample("a.wav", "/a.wav", "WAV", 100)])
10439            .unwrap();
10440        db.save_scan("au-new", "2024-06-01T00:00:00", 1, 200, &fc, &[])
10441            .unwrap();
10442        db.insert_audio_batch("au-new", &[sample("b.wav", "/b.wav", "WAV", 200)])
10443            .unwrap();
10444        assert_eq!(db.get_latest_audio_scan().unwrap().unwrap().id, "au-new");
10445
10446        db.delete_audio_scan("au-new").unwrap();
10447
10448        assert!(db.get_audio_scan_detail("au-new").is_err());
10449        let latest = db.get_latest_audio_scan().unwrap().unwrap();
10450        assert_eq!(latest.id, "au-old");
10451        assert_eq!(latest.samples[0].name, "a.wav");
10452    }
10453
10454    #[test]
10455    fn test_clear_audio_history_removes_all_audio_scans() {
10456        let db = test_db();
10457        let mut fc = HashMap::new();
10458        fc.insert("WAV".into(), 1);
10459        db.save_scan("s1", "2024-06-01T00:00:00", 1, 100, &fc, &[])
10460            .unwrap();
10461        db.insert_audio_batch("s1", &[sample("x.wav", "/x.wav", "WAV", 100)])
10462            .unwrap();
10463        db.clear_audio_history().unwrap();
10464        assert!(db.get_latest_audio_scan().unwrap().is_none());
10465        assert!(db.list_scans().unwrap().is_empty());
10466    }
10467
10468    #[test]
10469    fn test_latest_scan_id_returns_newest_audio_scan() {
10470        let db = test_db();
10471        let mut fc = HashMap::new();
10472        fc.insert("WAV".into(), 1);
10473        db.save_scan("a-old", "2024-01-01T00:00:00", 1, 100, &fc, &[])
10474            .unwrap();
10475        db.insert_audio_batch("a-old", &[sample("a.wav", "/a.wav", "WAV", 100)])
10476            .unwrap();
10477        db.save_scan("a-new", "2024-06-01T00:00:00", 1, 200, &fc, &[])
10478            .unwrap();
10479        db.insert_audio_batch("a-new", &[sample("b.wav", "/b.wav", "WAV", 200)])
10480            .unwrap();
10481        assert_eq!(db.latest_scan_id().unwrap().as_deref(), Some("a-new"));
10482    }
10483
10484    #[test]
10485    fn test_prune_old_scans_drops_oldest_audio_beyond_keep() {
10486        let db = test_db();
10487        let mut fc = HashMap::new();
10488        fc.insert("WAV".into(), 1);
10489        for (id, ts, name) in [
10490            ("s1", "2024-01-01T00:00:00", "n1.wav"),
10491            ("s2", "2024-02-01T00:00:00", "n2.wav"),
10492            ("s3", "2024-03-01T00:00:00", "n3.wav"),
10493            ("s4", "2024-04-01T00:00:00", "n4.wav"),
10494        ] {
10495            db.save_scan(id, ts, 1, 100, &fc, &[]).unwrap();
10496            db.insert_audio_batch(id, &[sample(name, &format!("/{name}"), "WAV", 100)])
10497                .unwrap();
10498        }
10499        db.prune_old_scans(2);
10500        let scans = db.list_scans().unwrap();
10501        assert_eq!(scans.len(), 2);
10502        assert_eq!(scans[0].id, "s4");
10503        assert_eq!(scans[1].id, "s3");
10504        assert!(db.get_audio_scan_detail("s1").is_err());
10505        assert!(db.get_audio_scan_detail("s2").is_err());
10506        assert!(db.get_audio_scan_detail("s3").is_ok());
10507        assert!(db.get_audio_scan_detail("s4").is_ok());
10508    }
10509
10510    #[test]
10511    fn test_save_audio_scan_full_roundtrip_and_get_audio_scans_list() {
10512        let db = test_db();
10513        let mut fc = HashMap::new();
10514        fc.insert("WAV".into(), 1);
10515        let roots = vec!["/Music/Samples".into()];
10516        let snap = AudioScanSnapshot {
10517            id: "full-1".into(),
10518            timestamp: "2024-05-01T12:00:00".into(),
10519            sample_count: 1,
10520            total_bytes: 100,
10521            format_counts: fc.clone(),
10522            samples: vec![sample("kick.wav", "/x/kick.wav", "WAV", 100)],
10523            roots: roots.clone(),
10524        };
10525        db.save_audio_scan_full(&snap).unwrap();
10526
10527        let list = db.get_audio_scans_list().unwrap();
10528        assert_eq!(list.len(), 1);
10529        let row = &list[0];
10530        assert_eq!(row["id"].as_str(), Some("full-1"));
10531        assert_eq!(row["sampleCount"].as_u64(), Some(1));
10532        assert_eq!(row["totalBytes"].as_u64(), Some(100));
10533
10534        let detail = db.get_audio_scan_detail("full-1").unwrap();
10535        assert_eq!(detail.id, "full-1");
10536        assert_eq!(detail.samples.len(), 1);
10537        assert_eq!(detail.samples[0].name, "kick.wav");
10538        assert_eq!(detail.roots, roots);
10539        assert_eq!(detail.format_counts.get("WAV"), Some(&1usize));
10540    }
10541
10542    #[test]
10543    fn test_migrate_from_json_imports_audio_scan_when_no_prior_scans() {
10544        let _lock = MIGRATE_JSON_TEST_LOCK.lock().unwrap();
10545        let tmp = std::env::temp_dir().join(format!(
10546            "ah_db_migrate_json_{}_{}",
10547            std::process::id(),
10548            std::time::SystemTime::now()
10549                .duration_since(std::time::UNIX_EPOCH)
10550                .map(|d| d.as_nanos())
10551                .unwrap_or(0)
10552        ));
10553        let _ = std::fs::remove_dir_all(&tmp);
10554        std::fs::create_dir_all(&tmp).unwrap();
10555        history::set_test_data_dir_path(tmp.clone());
10556
10557        let json = r#"{"scans":[{"id":"json-mig-1","timestamp":"2024-01-01T00:00:00","sampleCount":1,"totalBytes":100,"formatCounts":{"WAV":1},"samples":[{"name":"x.wav","path":"/a/x.wav","directory":"/a","format":"WAV","size":100,"sizeFormatted":"100 B","modified":"2024-01-01"}],"roots":["/root"]}]}"#;
10558        std::fs::write(tmp.join("audio-scan-history.json"), json).unwrap();
10559
10560        let db = test_db();
10561        let migrated = db.migrate_from_json().expect("migrate");
10562        assert!(migrated >= 1, "expected migrated sample count >= 1");
10563
10564        let latest = db.get_latest_audio_scan().unwrap().expect("scan");
10565        assert_eq!(latest.id, "json-mig-1");
10566        assert_eq!(latest.samples.len(), 1);
10567        assert_eq!(latest.samples[0].name, "x.wav");
10568        assert_eq!(latest.roots, vec!["/root".to_string()]);
10569
10570        assert_eq!(
10571            db.migrate_from_json().unwrap(),
10572            0,
10573            "second call must no-op once any scan table has rows"
10574        );
10575
10576        history::clear_test_data_dir_path();
10577        let _ = std::fs::remove_dir_all(&tmp);
10578    }
10579
10580    #[test]
10581    fn test_migrate_from_json_imports_plugin_scan_when_no_prior_scans() {
10582        let _lock = MIGRATE_JSON_TEST_LOCK.lock().unwrap();
10583        let tmp = std::env::temp_dir().join(format!(
10584            "ah_db_migrate_plugin_{}_{}",
10585            std::process::id(),
10586            std::time::SystemTime::now()
10587                .duration_since(std::time::UNIX_EPOCH)
10588                .map(|d| d.as_nanos())
10589                .unwrap_or(0)
10590        ));
10591        let _ = std::fs::remove_dir_all(&tmp);
10592        std::fs::create_dir_all(&tmp).unwrap();
10593        history::set_test_data_dir_path(tmp.clone());
10594
10595        let json = r#"{"scans":[{"id":"pl-mig-1","timestamp":"2024-01-01T00:00:00","pluginCount":1,"plugins":[{"name":"TestPlug","path":"/p/Test.vst3","type":"VST3","version":"1.0","manufacturer":"Co","manufacturerUrl":null,"size":"1 KB","sizeBytes":1024,"modified":"2024-01-01","architectures":["ARM64"]}],"directories":["/VST"],"roots":[]}]}"#;
10596        std::fs::write(tmp.join("scan-history.json"), json).unwrap();
10597
10598        let db = test_db();
10599        let migrated = db.migrate_from_json().expect("migrate");
10600        assert!(migrated >= 1, "expected at least one migrated row");
10601
10602        let latest = db.get_latest_plugin_scan().unwrap().expect("plugin scan");
10603        assert_eq!(latest.id, "pl-mig-1");
10604        assert_eq!(latest.plugins.len(), 1);
10605        assert_eq!(latest.plugins[0].name, "TestPlug");
10606        assert_eq!(latest.plugins[0].path, "/p/Test.vst3");
10607
10608        assert_eq!(db.migrate_from_json().unwrap(), 0);
10609
10610        history::clear_test_data_dir_path();
10611        let _ = std::fs::remove_dir_all(&tmp);
10612    }
10613
10614    #[test]
10615    fn test_migrate_from_json_returns_zero_when_scans_already_exist() {
10616        let _lock = MIGRATE_JSON_TEST_LOCK.lock().unwrap();
10617        let tmp = std::env::temp_dir().join(format!(
10618            "ah_db_migrate_skip_{}_{}",
10619            std::process::id(),
10620            std::time::SystemTime::now()
10621                .duration_since(std::time::UNIX_EPOCH)
10622                .map(|d| d.as_nanos())
10623                .unwrap_or(0)
10624        ));
10625        let _ = std::fs::remove_dir_all(&tmp);
10626        std::fs::create_dir_all(&tmp).unwrap();
10627        history::set_test_data_dir_path(tmp.clone());
10628
10629        let json = r#"{"scans":[{"id":"json-mig-2","timestamp":"2024-02-01T00:00:00","sampleCount":1,"totalBytes":50,"formatCounts":{"WAV":1},"samples":[{"name":"ignore.wav","path":"/i/ignore.wav","directory":"/i","format":"WAV","size":50,"sizeFormatted":"50 B","modified":"2024-01-01"}],"roots":[]}]}"#;
10630        std::fs::write(tmp.join("audio-scan-history.json"), json).unwrap();
10631
10632        let db = test_db();
10633        let mut fc = HashMap::new();
10634        fc.insert("WAV".into(), 1);
10635        db.save_scan("existing", "2024-01-01T00:00:00", 1, 100, &fc, &[])
10636            .unwrap();
10637        db.insert_audio_batch("existing", &[sample("a.wav", "/a.wav", "WAV", 100)])
10638            .unwrap();
10639
10640        assert_eq!(
10641            db.migrate_from_json().unwrap(),
10642            0,
10643            "must skip JSON import when DB already has scan rows"
10644        );
10645
10646        let latest = db.get_latest_audio_scan().unwrap().expect("scan");
10647        assert_eq!(latest.id, "existing");
10648        assert_eq!(latest.samples[0].name, "a.wav");
10649
10650        history::clear_test_data_dir_path();
10651        let _ = std::fs::remove_dir_all(&tmp);
10652    }
10653
10654    #[test]
10655    fn test_query_daw_multi_scan_library_unions_distinct_paths() {
10656        let db = test_db();
10657        // First (older) scan with 3 projects
10658        db.save_daw_scan(&daw_snap(
10659            "ds-old",
10660            "2024-01-01T00:00:00",
10661            vec![
10662                daw_project("old1.als", "Ableton"),
10663                daw_project("old2.als", "Ableton"),
10664                daw_project("old3.als", "Ableton"),
10665            ],
10666        ))
10667        .unwrap();
10668        // Second (newer) scan with 2 projects
10669        db.save_daw_scan(&daw_snap(
10670            "ds-new",
10671            "2024-06-01T00:00:00",
10672            vec![
10673                daw_project("new1.als", "Ableton"),
10674                daw_project("new2.als", "Ableton"),
10675            ],
10676        ))
10677        .unwrap();
10678
10679        let res = db
10680            .query_daw(None, None, "name", true, false, 0, 100)
10681            .unwrap();
10682        assert_eq!(res.total_unfiltered, 5, "library = union of distinct paths");
10683        assert_eq!(res.total_count, 5);
10684        assert_eq!(res.projects.len(), 5);
10685        let names: Vec<_> = res.projects.iter().map(|p| p.name.as_str()).collect();
10686        assert!(names.contains(&"old1.als"));
10687        assert!(names.contains(&"new2.als"));
10688    }
10689
10690    #[test]
10691    fn test_query_daw_empty_scan_does_not_hide_library() {
10692        // A later empty DAW scan (no project rows) must not make the library query return zero
10693        // when prior scans already inserted projects (rows remain keyed by older scan_ids).
10694        let db = test_db();
10695        db.save_daw_scan(&daw_snap(
10696            "ds-real",
10697            "2024-01-01T00:00:00",
10698            vec![daw_project("only.als", "Ableton")],
10699        ))
10700        .unwrap();
10701        // A subsequent empty scan (user hit Stop immediately, or nothing found)
10702        db.save_daw_scan(&daw_snap("ds-empty", "2024-12-01T00:00:00", vec![]))
10703            .unwrap();
10704
10705        let res = db
10706            .query_daw(None, None, "name", true, false, 0, 100)
10707            .unwrap();
10708        assert_eq!(
10709            res.total_unfiltered, 1,
10710            "empty scans with project_count=0 must not hide existing library projects"
10711        );
10712        assert_eq!(res.projects.len(), 1);
10713        assert_eq!(res.projects[0].name, "only.als");
10714    }
10715
10716    #[test]
10717    fn test_query_daw_total_unfiltered_stable_across_pagination() {
10718        let db = test_db();
10719        let projects: Vec<_> = (0..25)
10720            .map(|i| daw_project(&format!("p{i:02}.als"), "Ableton"))
10721            .collect();
10722        db.save_daw_scan(&daw_snap("ds-page", "2024-06-01T00:00:00", projects))
10723            .unwrap();
10724
10725        let p1 = db
10726            .query_daw(None, None, "name", true, false, 0, 10)
10727            .unwrap();
10728        let p2 = db
10729            .query_daw(None, None, "name", true, false, 10, 10)
10730            .unwrap();
10731        let p3 = db
10732            .query_daw(None, None, "name", true, false, 20, 10)
10733            .unwrap();
10734
10735        assert_eq!(p1.total_unfiltered, 25);
10736        assert_eq!(p2.total_unfiltered, 25);
10737        assert_eq!(p3.total_unfiltered, 25);
10738        assert_eq!(p1.total_count, 25);
10739        assert_eq!(p1.projects.len(), 10);
10740        assert_eq!(p2.projects.len(), 10);
10741        assert_eq!(p3.projects.len(), 5);
10742    }
10743
10744    #[test]
10745    fn test_query_daw_combined_search_and_filter() {
10746        let db = test_db();
10747        db.save_daw_scan(&daw_snap(
10748            "ds-combo",
10749            "2024-06-01T00:00:00",
10750            vec![
10751                daw_project("bass.als", "Ableton"),
10752                daw_project("drums.als", "Ableton"),
10753                daw_project("bass.logicx", "Logic"),
10754                daw_project("mix.logicx", "Logic"),
10755            ],
10756        ))
10757        .unwrap();
10758
10759        // search="bass" + daw_filter="Ableton" → 1 match, unfiltered stays 4
10760        let res = db
10761            .query_daw(Some("bass"), Some("Ableton"), "name", true, false, 0, 100)
10762            .unwrap();
10763        assert_eq!(res.total_count, 1);
10764        assert_eq!(res.total_unfiltered, 4);
10765        assert_eq!(res.projects.len(), 1);
10766        assert_eq!(res.projects[0].name, "bass.als");
10767    }
10768
10769    #[test]
10770    fn test_query_daw_comma_separated_filter_unfiltered_stable() {
10771        let db = test_db();
10772        db.save_daw_scan(&daw_snap(
10773            "ds-multi",
10774            "2024-06-01T00:00:00",
10775            vec![
10776                daw_project("a.als", "Ableton"),
10777                daw_project("b.logicx", "Logic"),
10778                daw_project("c.flp", "FL Studio"),
10779                daw_project("d.rpp", "REAPER"),
10780            ],
10781        ))
10782        .unwrap();
10783
10784        let res = db
10785            .query_daw(None, Some("Ableton,Logic"), "name", true, false, 0, 100)
10786            .unwrap();
10787        assert_eq!(res.total_count, 2);
10788        assert_eq!(res.total_unfiltered, 4);
10789        assert_eq!(
10790            res.projects.len(),
10791            2,
10792            "main SELECT must return matching rows"
10793        );
10794        assert!(
10795            res.projects
10796                .iter()
10797                .all(|p| p.daw == "Ableton" || p.daw == "Logic")
10798        );
10799    }
10800
10801    #[test]
10802    fn test_query_daw_comma_filter_with_pagination() {
10803        // Ensures LIMIT is respected when comma-separated filter is combined with offset/limit.
10804        let db = test_db();
10805        db.save_daw_scan(&daw_snap(
10806            "ds-comma-page",
10807            "2024-06-01T00:00:00",
10808            (0..12)
10809                .map(|i| {
10810                    let daw = if i % 2 == 0 { "Ableton" } else { "Logic" };
10811                    daw_project(&format!("p{i:02}.als"), daw)
10812                })
10813                .collect(),
10814        ))
10815        .unwrap();
10816
10817        let res = db
10818            .query_daw(None, Some("Ableton,Logic"), "name", true, false, 0, 5)
10819            .unwrap();
10820        assert_eq!(res.total_count, 12);
10821        assert_eq!(res.projects.len(), 5, "LIMIT=5 must be respected");
10822    }
10823
10824    fn preset_snap(id: &str, ts: &str, presets: Vec<PresetFile>) -> PresetScanSnapshot {
10825        let mut format_counts = HashMap::new();
10826        for p in &presets {
10827            *format_counts.entry(p.format.clone()).or_insert(0usize) += 1;
10828        }
10829        let total_bytes = presets.iter().map(|p| p.size).sum();
10830        PresetScanSnapshot {
10831            id: id.into(),
10832            timestamp: ts.into(),
10833            preset_count: presets.len(),
10834            total_bytes,
10835            format_counts,
10836            presets,
10837            roots: vec!["/presets".into()],
10838        }
10839    }
10840
10841    #[test]
10842    fn test_query_presets_multi_scan_library_unions_distinct_paths() {
10843        let db = test_db();
10844        db.save_preset_scan(&preset_snap(
10845            "pr-old",
10846            "2024-01-01T00:00:00",
10847            vec![
10848                preset_file("a.fxp", "FXP"),
10849                preset_file("b.fxp", "FXP"),
10850                preset_file("c.fxp", "FXP"),
10851            ],
10852        ))
10853        .unwrap();
10854        db.save_preset_scan(&preset_snap(
10855            "pr-new",
10856            "2024-06-01T00:00:00",
10857            vec![preset_file("x.fxp", "FXP")],
10858        ))
10859        .unwrap();
10860
10861        let res = db
10862            .query_presets(None, None, "name", true, false, 0, 100)
10863            .unwrap();
10864        assert_eq!(res.total_unfiltered, 4);
10865        assert_eq!(res.presets.len(), 4);
10866        assert_eq!(res.presets[0].name, "a.fxp");
10867        assert_eq!(res.presets[3].name, "x.fxp");
10868    }
10869
10870    #[test]
10871    fn test_query_presets_midi_filter_still_excluded() {
10872        // Even if the user explicitly format-filters for MID, the `NOT IN ('MID','MIDI')`
10873        // clause must still exclude them — MIDI belongs in its own tab. The filtered
10874        // AND unfiltered counts for presets should both be 0 in this case.
10875        let db = test_db();
10876        db.save_preset_scan(&preset_snap(
10877            "pr-midi",
10878            "2024-06-01T00:00:00",
10879            vec![
10880                preset_file("song.mid", "MID"),
10881                preset_file("beat.midi", "MIDI"),
10882                preset_file("lead.fxp", "FXP"),
10883            ],
10884        ))
10885        .unwrap();
10886
10887        // Explicit MID filter still returns 0 filtered results
10888        let res = db
10889            .query_presets(None, Some("MID"), "name", true, false, 0, 100)
10890            .unwrap();
10891        assert_eq!(res.total_count, 0);
10892        // Unfiltered excludes MIDI regardless of format_filter
10893        assert_eq!(res.total_unfiltered, 1);
10894    }
10895
10896    #[test]
10897    fn test_query_presets_comma_separated_filter_unfiltered_stable() {
10898        // Regression: comma-separated format_filter was binding the raw string to the
10899        // LIMIT placeholder, causing "column index out of range" on the main SELECT.
10900        let db = test_db();
10901        db.save_preset_scan(&preset_snap(
10902            "pr-multi-fmt",
10903            "2024-06-01T00:00:00",
10904            vec![
10905                preset_file("a.fxp", "FXP"),
10906                preset_file("b.h2p", "H2P"),
10907                preset_file("c.nmsv", "NMSV"),
10908                preset_file("d.fxp", "FXP"),
10909            ],
10910        ))
10911        .unwrap();
10912
10913        let res = db
10914            .query_presets(None, Some("FXP,H2P"), "name", true, false, 0, 100)
10915            .unwrap();
10916        assert_eq!(res.total_count, 3);
10917        assert_eq!(res.total_unfiltered, 4);
10918        assert_eq!(res.presets.len(), 3);
10919        assert!(
10920            res.presets
10921                .iter()
10922                .all(|p| p.format == "FXP" || p.format == "H2P")
10923        );
10924    }
10925
10926    #[test]
10927    fn test_query_presets_total_unfiltered_stable_across_pagination() {
10928        let db = test_db();
10929        let presets: Vec<_> = (0..30)
10930            .map(|i| preset_file(&format!("p{i:02}.fxp"), "FXP"))
10931            .collect();
10932        db.save_preset_scan(&preset_snap("pr-page", "2024-06-01T00:00:00", presets))
10933            .unwrap();
10934
10935        let p1 = db
10936            .query_presets(None, None, "name", true, false, 0, 10)
10937            .unwrap();
10938        let p2 = db
10939            .query_presets(None, None, "name", true, false, 10, 10)
10940            .unwrap();
10941        let p3 = db
10942            .query_presets(None, None, "name", true, false, 25, 10)
10943            .unwrap();
10944
10945        assert_eq!(p1.total_unfiltered, 30);
10946        assert_eq!(p2.total_unfiltered, 30);
10947        assert_eq!(p3.total_unfiltered, 30);
10948        assert_eq!(p1.presets.len(), 10);
10949        assert_eq!(p2.presets.len(), 10);
10950        assert_eq!(p3.presets.len(), 5);
10951    }
10952
10953    fn plugin_snap(id: &str, ts: &str, plugins: Vec<PluginInfo>) -> ScanSnapshot {
10954        ScanSnapshot {
10955            id: id.into(),
10956            timestamp: ts.into(),
10957            plugin_count: plugins.len(),
10958            plugins,
10959            directories: vec!["/vst".into()],
10960            roots: vec!["/vst".into()],
10961        }
10962    }
10963
10964    #[test]
10965    fn test_query_plugins_multi_scan_library_unions_distinct_paths() {
10966        let db = test_db();
10967        db.save_plugin_scan(&plugin_snap(
10968            "ps-old",
10969            "2024-01-01T00:00:00",
10970            vec![
10971                plugin_info("Old1", "VST3", "Acme"),
10972                plugin_info("Old2", "VST3", "Acme"),
10973                plugin_info("Old3", "VST3", "Acme"),
10974            ],
10975        ))
10976        .unwrap();
10977        db.save_plugin_scan(&plugin_snap(
10978            "ps-new",
10979            "2024-06-01T00:00:00",
10980            vec![plugin_info("New1", "VST3", "Acme")],
10981        ))
10982        .unwrap();
10983
10984        let res = db
10985            .query_plugins(None, None, None, "name", true, false, 0, 100)
10986            .unwrap();
10987        assert_eq!(res.total_unfiltered, 4);
10988        assert_eq!(res.plugins.len(), 4);
10989        assert_eq!(res.plugins[0].name, "New1");
10990        assert_eq!(res.plugins[3].name, "Old3");
10991    }
10992
10993    #[test]
10994    fn test_query_plugins_multi_type_returns_rows_not_empty() {
10995        // Regression: comma-separated type_filter was over-incrementing bind_offset,
10996        // binding `limit` to a wrong placeholder slot so the real LIMIT slot was NULL.
10997        // Result: main SELECT returned 0 rows even though the IN clause had matches.
10998        let db = test_db();
10999        db.save_plugin_scan(&plugin_snap(
11000            "ps-multi-bind",
11001            "2024-06-01T00:00:00",
11002            vec![
11003                plugin_info("A", "VST3", "X"),
11004                plugin_info("B", "VST2", "X"),
11005                plugin_info("C", "AU", "X"),
11006                plugin_info("D", "VST3", "X"),
11007                plugin_info("E", "AU", "X"),
11008            ],
11009        ))
11010        .unwrap();
11011
11012        let res = db
11013            .query_plugins(None, Some("VST3,AU"), None, "name", true, false, 0, 100)
11014            .unwrap();
11015        assert_eq!(res.total_count, 4);
11016        assert_eq!(
11017            res.plugins.len(),
11018            4,
11019            "main SELECT must return the 4 matching rows, not 0"
11020        );
11021        assert!(
11022            res.plugins
11023                .iter()
11024                .all(|p| p.plugin_type == "VST3" || p.plugin_type == "AU")
11025        );
11026    }
11027
11028    #[test]
11029    fn test_query_plugins_multi_type_with_search_and_pagination() {
11030        // Compound scenario: search + comma-filter + offset — exercises all three
11031        // bind-offset branches simultaneously.
11032        let db = test_db();
11033        db.save_plugin_scan(&plugin_snap(
11034            "ps-compound",
11035            "2024-06-01T00:00:00",
11036            vec![
11037                plugin_info("alpha", "VST3", "X"),
11038                plugin_info("alpen", "VST3", "X"),
11039                plugin_info("alto", "AU", "X"),
11040                plugin_info("bravo", "VST3", "X"),
11041                plugin_info("alps", "AU", "X"),
11042            ],
11043        ))
11044        .unwrap();
11045
11046        let res = db
11047            .query_plugins(Some("al"), Some("VST3,AU"), None, "name", true, false, 0, 2)
11048            .unwrap();
11049        assert_eq!(res.total_count, 4); // alpha, alpen, alto, alps
11050        assert_eq!(res.plugins.len(), 2, "LIMIT must be respected");
11051    }
11052
11053    #[test]
11054    fn test_query_plugins_type_filter_multi_type() {
11055        let db = test_db();
11056        db.save_plugin_scan(&plugin_snap(
11057            "ps-types",
11058            "2024-06-01T00:00:00",
11059            vec![
11060                plugin_info("A", "VST3", "X"),
11061                plugin_info("B", "VST2", "X"),
11062                plugin_info("C", "AU", "X"),
11063                plugin_info("D", "VST3", "X"),
11064            ],
11065        ))
11066        .unwrap();
11067
11068        let res = db
11069            .query_plugins(None, Some("VST3"), None, "name", true, false, 0, 100)
11070            .unwrap();
11071        assert_eq!(res.total_count, 2);
11072        assert_eq!(res.total_unfiltered, 4);
11073
11074        let res = db
11075            .query_plugins(None, Some("VST3,AU"), None, "name", true, false, 0, 100)
11076            .unwrap();
11077        assert_eq!(res.total_count, 3);
11078        assert_eq!(res.total_unfiltered, 4);
11079    }
11080
11081    #[test]
11082    fn test_query_plugins_status_filter_kvr_join() {
11083        use crate::history::KvrCacheUpdateEntry;
11084        let db = test_db();
11085        db.save_plugin_scan(&plugin_snap(
11086            "ps-kvr-status",
11087            "2024-06-01T00:00:00",
11088            vec![
11089                plugin_info("HasUpdate", "VST3", "Mfg"),
11090                plugin_info("Current", "VST3", "Mfg"),
11091                plugin_info("UnknownKvr", "VST3", "Mfg"),
11092                plugin_info("NoCache", "VST3", "Mfg"),
11093            ],
11094        ))
11095        .unwrap();
11096        db.update_kvr_cache(&[
11097            KvrCacheUpdateEntry {
11098                key: "mfg|||hasupdate".into(),
11099                kvr_url: Some("u".into()),
11100                update_url: None,
11101                latest_version: Some("2".into()),
11102                has_update: Some(true),
11103                source: Some("kvr".into()),
11104            },
11105            KvrCacheUpdateEntry {
11106                key: "mfg|||current".into(),
11107                kvr_url: Some("u".into()),
11108                update_url: None,
11109                latest_version: Some("1.0".into()),
11110                has_update: Some(false),
11111                source: Some("kvr".into()),
11112            },
11113            KvrCacheUpdateEntry {
11114                key: "mfg|||unknownkvr".into(),
11115                kvr_url: None,
11116                update_url: None,
11117                latest_version: None,
11118                has_update: Some(false),
11119                source: Some("not-found".into()),
11120            },
11121        ])
11122        .unwrap();
11123
11124        let r_up = db
11125            .query_plugins(None, None, Some("update"), "name", true, false, 0, 100)
11126            .unwrap();
11127        assert_eq!(r_up.total_count, 1);
11128        assert_eq!(r_up.plugins[0].name, "HasUpdate");
11129
11130        let r_cur = db
11131            .query_plugins(None, None, Some("current"), "name", true, false, 0, 100)
11132            .unwrap();
11133        assert_eq!(r_cur.total_count, 1);
11134        assert_eq!(r_cur.plugins[0].name, "Current");
11135
11136        let r_unk = db
11137            .query_plugins(None, None, Some("unknown"), "name", true, false, 0, 100)
11138            .unwrap();
11139        assert_eq!(r_unk.total_count, 2);
11140        let names: Vec<_> = r_unk.plugins.iter().map(|p| p.name.as_str()).collect();
11141        assert!(names.contains(&"UnknownKvr"));
11142        assert!(names.contains(&"NoCache"));
11143
11144        let r_all = db
11145            .query_plugins(
11146                None,
11147                None,
11148                Some("update,current,unknown"),
11149                "name",
11150                true,
11151                false,
11152                0,
11153                100,
11154            )
11155            .unwrap();
11156        assert_eq!(r_all.total_count, 4);
11157    }
11158
11159    #[test]
11160    fn test_query_plugins_total_unfiltered_stable_across_pagination() {
11161        let db = test_db();
11162        let plugins: Vec<_> = (0..40)
11163            .map(|i| plugin_info(&format!("plug{i:02}"), "VST3", "X"))
11164            .collect();
11165        db.save_plugin_scan(&plugin_snap("ps-page", "2024-06-01T00:00:00", plugins))
11166            .unwrap();
11167
11168        let p1 = db
11169            .query_plugins(None, None, None, "name", true, false, 0, 15)
11170            .unwrap();
11171        let p2 = db
11172            .query_plugins(None, None, None, "name", true, false, 15, 15)
11173            .unwrap();
11174
11175        assert_eq!(p1.total_unfiltered, 40);
11176        assert_eq!(p2.total_unfiltered, 40);
11177        assert_eq!(p1.plugins.len(), 15);
11178        assert_eq!(p2.plugins.len(), 15);
11179    }
11180
11181    #[test]
11182    fn test_query_plugins_search_by_manufacturer() {
11183        let db = test_db();
11184        db.save_plugin_scan(&plugin_snap(
11185            "ps-mfg",
11186            "2024-06-01T00:00:00",
11187            vec![
11188                plugin_info("Serum", "VST3", "Xfer"),
11189                plugin_info("Serum2", "VST3", "Xfer"),
11190                plugin_info("Vital", "VST3", "Matt"),
11191            ],
11192        ))
11193        .unwrap();
11194
11195        let res = db
11196            .query_plugins(Some("Xfer"), None, None, "name", true, false, 0, 100)
11197            .unwrap();
11198        assert_eq!(res.total_count, 2);
11199        assert_eq!(res.total_unfiltered, 3);
11200    }
11201
11202    // ── Unfiltered aggregate stats ──
11203    // These power the stats sections in the DAW/preset tabs and MUST be
11204    // independent of any table filter the user has applied.
11205
11206    #[test]
11207    fn test_daw_stats_returns_library_aggregates() {
11208        let db = test_db();
11209        db.save_daw_scan(&daw_snap(
11210            "ds-stats",
11211            "2024-06-01T00:00:00",
11212            vec![
11213                daw_project("a.als", "Ableton Live"),
11214                daw_project("b.als", "Ableton Live"),
11215                daw_project("c.logicx", "Logic Pro"),
11216                daw_project("d.flp", "FL Studio"),
11217            ],
11218        ))
11219        .unwrap();
11220
11221        let stats = db.daw_stats(None).unwrap();
11222        assert_eq!(stats.project_count, 4);
11223        assert_eq!(stats.total_bytes, 4000); // 4 × 1000 from daw_project helper
11224        assert_eq!(stats.daw_counts["Ableton Live"], 2);
11225        assert_eq!(stats.daw_counts["Logic Pro"], 1);
11226        assert_eq!(stats.daw_counts["FL Studio"], 1);
11227    }
11228
11229    #[test]
11230    fn test_daw_stats_empty_db() {
11231        let db = test_db();
11232        let stats = db.daw_stats(None).unwrap();
11233        assert_eq!(stats.project_count, 0);
11234        assert_eq!(stats.total_bytes, 0);
11235        assert!(stats.daw_counts.is_empty());
11236    }
11237
11238    #[test]
11239    fn test_daw_stats_multi_scan_library_unions_distinct_paths() {
11240        let db = test_db();
11241        db.save_daw_scan(&daw_snap(
11242            "ds-old",
11243            "2024-01-01T00:00:00",
11244            vec![
11245                daw_project("old1.als", "Ableton"),
11246                daw_project("old2.als", "Ableton"),
11247                daw_project("old3.als", "Ableton"),
11248            ],
11249        ))
11250        .unwrap();
11251        db.save_daw_scan(&daw_snap(
11252            "ds-new",
11253            "2024-06-01T00:00:00",
11254            vec![daw_project("new.logicx", "Logic")],
11255        ))
11256        .unwrap();
11257
11258        let stats = db.daw_stats(None).unwrap();
11259        assert_eq!(stats.project_count, 4);
11260        assert_eq!(stats.daw_counts["Ableton"], 3);
11261        assert_eq!(stats.daw_counts["Logic"], 1);
11262    }
11263
11264    #[test]
11265    fn test_daw_stats_empty_scan_ignored() {
11266        let db = test_db();
11267        db.save_daw_scan(&daw_snap(
11268            "ds-real",
11269            "2024-01-01T00:00:00",
11270            vec![daw_project("real.als", "Ableton")],
11271        ))
11272        .unwrap();
11273        db.save_daw_scan(&daw_snap("ds-empty", "2024-12-01T00:00:00", vec![]))
11274            .unwrap();
11275
11276        let stats = db.daw_stats(None).unwrap();
11277        assert_eq!(
11278            stats.project_count, 1,
11279            "empty scan must not clobber real one"
11280        );
11281    }
11282
11283    #[test]
11284    fn test_daw_stats_explicit_scan_id() {
11285        let db = test_db();
11286        db.save_daw_scan(&daw_snap(
11287            "ds-a",
11288            "2024-01-01T00:00:00",
11289            vec![
11290                daw_project("x.als", "Ableton"),
11291                daw_project("y.als", "Ableton"),
11292            ],
11293        ))
11294        .unwrap();
11295        db.save_daw_scan(&daw_snap(
11296            "ds-b",
11297            "2024-06-01T00:00:00",
11298            vec![daw_project("z.logicx", "Logic")],
11299        ))
11300        .unwrap();
11301
11302        // Explicitly request older scan
11303        let stats = db.daw_stats(Some("ds-a")).unwrap();
11304        assert_eq!(stats.project_count, 2);
11305        assert_eq!(stats.daw_counts["Ableton"], 2);
11306    }
11307
11308    #[test]
11309    fn test_preset_stats_returns_aggregates_excluding_midi() {
11310        let db = test_db();
11311        db.save_preset_scan(&preset_snap(
11312            "pr-stats",
11313            "2024-06-01T00:00:00",
11314            vec![
11315                preset_file("a.fxp", "FXP"),
11316                preset_file("b.fxp", "FXP"),
11317                preset_file("c.h2p", "H2P"),
11318                preset_file("song.mid", "MID"),
11319                preset_file("beat.midi", "MIDI"),
11320            ],
11321        ))
11322        .unwrap();
11323
11324        let stats = db.preset_stats(None).unwrap();
11325        assert_eq!(stats.preset_count, 3, "MIDI must be excluded");
11326        assert_eq!(stats.total_bytes, 3000); // 3 × 1000, MIDI sizes excluded
11327        assert_eq!(stats.format_counts["FXP"], 2);
11328        assert_eq!(stats.format_counts["H2P"], 1);
11329        assert!(stats.format_counts.get("MID").is_none());
11330        assert!(stats.format_counts.get("MIDI").is_none());
11331    }
11332
11333    #[test]
11334    fn test_preset_stats_empty_db() {
11335        let db = test_db();
11336        let stats = db.preset_stats(None).unwrap();
11337        assert_eq!(stats.preset_count, 0);
11338        assert_eq!(stats.total_bytes, 0);
11339        assert!(stats.format_counts.is_empty());
11340    }
11341
11342    #[test]
11343    fn test_preset_stats_all_midi_returns_zero() {
11344        // Edge case: a scan with only MIDI files should report zero presets
11345        // for the presets tab (MIDI lives in its own tab).
11346        let db = test_db();
11347        db.save_preset_scan(&preset_snap(
11348            "pr-midi-only",
11349            "2024-06-01T00:00:00",
11350            vec![preset_file("a.mid", "MID"), preset_file("b.midi", "MIDI")],
11351        ))
11352        .unwrap();
11353
11354        let stats = db.preset_stats(None).unwrap();
11355        assert_eq!(stats.preset_count, 0);
11356        assert_eq!(stats.total_bytes, 0);
11357        assert!(stats.format_counts.is_empty());
11358    }
11359
11360    #[test]
11361    fn test_preset_stats_multi_scan_library_unions_distinct_paths() {
11362        let db = test_db();
11363        db.save_preset_scan(&preset_snap(
11364            "pr-old",
11365            "2024-01-01T00:00:00",
11366            vec![
11367                preset_file("x.fxp", "FXP"),
11368                preset_file("y.fxp", "FXP"),
11369                preset_file("z.fxp", "FXP"),
11370            ],
11371        ))
11372        .unwrap();
11373        db.save_preset_scan(&preset_snap(
11374            "pr-new",
11375            "2024-06-01T00:00:00",
11376            vec![preset_file("a.h2p", "H2P")],
11377        ))
11378        .unwrap();
11379
11380        let stats = db.preset_stats(None).unwrap();
11381        assert_eq!(stats.preset_count, 4);
11382        assert_eq!(stats.format_counts["FXP"], 3);
11383        assert_eq!(stats.format_counts["H2P"], 1);
11384    }
11385
11386    #[test]
11387    fn test_kvr_cache_roundtrip() {
11388        use crate::history::KvrCacheUpdateEntry;
11389        let db = test_db();
11390
11391        let entries = vec![
11392            KvrCacheUpdateEntry {
11393                key: "serum".into(),
11394                kvr_url: Some("https://kvr.com/serum".into()),
11395                update_url: Some("https://xfer.com/update".into()),
11396                latest_version: Some("1.4".into()),
11397                has_update: Some(true),
11398                source: Some("kvr".into()),
11399            },
11400            KvrCacheUpdateEntry {
11401                key: "vital".into(),
11402                kvr_url: None,
11403                update_url: None,
11404                latest_version: Some("1.6".into()),
11405                has_update: Some(false),
11406                source: None,
11407            },
11408        ];
11409        db.update_kvr_cache(&entries).unwrap();
11410
11411        let cache = db.load_kvr_cache().unwrap();
11412        assert_eq!(cache.len(), 2);
11413        assert_eq!(
11414            cache["serum"].kvr_url.as_deref(),
11415            Some("https://kvr.com/serum")
11416        );
11417        assert!(cache["serum"].has_update);
11418        assert!(!cache["vital"].has_update);
11419        assert_eq!(cache["vital"].latest_version.as_deref(), Some("1.6"));
11420    }
11421
11422    #[test]
11423    fn test_clear_all_caches() {
11424        let db = test_db();
11425        let samples = vec![sample("kick.wav", "/kick.wav", "WAV", 1000)];
11426        db.save_scan("s1", "2024-01-01T00:00:00", 1, 1000, &HashMap::new(), &[])
11427            .unwrap();
11428        db.insert_audio_batch("s1", &samples).unwrap();
11429        db.update_bpm("/kick.wav", Some(120.0)).unwrap();
11430        db.update_key("/kick.wav", Some("A minor")).unwrap();
11431        db.update_lufs("/kick.wav", Some(-14.0)).unwrap();
11432
11433        // Verify analysis is set
11434        let analysis = db.get_analysis("/kick.wav").unwrap();
11435        assert_eq!(analysis["bpm"], 120.0);
11436
11437        db.clear_all_caches().unwrap();
11438
11439        let analysis = db.get_analysis("/kick.wav").unwrap();
11440        assert!(analysis.get("bpm").and_then(|v| v.as_f64()).is_none());
11441        assert!(analysis.get("key").and_then(|v| v.as_str()).is_none());
11442        assert!(analysis.get("lufs").and_then(|v| v.as_f64()).is_none());
11443    }
11444
11445    #[test]
11446    fn test_clear_cache_table_bpm() {
11447        let db = test_db();
11448        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
11449        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11450            .unwrap();
11451        db.insert_audio_batch("s1", &samples).unwrap();
11452        db.update_bpm("/a.wav", Some(140.0)).unwrap();
11453
11454        db.clear_cache_table("bpm").unwrap();
11455        let analysis = db.get_analysis("/a.wav").unwrap();
11456        assert!(analysis.get("bpm").and_then(|v| v.as_f64()).is_none());
11457    }
11458
11459    #[test]
11460    fn test_clear_cache_table_key() {
11461        let db = test_db();
11462        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
11463        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11464            .unwrap();
11465        db.insert_audio_batch("s1", &samples).unwrap();
11466        db.update_key("/a.wav", Some("D major")).unwrap();
11467
11468        db.clear_cache_table("key").unwrap();
11469        let analysis = db.get_analysis("/a.wav").unwrap();
11470        assert!(analysis.get("key").and_then(|v| v.as_str()).is_none());
11471    }
11472
11473    #[test]
11474    fn test_clear_cache_table_waveform() {
11475        let db = test_db();
11476        let data = serde_json::json!({"test_path": "some_waveform_data"});
11477        db.write_cache("waveform-cache.json", &data).unwrap();
11478
11479        let cached = db.read_cache("waveform-cache.json").unwrap();
11480        assert!(cached.as_object().unwrap().contains_key("test_path"));
11481
11482        db.clear_cache_table("waveform").unwrap();
11483        let cached = db.read_cache("waveform-cache.json").unwrap();
11484        assert!(cached.as_object().unwrap().is_empty());
11485    }
11486
11487    #[test]
11488    fn test_clear_cache_table_xref() {
11489        let db = test_db();
11490        let data = serde_json::json!({"/project.als": "[\"Serum\",\"Vital\"]"});
11491        db.write_cache("xref-cache.json", &data).unwrap();
11492
11493        db.clear_cache_table("xref").unwrap();
11494        let cached = db.read_cache("xref-cache.json").unwrap();
11495        assert!(cached.as_object().unwrap().is_empty());
11496    }
11497
11498    #[test]
11499    fn test_clear_cache_table_spectrogram() {
11500        let db = test_db();
11501        let data = serde_json::json!({"/a.wav": "spectrogram_payload"});
11502        db.write_cache("spectrogram-cache.json", &data).unwrap();
11503        assert!(
11504            db.read_cache("spectrogram-cache.json")
11505                .unwrap()
11506                .as_object()
11507                .unwrap()
11508                .contains_key("/a.wav")
11509        );
11510        db.clear_cache_table("spectrogram").unwrap();
11511        assert!(
11512            db.read_cache("spectrogram-cache.json")
11513                .unwrap()
11514                .as_object()
11515                .unwrap()
11516                .is_empty()
11517        );
11518    }
11519
11520    #[test]
11521    fn test_clear_cache_table_fingerprint() {
11522        let db = test_db();
11523        let data = serde_json::json!({"/sample.wav": "fpabc"});
11524        db.write_cache("fingerprint-cache.json", &data).unwrap();
11525        db.clear_cache_table("fingerprint").unwrap();
11526        assert!(
11527            db.read_cache("fingerprint-cache.json")
11528                .unwrap()
11529                .as_object()
11530                .unwrap()
11531                .is_empty()
11532        );
11533    }
11534
11535    #[test]
11536    fn test_clear_cache_table_kvr() {
11537        let db = test_db();
11538        let entries = vec![crate::history::KvrCacheUpdateEntry {
11539            key: "test_plugin_key".into(),
11540            kvr_url: Some("https://www.kvraudio.com/product/test".into()),
11541            update_url: None,
11542            latest_version: Some("2.0".into()),
11543            has_update: Some(true),
11544            source: Some("test".into()),
11545        }];
11546        db.update_kvr_cache(&entries).unwrap();
11547        assert_eq!(db.load_kvr_cache().unwrap().len(), 1);
11548        db.clear_cache_table("kvr").unwrap();
11549        assert!(db.load_kvr_cache().unwrap().is_empty());
11550    }
11551
11552    #[test]
11553    fn test_clear_cache_table_lufs() {
11554        let db = test_db();
11555        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
11556        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11557            .unwrap();
11558        db.insert_audio_batch("s1", &samples).unwrap();
11559        db.update_lufs("/a.wav", Some(-12.0)).unwrap();
11560        db.clear_cache_table("lufs").unwrap();
11561        let analysis = db.get_analysis("/a.wav").unwrap();
11562        assert!(analysis.get("lufs").and_then(|v| v.as_f64()).is_none());
11563    }
11564
11565    #[test]
11566    fn test_clear_cache_table_unknown() {
11567        let db = test_db();
11568        let result = db.clear_cache_table("bogus");
11569        assert!(result.is_err());
11570        assert!(result.unwrap_err().contains("Unknown cache"));
11571    }
11572
11573    #[test]
11574    fn test_read_write_cache_waveform() {
11575        let db = test_db();
11576        let data = serde_json::json!({"/path/to/file.wav": "base64waveformdata"});
11577        db.write_cache("waveform-cache.json", &data).unwrap();
11578
11579        let result = db.read_cache("waveform-cache.json").unwrap();
11580        assert_eq!(result["/path/to/file.wav"], "base64waveformdata");
11581    }
11582
11583    #[test]
11584    fn test_read_write_cache_xref() {
11585        let db = test_db();
11586        let data = serde_json::json!({"/project.flp": "[\"Serum\"]"});
11587        db.write_cache("xref-cache.json", &data).unwrap();
11588
11589        let result = db.read_cache("xref-cache.json").unwrap();
11590        let obj = result.as_object().unwrap();
11591        assert!(obj.contains_key("/project.flp"));
11592    }
11593
11594    #[test]
11595    fn test_table_counts() {
11596        let db = test_db();
11597        let counts = db.table_counts().unwrap();
11598        let obj = counts.as_object().unwrap();
11599
11600        // Fresh DB should have all zeros
11601        assert_eq!(obj["audio_samples"], 0);
11602        assert_eq!(obj["plugins"], 0);
11603        assert_eq!(obj["daw_projects"], 0);
11604        assert_eq!(obj["presets"], 0);
11605        assert_eq!(obj["kvr_cache"], 0);
11606
11607        // Insert some data and verify counts change
11608        let samples = vec![sample("a.wav", "/a.wav", "WAV", 100)];
11609        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11610            .unwrap();
11611        db.insert_audio_batch("s1", &samples).unwrap();
11612
11613        let counts = db.table_counts().unwrap();
11614        let obj = counts.as_object().unwrap();
11615        assert_eq!(obj["audio_samples"], 1);
11616        assert_eq!(obj["audio_samples_library"], 1);
11617        assert_eq!(obj["audio_scans"], 1);
11618    }
11619
11620    #[test]
11621    fn test_table_counts_raw_vs_library_when_same_path_rescanned() {
11622        let db = test_db();
11623        let s = sample("x.wav", "/same/x.wav", "WAV", 100);
11624        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11625            .unwrap();
11626        db.insert_audio_batch("s1", &[s.clone()]).unwrap();
11627        db.save_scan("s2", "2024-01-02T00:00:00", 1, 100, &HashMap::new(), &[])
11628            .unwrap();
11629        db.insert_audio_batch("s2", &[s]).unwrap();
11630        let obj = db.table_counts().unwrap();
11631        let obj = obj.as_object().unwrap();
11632        assert_eq!(obj["audio_samples"], 2);
11633        assert_eq!(obj["audio_samples_library"], 1);
11634    }
11635
11636    #[test]
11637    fn test_audio_library_sample_id_is_max_id_per_path() {
11638        let db = test_db();
11639        let s = sample("x.wav", "/same/x.wav", "WAV", 100);
11640        db.save_scan("s1", "2024-01-01T00:00:00", 1, 100, &HashMap::new(), &[])
11641            .unwrap();
11642        db.insert_audio_batch("s1", &[s.clone()]).unwrap();
11643        db.save_scan("s2", "2024-01-02T00:00:00", 1, 100, &HashMap::new(), &[])
11644            .unwrap();
11645        db.insert_audio_batch("s2", &[s]).unwrap();
11646        let conn = db.read_conn();
11647        let n: i64 = conn
11648            .query_row(
11649                "SELECT COUNT(*) FROM audio_library lib
11650                 WHERE lib.sample_id = (SELECT MAX(id) FROM audio_samples a WHERE a.path = lib.path)",
11651                [],
11652                |r| r.get::<_, i64>(0),
11653            )
11654            .unwrap();
11655        assert_eq!(n, 1);
11656    }
11657
11658    #[test]
11659    fn test_plugin_library_plugin_id_is_max_id_per_path() {
11660        let db = test_db();
11661        let p = |name: &str, path: &str| PluginInfo {
11662            name: name.into(),
11663            path: path.into(),
11664            plugin_type: "VST3".into(),
11665            version: "1".into(),
11666            manufacturer: "m".into(),
11667            manufacturer_url: None,
11668            size: "1 B".into(),
11669            size_bytes: 1,
11670            modified: "2024-01-01".into(),
11671            architectures: vec![],
11672        };
11673        db.plugin_scan_parent_create("ps1", "2024-01-01T00:00:00", &[])
11674            .unwrap();
11675        db.insert_plugin_batch("ps1", &[p("a", "/same/x.vst3")])
11676            .unwrap();
11677        db.plugin_scan_parent_create("ps2", "2024-06-01T00:00:00", &[])
11678            .unwrap();
11679        db.insert_plugin_batch("ps2", &[p("a", "/same/x.vst3")])
11680            .unwrap();
11681        let conn = db.read_conn();
11682        let n: i64 = conn
11683            .query_row(
11684                "SELECT COUNT(*) FROM plugin_library lib
11685                 WHERE lib.plugin_id = (SELECT MAX(id) FROM plugins p WHERE p.path = lib.path)",
11686                [],
11687                |r| r.get::<_, i64>(0),
11688            )
11689            .unwrap();
11690        assert_eq!(n, 1);
11691    }
11692
11693    #[test]
11694    fn test_pdf_midi_preset_library_ids_match_max_id_per_path() {
11695        let db = test_db();
11696        let pdf = |path: &str| PdfFile {
11697            name: "a.pdf".into(),
11698            path: path.into(),
11699            directory: "/d".into(),
11700            size: 1,
11701            size_formatted: "1 B".into(),
11702            modified: "2024-01-01".into(),
11703        };
11704        db.pdf_scan_parent_create("p1", "2024-01-01T00:00:00", &[])
11705            .unwrap();
11706        db.insert_pdf_batch("p1", &[pdf("/same/x.pdf")]).unwrap();
11707        db.pdf_scan_parent_create("p2", "2024-01-02T00:00:00", &[])
11708            .unwrap();
11709        db.insert_pdf_batch("p2", &[pdf("/same/x.pdf")]).unwrap();
11710        {
11711            let conn = db.read_conn();
11712            let n: i64 = conn
11713                .query_row(
11714                    "SELECT COUNT(*) FROM pdf_library lib
11715                 WHERE lib.pdf_id = (SELECT MAX(id) FROM pdfs p WHERE p.path = lib.path)",
11716                    [],
11717                    |r| r.get::<_, i64>(0),
11718                )
11719                .unwrap();
11720            assert_eq!(n, 1);
11721        }
11722
11723        let m = |path: &str| MidiFile {
11724            name: "a.mid".into(),
11725            path: path.into(),
11726            directory: "/d".into(),
11727            format: "MID".into(),
11728            size: 1,
11729            size_formatted: "1 B".into(),
11730            modified: "2024-01-01".into(),
11731        };
11732        db.midi_scan_parent_create("m1", "2024-01-01T00:00:00", &[])
11733            .unwrap();
11734        db.insert_midi_batch("m1", &[m("/same/y.mid")]).unwrap();
11735        db.midi_scan_parent_create("m2", "2024-01-02T00:00:00", &[])
11736            .unwrap();
11737        db.insert_midi_batch("m2", &[m("/same/y.mid")]).unwrap();
11738        {
11739            let conn = db.read_conn();
11740            let n: i64 = conn
11741                .query_row(
11742                    "SELECT COUNT(*) FROM midi_library lib
11743                 WHERE lib.midi_id = (SELECT MAX(id) FROM midi_files f WHERE f.path = lib.path)",
11744                    [],
11745                    |r| r.get::<_, i64>(0),
11746                )
11747                .unwrap();
11748            assert_eq!(n, 1);
11749        }
11750
11751        let pr = |path: &str| PresetFile {
11752            name: "a.fxp".into(),
11753            path: path.into(),
11754            directory: "/d".into(),
11755            format: "FXP".into(),
11756            size: 1,
11757            size_formatted: "1 B".into(),
11758            modified: "2024-01-01".into(),
11759        };
11760        db.preset_scan_parent_create("r1", "2024-01-01T00:00:00", &[])
11761            .unwrap();
11762        db.insert_preset_batch("r1", &[pr("/same/z.fxp")]).unwrap();
11763        db.preset_scan_parent_create("r2", "2024-01-02T00:00:00", &[])
11764            .unwrap();
11765        db.insert_preset_batch("r2", &[pr("/same/z.fxp")]).unwrap();
11766        {
11767            let conn = db.read_conn();
11768            let n: i64 = conn
11769                .query_row(
11770                    "SELECT COUNT(*) FROM preset_library lib
11771                 WHERE lib.preset_id = (SELECT MAX(id) FROM presets p WHERE p.path = lib.path)",
11772                    [],
11773                    |r| r.get::<_, i64>(0),
11774                )
11775                .unwrap();
11776            assert_eq!(n, 1);
11777        }
11778    }
11779
11780    #[test]
11781    fn test_table_counts_with_plugin_and_daw_data() {
11782        let db = test_db();
11783        let snap = ScanSnapshot {
11784            id: "ps1".into(),
11785            timestamp: "2024-01-01T00:00:00".into(),
11786            plugin_count: 1,
11787            plugins: vec![PluginInfo {
11788                name: "Test".into(),
11789                path: "/test.vst3".into(),
11790                plugin_type: "VST3".into(),
11791                version: "1.0".into(),
11792                manufacturer: "Test Co".into(),
11793                manufacturer_url: None,
11794                size: "1 MB".into(),
11795                size_bytes: 1_000_000,
11796                modified: "2024-01-01".into(),
11797                architectures: vec![],
11798            }],
11799            directories: vec![],
11800            roots: vec![],
11801        };
11802        db.save_plugin_scan(&snap).unwrap();
11803
11804        let daw_snap = DawScanSnapshot {
11805            id: "ds1".into(),
11806            timestamp: "2024-01-01T00:00:00".into(),
11807            project_count: 1,
11808            total_bytes: 1000,
11809            daw_counts: HashMap::new(),
11810            projects: vec![DawProject {
11811                name: "t.als".into(),
11812                path: "/t.als".into(),
11813                directory: "/".into(),
11814                format: "ALS".into(),
11815                daw: "Ableton".into(),
11816                size: 1000,
11817                size_formatted: "1 KB".into(),
11818                modified: "2024-01-01".into(),
11819            }],
11820            roots: vec![],
11821        };
11822        db.save_daw_scan(&daw_snap).unwrap();
11823
11824        let counts = db.table_counts().unwrap();
11825        let obj = counts.as_object().unwrap();
11826        assert_eq!(obj["plugins"], 1);
11827        assert_eq!(obj["plugin_scans"], 1);
11828        assert_eq!(obj["daw_projects"], 1);
11829        assert_eq!(obj["daw_scans"], 1);
11830    }
11831
11832    #[test]
11833    fn test_active_scan_inventory_counts_empty() {
11834        let db = test_db();
11835        let v = db.active_scan_inventory_counts().unwrap();
11836        assert_eq!(v["plugins"], 0);
11837        assert_eq!(v["audio_samples"], 0);
11838        assert_eq!(v["daw_projects"], 0);
11839        assert_eq!(v["presets"], 0);
11840        assert_eq!(v["pdfs"], 0);
11841        assert_eq!(v["midi_files"], 0);
11842    }
11843
11844    #[test]
11845    fn test_active_scan_inventory_counts_presets_exclude_midi_formats() {
11846        let db = test_db();
11847        db.save_preset_scan(&preset_snap(
11848            "pr-midi-only",
11849            "2024-06-01T00:00:00",
11850            vec![preset_file("a.mid", "MID"), preset_file("b.midi", "MIDI")],
11851        ))
11852        .unwrap();
11853        let v = db.active_scan_inventory_counts().unwrap();
11854        assert_eq!(v["presets"], 0);
11855    }
11856
11857    #[test]
11858    fn test_plugin_streaming_insert_seen_in_active_scan_counts() {
11859        let db = test_db();
11860        db.plugin_scan_parent_create(
11861            "ps-stream",
11862            "2024-01-01T00:00:00",
11863            &["/Applications".into()],
11864        )
11865        .unwrap();
11866        let p = PluginInfo {
11867            name: "Test".into(),
11868            path: "/test.vst3".into(),
11869            plugin_type: "VST3".into(),
11870            version: "1.0".into(),
11871            manufacturer: "Test Co".into(),
11872            manufacturer_url: None,
11873            size: "1 MB".into(),
11874            size_bytes: 1_000_000,
11875            modified: "2024-01-01".into(),
11876            architectures: vec![],
11877        };
11878        assert_eq!(db.insert_plugin_batch("ps-stream", &[p]).unwrap(), 1);
11879        db.plugin_scan_parent_finalize("ps-stream", 1, &[], &["/Applications".into()])
11880            .unwrap();
11881        db.set_plugin_scan_complete("ps-stream", true).unwrap();
11882        let v = db.active_scan_inventory_counts().unwrap();
11883        assert_eq!(v["plugins"], 1);
11884    }
11885
11886    /// Many lib tests call `init_global()` in parallel; migrations must not race on one file.
11887    /// Uses a temp [`history::get_data_dir`] so workers do not touch the real DB; the global
11888    /// test override is visible on all threads (see `TEST_DATA_DIR_GLOBAL` in `history.rs`).
11889    #[test]
11890    fn init_global_concurrent_ok() {
11891        let tmp = std::env::temp_dir().join(format!(
11892            "ah_init_global_conc_{}_{}",
11893            std::process::id(),
11894            std::time::SystemTime::now()
11895                .duration_since(std::time::UNIX_EPOCH)
11896                .map(|d| d.as_nanos())
11897                .unwrap_or(0)
11898        ));
11899        let _ = std::fs::remove_dir_all(&tmp);
11900        std::fs::create_dir_all(&tmp).unwrap();
11901        crate::history::set_test_data_dir_path(tmp.clone());
11902
11903        let threads: Vec<_> = (0..32)
11904            .map(|_| {
11905                std::thread::spawn(|| {
11906                    init_global().expect("init_global");
11907                    assert!(global_initialized());
11908                    let _ = global().read_cache("concurrent-init-smoke.json");
11909                })
11910            })
11911            .collect();
11912        for t in threads {
11913            t.join().expect("thread join");
11914        }
11915
11916        crate::history::clear_test_data_dir_path();
11917        let _ = std::fs::remove_dir_all(&tmp);
11918    }
11919
11920    #[test]
11921    fn init_global_idempotent_same_thread() {
11922        let tmp = std::env::temp_dir().join(format!(
11923            "ah_init_global_idem_{}_{}",
11924            std::process::id(),
11925            std::time::SystemTime::now()
11926                .duration_since(std::time::UNIX_EPOCH)
11927                .map(|d| d.as_nanos())
11928                .unwrap_or(0)
11929        ));
11930        let _ = std::fs::remove_dir_all(&tmp);
11931        std::fs::create_dir_all(&tmp).unwrap();
11932        crate::history::set_test_data_dir_path(tmp.clone());
11933
11934        for _ in 0..64 {
11935            init_global().expect("init_global");
11936        }
11937        assert!(global_initialized());
11938
11939        crate::history::clear_test_data_dir_path();
11940        let _ = std::fs::remove_dir_all(&tmp);
11941    }
11942
11943    /// Run this to migrate real JSON caches to SQLite.
11944    /// Not a real test — it's a one-shot migration runner.
11945    /// Run with: cargo test --manifest-path src-tauri/Cargo.toml "run_migration" -- --nocapture --ignored
11946    #[test]
11947    #[ignore]
11948    fn run_migration() {
11949        let db = Database::open().expect("Failed to open database");
11950        let count = db.migrate_from_json().expect("Migration failed");
11951        println!("Migrated {count} audio samples to SQLite");
11952        let scans = db.list_scans().expect("Failed to list scans");
11953        for s in &scans {
11954            println!(
11955                "  Scan {} — {} samples, {} bytes, {} roots",
11956                s.id,
11957                s.sample_count,
11958                s.total_bytes,
11959                s.roots.len()
11960            );
11961        }
11962        if let Ok(stats) = db.audio_stats(None) {
11963            println!(
11964                "Stats: {} samples, {} bytes, {} analyzed, {} formats",
11965                stats.sample_count,
11966                stats.total_bytes,
11967                stats.analyzed_count,
11968                stats.format_counts.len()
11969            );
11970        }
11971    }
11972}