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:
SindreKjelsrud 2025-12-21 21:18:17 +01:00
commit 758076fe32
Signed by: sidski
GPG key ID: D2BBDF3EDE6BA9A6
15 changed files with 6303 additions and 0 deletions

21
.gitignore vendored Normal file
View 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

File diff suppressed because it is too large Load diff

62
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&params)
.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(&params)
.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(&params)
.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(&params)
.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
View 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(())
}