1use 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();
22static INIT_GLOBAL_MUTEX: Mutex<()> = Mutex::new(());
26
27pub 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
49pub fn global_initialized() -> bool {
51 GLOBAL_DB.get().is_some()
52}
53
54pub fn global() -> &'static Database {
56 GLOBAL_DB.get().expect("Database not initialized")
57}
58
59pub type AnalysisBatchRow = (String, Option<f64>, Option<String>, Option<f64>);
61
62pub struct Database {
69 write: Mutex<Connection>,
70 read: Vec<Mutex<Connection>>,
71 read_deadlines: Vec<Arc<AtomicU64>>,
73 read_idx: AtomicUsize,
74 midi_library_total_cache: Mutex<Option<u64>>,
76 pdf_library_total_cache: Mutex<Option<u64>>,
78 audio_library_total_cache: Mutex<Option<u64>>,
80 preset_inventory_total_cache: Mutex<Option<u64>>,
82 daw_library_total_cache: Mutex<Option<u64>>,
84 plugin_library_total_cache: Mutex<Option<u64>>,
86}
87
88#[derive(Debug, Deserialize)]
90pub struct AudioQueryParams {
91 #[serde(default)]
92 pub scan_id: Option<String>,
93 #[serde(default)]
94 pub search: Option<String>,
95 #[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
121const FTS_INVENTORY_MATCH_COUNT_CAP: i64 = 100_000;
125
126fn 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
142fn 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
156fn 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
189fn 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
231fn 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
272fn 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#[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 #[serde(rename = "bpmExhausted", skip_serializing_if = "std::ops::Not::not")]
406 pub bpm_exhausted: bool,
407}
408
409#[derive(Debug, Serialize)]
411pub struct AudioQueryResult {
412 pub samples: Vec<AudioSampleRow>,
413 #[serde(rename = "totalCount")]
414 pub total_count: u64,
415 #[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#[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#[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#[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#[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#[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
484fn 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
525const AUDIO_LIBRARY_IDS: &str = "id IN (SELECT sample_id FROM audio_library)";
528const DAW_LIBRARY_IDS: &str = "id IN (SELECT project_id FROM daw_library)";
530const 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)";
534const 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
538fn 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
557const 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#[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#[derive(Debug, Clone, Serialize)]
686pub struct TopFolderRow {
687 pub path: String,
688 pub count: u64,
689}
690
691#[derive(Debug, Serialize)]
694pub struct FilterStatsResult {
695 pub count: u64,
696 #[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 #[serde(default, rename = "sizeBuckets")]
709 pub size_buckets: Vec<u64>,
710 #[serde(default, skip_serializing_if = "Vec::is_empty", rename = "bpmBuckets")]
712 pub bpm_buckets: Vec<u64>,
713 #[serde(default, rename = "bpmAnalyzedCount")]
715 pub bpm_analyzed_count: u64,
716 #[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 #[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#[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#[allow(dead_code)]
765const SCHEMA_VERSION: i64 = 4;
766
767const SQLITE_READ_POOL_AUTO_CAP: usize = 8;
771
772const SQLITE_READ_POOL_EXTRA_MAX: usize = 32;
774
775const SQLITE_CACHE_KIB_PRIMARY: i32 = -262_144; const SQLITE_MMAP_BYTES_PRIMARY: i64 = 536_870_912; fn read_pool_cache_kib(num_read_connections: usize) -> i32 {
782 const TOTAL_KIB_BUDGET: i64 = 512 * 1024; 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
788fn 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
813const SQLITE_QUERY_TIMEOUT_SECS: u64 = 30;
817
818const 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
837fn 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
856fn 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#[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
874fn 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 fn read_pool_extra() -> usize {
905 parse_sqlite_read_pool_extra_pref()
906 }
907
908 pub fn sqlite_read_pool_extra_slots(&self) -> usize {
910 self.read.len()
911 }
912
913 pub fn sqlite_read_pool_total_handles(&self) -> usize {
915 1 + self.read.len()
916 }
917
918 #[inline]
921 fn write_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
922 self.write.lock().unwrap_or_else(|e| e.into_inner())
923 }
924
925 #[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 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 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 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 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 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 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 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 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 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 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 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 pub fn housekeep(&self) {
1299 self.housekeep_light();
1300 self.housekeep_heavy();
1301 }
1302
1303 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 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 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 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 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 pub fn get_toast_strings(&self, locale: &str) -> Result<HashMap<String, String>, String> {
1438 self.get_app_strings(locale)
1439 }
1440
1441 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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) = ¶ms.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 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 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 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 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) = ¶ms.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 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 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 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 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 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 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 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 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 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 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 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 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 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 ®ex_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 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 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 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 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 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 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 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 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 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 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 pub fn save_audio_scan_full(&self, snap: &AudioScanSnapshot) -> Result<(), String> {
4411 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 ®ex_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 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 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 ®ex_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 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 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 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 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 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 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 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 pub fn cache_stats(&self) -> Result<Vec<CacheStat>, String> {
7690 let conn = self.write_conn();
7691 let mut stats = Vec::new();
7692
7693 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 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 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 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 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 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 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"), }
7904 }
7905
7906 pub fn migrate_from_json(&self) -> Result<usize, String> {
7908 let data_dir = history::get_data_dir();
7909 let mut total = 0;
7910
7911 {
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 total += self.migrate_audio_json(&data_dir)?;
7931
7932 total += self.migrate_plugin_json(&data_dir)?;
7934
7935 total += self.migrate_daw_json(&data_dir)?;
7937
7938 total += self.migrate_preset_json(&data_dir)?;
7940
7941 total += self.migrate_kvr_json(&data_dir)?;
7943
7944 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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); }
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 #[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 #[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: "/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 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 let res = db
10889 .query_presets(None, Some("MID"), "name", true, false, 0, 100)
10890 .unwrap();
10891 assert_eq!(res.total_count, 0);
10892 assert_eq!(res.total_unfiltered, 1);
10894 }
10895
10896 #[test]
10897 fn test_query_presets_comma_separated_filter_unfiltered_stable() {
10898 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 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 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); 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 #[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); 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 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); 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 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 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 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 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 #[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 #[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}