unsyncing podcasts feature; local playlists for syncing only
This commit is contained in:
+13
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)?;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user