feat: Add support for syncing playlists
Signed-off-by: SindreKjelsrud <sindre@kjelsrud.dev>
This commit is contained in:
parent
f1b31fabda
commit
86d45f324d
8 changed files with 470 additions and 17 deletions
29
README.md
29
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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
43
shell.nix
43
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 <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 "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"
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
52
src/ipod.rs
52
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<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<()> {
|
||||
|
|
|
|||
40
src/lib.rs
40
src/lib.rs
|
|
@ -32,9 +32,14 @@ pub enum Commands {
|
|||
/// Sync specific artist
|
||||
#[arg(long)]
|
||||
artist: Option<String>,
|
||||
/// Sync specific playlist by name
|
||||
#[arg(long)]
|
||||
playlist: Option<String>,
|
||||
},
|
||||
/// 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");
|
||||
|
|
|
|||
|
|
@ -116,3 +116,4 @@ impl ListenBrainzClient {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
192
src/navidrome.rs
192
src/navidrome.rs
|
|
@ -36,6 +36,16 @@ pub struct Song {
|
|||
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)]
|
||||
struct LoginResponse {
|
||||
#[serde(rename = "subsonic-response")]
|
||||
|
|
@ -465,6 +475,188 @@ impl NavidromeClient {
|
|||
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(¶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<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(¶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<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)> {
|
||||
let creds = self
|
||||
.auth_token
|
||||
|
|
|
|||
126
src/sync.rs
126
src/sync.rs
|
|
@ -12,14 +12,16 @@ pub async fn sync_content(
|
|||
config: Config,
|
||||
album_filter: Option<String>,
|
||||
artist_filter: Option<String>,
|
||||
playlist_filter: Option<String>,
|
||||
) -> 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<String>,
|
||||
artist_filter: Option<String>,
|
||||
playlist_filter: Option<String>,
|
||||
progress_callback: Option<ProgressCallback>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue