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 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
@@ -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
@@ -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)?;
|
||||||
|
|||||||
+76
-16
@@ -3,7 +3,10 @@ use std::{
|
|||||||
io,
|
io,
|
||||||
iter,
|
iter,
|
||||||
path,
|
path,
|
||||||
collections::BTreeMap,
|
collections::{
|
||||||
|
BTreeMap,
|
||||||
|
BTreeSet,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -119,26 +122,32 @@ 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();
|
||||||
|
|
||||||
for (published, episodes) in feed {
|
for (published, episodes) in feed {
|
||||||
for episode in episodes {
|
for episode in episodes {
|
||||||
let path = output.join(
|
let path = output.join(
|
||||||
files.get(episode.id()).unwrap()
|
files.get(episode.id()).unwrap()
|
||||||
);
|
);
|
||||||
|
|
||||||
playlist.insert(
|
playlist.insert(
|
||||||
published,
|
published,
|
||||||
&path
|
&path
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-22
@@ -148,25 +148,42 @@ 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)?;
|
||||||
|
|
||||||
for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() {
|
// This is the directory on the target which corresponds to the
|
||||||
let episode_local_path = spec.path_from_id(episode.id()).unwrap();
|
// podcast itself.
|
||||||
// let relative_path = path::PathBuf::from().join(podcast.as_str()).join(path);
|
let output_podcast_dir = target_dir.join(output.strip_prefix(root).unwrap());
|
||||||
|
|
||||||
let source = output.join(&episode_local_path);
|
// If the podcast shouldn't be synced, then we remove it if it
|
||||||
let target = target_dir.join(source.strip_prefix(root).unwrap());
|
// 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() {
|
let source = output.join(&episode_local_path);
|
||||||
println!("[info] copying from {:?} to {:?}.", source, target);
|
|
||||||
let parent = target.parent().unwrap();
|
let target = output_podcast_dir.join(&episode_local_path);
|
||||||
if !parent.exists() {
|
|
||||||
fs::create_dir_all(parent)?;
|
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
|
// 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)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user