reorganise modules
This commit is contained in:
136
src/download.rs
136
src/download.rs
@@ -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;
|
||||||
}
|
}
|
||||||
|
@@ -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.
|
||||||
|
22
src/main.rs
22
src/main.rs
@@ -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
142
src/manage.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
@@ -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(())
|
|
||||||
}
|
|
Reference in New Issue
Block a user