Skip to main content

max / audiofiles

TagTree integration, replace inline tag validation and escape_like Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-22 05:29 UTC
Commit: ba71f6633795be3916d23e2bdf2335adb148872f
Parent: 2da535a
6 files changed, +21 insertions, -97 deletions
M Cargo.lock +5
@@ -444,6 +444,7 @@ dependencies = [
444 444 "sha2",
445 445 "stratum-dsp",
446 446 "symphonia",
447 + "tagtree",
447 448 "tempfile",
448 449 "thiserror 2.0.18",
449 450 "tracing",
@@ -4928,6 +4929,10 @@ dependencies = [
4928 4929 ]
4929 4930
4930 4931 [[package]]
4932 + name = "tagtree"
4933 + version = "0.3.0"
4934 +
4935 + [[package]]
4931 4936 name = "target-lexicon"
4932 4937 version = "0.12.16"
4933 4938 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1
@@ -43,3 +43,4 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat
43 43 semver = "1"
44 44 open = "5"
45 45 docengine = { path = "../docengine" }
46 + tagtree = { path = "../tagtree" }
@@ -20,6 +20,7 @@ realfft = { workspace = true, optional = true }
20 20 rubato = { workspace = true }
21 21 hound = { workspace = true }
22 22 tracing = { workspace = true }
23 + tagtree = { workspace = true }
23 24
24 25 [dev-dependencies]
25 26 tempfile = "3.25.0"
@@ -3,7 +3,6 @@
3 3 use crate::db::Database;
4 4 use crate::error::Result;
5 5 use crate::id_types::{NodeId, VfsId};
6 - use crate::util::escape_like;
7 6 use crate::vfs::{self as vfs_mod, VfsNodeWithAnalysis};
8 7 use tracing::instrument;
9 8
@@ -199,7 +198,7 @@ fn append_filter_clauses(
199 198 filter: &SearchFilter,
200 199 ) {
201 200 if !filter.text_query.is_empty() {
202 - let pattern = format!("%{}%", escape_like(&filter.text_query));
201 + let pattern = format!("%{}%", tagtree::escape_like(&filter.text_query));
203 202 sql.push_str(&format!(
204 203 " AND (n.name LIKE ?{idx} ESCAPE '\\')",
205 204 idx = params.len() + 1
@@ -280,7 +279,7 @@ fn append_filter_clauses(
280 279 " AND EXISTS (SELECT 1 FROM tags WHERE sample_hash = n.sample_hash AND tag LIKE ?{} ESCAPE '\\')",
281 280 params.len() + 1
282 281 ));
283 - let pattern = format!("{}%", escape_like(tag));
282 + let pattern = format!("{}%", tagtree::escape_like(tag));
284 283 params.push(Box::new(pattern));
285 284 }
286 285 }
@@ -2,9 +2,15 @@
2 2
3 3 use crate::db::Database;
4 4 use crate::error::{CoreError, Result};
5 - use crate::util::escape_like;
6 5 use tracing::instrument;
7 6
7 + /// Tag rules for audiofiles: deep hierarchy, no required semantic prefix.
8 + const TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
9 + max_depth: 5,
10 + max_length: 100,
11 + semantic_depth: 0,
12 + };
13 +
8 14 /// Validate a dot-notation tag.
9 15 ///
10 16 /// Rules:
@@ -12,38 +18,7 @@ use tracing::instrument;
12 18 /// - No spaces, no consecutive dots, no leading/trailing dots
13 19 /// - Max 5 levels (4 dots), max 100 chars
14 20 pub fn validate_tag(tag: &str) -> Result<()> {
15 - if tag.is_empty() {
16 - return Err(CoreError::TagInvalid("tag cannot be empty".into()));
17 - }
18 - if tag.len() > 100 {
19 - return Err(CoreError::TagInvalid(
20 - "tag exceeds 100 characters".into(),
21 - ));
22 - }
23 - if tag.starts_with('.') || tag.ends_with('.') {
24 - return Err(CoreError::TagInvalid(
25 - "tag cannot start or end with a dot".into(),
26 - ));
27 - }
28 - if tag.contains("..") {
29 - return Err(CoreError::TagInvalid(
30 - "tag cannot contain consecutive dots".into(),
31 - ));
32 - }
33 - let dot_count = tag.chars().filter(|&c| c == '.').count();
34 - if dot_count > 4 {
35 - return Err(CoreError::TagInvalid(
36 - "tag cannot exceed 5 levels (4 dots)".into(),
37 - ));
38 - }
39 - for ch in tag.chars() {
40 - if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '.') {
41 - return Err(CoreError::TagInvalid(format!(
42 - "invalid character '{ch}': only lowercase alphanumeric, hyphens, and dots allowed"
43 - )));
44 - }
45 - }
46 - Ok(())
21 + tagtree::validate_with(tag, &TAG_CONFIG).map_err(|e| CoreError::TagInvalid(e.0))
47 22 }
48 23
49 24 /// Add a tag to a sample. Validates format first. Idempotent.
@@ -72,7 +47,7 @@ pub fn remove_tag(db: &Database, hash: &str, tag: &str) -> Result<()> {
72 47 pub fn remove_tags_by_prefix(db: &Database, hash: &str, prefix: &str) -> Result<()> {
73 48 db.conn().execute(
74 49 "DELETE FROM tags WHERE sample_hash = ?1 AND (tag = ?2 OR tag LIKE ?3 ESCAPE '\\')",
75 - rusqlite::params![hash, prefix, format!("{}.%", escape_like(prefix))],
50 + rusqlite::params![hash, prefix, tagtree::like_descendant_pattern(prefix)],
76 51 )?;
77 52 Ok(())
78 53 }
@@ -104,7 +79,7 @@ pub fn find_by_prefix(db: &Database, prefix: &str) -> Result<Vec<String>> {
104 79 "SELECT DISTINCT sample_hash FROM tags WHERE tag = ?1 OR tag LIKE ?2 ESCAPE '\\' ORDER BY sample_hash",
105 80 )?;
106 81 let rows = stmt.query_map(
107 - rusqlite::params![prefix, format!("{}.%", escape_like(prefix))],
82 + rusqlite::params![prefix, tagtree::like_descendant_pattern(prefix)],
108 83 |row| row.get(0),
109 84 )?;
110 85 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
@@ -166,37 +141,13 @@ pub fn list_children_tags(db: &Database, prefix: &str) -> Result<Vec<String>> {
166 141 let mut stmt = db
167 142 .conn()
168 143 .prepare("SELECT DISTINCT tag FROM tags WHERE tag LIKE ?1 ESCAPE '\\' ORDER BY tag")?;
169 - let rows = stmt.query_map([format!("{}.%", escape_like(prefix))], |row| {
144 + let rows = stmt.query_map([tagtree::like_descendant_pattern(prefix)], |row| {
170 145 row.get::<_, String>(0)
171 146 })?;
172 147 rows.collect::<std::result::Result<Vec<_>, _>>()?
173 148 };
174 149
175 - let strip_depth = if prefix.is_empty() {
176 - 0
177 - } else {
178 - prefix.len() + 1 // skip the prefix + the dot
179 - };
180 -
181 - let mut children: Vec<String> = Vec::new();
182 - for tag in &tags {
183 - if tag.len() <= strip_depth {
184 - continue;
185 - }
186 - let suffix = &tag[strip_depth..];
187 - let child = match suffix.find('.') {
188 - Some(pos) => &suffix[..pos],
189 - None => suffix,
190 - };
191 - if !child.is_empty()
192 - && children.last().is_none_or(|last| last != child)
193 - {
194 - children.push(child.to_string());
195 - }
196 - }
197 - children.sort();
198 - children.dedup();
199 - Ok(children)
150 + Ok(tagtree::children_at_prefix(prefix, &tags))
200 151 }
201 152
202 153 #[cfg(test)]
@@ -34,15 +34,6 @@ pub fn split_name_ext(filename: &str) -> (String, String) {
34 34 }
35 35 }
36 36
37 - /// Escape SQL LIKE wildcards (`%` and `_`) in user input.
38 - ///
39 - /// The escaped string should be used with `ESCAPE '\'` in the LIKE clause.
40 - pub fn escape_like(s: &str) -> String {
41 - s.replace('\\', "\\\\")
42 - .replace('%', "\\%")
43 - .replace('_', "\\_")
44 - }
45 -
46 37 /// Check whether a path has an audio file extension.
47 38 pub fn is_audio_file(path: &Path) -> bool {
48 39 let ext = get_extension(path);
@@ -160,28 +151,4 @@ mod tests {
160 151 assert_eq!(ext, "wav");
161 152 }
162 153
163 - #[test]
164 - fn escape_like_no_wildcards() {
165 - assert_eq!(escape_like("kick"), "kick");
166 - }
167 -
168 - #[test]
169 - fn escape_like_percent() {
170 - assert_eq!(escape_like("100%"), "100\\%");
171 - }
172 -
173 - #[test]
174 - fn escape_like_underscore() {
175 - assert_eq!(escape_like("a_b"), "a\\_b");
176 - }
177 -
178 - #[test]
179 - fn escape_like_backslash() {
180 - assert_eq!(escape_like("a\\b"), "a\\\\b");
181 - }
182 -
183 - #[test]
184 - fn escape_like_all_special() {
185 - assert_eq!(escape_like("100%_\\x"), "100\\%\\_\\\\x");
186 - }
187 154 }