Initial import into Git
This commit is contained in:
commit
aac6248878
|
@ -0,0 +1 @@
|
|||
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "podbringer"
|
||||
version = "0.1.0"
|
||||
authors = ["Paul van Tilburg <paul@luon.net>"]
|
||||
edition = "2021"
|
||||
description = "Web services to provide an podcast (RSS) interface for Mixcloud"
|
||||
#readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
cached = "0.34.0"
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
color-eyre = "0.6.1"
|
||||
reqwest = { version = "0.11.10", features = ["json"] }
|
||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||
rss = "2.0.1"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.6.1", features = ["process"] }
|
|
@ -0,0 +1,145 @@
|
|||
// #![doc = include_str!("../README.md")]
|
||||
#![warn(
|
||||
clippy::all,
|
||||
missing_debug_implementations,
|
||||
rust_2018_idioms,
|
||||
rustdoc::broken_intra_doc_links
|
||||
)]
|
||||
// #![deny(missing_docs)]
|
||||
|
||||
use std::process::Stdio;
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use reqwest::Url;
|
||||
use rocket::response::stream::ReaderStream;
|
||||
use rocket::{get, routes, Build, Responder, Rocket};
|
||||
use rss::extension::itunes::ITunesItemExtensionBuilder;
|
||||
use rss::{
|
||||
CategoryBuilder, ChannelBuilder, EnclosureBuilder, GuidBuilder, ImageBuilder, ItemBuilder,
|
||||
};
|
||||
use tokio::process::{ChildStdout, Command};
|
||||
|
||||
pub(crate) mod mixcloud;
|
||||
|
||||
/// A Rocket responder wrapper type for RSS feeds.
|
||||
#[derive(Responder)]
|
||||
#[response(content_type = "application/xml")]
|
||||
struct RssFeed(String);
|
||||
|
||||
/// Handler for retrieving the RSS feed of an Mixcloud user.
|
||||
#[get("/<username>")]
|
||||
async fn feed(username: &str) -> Option<RssFeed> {
|
||||
let user = mixcloud::get_user(username).await?;
|
||||
let cloudcasts = mixcloud::get_cloudcasts(username).await?;
|
||||
let mut last_build = DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc);
|
||||
let generator = String::from(concat!(
|
||||
env!("CARGO_PKG_NAME"),
|
||||
" ",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
|
||||
let items = cloudcasts
|
||||
.into_iter()
|
||||
.map(|cloudcast| {
|
||||
let slug = cloudcast.slug;
|
||||
let mut url = Url::parse("http://localhost:8000/download").unwrap();
|
||||
url.query_pairs_mut().append_pair("url", &cloudcast.url);
|
||||
let description = format!("Taken from Mixcloud: <{}>", cloudcast.url);
|
||||
let keywords = cloudcast
|
||||
.tags
|
||||
.iter()
|
||||
.map(|tag| &tag.name)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let categories = cloudcast
|
||||
.tags
|
||||
.into_iter()
|
||||
.map(|tag| {
|
||||
CategoryBuilder::default()
|
||||
.name(tag.name)
|
||||
.domain(Some(tag.url))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let enclosure = EnclosureBuilder::default()
|
||||
.url(url)
|
||||
.length(format!(
|
||||
"{}",
|
||||
mixcloud::estimated_file_size(cloudcast.audio_length)
|
||||
))
|
||||
.mime_type(String::from(mixcloud::default_file_type()))
|
||||
.build();
|
||||
let guid = GuidBuilder::default().value(slug).permalink(false).build();
|
||||
let itunes_ext = ITunesItemExtensionBuilder::default()
|
||||
.image(Some(cloudcast.pictures.large))
|
||||
.duration(Some(format!("{}", cloudcast.audio_length)))
|
||||
.subtitle(Some(description.clone()))
|
||||
.keywords(Some(keywords))
|
||||
.build();
|
||||
|
||||
if cloudcast.updated_time > last_build {
|
||||
last_build = cloudcast.updated_time;
|
||||
}
|
||||
|
||||
ItemBuilder::default()
|
||||
.title(Some(cloudcast.name))
|
||||
.link(Some(cloudcast.url))
|
||||
.description(Some(description))
|
||||
.categories(categories)
|
||||
.enclosure(Some(enclosure))
|
||||
.guid(Some(guid))
|
||||
.pub_date(Some(cloudcast.updated_time.to_rfc2822()))
|
||||
.itunes_ext(Some(itunes_ext))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let image = ImageBuilder::default()
|
||||
.link(user.pictures.large.clone())
|
||||
.url(user.pictures.large)
|
||||
.build();
|
||||
|
||||
let channel = ChannelBuilder::default()
|
||||
.title(&format!("{} via Mixcloud", user.name))
|
||||
.link(&user.url)
|
||||
.description(&user.biog)
|
||||
.last_build_date(Some(last_build.to_rfc2822()))
|
||||
.generator(Some(generator))
|
||||
.image(Some(image))
|
||||
.items(items)
|
||||
.build();
|
||||
let feed = RssFeed(channel.to_string());
|
||||
|
||||
Some(feed)
|
||||
}
|
||||
|
||||
/// Retrieves a download using youtube-dl.
|
||||
#[get("/?<url>")]
|
||||
pub(crate) async fn download(url: &str) -> Option<ReaderStream![ChildStdout]> {
|
||||
let parsed_url = Url::parse(url).ok()?;
|
||||
let mut cmd = Command::new("youtube-dl");
|
||||
cmd.args(&["--output", "-"])
|
||||
.arg(parsed_url.as_str())
|
||||
.stdout(Stdio::piped());
|
||||
|
||||
println!("▶️ Streaming enclosure from {parsed_url} using youtube-dl...");
|
||||
let mut child = cmd.spawn().ok()?;
|
||||
let stdout = child.stdout.take()?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.expect("child process encounterd an error");
|
||||
println!("✅ youtube-dl finished with {}", status);
|
||||
});
|
||||
|
||||
Some(ReaderStream::one(stdout))
|
||||
}
|
||||
/// Sets up Rocket.
|
||||
pub fn setup() -> Rocket<Build> {
|
||||
rocket::build()
|
||||
.mount("/mixcloud", routes![feed])
|
||||
.mount("/download", routes![download])
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
// #![doc = include_str!("../README.md")]
|
||||
#![warn(
|
||||
clippy::all,
|
||||
missing_debug_implementations,
|
||||
rust_2018_idioms,
|
||||
rustdoc::broken_intra_doc_links
|
||||
)]
|
||||
// #![deny(missing_docs)]
|
||||
|
||||
use color_eyre::Result;
|
||||
|
||||
/// Sets up and launches Rocket.
|
||||
#[rocket::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
let rocket = podbringer::setup();
|
||||
let _ = rocket.ignite().await?.launch().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -0,0 +1,129 @@
|
|||
//! The Mixcloud back-end.
|
||||
//!
|
||||
//! It uses the Mixcloud API to retrieve the feed (user) and items (cloudcasts)).
|
||||
//! See also: <https://www.mixcloud.com/developers/>
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Url;
|
||||
use rocket::serde::Deserialize;
|
||||
|
||||
/// A Mixcloud user.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct User {
|
||||
/// The name of the user.
|
||||
pub(crate) name: String,
|
||||
|
||||
/// The bio (description) of the user.
|
||||
pub(crate) biog: String,
|
||||
|
||||
/// The picture URLs associated with the user.
|
||||
pub(crate) pictures: Pictures,
|
||||
|
||||
/// The original URL of the user.
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
/// A collection of different sizes/variants of a picture.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Pictures {
|
||||
/// The large picture of the user.
|
||||
pub(crate) large: String,
|
||||
}
|
||||
|
||||
/// The Mixcloud cloudcasts container.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct CloudcastData {
|
||||
/// The contained cloudcasts.
|
||||
data: Vec<Cloudcast>,
|
||||
}
|
||||
|
||||
/// A Mixcloud cloudcast.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Cloudcast {
|
||||
/// The name of the cloudcast.
|
||||
pub(crate) name: String,
|
||||
|
||||
/// The slug of the cloudcast (used for the enclosure).
|
||||
pub(crate) slug: String,
|
||||
|
||||
/// The picture URLs associated with the cloudcast.
|
||||
pub(crate) pictures: Pictures,
|
||||
|
||||
/// The tags of the cloudcast.
|
||||
pub(crate) tags: Vec<Tag>,
|
||||
|
||||
/// The time the feed was created/started.
|
||||
pub(crate) updated_time: DateTime<Utc>,
|
||||
|
||||
/// The original URL of the cloudcast.
|
||||
pub(crate) url: String,
|
||||
|
||||
/// The length of the cloudcast (in seconds).
|
||||
pub(crate) audio_length: u32,
|
||||
}
|
||||
|
||||
/// A Mixcloud cloudcast tag.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(crate = "rocket::serde")]
|
||||
pub(crate) struct Tag {
|
||||
/// The name of the tag.
|
||||
pub(crate) name: String,
|
||||
|
||||
/// The URL of the tag.
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
/// The base URL for the Mixcloud API.
|
||||
const API_BASE_URL: &str = "https://api.mixcloud.com";
|
||||
|
||||
/// The default bitrate used by
|
||||
const DEFAULT_BITRATE: u32 = 64 * 1024;
|
||||
|
||||
/// The default file (MIME) type.
|
||||
const DEFAULT_FILE_TYPE: &str = "audio/mpeg";
|
||||
|
||||
/// Returns the default file type used by Mixcloud.
|
||||
pub(crate) fn default_file_type() -> &'static str {
|
||||
DEFAULT_FILE_TYPE
|
||||
}
|
||||
|
||||
/// Returns the estimated file size in bytes for a given duration.
|
||||
///
|
||||
/// This uses the default bitrate (see [`DEFAULT_BITRATE`]) which is in B/s.
|
||||
pub(crate) fn estimated_file_size(duration: u32) -> u32 {
|
||||
DEFAULT_BITRATE * duration / 8
|
||||
}
|
||||
|
||||
/// Retrieves the user data using the Mixcloud API.
|
||||
pub(crate) async fn get_user(username: &str) -> Option<User> {
|
||||
let mut url = Url::parse(API_BASE_URL).unwrap();
|
||||
url.set_path(username);
|
||||
|
||||
println!("⏬ Retrieving user {username} from {url}...");
|
||||
let response = reqwest::get(url).await.ok()?;
|
||||
let user = match response.error_for_status() {
|
||||
Ok(res) => res.json().await.ok()?,
|
||||
Err(_err) => return None,
|
||||
};
|
||||
|
||||
Some(user)
|
||||
}
|
||||
|
||||
/// Retrieves the cloudcasts of the user using the Mixcloud API.
|
||||
pub(crate) async fn get_cloudcasts(username: &str) -> Option<Vec<Cloudcast>> {
|
||||
let mut url = Url::parse(API_BASE_URL).unwrap();
|
||||
url.set_path(&format!("{username}/cloudcasts/"));
|
||||
|
||||
println!("⏬ Retrieving cloudcasts of user {username} from {url}...");
|
||||
let response = reqwest::get(url).await.ok()?;
|
||||
let cloudcasts: CloudcastData = match response.error_for_status() {
|
||||
Ok(res) => res.json().await.ok()?,
|
||||
Err(_err) => return None,
|
||||
};
|
||||
|
||||
Some(cloudcasts.data)
|
||||
}
|
Loading…
Reference in New Issue