WIP
This commit is contained in:
parent
cb40f6b192
commit
43f995fd58
|
@ -538,6 +538,18 @@ dependencies = [
|
|||
"cfg-if 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum_dispatch"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eb359f1476bf611266ac1f5355bc14aeca37b299d0ebccc038ee7058891c9cb"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "1.8.0"
|
||||
|
@ -894,6 +906,19 @@ dependencies = [
|
|||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d87c48c02e0dc5e3b849a2041db3029fd066650f8f717c07bf8ed78ccb895cac"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
|
@ -1189,7 +1214,7 @@ dependencies = [
|
|||
"log",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"spin 0.9.4",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"version_check",
|
||||
|
@ -1521,6 +1546,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"cached",
|
||||
"chrono",
|
||||
"enum_dispatch",
|
||||
"reqwest",
|
||||
"rocket",
|
||||
"rocket_dyn_templates",
|
||||
|
@ -1528,6 +1554,7 @@ dependencies = [
|
|||
"thiserror",
|
||||
"url",
|
||||
"youtube_dl",
|
||||
"ytextract",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1698,6 +1725,7 @@ dependencies = [
|
|||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
|
@ -1707,19 +1735,39 @@ dependencies = [
|
|||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.16.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"spin 0.5.2",
|
||||
"untrusted",
|
||||
"web-sys",
|
||||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rocket"
|
||||
version = "0.5.0-rc.2"
|
||||
|
@ -1827,6 +1875,27 @@ dependencies = [
|
|||
"quick-xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.20.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"sct",
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0864aeff53f8c05aa08d86e5ef839d3dfcf07aeba2db32f12db0ef716e87bd55"
|
||||
dependencies = [
|
||||
"base64",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.9"
|
||||
|
@ -1870,6 +1939,16 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
|
||||
|
||||
[[package]]
|
||||
name = "sct"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.6.1"
|
||||
|
@ -1936,6 +2015,28 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_with_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha-1"
|
||||
version = "0.10.0"
|
||||
|
@ -2016,6 +2117,12 @@ dependencies = [
|
|||
"winapi 0.3.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.4"
|
||||
|
@ -2213,6 +2320,17 @@ dependencies = [
|
|||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.9"
|
||||
|
@ -2439,6 +2557,12 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.2.2"
|
||||
|
@ -2588,6 +2712,25 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf"
|
||||
dependencies = [
|
||||
"webpki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.2.8"
|
||||
|
@ -2754,3 +2897,23 @@ dependencies = [
|
|||
"tokio",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ytextract"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca88fc42bde556e9f8343d9f0f8a13eba27f54445529dbb6df3b69fea52236a2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-core",
|
||||
"log",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"thiserror",
|
||||
"url",
|
||||
]
|
||||
|
|
|
@ -11,6 +11,7 @@ license = "MIT"
|
|||
async-trait = "0.1.57"
|
||||
cached = { version = "0.38.0", features = ["async"] }
|
||||
chrono = { version = "0.4.19", features = ["serde"] }
|
||||
enum_dispatch = "0.3.8"
|
||||
reqwest = { version = "0.11.10", features = ["json"] }
|
||||
rocket = { version = "0.5.0-rc.2", features = ["json"] }
|
||||
rocket_dyn_templates = { version = "0.1.0-rc.2", features = ["tera"] }
|
||||
|
@ -18,6 +19,7 @@ rss = "2.0.1"
|
|||
thiserror = "1.0.31"
|
||||
url = { version = "2.2.2", features = ["serde"] }
|
||||
youtube_dl = { version = "0.7.0", features = ["tokio"] }
|
||||
ytextract = "0.11.0"
|
||||
|
||||
[package.metadata.deb]
|
||||
maintainer = "Paul van Tilburg <paul@luon.net>"
|
||||
|
|
|
@ -9,14 +9,33 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use enum_dispatch::enum_dispatch;
|
||||
use reqwest::Url;
|
||||
|
||||
use crate::Result;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) mod mixcloud;
|
||||
pub(crate) mod youtube;
|
||||
|
||||
/// Retrieves the back-end for the provided ID (if supported).
|
||||
pub(crate) fn get(backend_id: &str) -> Result<Backends> {
|
||||
match backend_id {
|
||||
"mixcloud" => Ok(Backends::Mixcloud(mixcloud::backend())),
|
||||
"youtube" => Ok(Backends::YouTube(youtube::backend())),
|
||||
_ => Err(Error::UnsupportedBackend(backend_id.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The support back-ends.
|
||||
#[enum_dispatch(Backend)]
|
||||
pub(crate) enum Backends {
|
||||
Mixcloud(mixcloud::Backend),
|
||||
YouTube(youtube::Backend),
|
||||
}
|
||||
|
||||
/// Functionality of a content back-end.
|
||||
#[async_trait]
|
||||
#[enum_dispatch]
|
||||
pub(crate) trait Backend {
|
||||
/// Returns the name of the backend.
|
||||
fn name(&self) -> &'static str;
|
||||
|
@ -48,7 +67,7 @@ pub(crate) struct Channel {
|
|||
pub(crate) categories: Vec<String>,
|
||||
|
||||
/// The URL of the image/logo/avatar of a channel.
|
||||
pub(crate) image: Url,
|
||||
pub(crate) image: Option<Url>,
|
||||
|
||||
/// The contained content items.
|
||||
pub(crate) items: Vec<Item>,
|
||||
|
@ -83,7 +102,7 @@ pub(crate) struct Item {
|
|||
pub(crate) keywords: Vec<String>,
|
||||
|
||||
/// The URL of the image of the item.
|
||||
pub(crate) image: Url,
|
||||
pub(crate) image: Option<Url>,
|
||||
|
||||
/// The timestamp the item was last updated.
|
||||
pub(crate) updated_at: DateTime<Utc>,
|
||||
|
@ -101,5 +120,5 @@ pub(crate) struct Enclosure {
|
|||
pub(crate) mime_type: String,
|
||||
|
||||
/// The length of the enclosed media content (in bytes).
|
||||
pub(crate) length: u32,
|
||||
pub(crate) length: u64,
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ const API_BASE_URL: &str = "https://api.mixcloud.com";
|
|||
const FILES_BASE_URL: &str = "https://www.mixcloud.com";
|
||||
|
||||
/// The default bitrate used by Mixcloud.
|
||||
const DEFAULT_BITRATE: u32 = 64 * 1024;
|
||||
const DEFAULT_BITRATE: u64 = 64 * 1024;
|
||||
|
||||
/// The default file (MIME) type used by Mixcloud.
|
||||
const DEFAULT_FILE_TYPE: &str = "audio/mpeg";
|
||||
|
@ -190,7 +190,7 @@ impl From<UserWithCloudcasts> for Channel {
|
|||
description: user.biog,
|
||||
author: Some(user.name),
|
||||
categories,
|
||||
image: user.pictures.large,
|
||||
image: Some(user.pictures.large),
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
@ -225,7 +225,7 @@ impl From<Cloudcast> for Item {
|
|||
duration: Some(cloudcast.audio_length),
|
||||
guid: cloudcast.slug,
|
||||
keywords,
|
||||
image: cloudcast.pictures.large,
|
||||
image: Some(cloudcast.pictures.large),
|
||||
updated_at: cloudcast.updated_time,
|
||||
}
|
||||
}
|
||||
|
@ -234,11 +234,13 @@ impl From<Cloudcast> for Item {
|
|||
/// Returns the estimated file size in bytes for a given duration.
|
||||
///
|
||||
/// This uses the default bitrate (see [`DEFAULT_BITRATE`]) which is in B/s.
|
||||
fn estimated_file_size(duration: u32) -> u32 {
|
||||
DEFAULT_BITRATE * duration / 8
|
||||
fn estimated_file_size(duration: u32) -> u64 {
|
||||
DEFAULT_BITRATE * duration as u64 / 8
|
||||
}
|
||||
|
||||
/// Fetches the user from the URL.
|
||||
///
|
||||
/// If the result is [`Ok`], the user will be cached for 24 hours for the given URL.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ url.to_string() }"#,
|
||||
|
@ -256,7 +258,7 @@ async fn fetch_user(url: Url) -> Result<User> {
|
|||
|
||||
/// Fetches cloudcasts from the URL.
|
||||
///
|
||||
/// If the result is [`Ok`], the cloudcasts will be cached for 24 hours for the given username.
|
||||
/// If the result is [`Ok`], the cloudcasts will be cached for 24 hours for the given URL.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ url.to_string() }"#,
|
||||
|
@ -285,6 +287,9 @@ fn set_paging_query(url: &mut Url, limit: usize, offset: usize) {
|
|||
}
|
||||
|
||||
/// Retrieves the redirect URL for the provided Mixcloud cloudcast key.
|
||||
///
|
||||
/// If the result is [`Ok`], the redirect URL will be cached for 24 hours for the given cloudcast
|
||||
/// key.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ download_key.to_owned() }"#,
|
||||
|
|
|
@ -0,0 +1,326 @@
|
|||
//! The YouTube back-end.
|
||||
//!
|
||||
//! It uses the `ytextract` crate to retrieve the feed (channel or playlist) and items (videos).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use cached::proc_macro::cached;
|
||||
use chrono::Utc;
|
||||
use reqwest::Url;
|
||||
use rocket::futures::StreamExt;
|
||||
use ytextract::playlist::video::{Error as YouTubeVideoError, Video as YouTubeVideo};
|
||||
use ytextract::{
|
||||
Channel as YouTubeChannel, Client, Playlist as YouTubePlaylist, Stream as YouTubeStream,
|
||||
};
|
||||
|
||||
use super::{Channel, Enclosure, Item};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The base URL for YouTube channels.
|
||||
const CHANNEL_BASE_URL: &str = "https://www.youtube.com/channel";
|
||||
|
||||
/// The base URL for YouTube playlists.
|
||||
const PLAYLIST_BASE_URL: &str = "https://www.youtube.com/channel";
|
||||
|
||||
/// The base URL for YouTube videos.
|
||||
const VIDEO_BASE_URL: &str = "https://www.youtube.com/watch";
|
||||
|
||||
/// Creates a YouTube back-end.
|
||||
pub(crate) fn backend() -> Backend {
|
||||
Backend::new()
|
||||
}
|
||||
|
||||
/// The YouTube back-end.
|
||||
pub struct Backend {
|
||||
/// The client capable of interacting with YouTube.
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// Creates a new YouTube back-end.
|
||||
fn new() -> Self {
|
||||
let client = Client::new();
|
||||
|
||||
Self { client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl super::Backend for Backend {
|
||||
fn name(&self) -> &'static str {
|
||||
"YouTube"
|
||||
}
|
||||
|
||||
async fn channel(&self, channel_id: &str, item_limit: Option<usize>) -> Result<Channel> {
|
||||
// We assume it is a YouTube playlist ID if the channel ID starts with "PLO"; it is
|
||||
// considered to be a YouTube channel ID otherwise.
|
||||
if channel_id.starts_with("PLO") {
|
||||
let (yt_playlist, yt_videos_w_streams) =
|
||||
fetch_playlist_videos(&self.client, channel_id, item_limit).await?;
|
||||
|
||||
Ok(Channel::from(YouTubePlaylistWithVideos(
|
||||
yt_playlist,
|
||||
yt_videos_w_streams,
|
||||
)))
|
||||
} else {
|
||||
let (yt_channel, yt_videos_w_streams) =
|
||||
fetch_channel_videos(&self.client, channel_id, item_limit).await?;
|
||||
|
||||
Ok(Channel::from(YouTubeChannelWithVideos(
|
||||
yt_channel,
|
||||
yt_videos_w_streams,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn redirect_url(&self, file: &Path) -> Result<String> {
|
||||
let id_part = file.with_extension("");
|
||||
let video_id = id_part.to_string_lossy();
|
||||
|
||||
retrieve_redirect_url(&self.client, &video_id).await
|
||||
}
|
||||
}
|
||||
|
||||
/// A YouTube playlist with its videos.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct YouTubePlaylistWithVideos(YouTubePlaylist, Vec<YouTubeVideoWithStream>);
|
||||
|
||||
/// A YouTube channel with its videos.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct YouTubeChannelWithVideos(YouTubeChannel, Vec<YouTubeVideoWithStream>);
|
||||
|
||||
/// A YouTube video with its stream.
|
||||
#[derive(Clone, Debug)]
|
||||
struct YouTubeVideoWithStream {
|
||||
/// The information of the YouTube video.
|
||||
video: YouTubeVideo,
|
||||
|
||||
/// The metadata of the selected YouTube stream.
|
||||
stream: YouTubeStream,
|
||||
|
||||
/// The content of the selected YouTube stream.
|
||||
content_length: u64,
|
||||
}
|
||||
|
||||
impl From<YouTubeChannelWithVideos> for Channel {
|
||||
fn from(
|
||||
YouTubeChannelWithVideos(yt_channel, yt_videos_w_streams): YouTubeChannelWithVideos,
|
||||
) -> Self {
|
||||
let mut link = Url::parse(CHANNEL_BASE_URL).expect("valid URL");
|
||||
link.path_segments_mut()
|
||||
.expect("valid URL")
|
||||
.push(&yt_channel.id());
|
||||
let author = Some(yt_channel.name().to_string());
|
||||
let categories = Vec::from([String::from("Video")]);
|
||||
let image = yt_channel
|
||||
.avatar()
|
||||
.max_by_key(|av| av.width * av.height)
|
||||
.map(|av| av.url.clone());
|
||||
let items = yt_videos_w_streams.into_iter().map(Item::from).collect();
|
||||
|
||||
Channel {
|
||||
title: format!("{0} (via YouTube)", yt_channel.name()),
|
||||
link,
|
||||
description: yt_channel.description().to_string(),
|
||||
author,
|
||||
categories,
|
||||
image,
|
||||
items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<YouTubePlaylistWithVideos> for Channel {
|
||||
fn from(
|
||||
YouTubePlaylistWithVideos(yt_playlist, yt_videos_w_streams): YouTubePlaylistWithVideos,
|
||||
) -> Self {
|
||||
let mut link = Url::parse(PLAYLIST_BASE_URL).expect("valid URL");
|
||||
link.query_pairs_mut()
|
||||
.append_pair("list", &yt_playlist.id().to_string());
|
||||
let author = yt_playlist.channel().map(|chan| chan.name().to_string());
|
||||
// FIXME: Don't hardcode the category!
|
||||
let categories = Vec::from([String::from("Video")]);
|
||||
let image = yt_playlist
|
||||
.thumbnails()
|
||||
.iter()
|
||||
.max_by_key(|tn| tn.width * tn.height)
|
||||
.map(|tn| tn.url.clone());
|
||||
let items = yt_videos_w_streams.into_iter().map(Item::from).collect();
|
||||
|
||||
Channel {
|
||||
title: format!("{0} (via YouTube)", yt_playlist.title()),
|
||||
link,
|
||||
description: yt_playlist.description().to_string(),
|
||||
author,
|
||||
categories,
|
||||
image,
|
||||
items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<YouTubeVideoWithStream> for Item {
|
||||
fn from(
|
||||
YouTubeVideoWithStream {
|
||||
video,
|
||||
stream,
|
||||
content_length,
|
||||
}: YouTubeVideoWithStream,
|
||||
) -> Self {
|
||||
let id = video.id().to_string();
|
||||
let mut link = Url::parse(VIDEO_BASE_URL).expect("valid URL");
|
||||
let description = Some(format!("Taken from YouTube: {0}", link));
|
||||
link.query_pairs_mut().append_pair("v", &id);
|
||||
let enclosure = Enclosure {
|
||||
file: PathBuf::from(&format!("{id}.webm")), // FIXME: Don't hardcode the extension!
|
||||
mime_type: stream.mime_type().to_string(),
|
||||
length: content_length,
|
||||
};
|
||||
|
||||
let duration = Some(video.length().as_secs() as u32);
|
||||
let image = video
|
||||
.thumbnails()
|
||||
.iter()
|
||||
.max_by_key(|tn| tn.width * tn.height)
|
||||
.map(|tn| tn.url.clone());
|
||||
|
||||
Item {
|
||||
title: video.title().to_string(),
|
||||
link,
|
||||
description,
|
||||
categories: Default::default(),
|
||||
enclosure,
|
||||
duration,
|
||||
guid: id,
|
||||
keywords: Default::default(),
|
||||
image,
|
||||
updated_at: Utc::now(), // TODO: Get a decent timestamp somewhere?!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the YouTube playlist videos for the given ID.
|
||||
///
|
||||
/// If the result is [`Ok`], the playlist will be cached for 24 hours for the given playlist ID.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ playlist_id.to_owned() }"#,
|
||||
time = 86400,
|
||||
result = true
|
||||
)]
|
||||
// FIXME: Caching disregards the item limit!
|
||||
async fn fetch_playlist_videos(
|
||||
client: &Client,
|
||||
playlist_id: &str,
|
||||
item_limit: Option<usize>,
|
||||
) -> Result<(YouTubePlaylist, Vec<YouTubeVideoWithStream>)> {
|
||||
let id = playlist_id.parse()?;
|
||||
let yt_playlist = client.playlist(id).await?;
|
||||
let yt_videos_w_streams: Vec<_> = match item_limit {
|
||||
Some(n) => {
|
||||
yt_playlist
|
||||
.videos()
|
||||
.filter_map(fetch_stream)
|
||||
.take(n)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
yt_playlist
|
||||
.videos()
|
||||
.filter_map(fetch_stream)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
Ok((yt_playlist, yt_videos_w_streams))
|
||||
}
|
||||
|
||||
/// Fetches the YouTube channel videos for the given ID.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ channel_id.to_owned() }"#,
|
||||
time = 86400,
|
||||
result = true
|
||||
)]
|
||||
// FIXME: Caching disregards the item limit!
|
||||
async fn fetch_channel_videos(
|
||||
client: &Client,
|
||||
channel_id: &str,
|
||||
item_limit: Option<usize>,
|
||||
) -> Result<(YouTubeChannel, Vec<YouTubeVideoWithStream>)> {
|
||||
let id = channel_id.parse()?;
|
||||
let yt_channel = client.channel(id).await?;
|
||||
let yt_videos_w_streams: Vec<_> = match item_limit {
|
||||
Some(n) => {
|
||||
yt_channel
|
||||
.uploads()
|
||||
.await?
|
||||
.take(n)
|
||||
.filter_map(fetch_stream)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
yt_channel
|
||||
.uploads()
|
||||
.await?
|
||||
.filter_map(fetch_stream)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
Ok((yt_channel, yt_videos_w_streams))
|
||||
}
|
||||
|
||||
/// Fetches the stream and relevant metadata for a YouTube video result.
|
||||
///
|
||||
/// If there is a video retieving the metadata, the video is discarded/ignored.
|
||||
/// If there are problems retrieving the streams or metadata, the video is also discarded.
|
||||
async fn fetch_stream(
|
||||
yt_video: Result<YouTubeVideo, YouTubeVideoError>,
|
||||
) -> Option<YouTubeVideoWithStream> {
|
||||
match yt_video {
|
||||
Ok(video) => {
|
||||
let stream = video
|
||||
.streams()
|
||||
.await
|
||||
.ok()?
|
||||
.filter(|v| v.is_audio())
|
||||
.max_by_key(|v| v.bitrate())?;
|
||||
let content_length = stream.content_length().await.ok()?;
|
||||
|
||||
Some(YouTubeVideoWithStream {
|
||||
video,
|
||||
stream,
|
||||
content_length,
|
||||
})
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the redirect URL for the provided YouTube video ID.
|
||||
///
|
||||
/// If the result is [`Ok`], the redirect URL will be cached for 24 hours for the given video ID.
|
||||
#[cached(
|
||||
key = "String",
|
||||
convert = r#"{ video_id.to_owned() }"#,
|
||||
time = 86400,
|
||||
result = true
|
||||
)]
|
||||
async fn retrieve_redirect_url(client: &Client, video_id: &str) -> Result<String> {
|
||||
let video_id = video_id.parse()?;
|
||||
let video = client.video(video_id).await?;
|
||||
let stream = video
|
||||
.streams()
|
||||
.await?
|
||||
.filter(|v| v.is_audio())
|
||||
.max_by_key(|v| v.bitrate())
|
||||
.ok_or(Error::NoRedirectUrlFound)?;
|
||||
|
||||
Ok(stream.url().to_string())
|
||||
}
|
22
src/feed.rs
22
src/feed.rs
|
@ -18,7 +18,7 @@ use crate::Config;
|
|||
/// Constructs a feed as string from a back-end channel using the `rss` crate.
|
||||
///
|
||||
/// It requires the backend and configuration to be able to construct download URLs.
|
||||
pub(crate) fn construct(backend: &str, config: &Config, channel: Channel) -> rss::Channel {
|
||||
pub(crate) fn construct(backend_id: &str, config: &Config, channel: Channel) -> rss::Channel {
|
||||
let category = CategoryBuilder::default()
|
||||
.name(
|
||||
channel
|
||||
|
@ -34,14 +34,14 @@ pub(crate) fn construct(backend: &str, config: &Config, channel: Channel) -> rss
|
|||
" ",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
));
|
||||
let image = ImageBuilder::default()
|
||||
.link(channel.image.clone())
|
||||
.url(channel.image.clone())
|
||||
.build();
|
||||
let image = channel
|
||||
.image
|
||||
.clone()
|
||||
.map(|url| ImageBuilder::default().link(url.clone()).url(url).build());
|
||||
let items = channel
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| construct_item(backend, config, item, &mut last_build))
|
||||
.map(|item| construct_item(backend_id, config, item, &mut last_build))
|
||||
.collect::<Vec<_>>();
|
||||
let itunes_ext = ITunesChannelExtensionBuilder::default()
|
||||
.author(channel.author)
|
||||
|
@ -52,7 +52,7 @@ pub(crate) fn construct(backend: &str, config: &Config, channel: Channel) -> rss
|
|||
.map(|cat| ITunesCategoryBuilder::default().text(cat).build())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.image(Some(channel.image.to_string()))
|
||||
.image(channel.image.map(String::from))
|
||||
.explicit(Some(String::from("no")))
|
||||
.summary(Some(channel.description.clone()))
|
||||
.build();
|
||||
|
@ -64,7 +64,7 @@ pub(crate) fn construct(backend: &str, config: &Config, channel: Channel) -> rss
|
|||
.category(category)
|
||||
.last_build_date(Some(last_build.to_rfc2822()))
|
||||
.generator(Some(generator))
|
||||
.image(Some(image))
|
||||
.image(image)
|
||||
.items(items)
|
||||
.itunes_ext(Some(itunes_ext))
|
||||
.build()
|
||||
|
@ -76,7 +76,7 @@ pub(crate) fn construct(backend: &str, config: &Config, channel: Channel) -> rss
|
|||
/// It also bumps the last build timestamp if the last updated timestamp is later than the current
|
||||
/// value.
|
||||
fn construct_item(
|
||||
backend: &str,
|
||||
backend_id: &str,
|
||||
config: &Config,
|
||||
item: Item,
|
||||
last_build: &mut DateTime<Utc>,
|
||||
|
@ -93,7 +93,7 @@ fn construct_item(
|
|||
.collect::<Vec<_>>();
|
||||
let url = uri!(
|
||||
Absolute::parse(&config.url).expect("valid URL"),
|
||||
crate::get_download(backend = backend, file = item.enclosure.file)
|
||||
crate::get_download(backend_id = backend_id, file = item.enclosure.file)
|
||||
);
|
||||
let enclosure = EnclosureBuilder::default()
|
||||
.url(url.to_string())
|
||||
|
@ -106,7 +106,7 @@ fn construct_item(
|
|||
.build();
|
||||
let keywords = item.keywords.join(", ");
|
||||
let itunes_ext = ITunesItemExtensionBuilder::default()
|
||||
.image(Some(item.image.to_string()))
|
||||
.image(item.image.map(String::from))
|
||||
.duration(item.duration.map(|dur| format!("{dur}")))
|
||||
.subtitle(item.description.clone())
|
||||
.keywords(Some(keywords))
|
||||
|
|
49
src/lib.rs
49
src/lib.rs
|
@ -18,7 +18,7 @@ use rocket::serde::{Deserialize, Serialize};
|
|||
use rocket::{get, routes, Build, Request, Responder, Rocket, State};
|
||||
use rocket_dyn_templates::{context, Template};
|
||||
|
||||
use crate::backends::{mixcloud, Backend};
|
||||
use crate::backends::Backend;
|
||||
|
||||
pub(crate) mod backends;
|
||||
pub(crate) mod feed;
|
||||
|
@ -49,6 +49,26 @@ pub(crate) enum Error {
|
|||
/// An error occurred in youtube-dl.
|
||||
#[error("Youtube-dl failed: {0}")]
|
||||
YoutubeDl(#[from] youtube_dl::Error),
|
||||
|
||||
/// An YouTube extract error occured.
|
||||
#[error("YouTube extract error: {0}")]
|
||||
YtExtract(#[from] ytextract::Error),
|
||||
|
||||
/// An YouTube extract ID parsing error occured.
|
||||
#[error("YouTube extract ID parsing error: {0}")]
|
||||
YtExtractId0(#[from] ytextract::error::Id<0>),
|
||||
|
||||
/// An YouTube extract ID parsing error occured.
|
||||
#[error("YouTube extract ID parsing error: {0}")]
|
||||
YtExtractId11(#[from] ytextract::error::Id<11>),
|
||||
|
||||
/// An YouTube extract ID parsing error occured.
|
||||
#[error("YouTube extract ID parsing error: {0}")]
|
||||
YtExtractId24(#[from] ytextract::error::Id<24>),
|
||||
|
||||
/// An YouTube extract playlist video error occured.
|
||||
#[error("YouTube extract playlist video error: {0}")]
|
||||
YtExtractPlaylistVideo(#[from] ytextract::playlist::video::Error),
|
||||
}
|
||||
|
||||
impl<'r, 'o: 'r> rocket::response::Responder<'r, 'o> for Error {
|
||||
|
@ -80,32 +100,27 @@ pub(crate) struct Config {
|
|||
struct RssFeed(String);
|
||||
|
||||
/// Retrieves a download by redirecting to the URL resolved by the selected back-end.
|
||||
#[get("/download/<backend>/<file..>")]
|
||||
pub(crate) async fn get_download(file: PathBuf, backend: &str) -> Result<Redirect> {
|
||||
match backend {
|
||||
"mixcloud" => mixcloud::backend()
|
||||
.redirect_url(&file)
|
||||
.await
|
||||
.map(Redirect::to),
|
||||
_ => Err(Error::UnsupportedBackend(backend.to_string())),
|
||||
}
|
||||
#[get("/download/<backend_id>/<file..>")]
|
||||
pub(crate) async fn get_download(file: PathBuf, backend_id: &str) -> Result<Redirect> {
|
||||
let backend = backends::get(backend_id)?;
|
||||
|
||||
backend.redirect_url(&file).await.map(Redirect::to)
|
||||
}
|
||||
|
||||
/// Handler for retrieving the RSS feed of a channel on a certain back-end.
|
||||
///
|
||||
/// The limit parameter determines the maximum of items that can be in the feed.
|
||||
#[get("/feed/<backend>/<channel_id>?<limit>")]
|
||||
#[get("/feed/<backend_id>/<channel_id>?<limit>")]
|
||||
async fn get_feed(
|
||||
backend: &str,
|
||||
backend_id: &str,
|
||||
channel_id: &str,
|
||||
limit: Option<usize>,
|
||||
config: &State<Config>,
|
||||
) -> Result<RssFeed> {
|
||||
let channel = match backend {
|
||||
"mixcloud" => mixcloud::backend().channel(channel_id, limit).await?,
|
||||
_ => return Err(Error::UnsupportedBackend(backend.to_string())),
|
||||
};
|
||||
let feed = feed::construct(backend, config, channel);
|
||||
dbg!(limit);
|
||||
let backend = backends::get(backend_id)?;
|
||||
let channel = backend.channel(channel_id, limit).await?;
|
||||
let feed = feed::construct(backend_id, config, channel);
|
||||
|
||||
Ok(RssFeed(feed.to_string()))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue