Skip to main content

max / audiofiles

6.6 KB · 188 lines History Blame Raw
1 //! Folder-label harvesting (cold-start companion to clustering).
2 //!
3 //! Turns an already-organized library's folder structure into candidate tags: each VFS
4 //! directory that directly contains samples becomes a suggested tag (normalized from the
5 //! folder name) over those samples. The user reviews/edits the mapping, then applies it.
6 //!
7 //! Applied harvest tags carry `source = 'harvest'` provenance, sticky, like manual and
8 //! cluster tags (the rules engine never reconciles them away). See [`crate::rules`].
9
10 use crate::db::Database;
11 use crate::error::Result;
12 use crate::tags::validate_tag;
13 use tracing::instrument;
14
15 /// A candidate label derived from one VFS folder.
16 #[derive(Debug, Clone, PartialEq, Eq)]
17 pub struct FolderLabel {
18 /// The raw directory name (e.g. `808 Kicks`).
19 pub folder_name: String,
20 /// Normalized, validated tag suggestion (e.g. `808-kicks`). The user can
21 /// re-namespace it before applying.
22 pub suggested_tag: String,
23 /// Hashes of the samples directly under this folder.
24 pub sample_hashes: Vec<String>,
25 }
26
27 /// Normalize a folder name into a valid flat tag, or `None` if it can't form one.
28 ///
29 /// Lowercases, maps every non-alphanumeric run to a single hyphen, trims hyphens, and
30 /// validates against the tag grammar.
31 pub fn normalize_to_tag(folder_name: &str) -> Option<String> {
32 let mut out = String::with_capacity(folder_name.len());
33 let mut last_dash = false;
34 for ch in folder_name.chars() {
35 if ch.is_ascii_alphanumeric() {
36 out.push(ch.to_ascii_lowercase());
37 last_dash = false;
38 } else if !last_dash {
39 out.push('-');
40 last_dash = true;
41 }
42 }
43 let trimmed = out.trim_matches('-').to_string();
44 if trimmed.is_empty() {
45 return None;
46 }
47 validate_tag(&trimmed).ok().map(|()| trimmed)
48 }
49
50 /// Scan the VFS for directories that directly contain samples and propose a label per
51 /// folder. Folders whose name can't normalize to a valid tag are skipped. Ordered by
52 /// folder name.
53 #[instrument(skip_all)]
54 pub fn harvest_folder_labels(db: &Database) -> Result<Vec<FolderLabel>> {
55 let mut stmt = db.conn().prepare(
56 "SELECT d.id, d.name, s.sample_hash
57 FROM vfs_nodes d
58 JOIN vfs_nodes s ON s.parent_id = d.id AND s.node_type = 'sample'
59 WHERE d.node_type = 'directory' AND s.sample_hash IS NOT NULL
60 ORDER BY d.name, d.id, s.sample_hash",
61 )?;
62 let rows = stmt.query_map([], |row| {
63 Ok((
64 row.get::<_, i64>(0)?,
65 row.get::<_, String>(1)?,
66 row.get::<_, String>(2)?,
67 ))
68 })?;
69
70 // Group hashes by directory id, not bare name: two sibling folders both
71 // named e.g. "Kicks" (under 808/ and 909/) are distinct folders and must
72 // each propose their own label, not merge into one union.
73 let mut order: Vec<i64> = Vec::new();
74 let mut by_folder: std::collections::HashMap<i64, (String, Vec<String>)> =
75 std::collections::HashMap::new();
76 for r in rows {
77 let (id, name, hash) = r?;
78 by_folder
79 .entry(id)
80 .or_insert_with(|| {
81 order.push(id);
82 (name, Vec::new())
83 })
84 .1
85 .push(hash);
86 }
87
88 let mut labels = Vec::new();
89 for id in order {
90 if let Some((folder_name, sample_hashes)) = by_folder.remove(&id)
91 && let Some(suggested_tag) = normalize_to_tag(&folder_name)
92 {
93 labels.push(FolderLabel {
94 folder_name,
95 suggested_tag,
96 sample_hashes,
97 });
98 }
99 }
100 Ok(labels)
101 }
102
103 /// Apply a harvested tag to the given samples (`source = 'harvest'` provenance).
104 /// Returns the number of samples that gained the tag.
105 #[instrument(skip_all)]
106 pub fn apply_harvested_tag(db: &Database, hashes: &[String], tag: &str) -> Result<usize> {
107 // One transaction for the whole harvest label, not an autocommit per sample.
108 db.transaction_core(|tx| {
109 let mut added = 0;
110 for hash in hashes {
111 if crate::rules::apply_tag_sourced(db, tx, hash, tag, "harvest")? {
112 added += 1;
113 }
114 }
115 Ok(added)
116 })
117 }
118
119 #[cfg(test)]
120 mod tests {
121 use super::*;
122
123 #[test]
124 fn normalize_handles_messy_names() {
125 assert_eq!(normalize_to_tag("808 Kicks").as_deref(), Some("808-kicks"));
126 assert_eq!(normalize_to_tag(" Hi-Hats!! ").as_deref(), Some("hi-hats"));
127 assert_eq!(
128 normalize_to_tag("Vocals/Chops").as_deref(),
129 Some("vocals-chops")
130 );
131 assert_eq!(normalize_to_tag("___").as_deref(), None);
132 assert_eq!(normalize_to_tag("").as_deref(), None);
133 }
134
135 fn setup(db: &Database) -> (i64, i64) {
136 let conn = db.conn();
137 conn.execute(
138 "INSERT INTO vfs (name, created_at, modified_at, sync_files) VALUES ('Lib', 0, 0, 0)",
139 [],
140 )
141 .unwrap();
142 let vfs_id = conn.last_insert_rowid();
143 // Directory "808 Kicks" with two samples.
144 conn.execute(
145 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, created_at) VALUES (?1, NULL, '808 Kicks', 'directory', 0)",
146 [vfs_id],
147 )
148 .unwrap();
149 let dir_id = conn.last_insert_rowid();
150 for h in ["k1", "k2"] {
151 conn.execute(
152 "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) VALUES (?1, ?1, 'wav', 1, 0, 0)",
153 [h],
154 )
155 .unwrap();
156 conn.execute(
157 "INSERT INTO vfs_nodes (vfs_id, parent_id, name, node_type, sample_hash, created_at) VALUES (?1, ?2, ?3, 'sample', ?3, 0)",
158 rusqlite::params![vfs_id, dir_id, h],
159 )
160 .unwrap();
161 }
162 (vfs_id, dir_id)
163 }
164
165 #[test]
166 fn harvest_and_apply() {
167 let db = Database::open_in_memory().unwrap();
168 setup(&db);
169
170 let labels = harvest_folder_labels(&db).unwrap();
171 assert_eq!(labels.len(), 1);
172 assert_eq!(labels[0].folder_name, "808 Kicks");
173 assert_eq!(labels[0].suggested_tag, "808-kicks");
174 assert_eq!(labels[0].sample_hashes.len(), 2);
175
176 // Apply under a re-namespaced tag.
177 let n = apply_harvested_tag(&db, &labels[0].sample_hashes, "instrument.drum.kick").unwrap();
178 assert_eq!(n, 2);
179 assert!(
180 crate::tags::get_sample_tags(&db, "k1")
181 .unwrap()
182 .contains(&"instrument.drum.kick".to_string())
183 );
184 let prov = crate::rules::sample_tag_provenance(&db, "k1").unwrap();
185 assert_eq!(prov[0].1, "harvest");
186 }
187 }
188