reorganise modules

This commit is contained in:
Aaron Manning
2025-09-01 10:07:28 +10:00
parent 2b55c5b0af
commit 5291730793
5 changed files with 181 additions and 170 deletions

View File

@@ -1,92 +1,21 @@
use std::fs; use std::{
use std::path; fs,
use std::borrow::Cow; path,
use std::iter::Iterator; borrow::Cow,
use std::collections::{HashMap, BTreeMap, HashSet}; collections::HashSet,
};
use anyhow::Context; use anyhow::Context;
use sanitise_file_name::sanitise; use sanitise_file_name::sanitise;
use crate::folders; use crate::{
use crate::rss; rss,
folders,
manage::{
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] Specification,
pub(crate) struct Specification<'a> { Episode,
files: HashMap<Cow<'a, str>, Cow<'a, path::Path>>, },
/// This is a collection of episodes, where each entry contains a `Vec` of };
/// episodes to allow for the possibility that multiple episodes have the
/// same timestamp.
feed: BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>>,
image_url: Option<Cow<'a, str>>,
}
impl<'a> Specification<'a> {
pub(crate) fn read_from_with_default(path: &path::Path) -> Result<Self, anyhow::Error> {
Ok(if path.is_file() {
toml::from_str(&fs::read_to_string(&path)?[..])?
} else {
Specification::default()
})
}
pub(crate) fn read_from(path: &path::Path) -> Result<Self, anyhow::Error> {
Ok(if path.is_file() {
toml::from_str(&fs::read_to_string(&path)?[..])?
} else {
anyhow::bail!("could not find specification for the desired podcast")
})
}
pub(crate) fn write_to(&self, path: &path::Path) -> Result<(), anyhow::Error> {
Ok(fs::write(path, toml::to_string(self)?.as_bytes())?)
}
pub(crate) fn feed_iter(&self) -> impl Iterator<Item = (&chrono::NaiveDateTime, &Vec<Episode<'a>>)> {
self.feed.iter()
}
pub(crate) fn feed_iter_mut(&mut self) -> impl Iterator<Item = (&chrono::NaiveDateTime, &mut Vec<Episode<'a>>)> {
self.feed.iter_mut()
}
pub(crate) fn path_from_id(&self, id: &str) -> Option<&path::Path> {
self.files.get(id).map(|v| &**v)
}
pub(crate) fn feed(&self) -> &BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>> {
&self.feed
}
pub(crate) fn into_feed_and_files(self) -> (BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>>, HashMap<Cow<'a, str>, Cow<'a, path::Path>>) {
(self.feed, self.files)
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub (crate) struct Episode<'a> {
/// Episode title.
title: Cow<'a, str>,
/// Show notes pulled from description or summary tag.
show_notes: Option<Cow<'a, str>>,
/// This is the GUID or the URL if the GUID is not present.
id: Cow<'a, str>,
/// If the episode exists in the latest version of the feed.
current: bool,
/// Flag to keep track of which episodes have been listened to.
#[serde(default)]
pub(crate) listened: bool,
}
impl<'a> Episode<'a> {
pub (crate) fn title(&self) -> &str {
self.title.as_ref()
}
pub(crate) fn id(&self) -> &str {
&self.id
}
}
fn download_to_file(url: &str, path: &path::Path) -> anyhow::Result<()> { fn download_to_file(url: &str, path: &path::Path) -> anyhow::Result<()> {
let response = minreq::get(url) let response = minreq::get(url)
@@ -169,7 +98,7 @@ fn update_artwork<'a, 'b>(
_ => None, _ => None,
}; };
match (&spec.image_url, image_url) { match (spec.image_url.as_deref(), image_url) {
// They match, so no need to change anything // They match, so no need to change anything
(Some(old), Some(new)) if old == new => (), (Some(old), Some(new)) if old == new => (),
// New and different URL // New and different URL
@@ -268,7 +197,7 @@ pub(crate) fn update_podcast_from_feed(
let id = guid.unwrap_or(url); let id = guid.unwrap_or(url);
match spec.files.get(id) { match spec.path_from_id(id) {
// File already downloaded // File already downloaded
Some(path) => { Some(path) => {
// File has been deleted by another process but the specification hasn't been updated // File has been deleted by another process but the specification hasn't been updated
@@ -315,30 +244,15 @@ pub(crate) fn update_podcast_from_feed(
Ok(()) => { Ok(()) => {
let file_path = file_path.canonicalize().unwrap(); let file_path = file_path.canonicalize().unwrap();
spec.files.insert( spec.insert_into_files(
Cow::from(id.to_owned()), id.to_owned(),
Cow::from(file_path.strip_prefix(&output).unwrap().to_owned()), file_path.strip_prefix(&output).unwrap().to_owned(),
); )?;
let episode = Episode { let episode = Episode::new_downloaded(title, description, id.to_owned());
show_notes: description,
id: Cow::from(id.to_owned()),
current: true,
title,
listened: false,
};
match spec.feed.get_mut(&item.published) {
Some(existing) => { spec.insert_into_feed(item.published, episode);
existing.push(episode)
},
None => {
spec.feed.insert(
item.published,
vec![episode],
);
}
}
// Update the file as we go, but only if a change has occured // Update the file as we go, but only if a change has occured
spec.write_to(&spec_file)?; spec.write_to(&spec_file)?;
@@ -354,9 +268,9 @@ pub(crate) fn update_podcast_from_feed(
let mut feed_change = false; let mut feed_change = false;
// Setting episodes which have been removed to no longer be current // Setting episodes which have been removed to no longer be current
for (_, episodes) in &mut spec.feed { for (_, episodes) in spec.feed_iter_mut() {
for episode in episodes { for episode in episodes {
if !current_episodes.contains(episode.id.as_ref()) { if !current_episodes.contains(episode.id()) {
episode.current = false; episode.current = false;
feed_change = true; feed_change = true;
} }

View File

@@ -42,12 +42,6 @@ pub(crate) enum Command {
#[arg(long, short)] #[arg(long, short)]
podcast: String, podcast: String,
}, },
/// Tags files for use with an iPod, such that they don't show up in albums and artists.
Tag {
/// The podcast to tag.
#[arg(long, short)]
podcast: Option<String>,
},
/// Generates playlist for use with an iPod. /// Generates playlist for use with an iPod.
Playlist { Playlist {
/// The podcast to generate the playlist for. /// The podcast to generate the playlist for.

View File

@@ -1,7 +1,8 @@
mod rss; mod rss;
mod input; mod input;
mod tagging; mod manage;
mod folders; mod folders;
mod playlist;
mod download; mod download;
use input::{Command, ListenStatus}; use input::{Command, ListenStatus};
@@ -51,7 +52,7 @@ fn main() -> anyhow::Result<()> {
let output = folders::podcast_folder(root, &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 = manage::Specification::read_from(&spec_file)?;
for (_, episodes) in spec.feed_iter() { for (_, episodes) in spec.feed_iter() {
for episode in episodes { for episode in episodes {
@@ -67,7 +68,7 @@ fn main() -> anyhow::Result<()> {
let output = folders::podcast_folder(root, &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 = manage::Specification::read_from(&spec_file)?;
for (_, episodes) in spec.feed_iter_mut() { for (_, episodes) in spec.feed_iter_mut() {
for episode in episodes { for episode in episodes {
@@ -79,21 +80,12 @@ fn main() -> anyhow::Result<()> {
}, },
Command::Playlist { podcast } => { Command::Playlist { podcast } => {
if let Some(alias) = podcast { if let Some(alias) = podcast {
tagging::generate_podcast_m3u(alias.as_str(), root)?; playlist::generate_podcast_m3u(alias.as_str(), root)?;
} else { } else {
for (alias, _) in &config.podcasts { for (alias, _) in &config.podcasts {
tagging::generate_podcast_m3u(alias.as_str(), root)?; playlist::generate_podcast_m3u(alias.as_str(), root)?;
}
tagging::generate_master_m3u(&config, root)?;
}
},
Command::Tag { podcast } => {
if let Some(alias) = podcast {
tagging::strip_tags(alias.as_str(), root)?;
} else {
for (alias, _) in config.podcasts {
tagging::strip_tags(alias.as_str(), root)?;
} }
playlist::generate_master_m3u(&config, root)?;
} }
}, },
}; };

142
src/manage.rs Normal file
View File

@@ -0,0 +1,142 @@
use std::{
fs,
path,
borrow::Cow,
collections::{
BTreeMap,
HashMap,
},
};
use anyhow::Context;
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct Specification<'a> {
files: HashMap<Cow<'a, str>, Cow<'a, path::Path>>,
/// This is a collection of episodes, where each entry contains a `Vec` of
/// episodes to allow for the possibility that multiple episodes have the
/// same timestamp.
feed: BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>>,
pub(in crate) image_url: Option<Cow<'a, str>>,
}
impl<'a> Specification<'a> {
/// Reads from the specification file from a given path, or gives a default
/// if the file doesn't exist.
pub(crate) fn read_from_with_default(
path: &path::Path
) -> Result<Self, anyhow::Error> {
Ok(if path.is_file() {
toml::from_str(&fs::read_to_string(&path)?[..])?
} else {
Specification::default()
})
}
/// Reads from the specification file from a given path.
pub(crate) fn read_from(
path: &path::Path
) -> Result<Self, anyhow::Error> {
Ok(if path.is_file() {
toml::from_str(&fs::read_to_string(&path)?[..])?
} else {
anyhow::bail!("could not find specification for the desired podcast")
})
}
/// Writes the specification to the specific file path.
pub(crate) fn write_to(&self, path: &path::Path) -> Result<(), anyhow::Error> {
Ok(fs::write(path, toml::to_string(self)?.as_bytes())?)
}
pub(crate) fn feed_iter(&self) -> impl Iterator<Item = (&chrono::NaiveDateTime, &Vec<Episode<'a>>)> {
self.feed.iter()
}
pub(crate) fn feed_iter_mut(&mut self) -> impl Iterator<Item = (&chrono::NaiveDateTime, &mut Vec<Episode<'a>>)> {
self.feed.iter_mut()
}
pub(crate) fn path_from_id(&self, id: &str) -> Option<&path::Path> {
self.files.get(id).map(|v| &**v)
}
pub(crate) fn into_feed_and_files(
self
) -> (
BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>>,
HashMap<Cow<'a, str>, Cow<'a, path::Path>>,
) {
(self.feed, self.files)
}
pub(crate) fn insert_into_files(
&mut self,
id: impl Into<Cow<'a, str>>,
path: impl Into<Cow<'a, path::Path>>,
) -> anyhow::Result<()> {
self.files.insert(
id.into(),
path.into()
)
.context("insertion of episode with duplicate id")
.map(|_| ())
}
pub(crate) fn insert_into_feed(
&mut self,
published: chrono::NaiveDateTime,
episode: Episode<'a>
) {
match self.feed.get_mut(&published) {
Some(existing) => {
existing.push(episode)
},
None => {
self.feed.insert(
published,
vec![episode],
);
}
}
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub (crate) struct Episode<'a> {
/// Episode title.
title: Cow<'a, str>,
/// Show notes pulled from description or summary tag.
show_notes: Option<Cow<'a, str>>,
/// This is the GUID or the URL if the GUID is not present.
id: Cow<'a, str>,
/// If the episode exists in the latest version of the feed.
pub(crate) current: bool,
/// Flag to keep track of which episodes have been listened to.
#[serde(default)]
pub(crate) listened: bool,
}
impl<'a> Episode<'a> {
pub(crate) fn new_downloaded(
title: impl Into<Cow<'a, str>>,
show_notes: Option<Cow<'a, str>>,
id: impl Into<Cow<'a, str>>,
) -> Self {
Self {
title: title.into(),
show_notes,
id: id.into(),
current: true,
listened: false,
}
}
pub(crate) fn title(&self) -> &str {
self.title.as_ref()
}
pub(crate) fn id(&self) -> &str {
&self.id
}
}

View File

@@ -1,6 +1,10 @@
use std::{fs, path, collections::BTreeMap}; use std::{fs, path, collections::BTreeMap};
use crate::{input, folders, download}; use crate::{
input,
folders,
manage::Specification,
};
use anyhow::Context; use anyhow::Context;
@@ -15,7 +19,7 @@ pub(crate) fn generate_master_m3u(
for (podcast, _) in &config.podcasts { for (podcast, _) 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("spec.toml"); let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?; let spec = Specification::read_from(&spec_file)?;
let (feed, files) = spec.into_feed_and_files(); let (feed, files) = spec.into_feed_and_files();
@@ -72,7 +76,7 @@ pub(crate) fn generate_podcast_m3u(
let output = folders::podcast_folder(root, 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 = Specification::read_from(&spec_file)?;
let mut playlist = Vec::new(); let mut playlist = Vec::new();
@@ -108,38 +112,3 @@ pub(crate) fn generate_podcast_m3u(
Ok(()) Ok(())
} }
pub(crate) fn strip_tags(
alias: &str,
root: &path::Path,
) -> anyhow::Result<()> {
println!("[info] retagging podcast {}", alias);
let output = folders::podcast_folder(root, alias);
let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?;
for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() {
let path = output.join(
spec.path_from_id(episode.id()).unwrap()
);
let file = audiotags::Tag::new().read_from_path(
&path
);
let Ok(mut file) = file else {
println!("[warning] failed to read mp3 audio tags file. skipping.");
continue
};
file.remove_artist();
file.remove_album();
file.set_genre("Podcast");
file.set_title(episode.title());
file.write_to_path(path.as_path().to_str().unwrap())?;
}
Ok(())
}