feat: Working sync between Navidrome & iPod
First full session of vibecoding with agents, super cool! Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
commit
758076fe32
15 changed files with 6303 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Rust
|
||||||
|
/target/
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
|
# Note: Cargo.lock is kept for applications (not libraries)
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
config.toml
|
||||||
|
*.log
|
||||||
|
|
||||||
4653
Cargo.lock
generated
Normal file
4653
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
62
Cargo.toml
Normal file
62
Cargo.toml
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
[package]
|
||||||
|
name = "navipod"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <you@example.com>"]
|
||||||
|
description = "Sync songs and albums from Navidrome to iPod (stock OS or Rockbox)"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
repository = "https://github.com/yourusername/navipod"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Async runtime
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
|
||||||
|
# HTTP client for Navidrome API
|
||||||
|
reqwest = { version = "0.11", features = ["json", "multipart"] }
|
||||||
|
|
||||||
|
# Serialization/Deserialization
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
config = "0.14"
|
||||||
|
dirs = "5.0"
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# File operations
|
||||||
|
walkdir = "2.4"
|
||||||
|
fs_extra = "1.3"
|
||||||
|
|
||||||
|
# Path handling
|
||||||
|
pathdiff = "0.2"
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
|
|
||||||
|
# ID3 tag parsing
|
||||||
|
id3 = "1.13"
|
||||||
|
|
||||||
|
# UUID generation
|
||||||
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
|
|
||||||
|
# GUI
|
||||||
|
eframe = { version = "0.27", default-features = false, features = ["default_fonts", "glow"] }
|
||||||
|
egui = "0.27"
|
||||||
|
rfd = "0.14"
|
||||||
|
home = "0.5.11" # Pin to version compatible with Rust 1.86
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "navipod-gui"
|
||||||
|
path = "src/gui_main.rs"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
mockito = "1.0"
|
||||||
|
tokio-test = "0.4"
|
||||||
|
|
||||||
156
README.md
Normal file
156
README.md
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
# NaviPod
|
||||||
|
|
||||||
|
A native Rust application to sync songs and albums from your self-hosted Navidrome instance to your iPod running either stock OS or Rockbox.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎵 Sync songs and albums from Navidrome
|
||||||
|
- 📱 Support for iPod stock OS
|
||||||
|
- 🎸 Support for Rockbox firmware
|
||||||
|
- ⚙️ Configurable sync options
|
||||||
|
- 🔄 Incremental sync support
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Copy `config.example.toml` to `config.toml`:
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit `config.toml` with your settings:
|
||||||
|
```toml
|
||||||
|
[navidrome]
|
||||||
|
url = "http://localhost:4533"
|
||||||
|
username = "your_username"
|
||||||
|
password = "your_password"
|
||||||
|
|
||||||
|
[ipod]
|
||||||
|
mount_point = "/media/ipod"
|
||||||
|
firmware = "rockbox" # or "stock"
|
||||||
|
|
||||||
|
[sync]
|
||||||
|
albums = true
|
||||||
|
playlists = false
|
||||||
|
format = "mp3"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Alternatively, use environment variables (prefixed with `NAVIPOD__`):
|
||||||
|
```bash
|
||||||
|
export NAVIPOD__NAVIDROME__URL="http://localhost:4533"
|
||||||
|
export NAVIPOD__NAVIDROME__USERNAME="your_username"
|
||||||
|
export NAVIPOD__NAVIDROME__PASSWORD="your_password"
|
||||||
|
export NAVIPOD__IPOD__MOUNT_POINT="/media/ipod"
|
||||||
|
export NAVIPOD__IPOD__FIRMWARE="rockbox"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Navidrome must have JSON API enabled (default in recent versions). The application uses Subsonic API with `f=json` parameter.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### GUI Application
|
||||||
|
|
||||||
|
Launch the graphical interface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
navipod-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
The GUI provides:
|
||||||
|
- Input fields for Navidrome URL, username, and password
|
||||||
|
- Firmware type selector (Stock/Rockbox)
|
||||||
|
- Mount point browser
|
||||||
|
- Real-time sync status and progress
|
||||||
|
- Error display
|
||||||
|
|
||||||
|
### Command Line
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sync all configured content
|
||||||
|
navipod sync
|
||||||
|
|
||||||
|
# Sync specific album
|
||||||
|
navipod sync --album "Album Name"
|
||||||
|
|
||||||
|
# List available albums
|
||||||
|
navipod list-albums
|
||||||
|
|
||||||
|
# Check iPod connection
|
||||||
|
navipod check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Rust 1.70+ (and Cargo)
|
||||||
|
- C compiler (for building native dependencies)
|
||||||
|
- iPod mounted and accessible
|
||||||
|
- Navidrome instance running and accessible
|
||||||
|
- Navidrome with JSON API support (enabled by default)
|
||||||
|
|
||||||
|
### NixOS Setup
|
||||||
|
|
||||||
|
If you're using NixOS, enter the development shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell
|
||||||
|
```
|
||||||
|
|
||||||
|
This will provide Rust, Cargo, and all necessary build tools. The `shell.nix` file is already configured for this project.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On NixOS, first enter the development shell:
|
||||||
|
nix-shell
|
||||||
|
|
||||||
|
# Build in release mode
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
# The binaries will be at:
|
||||||
|
# - target/release/navipod (CLI)
|
||||||
|
# - target/release/navipod-gui (GUI)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the GUI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure you're in nix-shell first:
|
||||||
|
nix-shell
|
||||||
|
|
||||||
|
# After building, run:
|
||||||
|
./target/release/navipod-gui
|
||||||
|
|
||||||
|
# Or use cargo run:
|
||||||
|
cargo run --bin navipod-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** If you encounter Wayland or X11 library errors:
|
||||||
|
1. Make sure you're in `nix-shell` (it provides all necessary libraries)
|
||||||
|
2. The GUI will automatically use X11 if Wayland is not available
|
||||||
|
3. If you still have issues, try:
|
||||||
|
```bash
|
||||||
|
WAYLAND_DISPLAY= WINIT_UNIX_BACKEND=x11 ./target/release/navipod-gui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run in debug mode
|
||||||
|
cargo run -- sync
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# Check code
|
||||||
|
cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT OR Apache-2.0
|
||||||
|
|
||||||
25
config.example.toml
Normal file
25
config.example.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# NaviPod Configuration Example
|
||||||
|
# Copy this file to config.toml and fill in your details
|
||||||
|
|
||||||
|
[navidrome]
|
||||||
|
# URL of your Navidrome instance
|
||||||
|
url = "http://localhost:4533"
|
||||||
|
# Your Navidrome username
|
||||||
|
username = "your_username"
|
||||||
|
# Your Navidrome password
|
||||||
|
password = "your_password"
|
||||||
|
|
||||||
|
[ipod]
|
||||||
|
# Mount point of your iPod (e.g., /media/ipod or /mnt/ipod)
|
||||||
|
mount_point = "/media/ipod"
|
||||||
|
# Firmware type: "stock" or "rockbox"
|
||||||
|
firmware = "rockbox"
|
||||||
|
|
||||||
|
[sync]
|
||||||
|
# Sync albums
|
||||||
|
albums = true
|
||||||
|
# Sync playlists (not yet implemented)
|
||||||
|
playlists = false
|
||||||
|
# Preferred audio format (mp3, flac, ogg, etc.)
|
||||||
|
format = "mp3"
|
||||||
|
|
||||||
56
shell.nix
Normal file
56
shell.nix
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
let
|
||||||
|
nixpkgs = fetchTarball "https://github.com/NixOS/nixpkgs/tarball/nixos-25.05";
|
||||||
|
pkgs = import nixpkgs { config = {}; overlays = []; };
|
||||||
|
in
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
rustc
|
||||||
|
cargo
|
||||||
|
gcc # cc-command for building native dependencies
|
||||||
|
pkg-config # useful for C-dependencies
|
||||||
|
openssl # if crates are dependent on openssl
|
||||||
|
openssl.dev # openssl development headers
|
||||||
|
# GUI dependencies - X11
|
||||||
|
libGL
|
||||||
|
xorg.libX11
|
||||||
|
xorg.libXcursor
|
||||||
|
xorg.libXi
|
||||||
|
xorg.libXrandr
|
||||||
|
libxkbcommon
|
||||||
|
# GUI dependencies - Wayland (for Wayland support)
|
||||||
|
wayland
|
||||||
|
];
|
||||||
|
|
||||||
|
# Set up library paths for runtime
|
||||||
|
shellHook = let
|
||||||
|
libPath = pkgs.lib.makeLibraryPath (with pkgs; [
|
||||||
|
libGL
|
||||||
|
xorg.libX11
|
||||||
|
xorg.libXcursor
|
||||||
|
xorg.libXi
|
||||||
|
xorg.libXrandr
|
||||||
|
libxkbcommon
|
||||||
|
wayland
|
||||||
|
]);
|
||||||
|
in ''
|
||||||
|
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${libPath}"
|
||||||
|
|
||||||
|
echo "NaviPod Development Environment"
|
||||||
|
|
||||||
|
# Verify Rust is available
|
||||||
|
if command -v rustc &> /dev/null; then
|
||||||
|
echo "✓ Rust $(rustc --version) is available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v cargo &> /dev/null; then
|
||||||
|
echo "✓ Cargo $(cargo --version) is available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Welcome to NaviPod development shell!"
|
||||||
|
echo "Run 'cargo build' to build the project."
|
||||||
|
echo "Run 'cargo check' to check for compilation errors."
|
||||||
|
echo "Run './target/debug/navipod-gui' to launch the GUI (from within this shell)."
|
||||||
|
'';
|
||||||
|
}
|
||||||
79
src/config.rs
Normal file
79
src/config.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub navidrome: NavidromeConfig,
|
||||||
|
pub ipod: IpodConfig,
|
||||||
|
pub sync: SyncConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NavidromeConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct IpodConfig {
|
||||||
|
pub mount_point: PathBuf,
|
||||||
|
pub firmware: FirmwareType,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FirmwareType {
|
||||||
|
Stock,
|
||||||
|
Rockbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SyncConfig {
|
||||||
|
pub albums: bool,
|
||||||
|
pub playlists: bool,
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> anyhow::Result<Self> {
|
||||||
|
let config_path = Self::config_path()?;
|
||||||
|
|
||||||
|
let mut builder = config::Config::builder();
|
||||||
|
|
||||||
|
// Try to load from config file if it exists
|
||||||
|
if config_path.exists() {
|
||||||
|
builder = builder.add_source(config::File::from(config_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variables override config file
|
||||||
|
builder = builder.add_source(
|
||||||
|
config::Environment::with_prefix("NAVIPOD")
|
||||||
|
.separator("__")
|
||||||
|
.try_parsing(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let config = builder.build()?;
|
||||||
|
Ok(config.try_deserialize()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> anyhow::Result<PathBuf> {
|
||||||
|
// Try current directory first
|
||||||
|
let local_config = PathBuf::from("config.toml");
|
||||||
|
if local_config.exists() {
|
||||||
|
return Ok(local_config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to config directory
|
||||||
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
let config_file = config_dir.join("navipod").join("config.toml");
|
||||||
|
if config_file.exists() {
|
||||||
|
return Ok(config_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to current directory
|
||||||
|
Ok(PathBuf::from("config.toml"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
28
src/error.rs
Normal file
28
src/error.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum NaviPodError {
|
||||||
|
#[error("Navidrome API error: {0}")]
|
||||||
|
NavidromeApi(String),
|
||||||
|
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("Serialization error: {0}")]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Configuration error: {0}")]
|
||||||
|
Config(#[from] config::ConfigError),
|
||||||
|
|
||||||
|
#[error("iPod error: {0}")]
|
||||||
|
Ipod(String),
|
||||||
|
|
||||||
|
#[error("Sync error: {0}")]
|
||||||
|
Sync(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, NaviPodError>;
|
||||||
|
|
||||||
297
src/gui.rs
Normal file
297
src/gui.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
use crate::config::{Config, FirmwareType, IpodConfig, NavidromeConfig, SyncConfig};
|
||||||
|
use crate::sync;
|
||||||
|
use eframe::egui;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
pub struct NaviPodApp {
|
||||||
|
// Configuration inputs
|
||||||
|
navidrome_url: String,
|
||||||
|
navidrome_username: String,
|
||||||
|
navidrome_password: String,
|
||||||
|
firmware_type: FirmwareType,
|
||||||
|
mount_point: String,
|
||||||
|
|
||||||
|
// Status
|
||||||
|
status: Arc<Mutex<SyncStatus>>,
|
||||||
|
runtime: Option<Runtime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NaviPodApp {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Try to load config from file
|
||||||
|
let (url, username, password, firmware, mount) = if let Ok(config) = crate::config::Config::load() {
|
||||||
|
(
|
||||||
|
config.navidrome.url,
|
||||||
|
config.navidrome.username,
|
||||||
|
config.navidrome.password,
|
||||||
|
config.ipod.firmware,
|
||||||
|
config.ipod.mount_point.to_string_lossy().to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
String::new(),
|
||||||
|
FirmwareType::Rockbox,
|
||||||
|
String::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
navidrome_url: url,
|
||||||
|
navidrome_username: username,
|
||||||
|
navidrome_password: password,
|
||||||
|
firmware_type: firmware,
|
||||||
|
mount_point: mount,
|
||||||
|
status: Arc::new(Mutex::new(SyncStatus::Idle)),
|
||||||
|
runtime: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum SyncStatus {
|
||||||
|
Idle,
|
||||||
|
Connecting,
|
||||||
|
Syncing {
|
||||||
|
current_album: String,
|
||||||
|
current_song: String,
|
||||||
|
albums_total: usize,
|
||||||
|
albums_done: usize,
|
||||||
|
},
|
||||||
|
Success {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SyncStatus {
|
||||||
|
fn default() -> Self {
|
||||||
|
SyncStatus::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NaviPodApp {
|
||||||
|
fn start_sync(&mut self) {
|
||||||
|
if self.runtime.is_some() {
|
||||||
|
return; // Already syncing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if self.navidrome_url.is_empty() {
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Error {
|
||||||
|
message: "Navidrome URL is required".to_string(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.navidrome_username.is_empty() {
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Error {
|
||||||
|
message: "Navidrome username is required".to_string(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.navidrome_password.is_empty() {
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Error {
|
||||||
|
message: "Navidrome password is required".to_string(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.mount_point.is_empty() {
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Error {
|
||||||
|
message: "iPod mount point is required".to_string(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create runtime for async operations
|
||||||
|
let rt = Runtime::new().unwrap();
|
||||||
|
self.runtime = Some(rt);
|
||||||
|
|
||||||
|
// Build config
|
||||||
|
let config = Config {
|
||||||
|
navidrome: NavidromeConfig {
|
||||||
|
url: self.navidrome_url.clone(),
|
||||||
|
username: self.navidrome_username.clone(),
|
||||||
|
password: self.navidrome_password.clone(),
|
||||||
|
},
|
||||||
|
ipod: IpodConfig {
|
||||||
|
mount_point: PathBuf::from(&self.mount_point),
|
||||||
|
firmware: self.firmware_type,
|
||||||
|
},
|
||||||
|
sync: SyncConfig {
|
||||||
|
albums: true,
|
||||||
|
playlists: false,
|
||||||
|
format: "mp3".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_clone_progress = self.status.clone();
|
||||||
|
let status_clone_final = self.status.clone();
|
||||||
|
let runtime = self.runtime.as_ref().unwrap();
|
||||||
|
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Connecting;
|
||||||
|
|
||||||
|
// Create progress callback
|
||||||
|
let progress_callback: sync::ProgressCallback = Arc::new(move |album, song, albums_done, albums_total| {
|
||||||
|
*status_clone_progress.lock().unwrap() = SyncStatus::Syncing {
|
||||||
|
current_album: album,
|
||||||
|
current_song: song,
|
||||||
|
albums_total,
|
||||||
|
albums_done,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn sync task
|
||||||
|
runtime.spawn(async move {
|
||||||
|
match sync::sync_content_with_progress(config, None, None, Some(progress_callback)).await {
|
||||||
|
Ok(_) => {
|
||||||
|
*status_clone_final.lock().unwrap() = SyncStatus::Success {
|
||||||
|
message: "Sync completed successfully!".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
*status_clone_final.lock().unwrap() = SyncStatus::Error {
|
||||||
|
message: format!("Sync failed: {}", e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_sync(&mut self) {
|
||||||
|
self.runtime = None;
|
||||||
|
*self.status.lock().unwrap() = SyncStatus::Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for NaviPodApp {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.heading("NaviPod - Sync Navidrome to iPod");
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Configuration section
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Navidrome Configuration");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("URL:");
|
||||||
|
ui.text_edit_singleline(&mut self.navidrome_url);
|
||||||
|
});
|
||||||
|
if self.navidrome_url.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(" (e.g., https://music.example.com)").weak());
|
||||||
|
}
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Username:");
|
||||||
|
ui.text_edit_singleline(&mut self.navidrome_username);
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Password:");
|
||||||
|
ui.add(egui::TextEdit::singleline(&mut self.navidrome_password).password(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
// iPod Configuration
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("iPod Configuration");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Firmware:");
|
||||||
|
egui::ComboBox::from_id_source("firmware")
|
||||||
|
.selected_text(match self.firmware_type {
|
||||||
|
FirmwareType::Rockbox => "Rockbox",
|
||||||
|
FirmwareType::Stock => "Stock",
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.firmware_type, FirmwareType::Rockbox, "Rockbox");
|
||||||
|
ui.selectable_value(&mut self.firmware_type, FirmwareType::Stock, "Stock");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Mount Point:");
|
||||||
|
ui.text_edit_singleline(&mut self.mount_point);
|
||||||
|
if ui.button("Browse...").clicked() {
|
||||||
|
// Use native file dialog to select directory
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.set_directory("/")
|
||||||
|
.pick_folder()
|
||||||
|
{
|
||||||
|
self.mount_point = path.to_string_lossy().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if self.mount_point.is_empty() {
|
||||||
|
ui.label(egui::RichText::new(" (e.g., /run/media/user/IPOD)").weak());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
// Sync button
|
||||||
|
let is_syncing = matches!(
|
||||||
|
*self.status.lock().unwrap(),
|
||||||
|
SyncStatus::Connecting | SyncStatus::Syncing { .. }
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if is_syncing {
|
||||||
|
if ui.button("Stop Sync").clicked() {
|
||||||
|
self.stop_sync();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ui.button("Start Sync").clicked() {
|
||||||
|
self.start_sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
// Status display
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Status:");
|
||||||
|
let status = self.status.lock().unwrap().clone();
|
||||||
|
match status {
|
||||||
|
SyncStatus::Idle => {
|
||||||
|
ui.label(egui::RichText::new("Ready").color(egui::Color32::GRAY));
|
||||||
|
}
|
||||||
|
SyncStatus::Connecting => {
|
||||||
|
ui.label(egui::RichText::new("Connecting to Navidrome...").color(egui::Color32::BLUE));
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
SyncStatus::Syncing {
|
||||||
|
current_album,
|
||||||
|
current_song,
|
||||||
|
albums_total,
|
||||||
|
albums_done,
|
||||||
|
} => {
|
||||||
|
ui.label(egui::RichText::new(format!(
|
||||||
|
"Syncing: {} / {} albums",
|
||||||
|
albums_done, albums_total
|
||||||
|
)).color(egui::Color32::BLUE));
|
||||||
|
ui.label(format!("Album: {}", current_album));
|
||||||
|
ui.label(format!("Song: {}", current_song));
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
SyncStatus::Success { message } => {
|
||||||
|
ui.label(egui::RichText::new(&message).color(egui::Color32::GREEN));
|
||||||
|
}
|
||||||
|
SyncStatus::Error { message } => {
|
||||||
|
ui.label(egui::RichText::new(&message).color(egui::Color32::RED));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request repaint to update status
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
src/gui_main.rs
Normal file
29
src/gui_main.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
use eframe::egui;
|
||||||
|
use navipod::gui::NaviPodApp;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
// Force X11 backend - set before any winit code runs
|
||||||
|
// This must be set before eframe initializes
|
||||||
|
if std::env::var("WINIT_UNIX_BACKEND").is_err() {
|
||||||
|
std::env::set_var("WINIT_UNIX_BACKEND", "x11");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try setting WAYLAND_DISPLAY to empty to prevent Wayland detection
|
||||||
|
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||||
|
std::env::remove_var("WAYLAND_DISPLAY");
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([600.0, 500.0])
|
||||||
|
.with_title("NaviPod - Sync Navidrome to iPod"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"NaviPod",
|
||||||
|
options,
|
||||||
|
Box::new(|_cc| Box::new(NaviPodApp::default())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
150
src/ipod.rs
Normal file
150
src/ipod.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
use crate::config::{FirmwareType, IpodConfig};
|
||||||
|
use crate::error::{NaviPodError, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub struct Ipod {
|
||||||
|
mount_point: PathBuf,
|
||||||
|
firmware: FirmwareType,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ipod {
|
||||||
|
pub fn new(config: &IpodConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
mount_point: config.mount_point.clone(),
|
||||||
|
firmware: config.firmware,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_connection(&self) -> Result<()> {
|
||||||
|
if !self.mount_point.exists() {
|
||||||
|
return Err(NaviPodError::Ipod(format!(
|
||||||
|
"iPod mount point does not exist: {:?}",
|
||||||
|
self.mount_point
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.mount_point.is_dir() {
|
||||||
|
return Err(NaviPodError::Ipod(format!(
|
||||||
|
"iPod mount point is not a directory: {:?}",
|
||||||
|
self.mount_point
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for firmware-specific directories
|
||||||
|
match self.firmware {
|
||||||
|
FirmwareType::Stock => {
|
||||||
|
let ipod_control = self.mount_point.join("iPod_Control");
|
||||||
|
if !ipod_control.exists() {
|
||||||
|
return Err(NaviPodError::Ipod(
|
||||||
|
"iPod_Control directory not found. Is this a stock iPod?".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FirmwareType::Rockbox => {
|
||||||
|
// Rockbox uses a simpler directory structure
|
||||||
|
// Check for .rockbox directory or music directory
|
||||||
|
let rockbox_dir = self.mount_point.join(".rockbox");
|
||||||
|
if !rockbox_dir.exists() {
|
||||||
|
tracing::warn!(
|
||||||
|
".rockbox directory not found, but continuing anyway (may be mounted differently)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_music_directory(&self) -> PathBuf {
|
||||||
|
match self.firmware {
|
||||||
|
FirmwareType::Stock => {
|
||||||
|
// Stock iPod uses iPod_Control/Music structure
|
||||||
|
self.mount_point.join("iPod_Control").join("Music")
|
||||||
|
}
|
||||||
|
FirmwareType::Rockbox => {
|
||||||
|
// Rockbox typically uses a Music directory at the root
|
||||||
|
self.mount_point.join("Music")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_music_directory(&self) -> Result<()> {
|
||||||
|
let music_dir = self.get_music_directory();
|
||||||
|
std::fs::create_dir_all(&music_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_album_path(&self, artist: &str, album: &str) -> PathBuf {
|
||||||
|
let music_dir = self.get_music_directory();
|
||||||
|
|
||||||
|
match self.firmware {
|
||||||
|
FirmwareType::Stock => {
|
||||||
|
// Stock iPod uses F## directories (F00, F01, etc.)
|
||||||
|
// We'll use a simple hash-based approach
|
||||||
|
let hash = self.hash_string(&format!("{}/{}", artist, album));
|
||||||
|
let folder = format!("F{:02}", hash % 100);
|
||||||
|
music_dir.join(folder).join(self.sanitize_path(artist)).join(self.sanitize_path(album))
|
||||||
|
}
|
||||||
|
FirmwareType::Rockbox => {
|
||||||
|
// Rockbox uses a simple Artist/Album structure
|
||||||
|
music_dir.join(self.sanitize_path(artist)).join(self.sanitize_path(album))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_song_path(&self, artist: &str, album: &str, song: &str, extension: &str) -> PathBuf {
|
||||||
|
let album_path = self.get_album_path(artist, album);
|
||||||
|
let filename = format!("{}.{}", self.sanitize_filename(song), extension);
|
||||||
|
album_path.join(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path(&self, path: &str) -> String {
|
||||||
|
// Remove invalid characters for file paths
|
||||||
|
path.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_filename(&self, filename: &str) -> String {
|
||||||
|
self.sanitize_path(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_string(&self, s: &str) -> u32 {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
s.hash(&mut hasher);
|
||||||
|
(hasher.finish() % 100) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_file(&self, source: &Path, destination: &Path) -> Result<()> {
|
||||||
|
// Ensure destination directory exists
|
||||||
|
if let Some(parent) = destination.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy file
|
||||||
|
std::fs::copy(source, destination)?;
|
||||||
|
|
||||||
|
// For stock iPod, we may need to update the iTunes database
|
||||||
|
// For now, we'll just copy the file
|
||||||
|
// TODO: Implement iTunes DB updates for stock iPod
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn file_exists(&self, path: &Path) -> bool {
|
||||||
|
path.exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_ipod(config: &IpodConfig) -> Result<()> {
|
||||||
|
let ipod = Ipod::new(config);
|
||||||
|
ipod.check_connection()?;
|
||||||
|
ipod.ensure_music_directory()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
75
src/lib.rs
Normal file
75
src/lib.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
|
pub mod gui;
|
||||||
|
pub mod ipod;
|
||||||
|
pub mod navidrome;
|
||||||
|
pub mod sync;
|
||||||
|
|
||||||
|
// Re-export Config so it can be used from main.rs
|
||||||
|
pub use config::Config;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "navipod")]
|
||||||
|
#[command(about = "Sync songs and albums from Navidrome to iPod")]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum Commands {
|
||||||
|
/// Sync content from Navidrome to iPod
|
||||||
|
Sync {
|
||||||
|
/// Sync specific album by name
|
||||||
|
#[arg(long)]
|
||||||
|
album: Option<String>,
|
||||||
|
/// Sync specific artist
|
||||||
|
#[arg(long)]
|
||||||
|
artist: Option<String>,
|
||||||
|
},
|
||||||
|
/// List available albums from Navidrome
|
||||||
|
ListAlbums,
|
||||||
|
/// Check iPod connection and status
|
||||||
|
Check,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Commands::Sync { album, artist } => {
|
||||||
|
sync::sync_content(config, album, artist).await?;
|
||||||
|
}
|
||||||
|
Commands::ListAlbums => {
|
||||||
|
let mut navidrome = navidrome::NavidromeClient::new(&config.navidrome)?;
|
||||||
|
match navidrome
|
||||||
|
.authenticate(&config.navidrome.username, &config.navidrome.password)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
println!("✓ Authentication successful");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("✗ Authentication failed: {}", e);
|
||||||
|
return Err(anyhow::anyhow!("Authentication failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Fetching albums...");
|
||||||
|
let albums = navidrome.list_albums().await?;
|
||||||
|
println!("Available albums:");
|
||||||
|
for album in albums {
|
||||||
|
println!(" - {} by {}", album.name, album.artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Commands::Check => {
|
||||||
|
ipod::check_ipod(&config.ipod)?;
|
||||||
|
println!("✓ iPod connection OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
12
src/main.rs
Normal file
12
src/main.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
use navipod::{run, Config};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = Config::load()?;
|
||||||
|
run(config).await
|
||||||
|
}
|
||||||
|
|
||||||
485
src/navidrome.rs
Normal file
485
src/navidrome.rs
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
use crate::config::NavidromeConfig;
|
||||||
|
use crate::error::{NaviPodError, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct NavidromeClient {
|
||||||
|
base_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
auth_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Album {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub artist_id: String,
|
||||||
|
pub song_count: u32,
|
||||||
|
pub duration: u32,
|
||||||
|
pub cover_art_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Song {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub album_id: String,
|
||||||
|
pub duration: u32,
|
||||||
|
pub bitrate: u32,
|
||||||
|
pub size: u64,
|
||||||
|
pub suffix: String,
|
||||||
|
pub path: String,
|
||||||
|
pub cover_art_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct LoginResponse {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: SubsonicResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SubsonicResponse {
|
||||||
|
status: String,
|
||||||
|
version: String,
|
||||||
|
#[serde(rename = "musicFolderId")]
|
||||||
|
music_folder_id: Option<u32>,
|
||||||
|
#[serde(rename = "token")]
|
||||||
|
auth_token: Option<String>,
|
||||||
|
#[serde(rename = "salt")]
|
||||||
|
auth_salt: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct AlbumListResponse {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: AlbumListSubsonicResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct AlbumListSubsonicResponse {
|
||||||
|
status: String,
|
||||||
|
#[serde(rename = "albumList2")]
|
||||||
|
album_list: Option<AlbumListWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct AlbumListWrapper {
|
||||||
|
#[serde(rename = "album")]
|
||||||
|
album: AlbumOrArray,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum AlbumOrArray {
|
||||||
|
Single(AlbumResponse),
|
||||||
|
Multiple(Vec<AlbumResponse>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumOrArray {
|
||||||
|
fn into_vec(self) -> Vec<AlbumResponse> {
|
||||||
|
match self {
|
||||||
|
AlbumOrArray::Single(album) => vec![album],
|
||||||
|
AlbumOrArray::Multiple(albums) => albums,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct AlbumResponse {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
artist: String,
|
||||||
|
#[serde(rename = "artistId")]
|
||||||
|
artist_id: String,
|
||||||
|
#[serde(rename = "songCount")]
|
||||||
|
song_count: u32,
|
||||||
|
duration: u32,
|
||||||
|
#[serde(rename = "coverArt")]
|
||||||
|
cover_art_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SongsResponse {
|
||||||
|
#[serde(rename = "subsonic-response")]
|
||||||
|
subsonic_response: SongsSubsonicResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SongsSubsonicResponse {
|
||||||
|
status: String,
|
||||||
|
#[serde(rename = "album")]
|
||||||
|
album: Option<AlbumSongsResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct AlbumSongsResponse {
|
||||||
|
#[serde(rename = "song")]
|
||||||
|
song: SongOrArray,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum SongOrArray {
|
||||||
|
// Try array first since Navidrome always returns arrays
|
||||||
|
Multiple(Vec<SongResponse>),
|
||||||
|
// Fallback for single object (though Navidrome doesn't seem to use this)
|
||||||
|
Single(SongResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SongOrArray {
|
||||||
|
fn into_vec(self) -> Vec<SongResponse> {
|
||||||
|
match self {
|
||||||
|
SongOrArray::Single(song) => vec![song],
|
||||||
|
SongOrArray::Multiple(songs) => songs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SongResponse {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
#[serde(rename = "albumId")]
|
||||||
|
album_id: String,
|
||||||
|
duration: u32,
|
||||||
|
#[serde(rename = "bitRate")]
|
||||||
|
bitrate: Option<u32>,
|
||||||
|
size: u64,
|
||||||
|
suffix: String,
|
||||||
|
path: String,
|
||||||
|
#[serde(rename = "coverArt")]
|
||||||
|
cover_art_id: Option<String>,
|
||||||
|
// Additional fields that Navidrome may include (all optional)
|
||||||
|
#[serde(rename = "playCount")]
|
||||||
|
play_count: Option<u32>,
|
||||||
|
played: Option<String>,
|
||||||
|
bpm: Option<u32>,
|
||||||
|
comment: Option<String>,
|
||||||
|
#[serde(rename = "sortName")]
|
||||||
|
sort_name: Option<String>,
|
||||||
|
#[serde(rename = "mediaType")]
|
||||||
|
media_type: Option<String>,
|
||||||
|
#[serde(rename = "musicBrainzId")]
|
||||||
|
music_brainz_id: Option<String>,
|
||||||
|
isrc: Option<Vec<String>>,
|
||||||
|
genres: Option<Vec<serde_json::Value>>,
|
||||||
|
#[serde(rename = "replayGain")]
|
||||||
|
replay_gain: Option<serde_json::Value>,
|
||||||
|
#[serde(rename = "channelCount")]
|
||||||
|
channel_count: Option<u32>,
|
||||||
|
#[serde(rename = "samplingRate")]
|
||||||
|
sampling_rate: Option<u32>,
|
||||||
|
#[serde(rename = "bitDepth")]
|
||||||
|
bit_depth: Option<u32>,
|
||||||
|
moods: Option<Vec<serde_json::Value>>,
|
||||||
|
artists: Option<Vec<serde_json::Value>>,
|
||||||
|
#[serde(rename = "displayArtist")]
|
||||||
|
display_artist: Option<String>,
|
||||||
|
#[serde(rename = "albumArtists")]
|
||||||
|
album_artists: Option<Vec<serde_json::Value>>,
|
||||||
|
#[serde(rename = "displayAlbumArtist")]
|
||||||
|
display_album_artist: Option<String>,
|
||||||
|
contributors: Option<Vec<serde_json::Value>>,
|
||||||
|
#[serde(rename = "displayComposer")]
|
||||||
|
display_composer: Option<String>,
|
||||||
|
#[serde(rename = "explicitStatus")]
|
||||||
|
explicit_status: Option<String>,
|
||||||
|
track: Option<u32>,
|
||||||
|
year: Option<u32>,
|
||||||
|
genre: Option<String>,
|
||||||
|
#[serde(rename = "discNumber")]
|
||||||
|
disc_number: Option<u32>,
|
||||||
|
parent: Option<String>,
|
||||||
|
#[serde(rename = "isDir")]
|
||||||
|
is_dir: Option<bool>,
|
||||||
|
#[serde(rename = "contentType")]
|
||||||
|
content_type: Option<String>,
|
||||||
|
#[serde(rename = "artistId")]
|
||||||
|
artist_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NavidromeClient {
|
||||||
|
pub fn new(config: &NavidromeConfig) -> Result<Self> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
base_url: config.url.clone(),
|
||||||
|
client,
|
||||||
|
auth_token: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authenticate(&mut self, username: &str, password: &str) -> Result<()> {
|
||||||
|
// Ensure base_url doesn't have trailing slash
|
||||||
|
let base_url = self.base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{}/rest/ping.view", base_url);
|
||||||
|
|
||||||
|
tracing::info!("Authenticating to Navidrome at: {}", url);
|
||||||
|
tracing::info!("Username: {}", username);
|
||||||
|
|
||||||
|
// Navidrome uses Subsonic API with JSON format
|
||||||
|
// Note: Subsonic API supports both plain password (p) and token-based (t + s) auth
|
||||||
|
// We'll use plain password for simplicity
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("f", "json");
|
||||||
|
params.insert("u", username);
|
||||||
|
params.insert("p", password);
|
||||||
|
params.insert("v", "1.16.0");
|
||||||
|
params.insert("c", "navipod");
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
tracing::debug!("Authentication response status: {}, body: {}", status, text);
|
||||||
|
|
||||||
|
if status.is_success() {
|
||||||
|
// Try to parse the response
|
||||||
|
match serde_json::from_str::<LoginResponse>(&text) {
|
||||||
|
Ok(login_resp) => {
|
||||||
|
if login_resp.subsonic_response.status == "ok" {
|
||||||
|
// Store credentials for subsequent requests
|
||||||
|
self.auth_token = Some(format!("{}:{}", username, password));
|
||||||
|
tracing::debug!("Authentication successful");
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"Authentication failed: API returned status '{}'. Response: {}",
|
||||||
|
login_resp.subsonic_response.status, text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// JSON parsing failed - maybe the response format is different
|
||||||
|
// Check if it's an error response
|
||||||
|
if let Ok(error_resp) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||||
|
if let Some(error) = error_resp.get("subsonic-response").and_then(|r| r.get("error")) {
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"Authentication failed: {}",
|
||||||
|
error
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"Failed to parse authentication response: {}. Response: {}",
|
||||||
|
e, text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"Authentication failed with HTTP status {}: {}",
|
||||||
|
status, text
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_albums(&self) -> Result<Vec<Album>> {
|
||||||
|
let base_url = self.base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{}/rest/getAlbumList2.view", base_url);
|
||||||
|
|
||||||
|
let (username, password) = self.get_credentials()?;
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("f", "json");
|
||||||
|
params.insert("u", &username);
|
||||||
|
params.insert("p", &password);
|
||||||
|
params.insert("v", "1.16.0");
|
||||||
|
params.insert("c", "navipod");
|
||||||
|
params.insert("type", "alphabeticalByName");
|
||||||
|
params.insert("size", "500");
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Check for error response before consuming the response
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"HTTP error {}: {}",
|
||||||
|
status,
|
||||||
|
text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let album_list: AlbumListResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| {
|
||||||
|
NaviPodError::NavidromeApi(format!(
|
||||||
|
"Failed to parse JSON response: {}. Response: {}",
|
||||||
|
e, text
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if album_list.subsonic_response.status != "ok" {
|
||||||
|
return Err(NaviPodError::NavidromeApi(
|
||||||
|
"API returned non-ok status".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let albums = album_list
|
||||||
|
.subsonic_response
|
||||||
|
.album_list
|
||||||
|
.map(|w| {
|
||||||
|
w.album
|
||||||
|
.into_vec()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| Album {
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
artist: a.artist,
|
||||||
|
artist_id: a.artist_id,
|
||||||
|
song_count: a.song_count,
|
||||||
|
duration: a.duration,
|
||||||
|
cover_art_id: a.cover_art_id,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_album_songs(&self, album_id: &str) -> Result<Vec<Song>> {
|
||||||
|
let base_url = self.base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{}/rest/getAlbum.view", base_url);
|
||||||
|
|
||||||
|
let (username, password) = self.get_credentials()?;
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("f", "json");
|
||||||
|
params.insert("u", &username);
|
||||||
|
params.insert("p", &password);
|
||||||
|
params.insert("v", "1.16.0");
|
||||||
|
params.insert("c", "navipod");
|
||||||
|
params.insert("id", album_id);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Check for error response before consuming the response
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"HTTP error {}: {}",
|
||||||
|
status,
|
||||||
|
text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let songs_resp: SongsResponse = serde_json::from_str(&text)
|
||||||
|
.map_err(|e| {
|
||||||
|
NaviPodError::NavidromeApi(format!(
|
||||||
|
"Failed to parse JSON response: {}. Response: {}",
|
||||||
|
e, text
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if songs_resp.subsonic_response.status != "ok" {
|
||||||
|
return Err(NaviPodError::NavidromeApi(
|
||||||
|
"API returned non-ok status".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let songs = songs_resp
|
||||||
|
.subsonic_response
|
||||||
|
.album
|
||||||
|
.map(|a| a.song.into_vec())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| Song {
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
artist: s.artist,
|
||||||
|
album: s.album,
|
||||||
|
album_id: s.album_id,
|
||||||
|
duration: s.duration,
|
||||||
|
bitrate: s.bitrate.unwrap_or(0),
|
||||||
|
size: s.size,
|
||||||
|
suffix: s.suffix,
|
||||||
|
path: s.path,
|
||||||
|
cover_art_id: s.cover_art_id,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(songs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_song(&self, song_id: &str, output_path: &std::path::Path) -> Result<()> {
|
||||||
|
let base_url = self.base_url.trim_end_matches('/');
|
||||||
|
let url = format!("{}/rest/download.view", base_url);
|
||||||
|
|
||||||
|
let (username, password) = self.get_credentials()?;
|
||||||
|
|
||||||
|
let mut params = HashMap::new();
|
||||||
|
params.insert("f", "json");
|
||||||
|
params.insert("u", &username);
|
||||||
|
params.insert("p", &password);
|
||||||
|
params.insert("v", "1.16.0");
|
||||||
|
params.insert("c", "navipod");
|
||||||
|
params.insert("id", song_id);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.query(¶ms)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(NaviPodError::NavidromeApi(format!(
|
||||||
|
"Failed to download song: {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response.bytes().await?;
|
||||||
|
std::fs::write(output_path, bytes)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_credentials(&self) -> Result<(&str, &str)> {
|
||||||
|
let creds = self
|
||||||
|
.auth_token
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| NaviPodError::NavidromeApi("Not authenticated".to_string()))?;
|
||||||
|
|
||||||
|
let mut parts = creds.split(':');
|
||||||
|
let username = parts.next().ok_or_else(|| {
|
||||||
|
NaviPodError::NavidromeApi("Invalid credentials format".to_string())
|
||||||
|
})?;
|
||||||
|
let password = parts.next().ok_or_else(|| {
|
||||||
|
NaviPodError::NavidromeApi("Invalid credentials format".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((username, password))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
175
src/sync.rs
Normal file
175
src/sync.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::ipod::Ipod;
|
||||||
|
use crate::navidrome::NavidromeClient;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
pub type ProgressCallback = Arc<dyn Fn(String, String, usize, usize) + Send + Sync>;
|
||||||
|
|
||||||
|
pub async fn sync_content(
|
||||||
|
config: Config,
|
||||||
|
album_filter: Option<String>,
|
||||||
|
artist_filter: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sync_content_with_progress(config, album_filter, artist_filter, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_content_with_progress(
|
||||||
|
config: Config,
|
||||||
|
album_filter: Option<String>,
|
||||||
|
artist_filter: Option<String>,
|
||||||
|
progress_callback: Option<ProgressCallback>,
|
||||||
|
) -> Result<()> {
|
||||||
|
info!("Starting sync...");
|
||||||
|
|
||||||
|
// Initialize clients
|
||||||
|
let mut navidrome = NavidromeClient::new(&config.navidrome)?;
|
||||||
|
navidrome
|
||||||
|
.authenticate(&config.navidrome.username, &config.navidrome.password)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let ipod = Ipod::new(&config.ipod);
|
||||||
|
ipod.check_connection()?;
|
||||||
|
ipod.ensure_music_directory()?;
|
||||||
|
|
||||||
|
// Create temporary directory for downloads
|
||||||
|
let temp_dir = std::env::temp_dir().join("navipod");
|
||||||
|
std::fs::create_dir_all(&temp_dir)?;
|
||||||
|
|
||||||
|
// Get albums to sync
|
||||||
|
let albums = navidrome.list_albums().await?;
|
||||||
|
|
||||||
|
let albums_to_sync: Vec<_> = albums
|
||||||
|
.into_iter()
|
||||||
|
.filter(|album| {
|
||||||
|
if let Some(ref filter) = album_filter {
|
||||||
|
album.name.to_lowercase().contains(&filter.to_lowercase())
|
||||||
|
} else if let Some(ref filter) = artist_filter {
|
||||||
|
album.artist.to_lowercase().contains(&filter.to_lowercase())
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("Found {} album(s) to sync", albums_to_sync.len());
|
||||||
|
let total_albums = albums_to_sync.len();
|
||||||
|
|
||||||
|
// Sync each album
|
||||||
|
for (index, album) in albums_to_sync.iter().enumerate() {
|
||||||
|
info!("Syncing album: {} by {}", album.name, album.artist);
|
||||||
|
|
||||||
|
if let Some(ref callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
"Starting...".to_string(),
|
||||||
|
index,
|
||||||
|
total_albums,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match sync_album(&navidrome, &ipod, album, &temp_dir, &config.sync.format, progress_callback.as_ref()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("✓ Successfully synced: {} by {}", album.name, album.artist);
|
||||||
|
if let Some(ref callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
"Completed".to_string(),
|
||||||
|
index + 1,
|
||||||
|
total_albums,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("✗ Failed to sync {} by {}: {}", album.name, album.artist, e);
|
||||||
|
if let Some(ref callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
format!("Error: {}", e),
|
||||||
|
index + 1,
|
||||||
|
total_albums,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temp directory
|
||||||
|
if temp_dir.exists() {
|
||||||
|
std::fs::remove_dir_all(&temp_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Sync complete!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sync_album(
|
||||||
|
navidrome: &NavidromeClient,
|
||||||
|
ipod: &Ipod,
|
||||||
|
album: &crate::navidrome::Album,
|
||||||
|
temp_dir: &PathBuf,
|
||||||
|
_format: &str,
|
||||||
|
progress_callback: Option<&ProgressCallback>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Get songs for this album
|
||||||
|
let songs = navidrome.get_album_songs(&album.id).await?;
|
||||||
|
|
||||||
|
info!(" Found {} song(s) in album", songs.len());
|
||||||
|
|
||||||
|
// Sync each song
|
||||||
|
// Use album artist for folder structure, not individual song artist (which may include "feat.")
|
||||||
|
let album_artist = &album.artist;
|
||||||
|
|
||||||
|
for song in songs {
|
||||||
|
// Check if song already exists
|
||||||
|
// Use album artist instead of song artist to keep all songs in the same album folder
|
||||||
|
let song_path = ipod.get_song_path(album_artist, &album.name, &song.title, &song.suffix);
|
||||||
|
|
||||||
|
if ipod.file_exists(&song_path) {
|
||||||
|
info!(" Skipping {} (already exists)", song.title);
|
||||||
|
if let Some(callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
format!("Skipping: {}", song.title),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download song to temp directory
|
||||||
|
let temp_file = temp_dir.join(format!("{}.{}", song.id, song.suffix));
|
||||||
|
|
||||||
|
if let Some(callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
format!("Downloading: {}", song.title),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
info!(" Downloading: {}", song.title);
|
||||||
|
navidrome.download_song(&song.id, &temp_file).await?;
|
||||||
|
|
||||||
|
// Copy to iPod
|
||||||
|
if let Some(callback) = progress_callback {
|
||||||
|
callback(
|
||||||
|
album.name.clone(),
|
||||||
|
format!("Copying: {}", song.title),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
info!(" Copying to iPod: {}", song.title);
|
||||||
|
ipod.copy_file(&temp_file, &song_path)?;
|
||||||
|
|
||||||
|
// Cleanup temp file
|
||||||
|
std::fs::remove_file(&temp_file)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue