| 110 |
110 |
|
/// insert into DB. Returns the hex SHA-256 hash.
|
| 111 |
111 |
|
#[instrument(skip_all)]
|
| 112 |
112 |
|
pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
|
| 113 |
|
- |
if !crate::util::is_audio_file(path) {
|
| 114 |
|
- |
return Err(CoreError::Internal(format!(
|
| 115 |
|
- |
"not a supported audio file: {}",
|
| 116 |
|
- |
path.display()
|
| 117 |
|
- |
)));
|
| 118 |
|
- |
}
|
| 119 |
|
- |
|
| 120 |
|
- |
let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
|
| 121 |
|
- |
let metadata = file.metadata().map_err(|e| io_err(path, e))?;
|
| 122 |
|
- |
let file_size = metadata.len() as i64;
|
| 123 |
|
- |
|
| 124 |
|
- |
if file_size == 0 {
|
| 125 |
|
- |
return Err(CoreError::Internal(format!(
|
| 126 |
|
- |
"cannot import zero-byte file: {}",
|
| 127 |
|
- |
path.display()
|
| 128 |
|
- |
)));
|
| 129 |
|
- |
}
|
| 130 |
|
- |
|
| 131 |
|
- |
// Stream through SHA-256
|
| 132 |
|
- |
let mut hasher = Sha256::new();
|
| 133 |
|
- |
let mut buf = [0u8; 8192];
|
| 134 |
|
- |
loop {
|
| 135 |
|
- |
let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
|
| 136 |
|
- |
if n == 0 {
|
| 137 |
|
- |
break;
|
| 138 |
|
- |
}
|
| 139 |
|
- |
hasher.update(&buf[..n]);
|
| 140 |
|
- |
}
|
| 141 |
|
- |
let hash = format!("{:x}", hasher.finalize());
|
|
113 |
+ |
let (hash, file_size) = hash_file(path)?;
|
|
114 |
+ |
self.import_hashed(path, &hash, file_size, db)?;
|
|
115 |
+ |
Ok(hash)
|
|
116 |
+ |
}
|
| 142 |
117 |
|
|
|
118 |
+ |
/// Import a file whose SHA-256 hash and size were already computed (e.g. by a
|
|
119 |
+ |
/// parallel pre-hash pass). Does the serial side-effecting work — content-
|
|
120 |
+ |
/// addressed blob copy + DB insert + orphan cleanup — exactly as [`import`].
|
|
121 |
+ |
/// Splitting the pure hash out lets the import pipeline hash a batch in
|
|
122 |
+ |
/// parallel while keeping every store/DB mutation serial.
|
|
123 |
+ |
#[instrument(skip_all)]
|
|
124 |
+ |
pub fn import_hashed(
|
|
125 |
+ |
&self,
|
|
126 |
+ |
path: &Path,
|
|
127 |
+ |
hash: &str,
|
|
128 |
+ |
file_size: i64,
|
|
129 |
+ |
db: &Database,
|
|
130 |
+ |
) -> Result<()> {
|
| 143 |
131 |
|
// Resolve the blob extension from an existing row if this hash is already
|
| 144 |
132 |
|
// known. The store is content-addressed (one blob per hash), so identical
|
| 145 |
133 |
|
// bytes imported under a different extension must reuse the existing blob
|
| 150 |
138 |
|
// falls back to the incoming file's extension.
|
| 151 |
139 |
|
let ext = match db.conn().query_row(
|
| 152 |
140 |
|
"SELECT file_extension FROM samples WHERE hash = ?1",
|
| 153 |
|
- |
[&hash],
|
|
141 |
+ |
[hash],
|
| 154 |
142 |
|
|row| row.get::<_, String>(0),
|
| 155 |
143 |
|
) {
|
| 156 |
144 |
|
Ok(existing) => existing,
|
| 173 |
161 |
|
// Mirrors the atomic temp+rename the sync download path uses. The pid
|
| 174 |
162 |
|
// suffix keeps two concurrent imports of the same hash from colliding on
|
| 175 |
163 |
|
// the temp file.
|
| 176 |
|
- |
let dest = self.sample_path(&hash, &ext)?;
|
|
164 |
+ |
let dest = self.sample_path(hash, &ext)?;
|
| 177 |
165 |
|
let needs_write = match fs::metadata(&dest) {
|
| 178 |
166 |
|
Ok(m) => m.len() != file_size as u64,
|
| 179 |
167 |
|
Err(_) => true,
|
| 207 |
195 |
|
return Err(CoreError::Db(e));
|
| 208 |
196 |
|
}
|
| 209 |
197 |
|
|
| 210 |
|
- |
Ok(hash)
|
|
198 |
+ |
Ok(())
|
| 211 |
199 |
|
}
|
| 212 |
200 |
|
|
| 213 |
201 |
|
/// Check if a sample file exists in the store.
|
| 645 |
633 |
|
/// The file stays where it is on disk.
|
| 646 |
634 |
|
#[instrument(skip_all)]
|
| 647 |
635 |
|
pub fn import_loose_files(&self, path: &Path, db: &Database) -> Result<String> {
|
| 648 |
|
- |
if !crate::util::is_audio_file(path) {
|
| 649 |
|
- |
return Err(CoreError::Internal(format!(
|
| 650 |
|
- |
"not a supported audio file: {}",
|
| 651 |
|
- |
path.display()
|
| 652 |
|
- |
)));
|
| 653 |
|
- |
}
|
| 654 |
|
- |
|
| 655 |
|
- |
let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
|
| 656 |
|
- |
let metadata = file.metadata().map_err(|e| io_err(path, e))?;
|
| 657 |
|
- |
let file_size = metadata.len() as i64;
|
| 658 |
|
- |
|
| 659 |
|
- |
if file_size == 0 {
|
| 660 |
|
- |
return Err(CoreError::Internal(format!(
|
| 661 |
|
- |
"cannot import zero-byte file: {}",
|
| 662 |
|
- |
path.display()
|
| 663 |
|
- |
)));
|
| 664 |
|
- |
}
|
| 665 |
|
- |
|
| 666 |
|
- |
// Stream through SHA-256
|
| 667 |
|
- |
let mut hasher = Sha256::new();
|
| 668 |
|
- |
let mut buf = [0u8; 8192];
|
| 669 |
|
- |
loop {
|
| 670 |
|
- |
let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
|
| 671 |
|
- |
if n == 0 {
|
| 672 |
|
- |
break;
|
| 673 |
|
- |
}
|
| 674 |
|
- |
hasher.update(&buf[..n]);
|
| 675 |
|
- |
}
|
| 676 |
|
- |
let hash = format!("{:x}", hasher.finalize());
|
|
636 |
+ |
let (hash, file_size) = hash_file(path)?;
|
|
637 |
+ |
self.import_loose_files_hashed(path, &hash, file_size, db)?;
|
|
638 |
+ |
Ok(hash)
|
|
639 |
+ |
}
|
| 677 |
640 |
|
|
|
641 |
+ |
/// Loose-files import with a pre-computed hash (see [`import_hashed`]). Records
|
|
642 |
+ |
/// the source path; no blob copy.
|
|
643 |
+ |
///
|
|
644 |
+ |
/// [`import_hashed`]: SampleStore::import_hashed
|
|
645 |
+ |
#[instrument(skip_all)]
|
|
646 |
+ |
pub fn import_loose_files_hashed(
|
|
647 |
+ |
&self,
|
|
648 |
+ |
path: &Path,
|
|
649 |
+ |
hash: &str,
|
|
650 |
+ |
file_size: i64,
|
|
651 |
+ |
db: &Database,
|
|
652 |
+ |
) -> Result<()> {
|
| 678 |
653 |
|
let ext = crate::util::get_extension(path);
|
| 679 |
654 |
|
let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
|
| 680 |
655 |
|
|
| 696 |
671 |
|
rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path],
|
| 697 |
672 |
|
)?;
|
| 698 |
673 |
|
|
| 699 |
|
- |
Ok(hash)
|
|
674 |
+ |
Ok(())
|
|
675 |
+ |
}
|
|
676 |
+ |
}
|
|
677 |
+ |
|
|
678 |
+ |
/// Hash a file's bytes with SHA-256, returning the hex digest and its size.
|
|
679 |
+ |
///
|
|
680 |
+ |
/// Pure (no store/DB side effects), so a batch can be hashed in parallel before
|
|
681 |
+ |
/// the serial copy+record step. Applies the same guards [`SampleStore::import`]
|
|
682 |
+ |
/// did inline: rejects non-audio and zero-byte files.
|
|
683 |
+ |
pub fn hash_file(path: &Path) -> Result<(String, i64)> {
|
|
684 |
+ |
if !crate::util::is_audio_file(path) {
|
|
685 |
+ |
return Err(CoreError::Internal(format!(
|
|
686 |
+ |
"not a supported audio file: {}",
|
|
687 |
+ |
path.display()
|
|
688 |
+ |
)));
|
| 700 |
689 |
|
}
|
|
690 |
+ |
|
|
691 |
+ |
let mut file = fs::File::open(path).map_err(|e| io_err(path, e))?;
|
|
692 |
+ |
let metadata = file.metadata().map_err(|e| io_err(path, e))?;
|
|
693 |
+ |
let file_size = metadata.len() as i64;
|
|
694 |
+ |
|
|
695 |
+ |
if file_size == 0 {
|
|
696 |
+ |
return Err(CoreError::Internal(format!(
|
|
697 |
+ |
"cannot import zero-byte file: {}",
|
|
698 |
+ |
path.display()
|
|
699 |
+ |
)));
|
|
700 |
+ |
}
|
|
701 |
+ |
|
|
702 |
+ |
let mut hasher = Sha256::new();
|
|
703 |
+ |
let mut buf = [0u8; 8192];
|
|
704 |
+ |
loop {
|
|
705 |
+ |
let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
|
|
706 |
+ |
if n == 0 {
|
|
707 |
+ |
break;
|
|
708 |
+ |
}
|
|
709 |
+ |
hasher.update(&buf[..n]);
|
|
710 |
+ |
}
|
|
711 |
+ |
Ok((format!("{:x}", hasher.finalize()), file_size))
|
|
712 |
+ |
}
|
|
713 |
+ |
|
|
714 |
+ |
/// Hash a batch of files in parallel (rayon). Each result is aligned to the
|
|
715 |
+ |
/// corresponding entry in `paths`. Hashing is the dominant per-file import cost
|
|
716 |
+ |
/// (SHA-256 over the whole file) and is embarrassingly parallel — every side
|
|
717 |
+ |
/// effect (blob copy, DB insert) stays serial in the caller, so the content-
|
|
718 |
+ |
/// addressed store invariants are untouched.
|
|
719 |
+ |
pub fn hash_files_parallel(paths: &[PathBuf]) -> Vec<Result<(String, i64)>> {
|
|
720 |
+ |
use rayon::prelude::*;
|
|
721 |
+ |
paths.par_iter().map(|p| hash_file(p)).collect()
|
| 701 |
722 |
|
}
|
| 702 |
723 |
|
|
| 703 |
724 |
|
#[cfg(test)]
|
| 738 |
759 |
|
}
|
| 739 |
760 |
|
|
| 740 |
761 |
|
#[test]
|
|
762 |
+ |
fn hash_file_matches_import_and_rejects_bad_input() {
|
|
763 |
+ |
let (dir, db, store) = setup();
|
|
764 |
+ |
let src = create_test_file(&dir, "kick.wav", b"fake audio data");
|
|
765 |
+ |
|
|
766 |
+ |
// hash_file's digest equals the hash import() records.
|
|
767 |
+ |
let (hash, size) = hash_file(&src).unwrap();
|
|
768 |
+ |
assert_eq!(size, "fake audio data".len() as i64);
|
|
769 |
+ |
let imported = store.import(&src, &db).unwrap();
|
|
770 |
+ |
assert_eq!(hash, imported);
|
|
771 |
+ |
|
|
772 |
+ |
// Zero-byte and non-audio files are rejected (same guards as import).
|
|
773 |
+ |
let empty = create_test_file(&dir, "empty.wav", b"");
|
|
774 |
+ |
assert!(hash_file(&empty).is_err());
|
|
775 |
+ |
let txt = create_test_file(&dir, "notes.txt", b"hello");
|
|
776 |
+ |
assert!(hash_file(&txt).is_err());
|
|
777 |
+ |
}
|
|
778 |
+ |
|
|
779 |
+ |
#[test]
|
|
780 |
+ |
fn import_hashed_matches_serial_import() {
|
|
781 |
+ |
let (dir, db, store) = setup();
|
|
782 |
+ |
let src = create_test_file(&dir, "snare.wav", b"some audio bytes");
|
|
783 |
+ |
|
|
784 |
+ |
// Pre-hash then record — the prehashed path must land the same blob + row
|
|
785 |
+ |
// as the all-in-one import().
|
|
786 |
+ |
let (hash, size) = hash_file(&src).unwrap();
|
|
787 |
+ |
store.import_hashed(&src, &hash, size, &db).unwrap();
|
|
788 |
+ |
|
|
789 |
+ |
assert!(store.exists(&hash, "wav").unwrap());
|
|
790 |
+ |
let count: i64 = db
|
|
791 |
+ |
.conn()
|
|
792 |
+ |
.query_row("SELECT COUNT(*) FROM samples WHERE hash = ?1", [&hash], |r| r.get(0))
|
|
793 |
+ |
.unwrap();
|
|
794 |
+ |
assert_eq!(count, 1);
|
|
795 |
+ |
}
|
|
796 |
+ |
|
|
797 |
+ |
#[test]
|
|
798 |
+ |
fn hash_files_parallel_aligns_results() {
|
|
799 |
+ |
let (dir, _db, _store) = setup();
|
|
800 |
+ |
let a = create_test_file(&dir, "a.wav", b"aaaa");
|
|
801 |
+ |
let b = create_test_file(&dir, "b.wav", b"bbbbbb");
|
|
802 |
+ |
let bad = create_test_file(&dir, "z.txt", b"nope"); // non-audio -> Err
|
|
803 |
+ |
|
|
804 |
+ |
let results = hash_files_parallel(&[a.clone(), b.clone(), bad.clone()]);
|
|
805 |
+ |
assert_eq!(results.len(), 3);
|
|
806 |
+ |
// Aligned to input order; each Ok hash equals a direct hash_file call.
|
|
807 |
+ |
assert_eq!(results[0].as_ref().unwrap().0, hash_file(&a).unwrap().0);
|
|
808 |
+ |
assert_eq!(results[1].as_ref().unwrap().1, 6);
|
|
809 |
+ |
assert!(results[2].is_err());
|
|
810 |
+ |
}
|
|
811 |
+ |
|
|
812 |
+ |
#[test]
|
| 741 |
813 |
|
fn import_creates_file_and_row() {
|
| 742 |
814 |
|
let (dir, db, store) = setup();
|
| 743 |
815 |
|
let src = create_test_file(&dir, "kick.wav", b"fake audio data");
|