unsyncing podcasts feature; local playlists for syncing only

This commit is contained in:
Aaron Manning
2026-05-02 09:14:40 +01:00
parent f0c77aed29
commit 7c744d575a
5 changed files with 174 additions and 85 deletions
+13 -2
View File
@@ -8,8 +8,9 @@ pub(crate) const LOCAL_PLAYLISTS_DIR: &str = "playlists";
pub(crate) const IPOD_PODCASTS_DIR: &str = "Podcasts"; pub(crate) const IPOD_PODCASTS_DIR: &str = "Podcasts";
pub(crate) const LISTENED_PLAYLIST_PATH: &str = "[PC Meta] [Listened].m3u"; pub(crate) const LISTENED_PLAYLIST_PATH: &str = "[PC] (Listened).m3u";
pub(crate) const MASTER_PLAYLIST_PATH: &str = "[PC Meta] [Master Feed].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 PLAYLIST_PREFIX: &str = "[PC]";
pub(crate) const UNLISTENED_SUFFIX: &str = "(unlistened)"; pub(crate) const UNLISTENED_SUFFIX: &str = "(unlistened)";
@@ -20,3 +21,13 @@ pub(crate) fn podcast_folder(
) -> path::PathBuf { ) -> path::PathBuf {
root.join(LOCAL_PODCASTS_DIR).join(sanitise(alias)) 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)
}
+34 -12
View File
@@ -1,5 +1,9 @@
use std::path; use std::{
use std::collections::BTreeMap; fs,
path,
collections::BTreeMap,
};
use anyhow::Context;
#[derive(clap::Parser)] #[derive(clap::Parser)]
pub(crate) struct Args { pub(crate) struct Args {
@@ -43,11 +47,7 @@ pub(crate) enum Command {
podcast: String, podcast: String,
}, },
/// Generates playlist for use with an iPod. /// Generates playlist for use with an iPod.
Playlist { Playlist,
/// The podcast to generate the playlist for.
#[arg(long, short)]
podcast: Option<String>,
},
/// Syncs local copy of podcasts to Rockbox iPod. /// Syncs local copy of podcasts to Rockbox iPod.
Sync { Sync {
/// Directory to the root of the iPod running Rockbox. /// Directory to the root of the iPod running Rockbox.
@@ -65,14 +65,33 @@ pub(crate) struct Config {
pub(crate) podcasts: BTreeMap<String, Source>, pub(crate) podcasts: BTreeMap<String, Source>,
} }
impl Config {
pub(crate) fn read(config_path: &path::Path) -> Result<Config, anyhow::Error> {
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)] #[derive(serde::Deserialize)]
#[serde(untagged)] #[serde(untagged)]
pub(crate) enum Source { pub(crate) enum Source {
String(String), String(String),
Object { Object {
source: String, source: String,
#[serde(rename = "skip-download")] #[serde(rename = "skip-download", default = "default_false")]
skip_download: bool, 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 { fn is_url(&self) -> bool {
self.source_raw().starts_with("http") self.source_raw().starts_with("http")
} }
@@ -107,7 +133,3 @@ pub(crate) enum SourceKind<'a> {
Url(&'a str), Url(&'a str),
Path(&'a str), Path(&'a str),
} }
+4 -33
View File
@@ -8,10 +8,6 @@ mod download;
use input::{Command, ListenStatus}; use input::{Command, ListenStatus};
use std::fs;
use anyhow::Context;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
let args = { let args = {
@@ -19,12 +15,7 @@ fn main() -> anyhow::Result<()> {
input::Args::parse() input::Args::parse()
}; };
let config: input::Config = { let config = input::Config::read(&args.config)?;
let config = fs::read_to_string(&args.config)
.context("failed to read in podcast configuration file")?;
toml::from_str(&config[..])?
};
let config_path = args.config.canonicalize()?; let config_path = args.config.canonicalize()?;
let Some(root) = config_path.parent() else { let Some(root) = config_path.parent() else {
@@ -79,20 +70,8 @@ fn main() -> anyhow::Result<()> {
spec.write_to(&spec_file)?; spec.write_to(&spec_file)?;
} }
Command::Playlist { podcast } => { Command::Playlist => {
// Empty playlist folder. playlist::regenerate_podcast_folder(&root, &config)?;
// 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::Sync { ipod_dir, download } => { Command::Sync { ipod_dir, download } => {
// Sync listened podcasts // Sync listened podcasts
@@ -109,15 +88,7 @@ fn main() -> anyhow::Result<()> {
} }
} }
// Empty playlist folder. playlist::regenerate_podcast_folder(&root, &config)?;
// 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)?;
// Sync podcasts and playlists // Sync podcasts and playlists
sync::sync(root, &config, &ipod_dir)?; sync::sync(root, &config, &ipod_dir)?;
+66 -6
View File
@@ -3,7 +3,10 @@ use std::{
io, io,
iter, iter,
path, path,
collections::BTreeMap, collections::{
BTreeMap,
BTreeSet,
},
}; };
use crate::{ use crate::{
@@ -119,13 +122,18 @@ impl<'a> Playlist<'a> {
pub(crate) fn generate_master_m3u( pub(crate) fn generate_master_m3u(
config: &input::Config, config: &input::Config,
root: &path::Path, 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<()> { ) -> anyhow::Result<()> {
let mut playlist = Playlist::new(root); let mut playlist = Playlist::new(root);
for (podcast, _) in &config.podcasts { for (podcast, source) in &config.podcasts {
let output = folders::podcast_folder(root, podcast.as_str()); // Only include episode if marked as synced or if we are not just doing
let spec_file = output.join(folders::SPEC_FILE); // synced only.
let spec = Specification::read_from(&spec_file)?; 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();
@@ -142,6 +150,7 @@ pub(crate) fn generate_master_m3u(
} }
} }
} }
}
if playlist.write_as(folders::MASTER_PLAYLIST_PATH, true)? { if playlist.write_as(folders::MASTER_PLAYLIST_PATH, true)? {
println!("[info] generated master playlist"); println!("[info] generated master playlist");
@@ -161,6 +170,7 @@ pub(crate) fn empty_playlists(
} }
fs::create_dir(&playlists_folder) fs::create_dir(&playlists_folder)
.context("failed to create output directory for playlists")?; .context("failed to create output directory for playlists")?;
Ok(()) Ok(())
} }
@@ -168,6 +178,7 @@ pub(crate) fn empty_playlists(
pub(crate) fn generate_podcast_m3u( pub(crate) fn generate_podcast_m3u(
alias: &str, alias: &str,
root: &path::Path, root: &path::Path,
// Write the unlistened feed (or the normal feed if false).
unlistened_only: bool, unlistened_only: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let mut playlist = Playlist::new(root); let mut playlist = Playlist::new(root);
@@ -204,8 +215,57 @@ pub(crate) fn generate_podcast_m3u(
}?; }?;
if written { 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(()) Ok(())
} }
+38 -13
View File
@@ -148,19 +148,35 @@ pub(crate) fn sync(
anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PODCASTS_DIR); 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 output = folders::podcast_folder(root, podcast.as_str());
let spec_file = output.join(folders::SPEC_FILE); let spec_file = output.join(folders::SPEC_FILE);
let spec = Specification::read_from(&spec_file)?; let spec = Specification::read_from(&spec_file)?;
// 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());
// 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() { for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() {
let episode_local_path = spec.path_from_id(episode.id()).unwrap(); let episode_local_path = spec.path_from_id(episode.id()).unwrap();
// let relative_path = path::PathBuf::from().join(podcast.as_str()).join(path); // let relative_path = path::PathBuf::from().join(podcast.as_str()).join(path);
let source = output.join(&episode_local_path); let source = output.join(&episode_local_path);
let target = target_dir.join(source.strip_prefix(root).unwrap());
if !target.exists() { let target = output_podcast_dir.join(&episode_local_path);
if !target.exists() && spec_entry.sync() {
println!("[info] copying from {:?} to {:?}.", source, target); println!("[info] copying from {:?} to {:?}.", source, target);
let parent = target.parent().unwrap(); let parent = target.parent().unwrap();
if !parent.exists() { if !parent.exists() {
@@ -170,6 +186,7 @@ pub(crate) fn sync(
} }
} }
} }
}
if !target_dir.join(folders::LOCAL_PLAYLISTS_DIR).exists() { if !target_dir.join(folders::LOCAL_PLAYLISTS_DIR).exists() {
anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PLAYLISTS_DIR); anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PLAYLISTS_DIR);
@@ -181,6 +198,7 @@ pub(crate) fn sync(
// This won't delete any other extra podcasts, and is just a temporary // This won't delete any other extra podcasts, and is just a temporary
// fix to make sure empty unlistened playlists don't exist. // fix to make sure empty unlistened playlists don't exist.
let mut acknowledged = BTreeSet::new(); let mut acknowledged = BTreeSet::new();
for source in fs::read_dir(root.join(folders::LOCAL_PLAYLISTS_DIR))? { for source in fs::read_dir(root.join(folders::LOCAL_PLAYLISTS_DIR))? {
let source = source?.path(); let source = source?.path();
let target = target_dir let target = target_dir
@@ -189,25 +207,32 @@ pub(crate) fn sync(
acknowledged.insert(target.clone()); 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()); println!("[info] copying playlist {:?}.", source.file_name().unwrap());
fs::copy(source, target)?; fs::copy(source, target)?;
} }
} }
// Here we delete the excess unlistened playlists (if the don't exist // Here we delete the excess playlists (if they don't exist
// in local directory, and thus correspond to empty playlists). // in local directory).
//
// This could very easily be edited to remove other excess playlists.
for target in fs::read_dir(target_dir.join(folders::LOCAL_PLAYLISTS_DIR))? { for target in fs::read_dir(target_dir.join(folders::LOCAL_PLAYLISTS_DIR))? {
let target = target?.path(); let target = target?.path();
let file_name = target.file_stem().unwrap(); let file_name = target.file_name().unwrap().to_str().unwrap();
let file_name = file_name.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) // We delete playlists on the iPod which
&& file_name.ends_with(folders::UNLISTENED_SUFFIX) // - are affiliated with this app (by some reasonable heuristics),
&& !acknowledged.contains(&target) { // - 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)?; fs::remove_file(&target)?;
} }
} }