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