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 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)
}
+34 -12
View File
@@ -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<String>,
},
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<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)]
#[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),
}
+4 -33
View File
@@ -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)?;
+66 -6
View File
@@ -3,7 +3,10 @@ use std::{
io,
iter,
path,
collections::BTreeMap,
collections::{
BTreeMap,
BTreeSet,
},
};
use crate::{
@@ -119,13 +122,18 @@ 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();
@@ -142,6 +150,7 @@ pub(crate) fn generate_master_m3u(
}
}
}
}
if playlist.write_as(folders::MASTER_PLAYLIST_PATH, true)? {
println!("[info] generated master playlist");
@@ -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(())
}
+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);
}
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)?;
// 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() {
let episode_local_path = spec.path_from_id(episode.id()).unwrap();
// let relative_path = path::PathBuf::from().join(podcast.as_str()).join(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);
let parent = target.parent().unwrap();
if !parent.exists() {
@@ -170,6 +186,7 @@ pub(crate) fn sync(
}
}
}
}
if !target_dir.join(folders::LOCAL_PLAYLISTS_DIR).exists() {
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
// 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)?;
}
}