2022-05-20 16:18:13 +02:00
|
|
|
#![doc = include_str!("../README.md")]
|
2022-05-17 11:15:22 +02:00
|
|
|
#![warn(
|
|
|
|
clippy::all,
|
2022-05-26 20:03:44 +02:00
|
|
|
missing_copy_implementations,
|
2022-05-17 11:15:22 +02:00
|
|
|
missing_debug_implementations,
|
|
|
|
rust_2018_idioms,
|
2022-05-26 20:03:44 +02:00
|
|
|
rustdoc::broken_intra_doc_links,
|
|
|
|
trivial_numeric_casts
|
2022-05-17 11:15:22 +02:00
|
|
|
)]
|
2022-05-20 16:18:13 +02:00
|
|
|
#![deny(missing_docs)]
|
2022-05-17 11:15:22 +02:00
|
|
|
|
2022-05-24 09:58:57 +02:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
2022-05-17 11:15:22 +02:00
|
|
|
use chrono::{DateTime, NaiveDateTime, Utc};
|
2022-05-20 16:15:33 +02:00
|
|
|
use rocket::fairing::AdHoc;
|
2022-05-24 09:58:57 +02:00
|
|
|
use rocket::http::uri::Absolute;
|
2022-05-26 19:57:16 +02:00
|
|
|
use rocket::http::Status;
|
2022-05-23 22:21:38 +02:00
|
|
|
use rocket::response::Redirect;
|
2022-05-20 16:15:33 +02:00
|
|
|
use rocket::serde::{Deserialize, Serialize};
|
2022-05-26 19:57:16 +02:00
|
|
|
use rocket::{get, routes, uri, Build, Request, Responder, Rocket, State};
|
2022-05-24 11:04:27 +02:00
|
|
|
use rocket_dyn_templates::{context, Template};
|
2022-05-24 10:35:10 +02:00
|
|
|
use rss::extension::itunes::{
|
|
|
|
ITunesCategoryBuilder, ITunesChannelExtensionBuilder, ITunesItemExtensionBuilder,
|
|
|
|
};
|
2022-05-17 11:15:22 +02:00
|
|
|
use rss::{
|
|
|
|
CategoryBuilder, ChannelBuilder, EnclosureBuilder, GuidBuilder, ImageBuilder, ItemBuilder,
|
|
|
|
};
|
|
|
|
|
|
|
|
pub(crate) mod mixcloud;
|
|
|
|
|
2022-05-26 19:57:16 +02:00
|
|
|
/// The possible errors that can occur.
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
|
|
pub(crate) enum Error {
|
2022-06-05 21:58:02 +02:00
|
|
|
/// A standard I/O error occurred.
|
2022-05-26 19:57:16 +02:00
|
|
|
#[error("IO error: {0}")]
|
|
|
|
Io(#[from] std::io::Error),
|
|
|
|
|
2022-06-05 21:58:02 +02:00
|
|
|
/// No redirect URL found in item metadata.
|
2022-05-26 19:57:16 +02:00
|
|
|
#[error("No redirect URL found")]
|
|
|
|
NoRedirectUrlFound,
|
|
|
|
|
2022-06-05 21:58:02 +02:00
|
|
|
/// A (reqwest) HTTP error occurred.
|
2022-05-26 19:57:16 +02:00
|
|
|
#[error("HTTP error: {0}")]
|
|
|
|
Request(#[from] reqwest::Error),
|
|
|
|
|
2022-06-05 21:58:02 +02:00
|
|
|
/// Unsupported back-end encountered.
|
|
|
|
#[error("Unsupported back-end: {0}")]
|
2022-05-26 19:57:16 +02:00
|
|
|
UnsupportedBackend(String),
|
2022-05-26 20:37:27 +02:00
|
|
|
|
2022-06-05 21:58:02 +02:00
|
|
|
/// A URL parse error occurred.
|
2022-05-26 22:06:22 +02:00
|
|
|
#[error("URL parse error: {0}")]
|
|
|
|
UrlParse(#[from] url::ParseError),
|
|
|
|
|
2022-06-05 21:58:02 +02:00
|
|
|
/// An error occurred in youtube-dl.
|
|
|
|
#[error("Youtube-dl failed: {0}")]
|
2022-05-26 20:37:27 +02:00
|
|
|
YoutubeDl(#[from] youtube_dl::Error),
|
2022-05-26 19:57:16 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error {
|
|
|
|
fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'o> {
|
|
|
|
eprintln!("💥 Encountered error: {}", self);
|
|
|
|
|
|
|
|
match self {
|
|
|
|
Error::NoRedirectUrlFound => Err(Status::NotFound),
|
|
|
|
_ => Err(Status::InternalServerError),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Result type that defaults to [`Error`] as the default error type.
|
|
|
|
pub(crate) type Result<T, E = Error> = std::result::Result<T, E>;
|
|
|
|
|
2022-05-20 16:15:33 +02:00
|
|
|
/// The extra application specific configuration.
|
|
|
|
#[derive(Debug, Deserialize, Serialize)]
|
|
|
|
#[serde(crate = "rocket::serde")]
|
|
|
|
pub(crate) struct Config {
|
2022-05-24 09:58:57 +02:00
|
|
|
/// The URL at which the application is hosted or proxied from.
|
2022-05-20 16:15:33 +02:00
|
|
|
#[serde(default)]
|
|
|
|
url: String,
|
|
|
|
}
|
|
|
|
|
2022-05-17 11:15:22 +02:00
|
|
|
/// A Rocket responder wrapper type for RSS feeds.
|
|
|
|
#[derive(Responder)]
|
|
|
|
#[response(content_type = "application/xml")]
|
|
|
|
struct RssFeed(String);
|
|
|
|
|
2022-05-26 19:57:16 +02:00
|
|
|
/// Retrieves a download by redirecting to the URL resolved by the selected back-end.
|
2022-05-24 09:58:57 +02:00
|
|
|
#[get("/download/<backend>/<file..>")]
|
2022-05-26 19:57:16 +02:00
|
|
|
pub(crate) async fn download(file: PathBuf, backend: &str) -> Result<Redirect> {
|
2022-05-24 09:58:57 +02:00
|
|
|
match backend {
|
|
|
|
"mixcloud" => {
|
|
|
|
let key = format!("/{}/", file.with_extension("").to_string_lossy());
|
|
|
|
|
|
|
|
mixcloud::redirect_url(&key).await.map(Redirect::to)
|
|
|
|
}
|
2022-05-26 19:57:16 +02:00
|
|
|
_ => Err(Error::UnsupportedBackend(backend.to_string())),
|
2022-05-24 09:58:57 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-05-26 19:57:16 +02:00
|
|
|
/// Handler for retrieving the RSS feed of user on a certain back-end.
|
2022-05-27 22:47:36 +02:00
|
|
|
///
|
|
|
|
/// The limit parameter determines the maximum of items that can be in the feed.
|
2022-05-27 22:31:17 +02:00
|
|
|
#[get("/feed/<backend>/<username>?<limit>")]
|
|
|
|
async fn feed(
|
|
|
|
backend: &str,
|
|
|
|
username: &str,
|
|
|
|
limit: Option<usize>,
|
|
|
|
config: &State<Config>,
|
|
|
|
) -> Result<RssFeed> {
|
2022-05-26 21:25:37 +02:00
|
|
|
let user = mixcloud::user(username).await?;
|
2022-05-27 22:31:17 +02:00
|
|
|
let cloudcasts = mixcloud::cloudcasts(username, limit).await?;
|
2022-05-17 11:15:22 +02:00
|
|
|
let mut last_build = DateTime::<Utc>::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc);
|
2022-05-24 10:35:10 +02:00
|
|
|
|
|
|
|
let category = CategoryBuilder::default()
|
|
|
|
.name(String::from("Music")) // FIXME: Don't hardcode the category!
|
|
|
|
.build();
|
2022-05-17 11:15:22 +02:00
|
|
|
let generator = String::from(concat!(
|
|
|
|
env!("CARGO_PKG_NAME"),
|
|
|
|
" ",
|
|
|
|
env!("CARGO_PKG_VERSION")
|
|
|
|
));
|
2022-05-24 10:35:10 +02:00
|
|
|
let image = ImageBuilder::default()
|
|
|
|
.link(user.pictures.large.clone())
|
|
|
|
.url(user.pictures.large.clone())
|
|
|
|
.build();
|
2022-05-17 11:15:22 +02:00
|
|
|
let items = cloudcasts
|
|
|
|
.into_iter()
|
|
|
|
.map(|cloudcast| {
|
2022-05-24 09:58:57 +02:00
|
|
|
let mut file = PathBuf::from(cloudcast.key.trim_end_matches('/'));
|
|
|
|
file.set_extension("m4a"); // FIXME: Don't hardcode the extension!
|
|
|
|
let url = uri!(
|
|
|
|
Absolute::parse(&config.url).expect("valid URL"),
|
|
|
|
download(backend = backend, file = file)
|
|
|
|
);
|
2022-05-24 10:35:10 +02:00
|
|
|
// FIXME: Don't hardcode the description!
|
2022-05-23 22:20:02 +02:00
|
|
|
let description = format!("Taken from Mixcloud: {}", cloudcast.url);
|
2022-05-17 11:15:22 +02:00
|
|
|
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<_>>();
|
|
|
|
|
2022-05-24 09:58:57 +02:00
|
|
|
let length = mixcloud::estimated_file_size(cloudcast.audio_length);
|
2022-05-17 11:15:22 +02:00
|
|
|
let enclosure = EnclosureBuilder::default()
|
2022-05-24 09:58:57 +02:00
|
|
|
.url(url.to_string())
|
|
|
|
.length(format!("{}", length))
|
2022-05-17 11:15:22 +02:00
|
|
|
.mime_type(String::from(mixcloud::default_file_type()))
|
|
|
|
.build();
|
2022-05-24 10:35:10 +02:00
|
|
|
let guid = GuidBuilder::default()
|
|
|
|
.value(cloudcast.slug)
|
|
|
|
.permalink(false)
|
|
|
|
.build();
|
2022-05-17 11:15:22 +02:00
|
|
|
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<_>>();
|
2022-05-24 10:35:10 +02:00
|
|
|
let itunes_ext = ITunesChannelExtensionBuilder::default()
|
|
|
|
.author(Some(user.name.clone()))
|
|
|
|
.categories(Vec::from([ITunesCategoryBuilder::default()
|
|
|
|
.text("Music")
|
|
|
|
.build()])) // FIXME: Don't hardcode the category!
|
|
|
|
.image(Some(user.pictures.large))
|
|
|
|
.explicit(Some(String::from("no")))
|
|
|
|
.summary(Some(user.biog.clone()))
|
2022-05-17 11:15:22 +02:00
|
|
|
.build();
|
|
|
|
|
|
|
|
let channel = ChannelBuilder::default()
|
2022-05-24 10:35:10 +02:00
|
|
|
.title(&format!("{} (via Mixcloud)", user.name))
|
2022-05-17 11:15:22 +02:00
|
|
|
.link(&user.url)
|
|
|
|
.description(&user.biog)
|
2022-05-24 10:35:10 +02:00
|
|
|
.category(category)
|
2022-05-17 11:15:22 +02:00
|
|
|
.last_build_date(Some(last_build.to_rfc2822()))
|
|
|
|
.generator(Some(generator))
|
|
|
|
.image(Some(image))
|
|
|
|
.items(items)
|
2022-05-24 10:35:10 +02:00
|
|
|
.itunes_ext(Some(itunes_ext))
|
2022-05-17 11:15:22 +02:00
|
|
|
.build();
|
|
|
|
let feed = RssFeed(channel.to_string());
|
|
|
|
|
2022-05-26 19:57:16 +02:00
|
|
|
Ok(feed)
|
2022-05-17 11:15:22 +02:00
|
|
|
}
|
|
|
|
|
2022-05-24 11:04:27 +02:00
|
|
|
/// Returns a simple index page that explains the usage.
|
|
|
|
#[get("/")]
|
|
|
|
pub(crate) async fn index(config: &State<Config>) -> Template {
|
|
|
|
Template::render("index", context! { url: &config.url })
|
|
|
|
}
|
|
|
|
|
2022-05-17 11:15:22 +02:00
|
|
|
/// Sets up Rocket.
|
|
|
|
pub fn setup() -> Rocket<Build> {
|
|
|
|
rocket::build()
|
2022-05-24 11:04:27 +02:00
|
|
|
.mount("/", routes![download, feed, index])
|
2022-05-20 16:15:33 +02:00
|
|
|
.attach(AdHoc::config::<Config>())
|
2022-05-24 11:04:27 +02:00
|
|
|
.attach(Template::fairing())
|
2022-05-17 11:15:22 +02:00
|
|
|
}
|