Files
podcast-hoarder/src/sync.rs
2025-09-25 23:26:01 +10:00

192 lines
5.9 KiB
Rust

use std::{
collections::{
BTreeMap,
BTreeSet,
},
fs,
io,
path,
};
use crate::{
folders, input, manage::Specification
};
use anyhow::Context;
/// Rockbox writes a BOM to playlist files it creates, so we use this function
/// to extract the BOM if it exists, from a reader.
fn extract_bom<R: io::Read>(
reader: &mut io::BufReader<R>,
) -> anyhow::Result<()> {
use io::Read;
let mut bom = Vec::with_capacity(3);
let mut bom_reader = reader.take(3);
bom_reader.read_to_end(&mut bom)?;
if &bom[..] == &[0xEF, 0xBB, 0xBF] {
let mut dummy = [0u8; 3];
reader.read_exact(&mut dummy)?;
}
Ok(())
}
/// Takes a playlist of listened files and updates the status of the files in
/// the master copy.
pub(crate) fn sync_listened(
// The folder containing the podcasts.toml file.
root: &path::Path,
// The path to the m3u playlist file.
playlist: &path::Path,
// Whether the playlist file given should be emptied when done.
empty_file: bool,
) -> anyhow::Result<()> {
// The way the specification file was written makes this operation much
// slower than it needs to be, since there isn't a convenient lookup from
// file to episodes, only the other way around.
//
// In practice it hardly matters though, the playlist can never really be
// that big.
if !playlist.exists() {
println!("[warning] specified playlist file does not exist. skipping.");
if empty_file {
fs::write(playlist, [])?;
}
return Ok(());
}
let mut reader =
io::BufReader::new(fs::File::open(playlist)?);
// Extract the BOM if it exists
extract_bom(&mut reader)?;
let mut reader = m3u::Reader::new(
reader
);
// We first extract all of the played files as a map from the podcast name
// to all of the played episodes of that podcast.
let mut played = BTreeMap::<String, BTreeSet<_>>::new();
for entry in reader.entries() {
if let m3u::Entry::Path(entry) = entry? {
let mut components: Vec<_> = entry.components().rev().take(2).collect();
let Some(podcast) = components.pop() else {
println!("[warning] could not identify podcast for file {:?}.", entry);
continue;
};
let Some(file_name) = components.pop() else {
println!("[warning] empty file name found in playlist. skipping.");
continue;
};
let podcast = podcast.as_os_str().to_str().context("podcast name was not a valid string")?;
let file_name = path::PathBuf::from(file_name.as_os_str());
// We append the episode to the appropriate entry of the map, or
// create a new one if we are looking at the first episode of a
// given podcast.
match played.get_mut(podcast) {
Some(existing) => {
existing.insert(file_name.to_owned());
}
None => {
played.insert(
podcast.to_owned(),
BTreeSet::from([file_name.to_owned()]),
);
}
}
}
}
for (podcast, played) in played {
println!("[info] syncing played episodes for {}", podcast);
let output = folders::podcast_folder(root, podcast.as_str());
let spec_file = output.join(folders::SPEC_FILE);
let mut spec = Specification::read_from(&spec_file)?;
let (feed, files) = spec.files_and_feed_mut();
for (_, episodes) in feed.iter_mut() {
for episode in episodes {
if played.contains(files.get(episode.id()).as_deref().unwrap().as_ref()) {
println!("[info] marking {:?} as played", episode.title());
episode.listened = true;
}
}
}
spec.write_to(&spec_file)?;
}
if empty_file {
fs::write(playlist, [])?;
}
Ok(())
}
pub(crate) fn sync(
root: &path::Path,
config: &input::Config,
target_dir: &path::Path,
) -> anyhow::Result<()> {
if !target_dir.exists() {
anyhow::bail!("specified target directory does not exist");
}
if !target_dir.join(folders::LOCAL_PODCASTS_DIR).exists() {
anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PODCASTS_DIR);
}
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 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() {
println!("[info] copying from {:?} to {:?}.", source, target);
fs::create_dir_all(target.parent().unwrap())?;
fs::copy(source, target)?;
}
}
}
if !target_dir.join(folders::LOCAL_PLAYLISTS_DIR).exists() {
anyhow::bail!("specified target directory does not contain a folder {:?}", folders::LOCAL_PLAYLISTS_DIR);
}
for source in fs::read_dir(root.join(folders::LOCAL_PLAYLISTS_DIR))? {
let source = source?.path();
let target = target_dir
.join(folders::LOCAL_PLAYLISTS_DIR)
.join(source.file_name().unwrap());
if !target.exists() || fs::metadata(&target)?.modified()? < fs::metadata(&source)?.modified()? {
println!("[info] copying playlist {:?}.", source.file_name().unwrap());
fs::copy(source, target)?;
}
}
Ok(())
}