23508fdc89
Two bugs kept the connected iPod from being auto-mounted: - `lsblk -o PATH,LABEL -n -l -P` is invalid (--pairs and --list are mutually exclusive), so device lookup by label always failed with "no block device found" — a regression from the earlier parse fix. Drop -l. - udisks mounted the iPod volume at /media/sebastian/IPOD1 because the configured directory /media/sebastian/IPOD already exists, so even a resolved device failed with AlreadyMounted. The configured mount point is now treated as a hint: mounted_path_for() resolves an exact mount or the lowest-numbered udisks-suffixed sibling (IPOD → IPOD1) from /proc/self/mountinfo, and the sync destination, storage stats, settings display and fatsort resolution all use the real mount. Also harden fatsort: resolve_fatsort_target now refuses to run when the path is not a mount point, so findmnt can never fall back to the root filesystem's block device. Verified on the connected iPod: mounted_path_for → IPOD1, resolve_block_device → /dev/sdb2, mount_device → "already mounted at /media/sebastian/IPOD1", UI shows ● mounted with 9.7 GiB free · 92% and mount point ✓ /media/sebastian/IPOD1. Bump to 0.3.3.
400 lines
12 KiB
Rust
400 lines
12 KiB
Rust
use anyhow::{Context, Result};
|
|
use chrono::{DateTime, Local};
|
|
use serde::Deserialize;
|
|
use std::collections::BTreeMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
pub const DEFAULT_CONFIG_FILE: &str = "~/.config/dap-sync/config.toml";
|
|
pub const DEFAULT_SYNC_COMMAND: &str = "podkit sync -c main";
|
|
pub const DEFAULT_STORAGEBOX_SOURCE: &str = "storagebox:masters";
|
|
pub const DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE: &str = "storagebox:backup/hoerspiele";
|
|
pub const DEFAULT_EXCLUDES: &[&str] = &["/Music/**", ".DS_Store", ".localized"];
|
|
|
|
fn expand_home(s: &str) -> PathBuf {
|
|
if let Some(rest) = s.strip_prefix("~/") {
|
|
if let Some(home) = dirs::home_dir() {
|
|
return home.join(rest);
|
|
}
|
|
}
|
|
PathBuf::from(s)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(default)]
|
|
struct RawPaths {
|
|
local_music_dir: Option<String>,
|
|
local_hoerspiele_dir: Option<String>,
|
|
output_dir: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(default)]
|
|
struct RawStoragebox {
|
|
source: Option<String>,
|
|
hoerspiele_source: Option<String>,
|
|
exclude: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(default)]
|
|
struct RawSync {
|
|
command: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(default)]
|
|
struct RawDevice {
|
|
name: Option<String>,
|
|
firmware: Option<String>,
|
|
content_type: Option<String>,
|
|
sync_command: Option<String>,
|
|
mount_point: Option<String>,
|
|
music_folder: Option<String>,
|
|
cache_dir: Option<String>,
|
|
preserve_fat_order: Option<bool>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Default)]
|
|
#[serde(default)]
|
|
struct RawConfig {
|
|
paths: RawPaths,
|
|
storagebox: RawStoragebox,
|
|
sync: RawSync,
|
|
devices: Vec<RawDevice>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum Firmware {
|
|
Apple,
|
|
Rockbox,
|
|
Android,
|
|
Mlove,
|
|
Sony,
|
|
}
|
|
|
|
impl Firmware {
|
|
pub fn from_str(s: &str) -> Firmware {
|
|
match s.trim().to_lowercase().as_str() {
|
|
"rockbox" => Firmware::Rockbox,
|
|
"android" => Firmware::Android,
|
|
"mlove" => Firmware::Mlove,
|
|
"sony" => Firmware::Sony,
|
|
_ => Firmware::Apple,
|
|
}
|
|
}
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
Firmware::Apple => "apple",
|
|
Firmware::Rockbox => "rockbox",
|
|
Firmware::Android => "android",
|
|
Firmware::Mlove => "mlove",
|
|
Firmware::Sony => "sony",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Device {
|
|
pub name: String,
|
|
pub firmware: Firmware,
|
|
pub content_type: String,
|
|
pub sync_command: String,
|
|
pub mount_point: Option<PathBuf>,
|
|
pub music_folder: String,
|
|
pub preserve_fat_order: bool,
|
|
pub mounted: bool,
|
|
pub last_sync: Option<DateTime<Local>>,
|
|
pub last_status: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Config {
|
|
pub config_file: PathBuf,
|
|
pub local_music_dir: PathBuf,
|
|
pub local_hoerspiele_dir: PathBuf,
|
|
pub output_dir: PathBuf,
|
|
pub storagebox_source: String,
|
|
pub storagebox_hoerspiele_source: String,
|
|
pub storagebox_excludes: Vec<String>,
|
|
pub devices: Vec<Device>,
|
|
}
|
|
|
|
impl Config {
|
|
/// Refresh `Device::mounted` from the kernel mount table (no subprocess).
|
|
/// A device counts as mounted when its volume is mounted at the configured
|
|
/// path or at a udisks-suffixed sibling (e.g. `IPOD` → `IPOD1`).
|
|
pub fn refresh_mounts(&mut self) {
|
|
for d in self.devices.iter_mut() {
|
|
d.mounted = d
|
|
.mount_point
|
|
.as_deref()
|
|
.map(|p| crate::disk::mounted_path_for(p).is_some())
|
|
.unwrap_or(false);
|
|
}
|
|
}
|
|
|
|
fn apply_state(&mut self, state: &StateStore) {
|
|
for d in self.devices.iter_mut() {
|
|
if let Some(rec) = state.records.get(&d.name) {
|
|
d.last_sync = rec.last_sync;
|
|
d.last_status = rec.status.clone();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct StateStore {
|
|
pub records: BTreeMap<String, StateRecord>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct StateRecord {
|
|
pub last_sync: Option<DateTime<Local>>,
|
|
pub status: String,
|
|
}
|
|
|
|
fn default_config_path() -> PathBuf {
|
|
expand_home(DEFAULT_CONFIG_FILE)
|
|
}
|
|
|
|
pub fn load(config_path: Option<String>) -> Result<Config> {
|
|
let path = match config_path {
|
|
Some(p) => expand_home(&p),
|
|
None => default_config_path(),
|
|
};
|
|
|
|
let mut cfg = if path.exists() {
|
|
let text = fs::read_to_string(&path)
|
|
.with_context(|| format!("failed to read config at {}", path.display()))?;
|
|
let raw: RawConfig = toml::from_str(&text)
|
|
.with_context(|| format!("failed to parse config at {}", path.display()))?;
|
|
let local_music = raw
|
|
.paths
|
|
.local_music_dir
|
|
.map(|s| expand_home(&s))
|
|
.unwrap_or_else(|| expand_home("~/Music"));
|
|
let local_hoerspiele = raw
|
|
.paths
|
|
.local_hoerspiele_dir
|
|
.map(|s| expand_home(&s))
|
|
.unwrap_or_else(|| expand_home("~/hoerspiele"));
|
|
let output_dir = raw
|
|
.paths
|
|
.output_dir
|
|
.map(|s| expand_home(&s))
|
|
.unwrap_or_else(|| expand_home("~/.local/share/dap-sync"));
|
|
let sync_cmd = raw
|
|
.sync
|
|
.command
|
|
.clone()
|
|
.unwrap_or_else(|| DEFAULT_SYNC_COMMAND.to_string());
|
|
|
|
let mut devices = Vec::new();
|
|
for rd in raw.devices.iter().filter(|d| d.name.is_some()) {
|
|
let name = rd.name.clone().unwrap();
|
|
let firmware = Firmware::from_str(rd.firmware.as_deref().unwrap_or("apple"));
|
|
let content_type = rd
|
|
.content_type
|
|
.clone()
|
|
.unwrap_or_else(|| "music".to_string())
|
|
.trim()
|
|
.to_lowercase();
|
|
let sync_command = rd
|
|
.sync_command
|
|
.clone()
|
|
.unwrap_or_else(|| sync_cmd.clone());
|
|
let mount_point = rd
|
|
.mount_point
|
|
.clone()
|
|
.map(|s| expand_home(&s));
|
|
let music_folder = rd
|
|
.music_folder
|
|
.clone()
|
|
.unwrap_or_else(|| {
|
|
match firmware {
|
|
Firmware::Mlove => "Hörspiele".to_string(),
|
|
_ => "Music".to_string(),
|
|
}
|
|
});
|
|
let preserve_fat_order = rd
|
|
.preserve_fat_order
|
|
.unwrap_or_else(|| firmware == Firmware::Mlove);
|
|
|
|
devices.push(Device {
|
|
name,
|
|
firmware,
|
|
content_type,
|
|
sync_command,
|
|
mount_point,
|
|
music_folder,
|
|
preserve_fat_order,
|
|
mounted: false,
|
|
last_sync: None,
|
|
last_status: String::new(),
|
|
});
|
|
}
|
|
|
|
Config {
|
|
config_file: path.clone(),
|
|
local_music_dir: local_music,
|
|
local_hoerspiele_dir: local_hoerspiele,
|
|
output_dir,
|
|
storagebox_source: raw
|
|
.storagebox
|
|
.source
|
|
.unwrap_or_else(|| DEFAULT_STORAGEBOX_SOURCE.to_string()),
|
|
storagebox_hoerspiele_source: raw
|
|
.storagebox
|
|
.hoerspiele_source
|
|
.unwrap_or_else(|| DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string()),
|
|
storagebox_excludes: raw
|
|
.storagebox
|
|
.exclude
|
|
.map(|v| v.clone())
|
|
.unwrap_or_else(|| DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect()),
|
|
devices,
|
|
}
|
|
} else {
|
|
Config {
|
|
config_file: path.clone(),
|
|
local_music_dir: expand_home("~/Music"),
|
|
local_hoerspiele_dir: expand_home("~/hoerspiele"),
|
|
output_dir: expand_home("~/.local/share/dap-sync"),
|
|
storagebox_source: DEFAULT_STORAGEBOX_SOURCE.to_string(),
|
|
storagebox_hoerspiele_source: DEFAULT_STORAGEBOX_HOERSPIELE_SOURCE.to_string(),
|
|
storagebox_excludes: DEFAULT_EXCLUDES.iter().map(|s| s.to_string()).collect(),
|
|
devices: Vec::new(),
|
|
}
|
|
};
|
|
|
|
cfg.refresh_mounts();
|
|
let store = load_state(&cfg.output_dir);
|
|
cfg.apply_state(&store);
|
|
|
|
Ok(cfg)
|
|
}
|
|
|
|
pub fn state_file(output_dir: &Path) -> PathBuf {
|
|
output_dir.join("sync-state.toml")
|
|
}
|
|
|
|
pub fn load_state(output_dir: &Path) -> StateStore {
|
|
let file = state_file(output_dir);
|
|
let mut records = BTreeMap::new();
|
|
if let Ok(text) = fs::read_to_string(&file) {
|
|
if let Ok(table) = text.parse::<toml::Table>() {
|
|
for (name, val) in table.iter() {
|
|
if let Some(sub) = val.as_table() {
|
|
let last_sync = sub
|
|
.get("last_sync")
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
.map(|dt| dt.with_timezone(&Local));
|
|
let status = sub
|
|
.get("status")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
records.insert(name.clone(), StateRecord { last_sync, status });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
StateStore { records }
|
|
}
|
|
|
|
pub fn save_state(
|
|
output_dir: &Path,
|
|
name: &str,
|
|
last_sync: DateTime<Local>,
|
|
status: &str,
|
|
) {
|
|
let file = state_file(output_dir);
|
|
let mut table: toml::Table = fs::read_to_string(&file)
|
|
.ok()
|
|
.and_then(|t| t.parse::<toml::Table>().ok())
|
|
.unwrap_or_default();
|
|
|
|
let mut sub = toml::map::Map::new();
|
|
sub.insert("last_sync".into(), toml::Value::String(last_sync.to_rfc3339()));
|
|
sub.insert("status".into(), toml::Value::String(status.to_string()));
|
|
table.insert(name.into(), toml::Value::Table(sub));
|
|
|
|
if let Some(parent) = file.parent() {
|
|
let _ = fs::create_dir_all(parent);
|
|
}
|
|
if let Ok(text) = toml::to_string(&table) {
|
|
let _ = fs::write(&file, text);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn expands_home() {
|
|
let p = expand_home("~/foo/bar");
|
|
assert_eq!(p, dirs::home_dir().unwrap().join("foo/bar"));
|
|
assert_eq!(expand_home("/abs/path"), PathBuf::from("/abs/path"));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_devices_from_config() {
|
|
let dir = std::env::temp_dir().join("dap-tui-config-test");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let path = dir.join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[paths]
|
|
local_music_dir = "~/Music"
|
|
|
|
[storagebox]
|
|
source = "storagebox:masters"
|
|
exclude = [".DS_Store"]
|
|
|
|
[[devices]]
|
|
name = "Test iPod"
|
|
firmware = "rockbox"
|
|
mount_point = "/media/ipod"
|
|
music_folder = "Music"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load(Some(path.display().to_string())).unwrap();
|
|
assert_eq!(cfg.devices.len(), 1);
|
|
let d = &cfg.devices[0];
|
|
assert_eq!(d.name, "Test iPod");
|
|
assert_eq!(d.firmware.label(), "rockbox");
|
|
assert_eq!(d.music_folder, "Music");
|
|
assert_eq!(cfg.storagebox_excludes, vec![".DS_Store"]);
|
|
assert_eq!(cfg.local_music_dir, dirs::home_dir().unwrap().join("Music"));
|
|
}
|
|
|
|
#[test]
|
|
fn loads_sony_firmware_as_rockbox_style() {
|
|
let dir = std::env::temp_dir().join("dap-tui-sony-test");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let path = dir.join("config.toml");
|
|
std::fs::write(
|
|
&path,
|
|
r#"
|
|
[[devices]]
|
|
name = "Sony NW-A55"
|
|
firmware = "sony"
|
|
mount_point = "/media/SD_CARD"
|
|
music_folder = "MUSIC"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let cfg = load(Some(path.display().to_string())).unwrap();
|
|
assert_eq!(cfg.devices.len(), 1);
|
|
let d = &cfg.devices[0];
|
|
assert_eq!(d.firmware, Firmware::Sony);
|
|
assert_eq!(d.music_folder, "MUSIC");
|
|
assert_eq!(d.preserve_fat_order, false);
|
|
}
|
|
}
|