From 7c744d575a501c0cc8dad1a0645317ca861ea71d Mon Sep 17 00:00:00 2001 From: Aaron Manning Date: Sat, 2 May 2026 09:14:40 +0100 Subject: [PATCH] unsyncing podcasts feature; local playlists for syncing only --- src/folders.rs | 15 ++++++-- src/input.rs | 46 ++++++++++++++++++------- src/main.rs | 37 +++----------------- src/playlist.rs | 92 ++++++++++++++++++++++++++++++++++++++++--------- src/sync.rs | 69 +++++++++++++++++++++++++------------ 5 files changed, 174 insertions(+), 85 deletions(-) diff --git a/src/folders.rs b/src/folders.rs index 6d8e033..743df75 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -8,8 +8,9 @@ pub(crate) const LOCAL_PLAYLISTS_DIR: &str = "playlists"; pub(crate) const IPOD_PODCASTS_DIR: &str = "Podcasts"; -pub(crate) const LISTENED_PLAYLIST_PATH: &str = "[PC Meta] [Listened].m3u"; -pub(crate) const MASTER_PLAYLIST_PATH: &str = "[PC Meta] [Master Feed].m3u"; +pub(crate) const LISTENED_PLAYLIST_PATH: &str = "[PC] (Listened).m3u"; +pub(crate) const MASTER_PLAYLIST_PATH: &str = "[PC] (Master Feed).m3u"; +pub(crate) const MASTER_PLAYLIST_NAME: &str = "[PC] (Master Feed)"; pub(crate) const PLAYLIST_PREFIX: &str = "[PC]"; pub(crate) const UNLISTENED_SUFFIX: &str = "(unlistened)"; @@ -20,3 +21,13 @@ pub(crate) fn podcast_folder( ) -> path::PathBuf { root.join(LOCAL_PODCASTS_DIR).join(sanitise(alias)) } + +pub(crate) fn spec_path( + root: &path::Path, + alias: &str, +) -> path::PathBuf { + let folder = podcast_folder(root, alias); + folder.join(SPEC_FILE) +} + + diff --git a/src/input.rs b/src/input.rs index 30d2f4b..180bde6 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,5 +1,9 @@ -use std::path; -use std::collections::BTreeMap; +use std::{ + fs, + path, + collections::BTreeMap, +}; +use anyhow::Context; #[derive(clap::Parser)] pub(crate) struct Args { @@ -43,11 +47,7 @@ pub(crate) enum Command { podcast: String, }, /// Generates playlist for use with an iPod. - Playlist { - /// The podcast to generate the playlist for. - #[arg(long, short)] - podcast: Option, - }, + Playlist, /// Syncs local copy of podcasts to Rockbox iPod. Sync { /// Directory to the root of the iPod running Rockbox. @@ -65,14 +65,33 @@ pub(crate) struct Config { pub(crate) podcasts: BTreeMap, } +impl Config { + pub(crate) fn read(config_path: &path::Path) -> Result { + let config = fs::read_to_string(&config_path) + .context("failed to read in podcast configuration file")?; + + Ok(toml::from_str(&config[..])?) + } +} + +fn default_true() -> bool { + true +} + +fn default_false() -> bool { + false +} + #[derive(serde::Deserialize)] #[serde(untagged)] pub(crate) enum Source { String(String), Object { source: String, - #[serde(rename = "skip-download")] + #[serde(rename = "skip-download", default = "default_false")] skip_download: bool, + #[serde(default = "default_true")] + sync: bool, }, } @@ -90,6 +109,13 @@ impl Source { } } + pub(crate) fn sync(&self) -> bool { + match self { + Self::String(_) => true, + Self::Object { sync, .. } => *sync, + } + } + fn is_url(&self) -> bool { self.source_raw().starts_with("http") } @@ -107,7 +133,3 @@ pub(crate) enum SourceKind<'a> { Url(&'a str), Path(&'a str), } - - - - diff --git a/src/main.rs b/src/main.rs index 18259e6..862967a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,6 @@ mod download; use input::{Command, ListenStatus}; -use std::fs; - -use anyhow::Context; - fn main() -> anyhow::Result<()> { let args = { @@ -19,12 +15,7 @@ fn main() -> anyhow::Result<()> { input::Args::parse() }; - let config: input::Config = { - let config = fs::read_to_string(&args.config) - .context("failed to read in podcast configuration file")?; - - toml::from_str(&config[..])? - }; + let config = input::Config::read(&args.config)?; let config_path = args.config.canonicalize()?; let Some(root) = config_path.parent() else { @@ -79,20 +70,8 @@ fn main() -> anyhow::Result<()> { spec.write_to(&spec_file)?; } - Command::Playlist { podcast } => { - // Empty playlist folder. - // playlist::empty_playlists(root)?; - - if let Some(alias) = podcast { - playlist::generate_podcast_m3u(alias.as_str(), root, false)?; - playlist::generate_podcast_m3u(alias.as_str(), root, true)?; - } else { - for (alias, _) in &config.podcasts { - playlist::generate_podcast_m3u(alias.as_str(), root, true)?; - playlist::generate_podcast_m3u(alias.as_str(), root, false)?; - } - playlist::generate_master_m3u(&config, root)?; - } + Command::Playlist => { + playlist::regenerate_podcast_folder(&root, &config)?; } Command::Sync { ipod_dir, download } => { // Sync listened podcasts @@ -109,15 +88,7 @@ fn main() -> anyhow::Result<()> { } } - // Empty playlist folder. - // playlist::empty_playlists(root)?; - - // Generate updated playlist files - for (alias, _) in &config.podcasts { - playlist::generate_podcast_m3u(alias.as_str(), root, true)?; - playlist::generate_podcast_m3u(alias.as_str(), root, false)?; - } - playlist::generate_master_m3u(&config, root)?; + playlist::regenerate_podcast_folder(&root, &config)?; // Sync podcasts and playlists sync::sync(root, &config, &ipod_dir)?; diff --git a/src/playlist.rs b/src/playlist.rs index 7bbf673..f74572a 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -3,7 +3,10 @@ use std::{ io, iter, path, - collections::BTreeMap, + collections::{ + BTreeMap, + BTreeSet, + }, }; use crate::{ @@ -119,26 +122,32 @@ impl<'a> Playlist<'a> { pub(crate) fn generate_master_m3u( config: &input::Config, root: &path::Path, + // Only include episodes from podcasts which are marked in the config as + // those that should be synced. + synced_only: bool, ) -> anyhow::Result<()> { let mut playlist = Playlist::new(root); - for (podcast, _) in &config.podcasts { - let output = folders::podcast_folder(root, podcast.as_str()); - let spec_file = output.join(folders::SPEC_FILE); - let spec = Specification::read_from(&spec_file)?; + for (podcast, source) in &config.podcasts { + // Only include episode if marked as synced or if we are not just doing + // synced only. + if !synced_only || source.sync() { + let output = folders::podcast_folder(root, podcast); + let spec = Specification::read_from(&folders::spec_path(root, podcast))?; - let (feed, files) = spec.into_feed_and_files(); + let (feed, files) = spec.into_feed_and_files(); - for (published, episodes) in feed { - for episode in episodes { - let path = output.join( - files.get(episode.id()).unwrap() - ); + for (published, episodes) in feed { + for episode in episodes { + let path = output.join( + files.get(episode.id()).unwrap() + ); - playlist.insert( - published, - &path - ) + playlist.insert( + published, + &path + ) + } } } } @@ -161,6 +170,7 @@ pub(crate) fn empty_playlists( } fs::create_dir(&playlists_folder) .context("failed to create output directory for playlists")?; + Ok(()) } @@ -168,6 +178,7 @@ pub(crate) fn empty_playlists( pub(crate) fn generate_podcast_m3u( alias: &str, root: &path::Path, + // Write the unlistened feed (or the normal feed if false). unlistened_only: bool, ) -> anyhow::Result<()> { let mut playlist = Playlist::new(root); @@ -204,8 +215,57 @@ pub(crate) fn generate_podcast_m3u( }?; if written { - println!("[info] generated playlist for podcast {:?}.", alias); + println!("[info] generated {} playlist for podcast {:?}.", if unlistened_only { "unlistened" } else { "full" }, alias); } + Ok(()) +} + +fn format_playlist_title( + alias: &str, + unlistened: bool, +) -> String { + if unlistened { + format!("{} {} {}", folders::PLAYLIST_PREFIX, alias, folders::UNLISTENED_SUFFIX) + } else { + format!("{} {}", folders::PLAYLIST_PREFIX, alias) + } +} + + +pub(crate) fn regenerate_podcast_folder( + root: &path::Path, + config: &input::Config, +) -> anyhow::Result<()> { + // File stems (not including extensions) of playlists that we have written. + let mut acknowledged = BTreeSet::new(); + + // Generate updated playlist files + for (alias, source) in &config.podcasts { + if source.sync() { + acknowledged.insert(format_playlist_title(alias, true)); + acknowledged.insert(format_playlist_title(alias, false)); + + generate_podcast_m3u(alias.as_str(), root, true)?; + generate_podcast_m3u(alias.as_str(), root, false)?; + } + } + + acknowledged.insert(folders::MASTER_PLAYLIST_NAME.to_string()); + generate_master_m3u(&config, root, true)?; + + let playlists_folder = root.join(folders::LOCAL_PLAYLISTS_DIR); + for file in fs::read_dir(playlists_folder)? { + let path = file?.path(); + let file_name = path.file_name().unwrap(); + let stem = path.file_stem().unwrap().to_str().unwrap(); + + if !acknowledged.contains(stem) { + println!("[info] removing playlist file {:?}", file_name); + fs::remove_file(path)?; + } + } + + Ok(()) } diff --git a/src/sync.rs b/src/sync.rs index e1e79e1..d9eba68 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -148,25 +148,42 @@ pub(crate) fn sync( anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PODCASTS_DIR); } - for (podcast, _) in &config.podcasts { + for (podcast, spec_entry) in &config.podcasts { let output = folders::podcast_folder(root, podcast.as_str()); let spec_file = output.join(folders::SPEC_FILE); let spec = Specification::read_from(&spec_file)?; - for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() { - let episode_local_path = spec.path_from_id(episode.id()).unwrap(); - // let relative_path = path::PathBuf::from().join(podcast.as_str()).join(path); + // This is the directory on the target which corresponds to the + // podcast itself. + let output_podcast_dir = target_dir.join(output.strip_prefix(root).unwrap()); - let source = output.join(&episode_local_path); - let target = target_dir.join(source.strip_prefix(root).unwrap()); + // If the podcast shouldn't be synced, then we remove it if it + // exists. Note this only effects the location we are syncing to, + // not the local version. + if output_podcast_dir.is_dir() && !spec_entry.sync() { + println!("[info] removing {:?} to unsync {:?}", output_podcast_dir, podcast); + if let Err(_) = fs::remove_dir_all(&output_podcast_dir) { + println!("[error] failed to remove unsynced directory {:?}", output_podcast_dir); + } + } + // Otherwise we sync all applicable podcasts. + else { + for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() { + let episode_local_path = spec.path_from_id(episode.id()).unwrap(); + // let relative_path = path::PathBuf::from().join(podcast.as_str()).join(path); - if !target.exists() { - println!("[info] copying from {:?} to {:?}.", source, target); - let parent = target.parent().unwrap(); - if !parent.exists() { - fs::create_dir_all(parent)?; + let source = output.join(&episode_local_path); + + let target = output_podcast_dir.join(&episode_local_path); + + if !target.exists() && spec_entry.sync() { + println!("[info] copying from {:?} to {:?}.", source, target); + let parent = target.parent().unwrap(); + if !parent.exists() { + fs::create_dir_all(parent)?; + } + fs::copy(source, target)?; } - fs::copy(source, target)?; } } } @@ -181,6 +198,7 @@ pub(crate) fn sync( // This won't delete any other extra podcasts, and is just a temporary // fix to make sure empty unlistened playlists don't exist. let mut acknowledged = BTreeSet::new(); + for source in fs::read_dir(root.join(folders::LOCAL_PLAYLISTS_DIR))? { let source = source?.path(); let target = target_dir @@ -189,25 +207,32 @@ pub(crate) fn sync( acknowledged.insert(target.clone()); - if !target.exists() || fs::metadata(&target)?.modified()? < fs::metadata(&source)?.modified()? { + if !target.exists() + || fs::metadata(&target)?.modified()? < fs::metadata(&source)?.modified()? { + // || fs::read(&target)? != fs::read(&source)? { println!("[info] copying playlist {:?}.", source.file_name().unwrap()); fs::copy(source, target)?; } } - // Here we delete the excess unlistened playlists (if the don't exist - // in local directory, and thus correspond to empty playlists). - // - // This could very easily be edited to remove other excess playlists. + // Here we delete the excess playlists (if they don't exist + // in local directory). for target in fs::read_dir(target_dir.join(folders::LOCAL_PLAYLISTS_DIR))? { let target = target?.path(); - let file_name = target.file_stem().unwrap(); - let file_name = file_name.to_str().unwrap(); + let file_name = target.file_name().unwrap().to_str().unwrap(); + let file_stem = target.file_stem().unwrap().to_str().unwrap(); + let extension = target.extension().unwrap().to_str().unwrap(); - if file_name.starts_with(folders::PLAYLIST_PREFIX) - && file_name.ends_with(folders::UNLISTENED_SUFFIX) - && !acknowledged.contains(&target) { + // We delete playlists on the iPod which + // - are affiliated with this app (by some reasonable heuristics), + // - have not been copied on checked to be up to date, + // - is not the listened folder (which is special). + if file_stem.starts_with(folders::PLAYLIST_PREFIX) + && extension == "m3u" + && !acknowledged.contains(&target) + && file_name != folders::LISTENED_PLAYLIST_PATH { + println!("[info] unsyncing playlist file {:?}", file_name); fs::remove_file(&target)?; } }