feat: Add support for syncing playlists

Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
SindreKjelsrud 2026-01-15 15:27:34 +01:00
parent f1b31fabda
commit 86d45f324d
Signed by: sidski
GPG key ID: D2BBDF3EDE6BA9A6
8 changed files with 470 additions and 17 deletions

View file

@ -5,6 +5,7 @@ A native Rust application to sync songs and albums from your self-hosted Navidro
## Features ## Features
- 🎵 Sync songs and albums from Navidrome - 🎵 Sync songs and albums from Navidrome
- 🎼 Sync playlists from Navidrome (as M3U8 files)
- 📱 Support for iPod stock OS - 📱 Support for iPod stock OS
- 🎸 Support for Rockbox firmware - 🎸 Support for Rockbox firmware
- 📊 Scrobble listening history to ListenBrainz - 📊 Scrobble listening history to ListenBrainz
@ -69,9 +70,15 @@ cargo run -- sync --album "Album Name"
# Sync specific artist # Sync specific artist
cargo run -- sync --artist "Artist Name" cargo run -- sync --artist "Artist Name"
# Sync specific playlist only
cargo run -- sync --playlist "Playlist Name"
# List available albums # List available albums
cargo run -- list-albums cargo run -- list-albums
# List available playlists
cargo run -- list-playlists
# Check iPod connection # Check iPod connection
cargo run -- check cargo run -- check
@ -85,6 +92,28 @@ cargo run -- scrobble --clear
cargo run -- scrobble --clear --no-backup 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 ### Scrobbling to ListenBrainz
NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz: NaviPod can read the `.scrobbler.log` file from your Rockbox iPod and submit your listening history to ListenBrainz:

View file

@ -18,8 +18,8 @@ firmware = "rockbox"
[sync] [sync]
# Sync albums # Sync albums
albums = true albums = true
# Sync playlists (not yet implemented) # Sync playlists from Navidrome to iPod (stored as M3U8 files in Playlists folder)
playlists = false playlists = true
# Preferred audio format (mp3, flac, ogg, etc.) # Preferred audio format (mp3, flac, ogg, etc.)
format = "mp3" format = "mp3"

View file

@ -14,7 +14,11 @@ pkgs.mkShell {
]; ];
shellHook = '' shellHook = ''
echo "NaviPod Development Environment" echo ""
echo " NaviPod Development Environment "
echo " Sync Navidrome to iPod | Scrobble to ListenBrainz "
echo ""
echo ""
# Verify Rust is available # Verify Rust is available
if command -v rustc &> /dev/null; then if command -v rustc &> /dev/null; then
@ -26,15 +30,34 @@ pkgs.mkShell {
fi fi
echo "" echo ""
echo "Welcome to NaviPod development shell!" echo "📦 Build Commands:"
echo "Run 'cargo build' to build the project." echo " cargo build - Build in debug mode"
echo "Run 'cargo run' to run the CLI." echo " cargo build --release - Build optimized release"
echo "Run 'cargo check' to check for compilation errors." 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 <NAME> Sync specific album"
echo " --artist <NAME> Sync specific artist"
echo " --playlist <NAME> 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 ""
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"
''; '';
} }

View file

@ -108,7 +108,7 @@ impl Ipod {
.collect() .collect()
} }
fn sanitize_filename(&self, filename: &str) -> String { pub fn sanitize_filename(&self, filename: &str) -> String {
self.sanitize_path(filename) self.sanitize_path(filename)
} }
@ -139,6 +139,56 @@ impl Ipod {
pub fn file_exists(&self, path: &Path) -> bool { pub fn file_exists(&self, path: &Path) -> bool {
path.exists() 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<PathBuf> {
// 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<()> { pub fn check_ipod(config: &IpodConfig) -> Result<()> {

View file

@ -32,9 +32,14 @@ pub enum Commands {
/// Sync specific artist /// Sync specific artist
#[arg(long)] #[arg(long)]
artist: Option<String>, artist: Option<String>,
/// Sync specific playlist by name
#[arg(long)]
playlist: Option<String>,
}, },
/// List available albums from Navidrome /// List available albums from Navidrome
ListAlbums, ListAlbums,
/// List available playlists from Navidrome
ListPlaylists,
/// Check iPod connection and status /// Check iPod connection and status
Check, Check,
/// Scrobble listening history from iPod to ListenBrainz /// Scrobble listening history from iPod to ListenBrainz
@ -52,7 +57,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Commands::Sync { album, artist } => { Commands::Sync { album, artist, playlist } => {
// Create multi-progress for TUI // Create multi-progress for TUI
let multi = MultiProgress::new(); let multi = MultiProgress::new();
@ -86,7 +91,7 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
}); });
// Run sync with progress // 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 // Finish progress bars
pb.finish_with_message("Sync complete!"); pb.finish_with_message("Sync complete!");
@ -111,11 +116,40 @@ pub async fn run(config: Config) -> anyhow::Result<()> {
println!("Fetching albums..."); println!("Fetching albums...");
let albums = navidrome.list_albums().await?; let albums = navidrome.list_albums().await?;
println!("Available albums:"); println!("\nAvailable albums ({} total):", albums.len());
for album in albums { for album in albums {
println!(" - {} by {}", album.name, album.artist); 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 => { Commands::Check => {
ipod::check_ipod(&config.ipod)?; ipod::check_ipod(&config.ipod)?;
println!("✓ iPod connection OK"); println!("✓ iPod connection OK");

View file

@ -116,3 +116,4 @@ impl ListenBrainzClient {
} }
} }

View file

@ -36,6 +36,16 @@ pub struct Song {
pub cover_art_id: Option<String>, pub cover_art_id: Option<String>,
} }
#[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)] #[derive(Debug, Serialize, Deserialize)]
struct LoginResponse { struct LoginResponse {
#[serde(rename = "subsonic-response")] #[serde(rename = "subsonic-response")]
@ -465,6 +475,188 @@ impl NavidromeClient {
Ok(()) Ok(())
} }
pub async fn list_playlists(&self) -> Result<Vec<Playlist>> {
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(&params).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<PlaylistsWrapper>,
}
#[derive(Debug, Deserialize)]
struct PlaylistsWrapper {
playlist: PlaylistOrArray,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum PlaylistOrArray {
Multiple(Vec<PlaylistResponse>),
Single(PlaylistResponse),
}
impl PlaylistOrArray {
fn into_vec(self) -> Vec<PlaylistResponse> {
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<Vec<Song>> {
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(&params).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<PlaylistWithSongs>,
}
#[derive(Debug, Deserialize)]
struct PlaylistWithSongs {
#[serde(rename = "entry")]
entry: Option<SongOrArray>,
}
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)> { fn get_credentials(&self) -> Result<(&str, &str)> {
let creds = self let creds = self
.auth_token .auth_token

View file

@ -12,14 +12,16 @@ pub async fn sync_content(
config: Config, config: Config,
album_filter: Option<String>, album_filter: Option<String>,
artist_filter: Option<String>, artist_filter: Option<String>,
playlist_filter: Option<String>,
) -> Result<()> { ) -> 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( pub async fn sync_content_with_progress(
config: Config, config: Config,
album_filter: Option<String>, album_filter: Option<String>,
artist_filter: Option<String>, artist_filter: Option<String>,
playlist_filter: Option<String>,
progress_callback: Option<ProgressCallback>, progress_callback: Option<ProgressCallback>,
) -> Result<()> { ) -> Result<()> {
info!("Starting sync..."); 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 // Cleanup temp directory
if temp_dir.exists() { if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir)?; std::fs::remove_dir_all(&temp_dir)?;
@ -173,3 +219,81 @@ async fn sync_album(
Ok(()) 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(())
}