diff --git a/Cargo.lock b/Cargo.lock index 05a4fb7..8dae89b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.21" @@ -146,6 +155,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.21.7" @@ -210,6 +225,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.53" @@ -798,6 +826,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1127,6 +1179,7 @@ name = "navipod" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "clap", "config", "dirs", @@ -1166,6 +1219,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -2274,12 +2336,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index a166786..aa2f4f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,9 @@ clap = { version = "4.0", features = ["derive"] } # TUI Progress bars indicatif = "0.17" +# Date/time parsing +chrono = "0.4" + # ID3 tag parsing id3 = "1.13" diff --git a/README.md b/README.md index a60ba39..201e3e5 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,10 @@ A native Rust application to sync songs and albums from your self-hosted Navidro - 🎵 Sync songs and albums from Navidrome - 📱 Support for iPod stock OS - 🎸 Support for Rockbox firmware +- 📊 Scrobble listening history to ListenBrainz - ⚙️ Configurable sync options - 🔄 Incremental sync support +- 📈 Terminal progress bars with live status ## Installation @@ -38,6 +40,10 @@ cargo build --release albums = true playlists = false format = "mp3" + + [listenbrainz] + # Optional - only needed for scrobbling to ListenBrainz + token = "your_listenbrainz_token" ``` 3. Alternatively, use environment variables (prefixed with `NAVIPOD__`): @@ -53,37 +59,50 @@ cargo build --release ## 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 all configured content (with progress bar) +cargo run -- sync # Sync specific album -navipod sync --album "Album Name" +cargo run -- sync --album "Album Name" + +# Sync specific artist +cargo run -- sync --artist "Artist Name" # List available albums -navipod list-albums +cargo run -- list-albums # Check iPod connection -navipod check +cargo run -- check + +# Scrobble listening history to ListenBrainz +cargo run -- scrobble + +# Scrobble and clear the log (with backup) +cargo run -- scrobble --clear + +# Scrobble and clear without backup +cargo run -- scrobble --clear --no-backup ``` +### Scrobbling to ListenBrainz + +NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz: + +1. Get your ListenBrainz user token from https://listenbrainz.org/profile/ +2. Add it to your `config.toml`: + ```toml + [listenbrainz] + token = "your_token_here" + ``` +3. Run the scrobble command: + ```bash + cargo run -- scrobble --clear + ``` + +The `--clear` flag will remove the scrobbler log after successful submission (with automatic backup). +This is useful to avoid re-submitting the same listens next time. + ## Requirements - Rust 1.70+ (and Cargo) @@ -111,32 +130,13 @@ nix-shell # Build in release mode cargo build --release -# The binaries will be at: -# - target/release/navipod (CLI) -# - target/release/navipod-gui (GUI) +# The binary will be at: +# - target/release/navipod + +# Install to your PATH (optional) +cargo install --path . ``` -### 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 diff --git a/config.example.toml b/config.example.toml index f6c0cef..548e752 100644 --- a/config.example.toml +++ b/config.example.toml @@ -23,3 +23,8 @@ playlists = false # Preferred audio format (mp3, flac, ogg, etc.) format = "mp3" +[listenbrainz] +# Your ListenBrainz user token (get it from https://listenbrainz.org/profile/) +# This is optional - only needed if you want to scrobble from iPod to ListenBrainz +token = "your_listenbrainz_token_here" + diff --git a/shell.nix b/shell.nix index 8aff332..7ade5f2 100644 --- a/shell.nix +++ b/shell.nix @@ -35,5 +35,6 @@ pkgs.mkShell { echo " navipod sync [--album ALBUM] [--artist ARTIST] - Sync content to iPod" echo " navipod list-albums - List available albums" echo " navipod check - Check iPod connection" + echo " navipod scrobble [--clear] [--backup] - Sync scrobbles to ListenBrainz" ''; } diff --git a/src/config.rs b/src/config.rs index 389d4b4..a93b943 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,8 @@ pub struct Config { pub navidrome: NavidromeConfig, pub ipod: IpodConfig, pub sync: SyncConfig, + #[serde(default)] + pub listenbrainz: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -35,6 +37,11 @@ pub struct SyncConfig { pub format: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListenBrainzConfig { + pub token: String, +} + impl Config { pub fn load() -> anyhow::Result { let config_path = Self::config_path()?; diff --git a/src/error.rs b/src/error.rs index d29fae0..882b2b3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -22,6 +22,12 @@ pub enum NaviPodError { #[error("Sync error: {0}")] Sync(String), + + #[error("ListenBrainz error: {0}")] + ListenBrainzError(String), + + #[error("Scrobbler parse error: {0}")] + ScrobblerParseError(String), } pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index e61297e..e31fbbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,9 @@ pub mod config; pub mod error; pub mod ipod; +pub mod listenbrainz; pub mod navidrome; +pub mod scrobbler; pub mod sync; // Re-export Config so it can be used from main.rs @@ -10,6 +12,7 @@ pub use config::Config; use clap::{Parser, Subcommand}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use std::sync::Arc; +use chrono; #[derive(Parser)] #[command(name = "navipod")] @@ -34,6 +37,15 @@ pub enum Commands { ListAlbums, /// Check iPod connection and status Check, + /// Scrobble listening history from iPod to ListenBrainz + Scrobble { + /// Clear scrobbler log after successful sync + #[arg(long, default_value_t = false)] + clear: bool, + /// Backup scrobbler log before clearing + #[arg(long, default_value_t = true)] + backup: bool, + }, } pub async fn run(config: Config) -> anyhow::Result<()> { @@ -108,6 +120,79 @@ pub async fn run(config: Config) -> anyhow::Result<()> { ipod::check_ipod(&config.ipod)?; println!("✓ iPod connection OK"); } + Commands::Scrobble { clear, backup } => { + // Check if ListenBrainz is configured + let lb_config = config.listenbrainz.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "ListenBrainz not configured. Add [listenbrainz] section with 'token' to your config.toml" + ) + })?; + + println!("Reading scrobbler log from iPod (mount: {:?})...", config.ipod.mount_point); + println!("Checking for scrobbler.log in:"); + println!(" - {:?}", config.ipod.mount_point.join("scrobbler.log")); + println!(" - {:?}", config.ipod.mount_point.join(".scrobbler.log")); + println!(" - {:?}", config.ipod.mount_point.join(".rockbox/scrobbler.log")); + + let listens = scrobbler::parse_scrobbler_log(&config.ipod)?; + + if listens.is_empty() { + println!("\nNo listens found in scrobbler log."); + println!("Make sure:"); + println!(" 1. Your iPod is mounted at the correct location"); + println!(" 2. Rockbox scrobbling is enabled"); + println!(" 3. You've played some songs since last scrobble"); + return Ok(()); + } + + println!("Found {} valid listens to sync", listens.len()); + + // Show sample of first few listens for debugging + if !listens.is_empty() { + println!("\nSample of listens to be submitted:"); + for (i, listen) in listens.iter().take(3).enumerate() { + let date = chrono::DateTime::from_timestamp(listen.timestamp, 0) + .map(|dt| dt.to_string()) + .unwrap_or_else(|| format!("Invalid timestamp: {}", listen.timestamp)); + println!( + " {}. {} - {} ({})", + i + 1, + listen.artist, + listen.track, + date + ); + } + if listens.len() > 3 { + println!(" ... and {} more", listens.len() - 3); + } + println!(); + } + + // Validate token and submit + let lb_client = listenbrainz::ListenBrainzClient::new(lb_config.token.clone())?; + + println!("Validating ListenBrainz token..."); + lb_client.validate_token().await?; + println!("✓ Token valid"); + + println!("Submitting listens to ListenBrainz..."); + lb_client.submit_listens(listens).await?; + println!("✓ Successfully submitted listens!"); + + // Backup if requested + if backup && clear { + println!("Backing up scrobbler log..."); + let backup_path = scrobbler::backup_scrobbler_log(&config.ipod)?; + println!("✓ Backed up to {:?}", backup_path); + } + + // Clear if requested + if clear { + println!("Clearing scrobbler log..."); + scrobbler::clear_scrobbler_log(&config.ipod)?; + println!("✓ Scrobbler log cleared"); + } + } } Ok(()) diff --git a/src/listenbrainz.rs b/src/listenbrainz.rs new file mode 100644 index 0000000..767328f --- /dev/null +++ b/src/listenbrainz.rs @@ -0,0 +1,118 @@ +use crate::error::{NaviPodError, Result}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +const LISTENBRAINZ_API_URL: &str = "https://api.listenbrainz.org/1"; + +#[derive(Debug, Clone)] +pub struct ListenBrainzClient { + client: Client, + token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Listen { + pub artist: String, + pub track: String, + pub album: Option, + pub timestamp: i64, +} + +#[derive(Serialize)] +struct SubmitListensPayload { + listen_type: String, + payload: Vec, +} + +#[derive(Serialize)] +struct ListenPayload { + listened_at: i64, + track_metadata: TrackMetadata, +} + +#[derive(Serialize)] +struct TrackMetadata { + artist_name: String, + track_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + release_name: Option, +} + +impl ListenBrainzClient { + pub fn new(token: String) -> Result { + let client = Client::builder() + .user_agent("NaviPod/0.1.0") + .build()?; + + Ok(Self { client, token }) + } + + pub async fn submit_listens(&self, listens: Vec) -> Result<()> { + if listens.is_empty() { + return Ok(()); + } + + // ListenBrainz has a limit of 1000 listens per request + const BATCH_SIZE: usize = 1000; + + for chunk in listens.chunks(BATCH_SIZE) { + self.submit_batch(chunk).await?; + } + + Ok(()) + } + + async fn submit_batch(&self, listens: &[Listen]) -> Result<()> { + let payload = SubmitListensPayload { + listen_type: "import".to_string(), + payload: listens + .iter() + .map(|listen| ListenPayload { + listened_at: listen.timestamp, + track_metadata: TrackMetadata { + artist_name: listen.artist.clone(), + track_name: listen.track.clone(), + release_name: listen.album.clone(), + }, + }) + .collect(), + }; + + let response = self + .client + .post(format!("{}/submit-listens", LISTENBRAINZ_API_URL)) + .header("Authorization", format!("Token {}", self.token)) + .json(&payload) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(NaviPodError::ListenBrainzError(format!( + "Failed to submit listens: {} - {}", + status, body + ))); + } + + Ok(()) + } + + pub async fn validate_token(&self) -> Result<()> { + let response = self + .client + .get(format!("{}/validate-token", LISTENBRAINZ_API_URL)) + .header("Authorization", format!("Token {}", self.token)) + .send() + .await?; + + if !response.status().is_success() { + return Err(NaviPodError::ListenBrainzError( + "Invalid ListenBrainz token".to_string(), + )); + } + + Ok(()) + } +} + diff --git a/src/scrobbler.rs b/src/scrobbler.rs new file mode 100644 index 0000000..ef5c1a5 --- /dev/null +++ b/src/scrobbler.rs @@ -0,0 +1,155 @@ +use crate::config::IpodConfig; +use crate::error::Result; +use crate::listenbrainz::Listen; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use tracing::{info, warn}; + +/// Parse Rockbox .scrobbler.log file +/// Format: #AUDIOSCROBBLER/1.1 +/// Each line: artist\talbum\ttrack\ttrack_number\tduration\trating\ttimestamp\tmusicbrainz_id +pub fn parse_scrobbler_log(ipod_config: &IpodConfig) -> Result> { + let log_path = get_scrobbler_log_path(ipod_config)?; + + if !log_path.exists() { + info!("No scrobbler log found at {:?}", log_path); + return Ok(Vec::new()); + } + + let file = File::open(&log_path)?; + let reader = BufReader::new(file); + let mut listens = Vec::new(); + let mut skipped_count = 0; + + // ListenBrainz requires timestamps after 2002-10-01 + const MIN_TIMESTAMP: i64 = 1033410600; + + for (line_num, line) in reader.lines().enumerate() { + let line = line?; + + // Skip header line + if line.starts_with('#') { + continue; + } + + // Skip empty lines + if line.trim().is_empty() { + continue; + } + + match parse_scrobbler_line(&line) { + Ok(listen) => { + // Filter out timestamps that are too old for ListenBrainz + if listen.timestamp < MIN_TIMESTAMP { + warn!( + "Skipping listen with timestamp {} ({}) - too old for ListenBrainz: {} - {}", + listen.timestamp, + chrono::DateTime::from_timestamp(listen.timestamp, 0) + .map(|dt| dt.to_string()) + .unwrap_or_else(|| "invalid date".to_string()), + listen.artist, + listen.track + ); + skipped_count += 1; + } else { + listens.push(listen); + } + } + Err(e) => { + warn!("Failed to parse line {}: {} - Error: {}", line_num + 1, line, e); + } + } + } + + info!("Parsed {} valid listens from scrobbler log", listens.len()); + if skipped_count > 0 { + info!("Skipped {} listens with invalid timestamps", skipped_count); + } + Ok(listens) +} + +fn parse_scrobbler_line(line: &str) -> Result { + let parts: Vec<&str> = line.split('\t').collect(); + + if parts.len() < 7 { + return Err(crate::error::NaviPodError::ScrobblerParseError( + format!("Not enough fields in scrobbler log line (got {}, need 7+): {}", parts.len(), line) + )); + } + + let artist = parts[0].to_string(); + let album = if parts[1].is_empty() { + None + } else { + Some(parts[1].to_string()) + }; + let track = parts[2].to_string(); + let timestamp_str = parts[6]; + + // Parse timestamp - Rockbox uses Unix timestamp format + let timestamp = timestamp_str.parse::().map_err(|e| { + crate::error::NaviPodError::ScrobblerParseError(format!( + "Failed to parse timestamp '{}' from line '{}': {}", + timestamp_str, line, e + )) + })?; + + Ok(Listen { + artist, + track, + album, + timestamp, + }) +} + +fn get_scrobbler_log_path(ipod_config: &IpodConfig) -> Result { + // Try multiple possible locations for the scrobbler log + let possible_paths = vec![ + ipod_config.mount_point.join("scrobbler.log"), // Root of mount + ipod_config.mount_point.join(".scrobbler.log"), // Hidden in root + ipod_config.mount_point.join(".rockbox/scrobbler.log"), // Rockbox subdirectory + ]; + + // Return the first path that exists, or default to root location + for path in &possible_paths { + if path.exists() { + info!("Found scrobbler log at {:?}", path); + return Ok(path.clone()); + } + } + + // Default to root location if none found + info!("Scrobbler log not found in any of: {:?}", possible_paths); + Ok(ipod_config.mount_point.join("scrobbler.log")) +} + +/// Clear the scrobbler log after successful sync +pub fn clear_scrobbler_log(ipod_config: &IpodConfig) -> Result<()> { + let log_path = get_scrobbler_log_path(ipod_config)?; + + if log_path.exists() { + std::fs::remove_file(&log_path)?; + info!("Cleared scrobbler log at {:?}", log_path); + } + + Ok(()) +} + +/// Backup the scrobbler log before clearing +pub fn backup_scrobbler_log(ipod_config: &IpodConfig) -> Result { + let log_path = get_scrobbler_log_path(ipod_config)?; + + if !log_path.exists() { + return Ok(log_path); + } + + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let backup_path = log_path.with_file_name(format!("scrobbler.log.backup.{}", timestamp)); + + std::fs::copy(&log_path, &backup_path)?; + info!("Backed up scrobbler log to {:?}", backup_path); + + Ok(backup_path) +} +