initial code for tag stripping and m3u generation

This commit is contained in:
Aaron Manning
2025-08-29 21:33:20 +10:00
parent 105a3eb892
commit cb47ff0cb8
6 changed files with 337 additions and 49 deletions

View File

@@ -9,15 +9,18 @@ use sanitise_file_name::sanitise;
use crate::rss;
#[derive(Default, serde::Serialize, serde::Deserialize)]
pub (crate) struct Specification<'a> {
files : HashMap<Cow<'a, str>, Cow<'a, path::Path>>,
feed : BTreeMap<chrono::NaiveDateTime, Vec<Episode<'a>>>,
image_url : Option<Cow<'a, str>>,
#[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>>>,
image_url: Option<Cow<'a, str>>,
}
impl<'a> Specification<'a> {
pub (crate) fn read_from_with_default(path : &path::Path) -> Result<Self, anyhow::Error> {
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 {
@@ -25,7 +28,7 @@ impl<'a> Specification<'a> {
})
}
pub (crate) fn read_from(path : &path::Path) -> Result<Self, anyhow::Error> {
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 {
@@ -33,41 +36,49 @@ impl<'a> Specification<'a> {
})
}
pub (crate) fn write_to(&self, path : &path::Path) -> Result<(), anyhow::Error> {
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>>)> {
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>>)> {
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)
}
}
#[derive(serde::Serialize, serde::Deserialize)]
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub (crate) struct Episode<'a> {
/// Episode title.
title : Cow<'a, str>,
title: Cow<'a, str>,
/// Show notes pulled from description or summary tag.
show_notes : Option<Cow<'a, str>>,
show_notes: Option<Cow<'a, str>>,
/// This is the GUID or the URL if the GUID is not present.
id : Cow<'a, str>,
id: Cow<'a, str>,
/// If the episode exists in the latest version of the feed.
current : bool,
current: bool,
/// Flag to keep track of which episodes have been listened to.
#[serde(default)]
pub (crate) listened : bool,
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)
.send()?;
@@ -80,10 +91,10 @@ fn download_to_file(url : &str, path : &path::Path) -> anyhow::Result<()> {
Ok(())
}
pub (crate) fn update_podcast(
alias : &str,
root : &path::Path,
feed_location : &str,
pub(crate) fn update_podcast(
alias: &str,
root: &path::Path,
feed_location: &str,
) -> anyhow::Result<()> {
// Create output directory
@@ -126,7 +137,7 @@ pub (crate) fn update_podcast(
}
}
fn extract_extension_from_url(url : &str) -> Result<Option<String>, url::ParseError> {
fn extract_extension_from_url(url: &str) -> Result<Option<String>, url::ParseError> {
let mut url_edited = url::Url::parse(url)?;
url_edited.set_query(None);
@@ -137,10 +148,10 @@ fn extract_extension_from_url(url : &str) -> Result<Option<String>, url::ParseEr
}
fn update_artwork<'a, 'b>(
channel : &rss::Channel<'a>,
spec : &mut Specification<'b>,
output : &path::Path,
) -> anyhow::Result<()> where 'a : 'b {
channel: &rss::Channel<'a>,
spec: &mut Specification<'b>,
output: &path::Path,
) -> anyhow::Result<()> where 'a: 'b {
let image_url = match (&channel.image, &channel.itunes_image) {
(Some(image), _) => Some(&image.url),
@@ -156,7 +167,7 @@ fn update_artwork<'a, 'b>(
match extract_extension_from_url(new.as_ref()) {
Ok(Some(extension)) => {
let cover_path = output.join(format!("cover.{}", extension));
let cover_path = output.join(format!("cover-original.{}", extension));
// Remove cover with conflicting file path if it exists
if cover_path.exists() {
@@ -184,9 +195,9 @@ fn update_artwork<'a, 'b>(
}
pub (crate) fn update_podcast_from_feed(
output : &path::Path,
feed : &str,
pub(crate) fn update_podcast_from_feed(
output: &path::Path,
feed: &str,
) -> anyhow::Result<()> {
let feed = match xml_serde::from_str::<rss::Feed>(&feed) {
@@ -300,11 +311,11 @@ pub (crate) fn update_podcast_from_feed(
);
let episode = Episode {
show_notes : description,
id : Cow::from(id.to_owned()),
current : true,
show_notes: description,
id: Cow::from(id.to_owned()),
current: true,
title,
listened : false,
listened: false,
};
match spec.feed.get_mut(&item.published) {
@@ -352,12 +363,12 @@ pub (crate) fn update_podcast_from_feed(
/// Given a file path `something.xyz`, returns the first path of the form
/// `something(a).xyz` where `a` is a non-negative integer which does not
/// currently exist, or `something.xyz` if it itself does not exist.
fn increment_file_name(path : &path::Path) -> Cow<'_, path::Path> {
fn increment_file_name(path: &path::Path) -> Cow<'_, path::Path> {
if path.exists() {
let mut new_path = path.to_owned();
let mut i : u32 = 0;
let mut i: u32 = 0;
while new_path.exists() {
let mut stem = path.file_stem().unwrap().to_owned();

View File

@@ -42,6 +42,12 @@ pub (crate) enum Command {
#[arg(long, short)]
podcast : String,
},
/// Tags files and generates playlists ready for use with an iPod.
Tag {
/// The podcast to tag and generate playlists for.
#[arg(long, short)]
podcast : Option<String>,
},
}
/// Struct modelling configuration file format.

View File

@@ -1,5 +1,6 @@
mod rss;
mod input;
mod tagging;
mod download;
use input::{Command, ListenStatus};
@@ -28,7 +29,6 @@ fn main() -> anyhow::Result<()> {
anyhow::bail!("could not get parent of configuration path for root directory")
};
match args.command {
Command::Download { podcast } => {
// Updating single podcast
@@ -77,10 +77,18 @@ fn main() -> anyhow::Result<()> {
spec.write_to(&spec_file)?;
},
Command::Tag { podcast } => {
if let Some(alias) = podcast {
tagging::generate_m3u(alias.as_str(), root)?;
tagging::strip_tags(alias.as_str(), root)?;
} else {
for (alias, _) in config.podcasts {
tagging::generate_m3u(alias.as_str(), root)?;
tagging::strip_tags(alias.as_str(), root)?;
}
}
}
};
Ok(())
}

81
src/tagging.rs Normal file
View File

@@ -0,0 +1,81 @@
use std::{fs, path};
use crate::download;
use anyhow::Context;
use sanitise_file_name::sanitise;
pub(crate) fn generate_m3u(
alias: &str,
root: &path::Path,
) -> anyhow::Result<()> {
let output = root.join(sanitise(&alias));
let spec_file = output.join("spec.toml");
let spec = download::Specification::read_from(&spec_file)?;
let mut playlist = Vec::new();
for episode in spec.feed_iter().map(|(_, eps)| eps.iter()).flatten() {
let path = output.join(
spec.path_from_id(episode.id()).unwrap()
);
playlist.push(m3u::path_entry({
let relative = path.strip_prefix(
output.parent().unwrap()
).unwrap();
path::Path::new("/Podcasts").join(relative)
}));
}
// Write the playlist file
{
let playlists_folder = root.join("Playlists");
if !playlists_folder.exists() {
fs::create_dir(&playlists_folder)
.context(format!("failed to create output directory for playlists"))?;
}
let mut path = playlists_folder.join(sanitise(&alias));
path.set_extension("m3u");
let mut file = fs::File::create(path)?;
let mut writer = m3u::Writer::new(&mut file);
for entry in &playlist {
writer.write_entry(entry)?;
}
}
Ok(())
}
pub(crate) fn strip_tags(
alias: &str,
root: &path::Path,
) -> anyhow::Result<()> {
let output = root.join(sanitise(&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 mut file = audiotags::Tag::new().read_from_path(
&path
)?;
file.remove_title();
file.remove_artist();
file.remove_year();
file.remove_album();
file.set_genre("Podcast");
file.set_title(episode.title());
file.write_to_path(path.as_path().to_str().unwrap())?;
}
Ok(())
}