diff --git a/README.md b/README.md index 201e3e5..a03ba52 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A native Rust application to sync songs and albums from your self-hosted Navidro ## Features - 🎵 Sync songs and albums from Navidrome +- 🎼 Sync playlists from Navidrome (as M3U8 files) - 📱 Support for iPod stock OS - 🎸 Support for Rockbox firmware - 📊 Scrobble listening history to ListenBrainz @@ -69,9 +70,15 @@ cargo run -- sync --album "Album Name" # Sync specific artist cargo run -- sync --artist "Artist Name" +# Sync specific playlist only +cargo run -- sync --playlist "Playlist Name" + # List available albums cargo run -- list-albums +# List available playlists +cargo run -- list-playlists + # Check iPod connection cargo run -- check @@ -85,6 +92,28 @@ cargo run -- scrobble --clear cargo run -- scrobble --clear --no-backup ``` +### Syncing Playlists + +NaviPod can sync your Navidrome playlists to your iPod as M3U8 files: + +1. Enable playlist syncing in your `config.toml`: + ```toml + [sync] + playlists = true + ``` +2. Run the sync command: + ```bash + # Sync all playlists + cargo run -- sync + + # Or sync a specific playlist only + cargo run -- sync --playlist "My Favorites" + ``` +3. Playlists will be created in the `Playlists` folder on your iPod +4. Only songs that are already on your iPod will be included in the playlists + +**Note:** Playlists use M3U8 format (UTF-8 encoded) with relative paths, which works with both Rockbox and most other media players. + ### Scrobbling to ListenBrainz NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz: diff --git a/config.example.toml b/config.example.toml index 548e752..bc236b3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,8 +18,8 @@ firmware = "rockbox" [sync] # Sync albums albums = true -# Sync playlists (not yet implemented) -playlists = false +# Sync playlists from Navidrome to iPod (stored as M3U8 files in Playlists folder) +playlists = true # Preferred audio format (mp3, flac, ogg, etc.) format = "mp3" diff --git a/shell.nix b/shell.nix index 7ade5f2..65c01c8 100644 --- a/shell.nix +++ b/shell.nix @@ -14,7 +14,11 @@ pkgs.mkShell { ]; shellHook = '' - echo "NaviPod Development Environment" + echo "╔═══════════════════════════════════════════════════════════════╗" + echo "║ NaviPod Development Environment ║" + echo "║ Sync Navidrome to iPod | Scrobble to ListenBrainz ║" + echo "╚═══════════════════════════════════════════════════════════════╝" + echo "" # Verify Rust is available if command -v rustc &> /dev/null; then @@ -26,15 +30,34 @@ pkgs.mkShell { fi echo "" - echo "Welcome to NaviPod development shell!" - echo "Run 'cargo build' to build the project." - echo "Run 'cargo run' to run the CLI." - echo "Run 'cargo check' to check for compilation errors." + echo "📦 Build Commands:" + echo " cargo build - Build in debug mode" + echo " cargo build --release - Build optimized release" + echo " cargo check - Check for compilation errors" + echo " cargo run -- [COMMAND] - Build and run a command" + echo "" + echo "🎵 NaviPod Commands:" + echo " cargo run -- sync [OPTIONS]" + echo " --album Sync specific album" + echo " --artist Sync specific artist" + echo " --playlist Sync specific playlist" + echo "" + echo " cargo run -- list-albums List all albums from Navidrome" + echo " cargo run -- list-playlists List all playlists from Navidrome" + echo " cargo run -- check Check iPod connection" + echo "" + echo " cargo run -- scrobble [OPTIONS]" + echo " --clear Clear scrobbler log after sync" + echo " --no-backup Skip backup when clearing" + echo "" + echo "📝 Configuration:" + echo " Edit config.toml to set your Navidrome URL, credentials," + echo " iPod mount point, and ListenBrainz token." + echo "" + echo "💡 Quick Start:" + echo " 1. cp config.example.toml config.toml" + echo " 2. Edit config.toml with your settings" + echo " 3. cargo run -- sync" echo "" - echo "Available commands:" - 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/ipod.rs b/src/ipod.rs index 02ae683..21c9080 100644 --- a/src/ipod.rs +++ b/src/ipod.rs @@ -108,7 +108,7 @@ impl Ipod { .collect() } - fn sanitize_filename(&self, filename: &str) -> String { + pub fn sanitize_filename(&self, filename: &str) -> String { self.sanitize_path(filename) } @@ -139,6 +139,56 @@ impl Ipod { pub fn file_exists(&self, path: &Path) -> bool { path.exists() } + + pub fn get_playlists_directory(&self) -> PathBuf { + match self.firmware { + FirmwareType::Stock => { + // Stock iPod might use iPod_Control/Playlists + self.mount_point.join("iPod_Control").join("Playlists") + } + FirmwareType::Rockbox => { + // Rockbox uses Playlists directory at the root + self.mount_point.join("Playlists") + } + } + } + + pub fn ensure_playlists_directory(&self) -> Result<()> { + let playlists_dir = self.get_playlists_directory(); + std::fs::create_dir_all(&playlists_dir)?; + Ok(()) + } + + pub fn write_playlist(&self, name: &str, songs: &[(String, String, String)]) -> Result { + // songs: Vec<(artist, album, song_filename)> + self.ensure_playlists_directory()?; + + let playlist_path = self.get_playlists_directory() + .join(format!("{}.m3u8", self.sanitize_filename(name))); + + let mut content = String::new(); + content.push_str("#EXTM3U\n"); + + for (artist, album, song_filename) in songs { + // Get relative path from playlists directory to song + let song_path = self.get_album_path(artist, album).join(song_filename); + + // Calculate relative path from playlists directory to song + let playlists_dir = self.get_playlists_directory(); + let relative_path = match pathdiff::diff_paths(&song_path, &playlists_dir) { + Some(path) => path.to_string_lossy().to_string(), + None => { + // Fallback to absolute path if relative path calculation fails + song_path.to_string_lossy().to_string() + } + }; + + content.push_str(&format!("{}\n", relative_path)); + } + + std::fs::write(&playlist_path, content)?; + Ok(playlist_path) + } } pub fn check_ipod(config: &IpodConfig) -> Result<()> { diff --git a/src/lib.rs b/src/lib.rs index e31fbbb..af79f2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,9 +32,14 @@ pub enum Commands { /// Sync specific artist #[arg(long)] artist: Option, + /// Sync specific playlist by name + #[arg(long)] + playlist: Option, }, /// List available albums from Navidrome ListAlbums, + /// List available playlists from Navidrome + ListPlaylists, /// Check iPod connection and status Check, /// Scrobble listening history from iPod to ListenBrainz @@ -52,7 +57,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> { let cli = Cli::parse(); match cli.command { - Commands::Sync { album, artist } => { + Commands::Sync { album, artist, playlist } => { // Create multi-progress for TUI let multi = MultiProgress::new(); @@ -86,7 +91,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> { }); // Run sync with progress - let result = sync::sync_content_with_progress(config, album, artist, Some(progress_callback)).await; + let result = sync::sync_content_with_progress(config, album, artist, playlist, Some(progress_callback)).await; // Finish progress bars pb.finish_with_message("Sync complete!"); @@ -111,11 +116,40 @@ pub async fn run(config: Config) -> anyhow::Result<()> { println!("Fetching albums..."); let albums = navidrome.list_albums().await?; - println!("Available albums:"); + println!("\nAvailable albums ({} total):", albums.len()); for album in albums { println!(" - {} by {}", album.name, album.artist); } } + Commands::ListPlaylists => { + 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 playlists..."); + let playlists = navidrome.list_playlists().await?; + println!("\nAvailable playlists ({} total):", playlists.len()); + for playlist in playlists { + let duration_mins = playlist.duration / 60; + println!( + " - {} ({} songs, {} min) - by {}", + playlist.name, + playlist.song_count, + duration_mins, + playlist.owner + ); + } + } Commands::Check => { ipod::check_ipod(&config.ipod)?; println!("✓ iPod connection OK"); diff --git a/src/listenbrainz.rs b/src/listenbrainz.rs index 767328f..3360348 100644 --- a/src/listenbrainz.rs +++ b/src/listenbrainz.rs @@ -116,3 +116,4 @@ impl ListenBrainzClient { } } + diff --git a/src/navidrome.rs b/src/navidrome.rs index d0ee9d0..11f5f3e 100644 --- a/src/navidrome.rs +++ b/src/navidrome.rs @@ -36,6 +36,16 @@ pub struct Song { pub cover_art_id: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playlist { + pub id: String, + pub name: String, + pub song_count: u32, + pub duration: u32, + pub public: bool, + pub owner: String, +} + #[derive(Debug, Serialize, Deserialize)] struct LoginResponse { #[serde(rename = "subsonic-response")] @@ -465,6 +475,188 @@ impl NavidromeClient { Ok(()) } + pub async fn list_playlists(&self) -> Result> { + let base_url = self.base_url.trim_end_matches('/'); + let url = format!("{}/rest/getPlaylists.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"); + + let response = self.client.get(&url).query(¶ms).send().await?; + + let status = response.status(); + let text = response.text().await?; + + if !status.is_success() { + return Err(NaviPodError::NavidromeApi(format!( + "HTTP error {}: {}", + status, text + ))); + } + + #[derive(Debug, Deserialize)] + struct PlaylistsResponse { + #[serde(rename = "subsonic-response")] + subsonic_response: PlaylistsSubsonicResponse, + } + + #[derive(Debug, Deserialize)] + struct PlaylistsSubsonicResponse { + status: String, + playlists: Option, + } + + #[derive(Debug, Deserialize)] + struct PlaylistsWrapper { + playlist: PlaylistOrArray, + } + + #[derive(Debug, Deserialize)] + #[serde(untagged)] + enum PlaylistOrArray { + Multiple(Vec), + Single(PlaylistResponse), + } + + impl PlaylistOrArray { + fn into_vec(self) -> Vec { + match self { + PlaylistOrArray::Single(p) => vec![p], + PlaylistOrArray::Multiple(p) => p, + } + } + } + + #[derive(Debug, Deserialize)] + struct PlaylistResponse { + id: String, + name: String, + #[serde(rename = "songCount")] + song_count: u32, + duration: u32, + public: bool, + owner: String, + } + + let playlists_resp: PlaylistsResponse = serde_json::from_str(&text) + .map_err(|e| NaviPodError::NavidromeApi(format!( + "Failed to parse playlists response: {}. Response: {}", e, text + )))?; + + if playlists_resp.subsonic_response.status != "ok" { + return Err(NaviPodError::NavidromeApi( + "API returned non-ok status".to_string(), + )); + } + + let playlists = playlists_resp + .subsonic_response + .playlists + .map(|w| { + w.playlist + .into_vec() + .into_iter() + .map(|p| Playlist { + id: p.id, + name: p.name, + song_count: p.song_count, + duration: p.duration, + public: p.public, + owner: p.owner, + }) + .collect() + }) + .unwrap_or_default(); + + Ok(playlists) + } + + pub async fn get_playlist_songs(&self, playlist_id: &str) -> Result> { + let base_url = self.base_url.trim_end_matches('/'); + let url = format!("{}/rest/getPlaylist.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", playlist_id); + + let response = self.client.get(&url).query(¶ms).send().await?; + + let status = response.status(); + let text = response.text().await?; + + if !status.is_success() { + return Err(NaviPodError::NavidromeApi(format!( + "HTTP error {}: {}", + status, text + ))); + } + + #[derive(Debug, Deserialize)] + struct PlaylistSongsResponse { + #[serde(rename = "subsonic-response")] + subsonic_response: PlaylistSongsSubsonicResponse, + } + + #[derive(Debug, Deserialize)] + struct PlaylistSongsSubsonicResponse { + status: String, + playlist: Option, + } + + #[derive(Debug, Deserialize)] + struct PlaylistWithSongs { + #[serde(rename = "entry")] + entry: Option, + } + + let playlist_resp: PlaylistSongsResponse = serde_json::from_str(&text) + .map_err(|e| NaviPodError::NavidromeApi(format!( + "Failed to parse playlist songs response: {}. Response: {}", e, text + )))?; + + if playlist_resp.subsonic_response.status != "ok" { + return Err(NaviPodError::NavidromeApi( + "API returned non-ok status".to_string(), + )); + } + + let songs = playlist_resp + .subsonic_response + .playlist + .and_then(|p| p.entry) + .map(|e| e.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) + } + fn get_credentials(&self) -> Result<(&str, &str)> { let creds = self .auth_token diff --git a/src/sync.rs b/src/sync.rs index 410c16e..6550e6c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -12,14 +12,16 @@ pub async fn sync_content( config: Config, album_filter: Option, artist_filter: Option, + playlist_filter: Option, ) -> Result<()> { - sync_content_with_progress(config, album_filter, artist_filter, None).await + sync_content_with_progress(config, album_filter, artist_filter, playlist_filter, None).await } pub async fn sync_content_with_progress( config: Config, album_filter: Option, artist_filter: Option, + playlist_filter: Option, progress_callback: Option, ) -> Result<()> { info!("Starting sync..."); @@ -96,6 +98,50 @@ pub async fn sync_content_with_progress( } } + // Sync playlists if enabled + if config.sync.playlists { + info!("Syncing playlists..."); + ipod.ensure_playlists_directory()?; + + let playlists = navidrome.list_playlists().await?; + + // Filter playlists if specific playlist requested + let playlists_to_sync: Vec<_> = playlists + .into_iter() + .filter(|playlist| { + if let Some(ref filter) = playlist_filter { + playlist.name.to_lowercase().contains(&filter.to_lowercase()) + } else { + true + } + }) + .collect(); + + info!("Found {} playlist(s) to sync", playlists_to_sync.len()); + + for playlist in playlists_to_sync { + info!("Syncing playlist: {}", playlist.name); + + if let Some(ref callback) = progress_callback { + callback( + format!("Playlist: {}", playlist.name), + "Loading...".to_string(), + 0, + 0, + ); + } + + match sync_playlist(&navidrome, &ipod, &playlist, progress_callback.as_ref()).await { + Ok(_) => { + info!("✓ Successfully synced playlist: {}", playlist.name); + } + Err(e) => { + warn!("✗ Failed to sync playlist {}: {}", playlist.name, e); + } + } + } + } + // Cleanup temp directory if temp_dir.exists() { std::fs::remove_dir_all(&temp_dir)?; @@ -173,3 +219,81 @@ async fn sync_album( Ok(()) } +async fn sync_playlist( + navidrome: &NavidromeClient, + ipod: &Ipod, + playlist: &crate::navidrome::Playlist, + progress_callback: Option<&ProgressCallback>, +) -> Result<()> { + // Get songs in the playlist + let songs = navidrome.get_playlist_songs(&playlist.id).await?; + + if songs.is_empty() { + info!(" Playlist is empty, skipping"); + return Ok(()); + } + + info!(" Found {} song(s) in playlist", songs.len()); + + // Build list of songs with their paths for M3U + let mut playlist_entries = Vec::new(); + + for song in songs { + if let Some(callback) = progress_callback { + callback( + format!("Playlist: {}", playlist.name), + format!("Checking: {}", song.title), + 0, + 0, + ); + } + + // Get the artist from the song or use "Unknown Artist" + let artist = if !song.artist.is_empty() { + song.artist.clone() + } else { + "Unknown Artist".to_string() + }; + + // Get the album from the song or use "Unknown Album" + let album = if !song.album.is_empty() { + song.album.clone() + } else { + "Unknown Album".to_string() + }; + + let song_filename = format!("{}.{}", ipod.sanitize_filename(&song.title), song.suffix); + + // Check if song exists on iPod + let song_path = ipod.get_song_path(&artist, &album, &song.title, &song.suffix); + + if ipod.file_exists(&song_path) { + // Song exists, add to playlist + playlist_entries.push((artist, album, song_filename)); + } else { + info!(" Song not on iPod, skipping from playlist: {}", song.title); + } + } + + if playlist_entries.is_empty() { + info!(" No songs from playlist found on iPod, skipping playlist"); + return Ok(()); + } + + // Write playlist file + if let Some(callback) = progress_callback { + callback( + format!("Playlist: {}", playlist.name), + "Writing playlist file...".to_string(), + 0, + 0, + ); + } + + let playlist_path = ipod.write_playlist(&playlist.name, &playlist_entries)?; + info!(" Wrote playlist to: {:?}", playlist_path); + info!(" Playlist contains {} song(s)", playlist_entries.len()); + + Ok(()) +} +