| 23 |
23 |
|
use crate::error::{io_err, unix_now, CoreError, Result};
|
| 24 |
24 |
|
use tracing::instrument;
|
| 25 |
25 |
|
|
|
26 |
+ |
mod loose_files;
|
|
27 |
+ |
pub use loose_files::*;
|
|
28 |
+ |
|
| 26 |
29 |
|
/// Probe an audio file to extract its duration from metadata/headers.
|
| 27 |
30 |
|
/// Returns `None` if the duration cannot be determined without full decode.
|
| 28 |
31 |
|
fn probe_duration(path: &Path) -> Option<f64> {
|
| 775 |
778 |
|
Ok(())
|
| 776 |
779 |
|
}
|
| 777 |
780 |
|
|
| 778 |
|
- |
/// Check integrity of loose-files mode samples.
|
| 779 |
|
- |
///
|
| 780 |
|
- |
/// Returns `(valid, missing)` — counts of source_path entries where the file
|
| 781 |
|
- |
/// exists vs. does not exist on disk.
|
| 782 |
|
- |
pub fn check_loose_files_integrity(db: &Database) -> Result<(usize, usize)> {
|
| 783 |
|
- |
let mut stmt = db.conn().prepare(
|
| 784 |
|
- |
"SELECT source_path FROM live_samples WHERE source_path IS NOT NULL",
|
| 785 |
|
- |
)?;
|
| 786 |
|
- |
let paths: Vec<String> = stmt
|
| 787 |
|
- |
.query_map([], |row| row.get(0))?
|
| 788 |
|
- |
.collect::<std::result::Result<Vec<_>, _>>()?;
|
| 789 |
|
- |
|
| 790 |
|
- |
let mut valid = 0;
|
| 791 |
|
- |
let mut missing = 0;
|
| 792 |
|
- |
for p in &paths {
|
| 793 |
|
- |
if Path::new(p).exists() {
|
| 794 |
|
- |
valid += 1;
|
| 795 |
|
- |
} else {
|
| 796 |
|
- |
missing += 1;
|
| 797 |
|
- |
}
|
| 798 |
|
- |
}
|
| 799 |
|
- |
Ok((valid, missing))
|
| 800 |
|
- |
}
|
| 801 |
|
- |
|
| 802 |
|
- |
/// Try to re-locate loose-files mode samples whose source files have moved.
|
| 803 |
|
- |
///
|
| 804 |
|
- |
/// Walks `search_root` recursively, building a basename map of candidate
|
| 805 |
|
- |
/// files. For each missing sample, looks up its basename, then hash-verifies
|
| 806 |
|
- |
/// candidates (cheapest: size-check before re-hashing the full file). On hash
|
| 807 |
|
- |
/// match, the sample's `source_path` is updated to the new location.
|
| 808 |
|
- |
///
|
| 809 |
|
- |
/// Returns `(relocated, still_missing)`. `relocated` counts samples whose
|
| 810 |
|
- |
/// `source_path` was successfully repointed; `still_missing` is the residual
|
| 811 |
|
- |
/// count for the dialog to surface so the user can run Locate again against a
|
| 812 |
|
- |
/// different directory.
|
| 813 |
|
- |
pub fn relocate_missing_loose_files(
|
| 814 |
|
- |
db: &Database,
|
| 815 |
|
- |
search_root: &Path,
|
| 816 |
|
- |
) -> Result<(usize, usize)> {
|
| 817 |
|
- |
// 1. Gather missing samples — hash, basename of stored source_path, and
|
| 818 |
|
- |
// the recorded file_size (used for cheap pre-filter before re-hashing).
|
| 819 |
|
- |
let mut stmt = db.conn().prepare(
|
| 820 |
|
- |
"SELECT hash, source_path, file_size FROM live_samples \
|
| 821 |
|
- |
WHERE source_path IS NOT NULL",
|
| 822 |
|
- |
)?;
|
| 823 |
|
- |
let rows: Vec<(String, String, i64)> = stmt
|
| 824 |
|
- |
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
|
| 825 |
|
- |
.collect::<std::result::Result<Vec<_>, _>>()?;
|
| 826 |
|
- |
let missing: Vec<(String, String, i64)> = rows
|
| 827 |
|
- |
.into_iter()
|
| 828 |
|
- |
.filter(|(_, source_path, _)| !Path::new(source_path).exists())
|
| 829 |
|
- |
.collect();
|
| 830 |
|
- |
|
| 831 |
|
- |
if missing.is_empty() {
|
| 832 |
|
- |
return Ok((0, 0));
|
| 833 |
|
- |
}
|
| 834 |
|
- |
|
| 835 |
|
- |
// 2. Walk search_root, build basename -> Vec<PathBuf>. Lowercased so a
|
| 836 |
|
- |
// case-changed filesystem (e.g. moved between macOS/Linux) still
|
| 837 |
|
- |
// matches. Bounded by what the filesystem returns; large trees walk
|
| 838 |
|
- |
// once and stay in memory for the duration of this call.
|
| 839 |
|
- |
let mut candidates: std::collections::HashMap<String, Vec<PathBuf>> =
|
| 840 |
|
- |
std::collections::HashMap::new();
|
| 841 |
|
- |
let mut dirs = vec![search_root.to_path_buf()];
|
| 842 |
|
- |
while let Some(d) = dirs.pop() {
|
| 843 |
|
- |
let Ok(entries) = std::fs::read_dir(&d) else { continue };
|
| 844 |
|
- |
for entry in entries.flatten() {
|
| 845 |
|
- |
let path = entry.path();
|
| 846 |
|
- |
if path.is_dir() {
|
| 847 |
|
- |
if !crate::util::is_macos_metadata_dir(&path) {
|
| 848 |
|
- |
dirs.push(path);
|
| 849 |
|
- |
}
|
| 850 |
|
- |
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
| 851 |
|
- |
candidates
|
| 852 |
|
- |
.entry(name.to_lowercase())
|
| 853 |
|
- |
.or_default()
|
| 854 |
|
- |
.push(path);
|
| 855 |
|
- |
}
|
| 856 |
|
- |
}
|
| 857 |
|
- |
}
|
| 858 |
|
- |
|
| 859 |
|
- |
// 3. For each missing sample, check candidates with matching basename.
|
| 860 |
|
- |
// Size check filters out same-name-different-file collisions before we
|
| 861 |
|
- |
// spend cycles hashing. Hash verify is the authoritative match.
|
| 862 |
|
- |
let mut relocated_pairs: Vec<(String, String)> = Vec::new();
|
| 863 |
|
- |
for (hash, source_path, file_size) in &missing {
|
| 864 |
|
- |
let Some(basename) = Path::new(source_path)
|
| 865 |
|
- |
.file_name()
|
| 866 |
|
- |
.and_then(|n| n.to_str())
|
| 867 |
|
- |
else { continue };
|
| 868 |
|
- |
let key = basename.to_lowercase();
|
| 869 |
|
- |
let Some(paths) = candidates.get(&key) else { continue };
|
| 870 |
|
- |
for cand in paths {
|
| 871 |
|
- |
let Ok(md) = std::fs::metadata(cand) else { continue };
|
| 872 |
|
- |
if md.len() as i64 != *file_size {
|
| 873 |
|
- |
continue;
|
| 874 |
|
- |
}
|
| 875 |
|
- |
// Hash verify. Bail on first match; sample hashes are unique so a
|
| 876 |
|
- |
// second match for the same hash would be redundant.
|
| 877 |
|
- |
let Ok(mut file) = fs::File::open(cand) else { continue };
|
| 878 |
|
- |
let mut hasher = Sha256::new();
|
| 879 |
|
- |
let mut buf = [0u8; 8192];
|
| 880 |
|
- |
let ok = loop {
|
| 881 |
|
- |
let Ok(n) = file.read(&mut buf) else { break false };
|
| 882 |
|
- |
if n == 0 {
|
| 883 |
|
- |
break true;
|
| 884 |
|
- |
}
|
| 885 |
|
- |
hasher.update(&buf[..n]);
|
| 886 |
|
- |
};
|
| 887 |
|
- |
if !ok {
|
| 888 |
|
- |
continue;
|
| 889 |
|
- |
}
|
| 890 |
|
- |
let computed = format!("{:x}", hasher.finalize());
|
| 891 |
|
- |
if computed == *hash {
|
| 892 |
|
- |
let abs = cand
|
| 893 |
|
- |
.canonicalize()
|
| 894 |
|
- |
.map(|p| p.to_string_lossy().to_string())
|
| 895 |
|
- |
.unwrap_or_else(|_| cand.to_string_lossy().to_string());
|
| 896 |
|
- |
relocated_pairs.push((hash.clone(), abs));
|
| 897 |
|
- |
break;
|
| 898 |
|
- |
}
|
| 899 |
|
- |
}
|
| 900 |
|
- |
}
|
| 901 |
|
- |
|
| 902 |
|
- |
// 4. Atomic update of all relocated source_paths.
|
| 903 |
|
- |
let relocated = relocated_pairs.len();
|
| 904 |
|
- |
if relocated > 0 {
|
| 905 |
|
- |
db.transaction(|_tx| {
|
| 906 |
|
- |
for (hash, new_path) in &relocated_pairs {
|
| 907 |
|
- |
db.conn().execute(
|
| 908 |
|
- |
"UPDATE samples SET source_path = ?1 WHERE hash = ?2",
|
| 909 |
|
- |
rusqlite::params![new_path, hash],
|
| 910 |
|
- |
)?;
|
| 911 |
|
- |
}
|
| 912 |
|
- |
Ok(())
|
| 913 |
|
- |
})?;
|
| 914 |
|
- |
}
|
| 915 |
|
- |
|
| 916 |
|
- |
let still_missing = missing.len() - relocated;
|
| 917 |
|
- |
Ok((relocated, still_missing))
|
| 918 |
|
- |
}
|
| 919 |
|
- |
|
| 920 |
|
- |
/// Delete all loose-files mode samples whose source files no longer exist on disk.
|
| 921 |
|
- |
///
|
| 922 |
|
- |
/// Returns the number of samples purged. CASCADE handles VFS nodes, tags, etc.
|
| 923 |
|
- |
pub fn purge_missing_loose_files(db: &Database) -> Result<usize> {
|
| 924 |
|
- |
let mut stmt = db.conn().prepare(
|
| 925 |
|
- |
"SELECT hash, source_path FROM live_samples WHERE source_path IS NOT NULL",
|
| 926 |
|
- |
)?;
|
| 927 |
|
- |
let rows: Vec<(String, String)> = stmt
|
| 928 |
|
- |
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
| 929 |
|
- |
.collect::<std::result::Result<Vec<_>, _>>()?;
|
| 930 |
|
- |
|
| 931 |
|
- |
// Collect hashes to purge, then delete atomically in one transaction.
|
| 932 |
|
- |
let to_purge: Vec<&str> = rows
|
| 933 |
|
- |
.iter()
|
| 934 |
|
- |
.filter(|(_, source_path)| !Path::new(source_path).exists())
|
| 935 |
|
- |
.map(|(hash, _)| hash.as_str())
|
| 936 |
|
- |
.collect();
|
| 937 |
|
- |
let purged = to_purge.len();
|
| 938 |
|
- |
|
| 939 |
|
- |
if purged > 0 {
|
| 940 |
|
- |
db.transaction(|_tx| {
|
| 941 |
|
- |
for hash in &to_purge {
|
| 942 |
|
- |
db.conn()
|
| 943 |
|
- |
.execute("DELETE FROM samples WHERE hash = ?1", [hash])?;
|
| 944 |
|
- |
}
|
| 945 |
|
- |
Ok(())
|
| 946 |
|
- |
})?;
|
| 947 |
|
- |
}
|
| 948 |
|
- |
|
| 949 |
|
- |
Ok(purged)
|
| 950 |
|
- |
}
|
| 951 |
|
- |
|
| 952 |
|
- |
impl SampleStore {
|
| 953 |
|
- |
/// Import a file in loose-files mode: hash it but do NOT copy to the store.
|
| 954 |
|
- |
///
|
| 955 |
|
- |
/// Records the original absolute path as `source_path` in the database.
|
| 956 |
|
- |
/// The file stays where it is on disk.
|
| 957 |
|
- |
#[instrument(skip_all)]
|
| 958 |
|
- |
pub fn import_loose_files(&self, path: &Path, db: &Database) -> Result<String> {
|
| 959 |
|
- |
let (hash, file_size) = hash_file(path)?;
|
| 960 |
|
- |
self.import_loose_files_hashed(path, &hash, file_size, db)?;
|
| 961 |
|
- |
Ok(hash)
|
| 962 |
|
- |
}
|
| 963 |
|
- |
|
| 964 |
|
- |
/// Loose-files import with a pre-computed hash (see [`import_hashed`]). Records
|
| 965 |
|
- |
/// the source path; no blob copy.
|
| 966 |
|
- |
///
|
| 967 |
|
- |
/// [`import_hashed`]: SampleStore::import_hashed
|
| 968 |
|
- |
#[instrument(skip_all)]
|
| 969 |
|
- |
pub fn import_loose_files_hashed(
|
| 970 |
|
- |
&self,
|
| 971 |
|
- |
path: &Path,
|
| 972 |
|
- |
hash: &str,
|
| 973 |
|
- |
file_size: i64,
|
| 974 |
|
- |
db: &Database,
|
| 975 |
|
- |
) -> Result<()> {
|
| 976 |
|
- |
let ext = crate::util::get_extension(path);
|
| 977 |
|
- |
let original_name = clamp_original_name(crate::util::get_filename(path, "unknown"));
|
| 978 |
|
- |
|
| 979 |
|
- |
// Probe duration from file headers (cheap, no full decode)
|
| 980 |
|
- |
let duration = probe_duration(path);
|
| 981 |
|
- |
|
| 982 |
|
- |
// Resolve absolute path for storage
|
| 983 |
|
- |
let abs_path = path
|
| 984 |
|
- |
.canonicalize()
|
| 985 |
|
- |
.map_err(|e| io_err(path, e))?
|
| 986 |
|
- |
.to_string_lossy()
|
| 987 |
|
- |
.to_string();
|
| 988 |
|
- |
|
| 989 |
|
- |
// Insert into DB with source_path (ignore if hash already exists)
|
| 990 |
|
- |
let now = unix_now();
|
| 991 |
|
- |
db.conn().execute(
|
| 992 |
|
- |
"INSERT OR IGNORE INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified, duration, source_path)
|
| 993 |
|
- |
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
| 994 |
|
- |
rusqlite::params![hash, original_name, ext, file_size, now, now, duration, abs_path],
|
| 995 |
|
- |
)?;
|
| 996 |
|
- |
|
| 997 |
|
- |
Ok(())
|
| 998 |
|
- |
}
|
| 999 |
|
- |
}
|
| 1000 |
|
- |
|
| 1001 |
781 |
|
/// Hash a file's bytes with SHA-256, returning the hex digest and its size.
|
| 1002 |
782 |
|
///
|
| 1003 |
783 |
|
/// Pure (no store/DB side effects), so a batch can be hashed in parallel before
|