move podcasts to within nested podcasts folder (significant breaking change)

This commit is contained in:
Aaron Manning
2025-08-29 21:46:22 +10:00
parent cb47ff0cb8
commit 07b43ba2dc
6 changed files with 65 additions and 51 deletions

View File

@@ -7,8 +7,10 @@ use std::collections::{HashMap, BTreeMap, HashSet};
use anyhow::Context; use anyhow::Context;
use sanitise_file_name::sanitise; use sanitise_file_name::sanitise;
use crate::folders;
use crate::rss; use crate::rss;
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] #[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct Specification<'a> { pub(crate) struct Specification<'a> {
files: HashMap<Cow<'a, str>, Cow<'a, path::Path>>, files: HashMap<Cow<'a, str>, Cow<'a, path::Path>>,
@@ -98,10 +100,10 @@ pub(crate) fn update_podcast(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Create output directory // Create output directory
let output = root.join(sanitise(&alias)); let output = folders::podcast_folder(root, alias);
if !output.exists() { if !output.exists() {
fs::create_dir(&output) fs::create_dir_all(&output)
.with_context(|| format!("failed to create output directory for podcast {}", alias))?; .context(format!("failed to create output directory for podcast {}", alias))?;
} }
println!(r#"[info] scanning feed for "{}""#, alias); println!(r#"[info] scanning feed for "{}""#, alias);
@@ -115,7 +117,7 @@ pub(crate) fn update_podcast(
.with_header("User-Agent", "podcast-downloader") .with_header("User-Agent", "podcast-downloader")
.with_header("Accept", "*/*") .with_header("Accept", "*/*")
.send() .send()
.with_context(|| format!(r#"error when requesting feed url "{}" for {}"#, feed_url, alias))?; .context(format!(r#"error when requesting feed url "{}" for {}"#, feed_url, alias))?;
if response.status_code != 200 { if response.status_code != 200 {
eprintln!(r#"[error] feed "{}" for alias {} responded with non-200 ({}) status code"#, feed_url, alias, response.status_code); eprintln!(r#"[error] feed "{}" for alias {} responded with non-200 ({}) status code"#, feed_url, alias, response.status_code);

12
src/folders.rs Normal file
View File

@@ -0,0 +1,12 @@
use std::path;
use sanitise_file_name::sanitise;
pub(crate) const PODCASTS_DIR: &str = "Podcasts";
pub(crate) fn podcast_folder(
root: &path::Path,
alias: &str,
) -> path::PathBuf {
root.join(PODCASTS_DIR).join(sanitise(alias))
}

View File

@@ -2,58 +2,58 @@ use std::path;
use std::collections::HashMap; use std::collections::HashMap;
#[derive(clap::Parser)] #[derive(clap::Parser)]
pub (crate) struct Args { pub(crate) struct Args {
/// Path to the configuration file listing podcast RSS feeds. /// Path to the configuration file listing podcast RSS feeds.
#[arg(default_value = "./podcasts.toml")] #[arg(default_value = "./podcasts.toml")]
pub (crate) config : path::PathBuf, pub(crate) config: path::PathBuf,
#[command(subcommand)] #[command(subcommand)]
pub (crate) command : Command, pub(crate) command: Command,
} }
#[derive(clap::ValueEnum, Copy, Clone, Debug,PartialEq, Eq, PartialOrd, Ord)] #[derive(clap::ValueEnum, Copy, Clone, Debug,PartialEq, Eq, PartialOrd, Ord)]
pub (crate) enum ListenStatus { pub(crate) enum ListenStatus {
Listened, Listened,
Unlistened, Unlistened,
} }
#[derive(clap::Subcommand)] #[derive(clap::Subcommand)]
pub (crate) enum Command { pub(crate) enum Command {
/// Updates feed and downloads latest episodes. /// Updates feed and downloads latest episodes.
Download { Download {
/// The podcast to update. Updates all in configuration file if unspecified. /// The podcast to update. Updates all in configuration file if unspecified.
#[arg(long, short)] #[arg(long, short)]
podcast : Option<String>, podcast: Option<String>,
}, },
/// Lists the episodes for a given podcast, filtered based on if it has been listened or not. /// Lists the episodes for a given podcast, filtered based on if it has been listened or not.
List { List {
/// Filter for if episodes have been listened to or not. /// Filter for if episodes have been listened to or not.
#[arg(long, short)] #[arg(long, short)]
status : Option<ListenStatus>, status: Option<ListenStatus>,
/// The podcast to list episodes for. /// The podcast to list episodes for.
#[arg(long, short)] #[arg(long, short)]
podcast : String, podcast: String,
}, },
/// Marks an entire podcast's history of episodes as played or unplayed. /// Marks an entire podcast's history of episodes as played or unplayed.
Mark { Mark {
/// The new listen status for the episodes. /// The new listen status for the episodes.
#[arg(long, short)] #[arg(long, short)]
status : ListenStatus, status: ListenStatus,
/// The podcast to change the listen status of. /// The podcast to change the listen status of.
#[arg(long, short)] #[arg(long, short)]
podcast : String, podcast: String,
}, },
/// Tags files and generates playlists ready for use with an iPod. /// Tags files and generates playlists ready for use with an iPod.
Tag { Tag {
/// The podcast to tag and generate playlists for. /// The podcast to tag and generate playlists for.
#[arg(long, short)] #[arg(long, short)]
podcast : Option<String>, podcast: Option<String>,
}, },
} }
/// Struct modelling configuration file format. /// Struct modelling configuration file format.
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub (crate) struct Config { pub(crate) struct Config {
/// Map from podcast alias to RSS feed either as a url (prefix: http) or file path. /// Map from podcast alias to RSS feed either as a url (prefix: http) or file path.
pub (crate) podcasts : HashMap<String, String>, pub(crate) podcasts: HashMap<String, String>,
} }

View File

@@ -1,6 +1,7 @@
mod rss; mod rss;
mod input; mod input;
mod tagging; mod tagging;
mod folders;
mod download; mod download;
use input::{Command, ListenStatus}; use input::{Command, ListenStatus};
@@ -8,7 +9,6 @@ use input::{Command, ListenStatus};
use std::fs; use std::fs;
use anyhow::Context; use anyhow::Context;
use sanitise_file_name::sanitise;
fn main() -> anyhow::Result<()> { fn main() -> anyhow::Result<()> {
@@ -17,7 +17,7 @@ fn main() -> anyhow::Result<()> {
input::Args::parse() input::Args::parse()
}; };
let config : input::Config = { let config: input::Config = {
let config = fs::read_to_string(&args.config) let config = fs::read_to_string(&args.config)
.context("failed to read in podcast configuration file")?; .context("failed to read in podcast configuration file")?;
@@ -48,7 +48,7 @@ fn main() -> anyhow::Result<()> {
} }
}, },
Command::List { status, podcast } => { Command::List { status, podcast } => {
let output = root.join(sanitise(&podcast)); let output = folders::podcast_folder(root, &podcast);
let spec_file = output.join("spec.toml"); let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?; let spec = download::Specification::read_from(&spec_file)?;
@@ -64,7 +64,7 @@ fn main() -> anyhow::Result<()> {
} }
}, },
Command::Mark { status, podcast } => { Command::Mark { status, podcast } => {
let output = root.join(sanitise(&podcast)); let output = folders::podcast_folder(root, &podcast);
let spec_file = output.join("spec.toml"); let spec_file = output.join("spec.toml");
let mut spec = download::Specification::read_from(&spec_file)?; let mut spec = download::Specification::read_from(&spec_file)?;

View File

@@ -8,55 +8,55 @@ use std::borrow::Cow;
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Feed<'a> { pub struct Feed<'a> {
pub (crate) rss : Rss<'a>, pub(crate) rss: Rss<'a>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Rss<'a> { pub struct Rss<'a> {
pub (crate) channel : Channel<'a>, pub(crate) channel: Channel<'a>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Channel<'a> { pub struct Channel<'a> {
#[serde(rename = "item", default)] #[serde(rename = "item", default)]
pub (crate) items : Vec<Item<'a>>, pub(crate) items: Vec<Item<'a>>,
pub (crate) link : Option<Cow<'a, str>>, pub(crate) link: Option<Cow<'a, str>>,
pub (crate) title : Cow<'a, str>, pub(crate) title: Cow<'a, str>,
pub (crate) description : Option<Cow<'a, str>>, pub(crate) description: Option<Cow<'a, str>>,
#[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:author")] #[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:author")]
pub (crate) author : Option<Cow<'a, str>>, pub(crate) author: Option<Cow<'a, str>>,
#[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:summary")] #[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:summary")]
pub (crate) summary : Option<Cow<'a, str>>, pub(crate) summary: Option<Cow<'a, str>>,
#[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:image")] #[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:image")]
pub (crate) itunes_image : Option<ItunesImage<'a>>, pub(crate) itunes_image: Option<ItunesImage<'a>>,
pub (crate) image : Option<Image<'a>>, pub(crate) image: Option<Image<'a>>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Image<'a> { pub struct Image<'a> {
pub (crate) link : Option<Cow<'a, str>>, pub(crate) link: Option<Cow<'a, str>>,
pub (crate) title : Cow<'a, str>, pub(crate) title: Cow<'a, str>,
pub (crate) url : Cow<'a, str>, pub(crate) url: Cow<'a, str>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct ItunesImage<'a> { pub struct ItunesImage<'a> {
#[serde(rename = "$attr:href")] #[serde(rename = "$attr:href")]
pub (crate) href : Cow<'a, str>, pub(crate) href: Cow<'a, str>,
} }
fn deserialize_publish_date<'de, D : serde::de::Deserializer<'de>> ( fn deserialize_publish_date<'de, D: serde::de::Deserializer<'de>> (
deserializer : D deserializer: D
) -> Result<chrono::NaiveDateTime, D::Error> { ) -> Result<chrono::NaiveDateTime, D::Error> {
struct Visitor; struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor { impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = chrono::NaiveDateTime; type Value = chrono::NaiveDateTime;
fn expecting(&self, formatter : &mut fmt::Formatter) -> fmt::Result { fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a string containing json data") formatter.write_str("a string containing json data")
} }
fn visit_str<E : serde::de::Error>(self, input : &str) -> Result<Self::Value, E> { fn visit_str<E: serde::de::Error>(self, input: &str) -> Result<Self::Value, E> {
chrono::NaiveDateTime::parse_from_str( chrono::NaiveDateTime::parse_from_str(
input, input,
"%a, %d %b %Y %H:%M:%S %Z" "%a, %d %b %Y %H:%M:%S %Z"
@@ -69,23 +69,23 @@ fn deserialize_publish_date<'de, D : serde::de::Deserializer<'de>> (
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Item<'a> { pub struct Item<'a> {
pub (crate) title : Cow<'a, str>, pub(crate) title: Cow<'a, str>,
pub (crate) enclosure : Option<Enclosure<'a>>, pub(crate) enclosure: Option<Enclosure<'a>>,
pub (crate) description : Option<Cow<'a, str>>, pub(crate) description: Option<Cow<'a, str>>,
#[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:summary")] #[serde(rename = "{http://www.itunes.com/dtds/podcast-1.0.dtd}itunes:summary")]
pub (crate) summary : Option<Cow<'a, str>>, pub(crate) summary: Option<Cow<'a, str>>,
#[serde(rename = "pubDate", deserialize_with = "deserialize_publish_date")] #[serde(rename = "pubDate", deserialize_with = "deserialize_publish_date")]
pub (crate) published : chrono::NaiveDateTime, pub(crate) published: chrono::NaiveDateTime,
pub (crate) guid : Option<Cow<'a, str>>, pub(crate) guid: Option<Cow<'a, str>>,
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]
pub struct Enclosure<'a> { pub struct Enclosure<'a> {
#[serde(rename = "$attr:url")] #[serde(rename = "$attr:url")]
pub (crate) url : Cow<'a, str>, pub(crate) url: Cow<'a, str>,
#[serde(rename = "$attr:type")] #[serde(rename = "$attr:type")]
pub (crate) mime_type : Option<Cow<'a, str>>, pub(crate) mime_type: Option<Cow<'a, str>>,
//#[serde(rename = "$attr:length")] //#[serde(rename = "$attr:length")]
//pub (crate) length : Option<u64>, //pub(crate) length: Option<u64>,
} }

View File

@@ -1,6 +1,6 @@
use std::{fs, path}; use std::{fs, path};
use crate::download; use crate::{folders, download};
use anyhow::Context; use anyhow::Context;
@@ -10,7 +10,7 @@ pub(crate) fn generate_m3u(
alias: &str, alias: &str,
root: &path::Path, root: &path::Path,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let output = root.join(sanitise(&alias)); let output = folders::podcast_folder(root, alias);
let spec_file = output.join("spec.toml"); let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?; let spec = download::Specification::read_from(&spec_file)?;
@@ -54,7 +54,7 @@ pub(crate) fn strip_tags(
alias: &str, alias: &str,
root: &path::Path, root: &path::Path,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let output = root.join(sanitise(&alias)); let output = folders::podcast_folder(root, alias);
let spec_file = output.join("spec.toml"); let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?; let spec = download::Specification::read_from(&spec_file)?;