initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
result/
|
||||||
Generated
+4271
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
|||||||
|
[package]
|
||||||
|
name = "minimal-youtube"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.82"
|
||||||
|
async-std = { version = "1.12.0", features = ["attributes", "tokio1"] }
|
||||||
|
chrono = "0.4.37"
|
||||||
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
cookie = "0.18.1"
|
||||||
|
feed-rs = "1.4.0"
|
||||||
|
futures = "0.3.30"
|
||||||
|
handlebars = "5.1.2"
|
||||||
|
html-escape = "0.2.13"
|
||||||
|
minify-html = "0.15.0"
|
||||||
|
openssl = { version = "0.10.66" }
|
||||||
|
regex = "1.10.4"
|
||||||
|
reqwest = "0.12.3"
|
||||||
|
rss = "2.0.7"
|
||||||
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
serde_qs = "0.13.0"
|
||||||
|
tide = "0.16.0"
|
||||||
|
time = { version = "0.2.11", features = ["std"] }
|
||||||
|
urlencoding = "2.1.3"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Minimal YouTube
|
||||||
|
|
||||||
|
A website designed to give you an old style YouTube subscription feed, based on the RSS feeds. An instance of this website is hosted [here](https://yt.aaronmanning.net), and you can read more about the technical side of the website [here](https://aaronmanning.net/blog/minimal%20youtube%20and%20the%20locality%20of%20behaviour%20stack.html).
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
let
|
||||||
|
manifest = (pkgs.lib.importTOML ./Cargo.toml).package;
|
||||||
|
in pkgs.rustPlatform.buildRustPackage rec {
|
||||||
|
pname = manifest.name;
|
||||||
|
version = manifest.version;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
src = pkgs.lib.cleanSource ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
openssl
|
||||||
|
];
|
||||||
|
}
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785454630,
|
||||||
|
"narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "1559d3daa3ecc813a650b79375ea61b6741b8746",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
description = "A web server which provides a neat interface to YouTube.";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
in {
|
||||||
|
packages.${system}.default = nixpkgs.legacyPackages.${system}.callPackage ./. { };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
window.cssScopeCount??=1,window.cssScope??=new MutationObserver((e=>{document?.body?.querySelectorAll("style:not([ready])").forEach((e=>{var t="me__"+window.cssScopeCount++;e.parentNode.classList.add(t),e.textContent=e.textContent.replace(/(?:^|\.|(\s|[^a-zA-Z0-9\-\_]))(me|this|self)(?![a-zA-Z])/g,"$1."+t).replace(/((@keyframes|animation:|animation-name:)[^{};]*)\.me__/g,"$1me__").replace(/(?:@media)\s(xs-|sm-|md-|lg-|xl-|sm|md|lg|xl|xx)/g,((e,t)=>"@media "+{sm:"(min-width: 640px)",md:"(min-width: 768px)",lg:"(min-width: 1024px)",xl:"(min-width: 1280px)",xx:"(min-width: 1536px)","xs-":"(max-width: 639px)","sm-":"(max-width: 767px)","md-":"(max-width: 1023px)","lg-":"(max-width: 1279px)","xl-":"(max-width: 1535px)"}[t])),e.setAttribute("ready","")}))})).observe(document.documentElement,{childList:!0,subtree:!0});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
/* cyrillic-ext */ @font-face { font-family: 'Nunito Sans'; font-style: italic; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(cyrillic-ext-italic.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* cyrillic */ @font-face { font-family: 'Nunito Sans'; font-style: italic; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(cyrillic-italic.woff2) format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* vietnamese */ @font-face { font-family: 'Nunito Sans'; font-style: italic; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(vietnamese-italic.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Nunito Sans'; font-style: italic; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(latin-ext-italic.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Nunito Sans'; font-style: italic; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(latin-italic.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* cyrillic-ext */ @font-face { font-family: 'Nunito Sans'; font-style: normal; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(cyrillic-ext.woff2) format('woff2'); unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; } /* cyrillic */ @font-face { font-family: 'Nunito Sans'; font-style: normal; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(cyrillic.woff2) format('woff2'); unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; } /* vietnamese */ @font-face { font-family: 'Nunito Sans'; font-style: normal; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(vietnamese.woff2) format('woff2'); unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; } /* latin-ext */ @font-face { font-family: 'Nunito Sans'; font-style: normal; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(latin-ext.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* latin */ @font-face { font-family: 'Nunito Sans'; font-style: normal; font-weight: 200 1000; font-stretch: 100%; font-display: swap; src: url(latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
|||||||
|
export class LiteYTEmbed extends HTMLElement{constructor(){super(),this.isIframeLoaded=!1,this.setupDom()}static get observedAttributes(){return["videoid","playlistid"]}connectedCallback(){this.addEventListener("pointerover",LiteYTEmbed.warmConnections,{once:!0}),this.addEventListener("click",()=>this.addIframe())}get videoId(){return encodeURIComponent(this.getAttribute("videoid")||"")}set videoId(e){this.setAttribute("videoid",e)}get playlistId(){return encodeURIComponent(this.getAttribute("playlistid")||"")}set playlistId(e){this.setAttribute("playlistid",e)}get videoTitle(){return this.getAttribute("videotitle")||"Video"}set videoTitle(e){this.setAttribute("videotitle",e)}get videoPlay(){return this.getAttribute("videoPlay")||"Play"}set videoPlay(e){this.setAttribute("videoPlay",e)}get videoStartAt(){return this.getAttribute("videoStartAt")||"0"}get autoLoad(){return this.hasAttribute("autoload")}get noCookie(){return this.hasAttribute("nocookie")}get posterQuality(){return this.getAttribute("posterquality")||"hqdefault"}get posterLoading(){return this.getAttribute("posterloading")||"lazy"}get params(){return`start=${this.videoStartAt}&${this.getAttribute("params")}`}set params(e){this.setAttribute("params",e)}setupDom(){const e=this.attachShadow({mode:"open"});let t="";window.liteYouTubeNonce&&(t=`nonce="${window.liteYouTubeNonce}"`),e.innerHTML=`<style ${t}>:host{contain:content;display:block;position:relative;width:100%;padding-bottom:calc(100%/(16/9))}@media(max-width:40em){:host([short]){padding-bottom:calc(100%/(9/16))}}#frame,#fallbackPlaceholder,iframe{position:absolute;width:100%;height:100%;left:0}#frame{cursor:pointer}#fallbackPlaceholder{object-fit:cover}#frame::before{content:'';display:block;position:absolute;top:0;background-image:linear-gradient(180deg,#111 -20%,transparent 90%);height:60px;width:100%;z-index:1}#playButton{width:68px;height:48px;background-color:initial;background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="red"/><path d="M45 24 27 14v20" fill="white"/></svg>');z-index:1;border:0;border-radius:inherit}#playButton:before{content:'';border-style:solid;border-width:11px 0 11px 19px;border-color:transparent transparent transparent #fff}#playButton,#playButton:before{position:absolute;top:50%;left:50%;transform:translate3d(-50%,-50%,0);cursor:inherit}.activated{cursor:unset}#frame.activated::before,#frame.activated>#playButton{display:none}</style><div id=frame><picture><source id=webpPlaceholder type=image/webp><source id=jpegPlaceholder type=image/jpeg><img id=fallbackPlaceholder referrerpolicy=origin loading=lazy>
|
||||||
|
</picture><button id=playButton></button></div>`,this.domRefFrame=e.querySelector("#frame"),this.domRefImg={fallback:e.querySelector("#fallbackPlaceholder"),webp:e.querySelector("#webpPlaceholder"),jpeg:e.querySelector("#jpegPlaceholder")},this.domRefPlayButton=e.querySelector("#playButton")}setupComponent(){this.initImagePlaceholder(),this.domRefPlayButton.setAttribute("aria-label",`${this.videoPlay}: ${this.videoTitle}`),this.setAttribute("title",`${this.videoPlay}: ${this.videoTitle}`),(this.autoLoad||this.isYouTubeShort())&&this.initIntersectionObserver()}attributeChangedCallback(e,t,n){switch(e){case"videoid":case"playlistid":case"videoTitle":case"videoPlay":{t!==n&&(this.setupComponent(),this.domRefFrame.classList.contains("activated")&&(this.domRefFrame.classList.remove("activated"),this.shadowRoot.querySelector("iframe").remove(),this.isIframeLoaded=!1));break}default:break}}addIframe(e=!1){if(!this.isIframeLoaded){let n=e?0:1;const s=this.noCookie?"-nocookie":"";let t;this.playlistId?t=`?listType=playlist&list=${this.playlistId}&`:t=`${this.videoId}?`,this.isYouTubeShort()&&(this.params=`loop=1&mute=1&modestbranding=1&playsinline=1&rel=0&enablejsapi=1&playlist=${this.videoId}`,n=1);const o=`
|
||||||
|
<iframe frameborder="0" title="${this.videoTitle}"
|
||||||
|
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen
|
||||||
|
src="https://www.youtube${s}.com/embed/${t}autoplay=${n}&${this.params}"
|
||||||
|
></iframe>`;this.domRefFrame.insertAdjacentHTML("beforeend",o),this.domRefFrame.classList.add("activated"),this.isIframeLoaded=!0,this.attemptShortAutoPlay(),this.dispatchEvent(new CustomEvent("liteYoutubeIframeLoaded",{detail:{videoId:this.videoId},bubbles:!0,cancelable:!0}))}}initImagePlaceholder(){const t=`https://i.ytimg.com/vi_webp/${this.videoId}/${this.posterQuality}.webp`,e=`https://i.ytimg.com/vi/${this.videoId}/${this.posterQuality}.jpg`;this.domRefImg.fallback.loading=this.posterLoading,this.domRefImg.webp.srcset=t,this.domRefImg.jpeg.srcset=e,this.domRefImg.fallback.src=e,this.domRefImg.fallback.setAttribute("aria-label",`${this.videoPlay}: ${this.videoTitle}`),this.domRefImg?.fallback?.setAttribute("alt",`${this.videoPlay}: ${this.videoTitle}`)}initIntersectionObserver(){const e={root:null,rootMargin:"0px",threshold:0},t=new IntersectionObserver((e,t)=>{e.forEach(e=>{e.isIntersecting&&!this.isIframeLoaded&&(LiteYTEmbed.warmConnections(),this.addIframe(!0),t.unobserve(this))})},e);t.observe(this)}attemptShortAutoPlay(){this.isYouTubeShort()&&setTimeout(()=>{this.shadowRoot.querySelector("iframe")?.contentWindow?.postMessage('{"event":"command","func":"playVideo","args":""}',"*")},2e3)}isYouTubeShort(){return this.getAttribute("short")===""&&window.matchMedia("(max-width: 40em)").matches}static addPrefetch(e,t){const n=document.createElement("link");n.rel=e,n.href=t,n.crossOrigin="true",document.head.append(n)}static warmConnections(){if(LiteYTEmbed.isPreconnected||window.liteYouTubeIsPreconnected)return;LiteYTEmbed.addPrefetch("preconnect","https://i.ytimg.com/"),LiteYTEmbed.addPrefetch("preconnect","https://s.ytimg.com"),LiteYTEmbed.addPrefetch("preconnect","https://www.youtube.com"),LiteYTEmbed.addPrefetch("preconnect","https://www.google.com"),LiteYTEmbed.addPrefetch("preconnect","https://googleads.g.doubleclick.net"),LiteYTEmbed.addPrefetch("preconnect","https://static.doubleclick.net"),LiteYTEmbed.isPreconnected=!0,window.liteYouTubeIsPreconnected=!0}}LiteYTEmbed.isPreconnected=!1,customElements.define("lite-youtube",LiteYTEmbed)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
macro_rules! get_binary_file {
|
||||||
|
($path:expr) => {{
|
||||||
|
#[cfg(not(debug_assertions))] {
|
||||||
|
::std::borrow::Cow::<'static, [u8]>::from(
|
||||||
|
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/", $path)).as_slice()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)] {
|
||||||
|
::std::borrow::Cow::<'static, [u8]>::from(
|
||||||
|
::std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/src/", $path))
|
||||||
|
.expect(format!("requested file {:?} from get_binary_file macro not found", $path).as_str())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For serving static assets at the provided file path.
|
||||||
|
macro_rules! serve_static_assets {
|
||||||
|
($server:expr; $($paths:expr),*,) => {
|
||||||
|
$(
|
||||||
|
$server.at($paths).get(|_: tide::Request<()>| async move {
|
||||||
|
let mut response = tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.body(get_binary_file!($paths).as_ref())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
response.insert_header("Cache-Control", "max-age=86400");
|
||||||
|
|
||||||
|
if let Some(mime) = tide::http::mime::Mime::from_extension(::std::path::PathBuf::from($paths).extension().unwrap().to_string_lossy()) {
|
||||||
|
response.set_content_type(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
});
|
||||||
|
)*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! get_page {
|
||||||
|
($path:expr) => {{
|
||||||
|
#[cfg(not(debug_assertions))] {
|
||||||
|
::std::borrow::Cow::<'static, str>::from(
|
||||||
|
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/pages/", $path))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)] {
|
||||||
|
::std::borrow::Cow::<'static, str>::from(
|
||||||
|
::std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/src/pages/", $path))
|
||||||
|
.expect(format!("requested file {:?} from get_page macro not found", $path).as_str())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
}
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
#[macro_use]
|
||||||
|
mod embed;
|
||||||
|
mod youtube;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::net;
|
||||||
|
use std::collections::{HashMap, BTreeMap};
|
||||||
|
|
||||||
|
use tide::http;
|
||||||
|
use tide::log::*;
|
||||||
|
|
||||||
|
const FEED_LIMIT : u8 = 15;
|
||||||
|
|
||||||
|
#[derive(clap::Parser)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(long, default_value_t = 8080)]
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_std::main]
|
||||||
|
async fn main() -> tide::Result<()> {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
tide::log::with_level(tide::log::LevelFilter::Info);
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
tide::log::with_level(tide::log::LevelFilter::Warn);
|
||||||
|
|
||||||
|
let args: Args = clap::Parser::parse();
|
||||||
|
|
||||||
|
let socket_addr = net::SocketAddr::new(
|
||||||
|
net::IpAddr::V4(net::Ipv4Addr::new(127, 0, 0, 1)),
|
||||||
|
args.port,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut server = tide::new();
|
||||||
|
|
||||||
|
// main page
|
||||||
|
server.at("/").get(home);
|
||||||
|
// rss
|
||||||
|
server.at("/rss").get(rss);
|
||||||
|
// hypermedia back end
|
||||||
|
server.at("/api/videos-list").get(videos_html);
|
||||||
|
server.at("/api/rss-redirect").get(rss_redirect);
|
||||||
|
// libraries
|
||||||
|
serve_static_assets!(server;
|
||||||
|
"assets/htmx.js",
|
||||||
|
"assets/css-scope-inline.js",
|
||||||
|
"assets/lite-youtube.js",
|
||||||
|
);
|
||||||
|
// fonts
|
||||||
|
serve_static_assets!(server;
|
||||||
|
"assets/fonts/cyrillic.woff2",
|
||||||
|
"assets/fonts/cyrillic-italic.woff2",
|
||||||
|
"assets/fonts/cyrillic-ext.woff2",
|
||||||
|
"assets/fonts/cyrillic-ext-italic.woff2",
|
||||||
|
"assets/fonts/latin.woff2",
|
||||||
|
"assets/fonts/latin-italic.woff2",
|
||||||
|
"assets/fonts/latin-ext.woff2",
|
||||||
|
"assets/fonts/latin-ext-italic.woff2",
|
||||||
|
"assets/fonts/vietnamese.woff2",
|
||||||
|
"assets/fonts/vietnamese-italic.woff2",
|
||||||
|
"assets/fonts/nunito-sans.css",
|
||||||
|
);
|
||||||
|
// 404 handling
|
||||||
|
server.at("/*").get(home);
|
||||||
|
|
||||||
|
while // address is not available
|
||||||
|
!net::TcpListener::bind(&socket_addr).ok()
|
||||||
|
.is_some_and(|listener| listener.local_addr().is_ok())
|
||||||
|
{
|
||||||
|
warn!("Could not start server on specified IP address, check that the port is not busy. Retrying...");
|
||||||
|
async_std::task::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(&socket_addr.to_string()).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minify(html : &[u8]) -> Vec<u8> {
|
||||||
|
minify_html::minify(
|
||||||
|
html,
|
||||||
|
&minify_html::Cfg {
|
||||||
|
do_not_minify_doctype: true,
|
||||||
|
ensure_spec_compliant_unquoted_attribute_values: true,
|
||||||
|
keep_closing_tags: true,
|
||||||
|
keep_html_and_head_opening_tags: true,
|
||||||
|
keep_spaces_between_attributes: true,
|
||||||
|
keep_comments: false,
|
||||||
|
keep_input_type_text_attr: true,
|
||||||
|
keep_ssi_comments: false,
|
||||||
|
preserve_brace_template_syntax: false,
|
||||||
|
preserve_chevron_percent_template_syntax: false,
|
||||||
|
minify_css: true,
|
||||||
|
minify_js: true,
|
||||||
|
remove_bangs: false,
|
||||||
|
remove_processing_instructions: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct FeedQuery {
|
||||||
|
channels: Option<String>,
|
||||||
|
playlists: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a string representing a comma separated list into a `Vec<&str>`
|
||||||
|
/// where each element is one element from the original list.
|
||||||
|
/// Removes any empty element, and thus parses `""` as `[]`
|
||||||
|
fn parse_list(list: Option<&str>) -> Vec<&str> {
|
||||||
|
match list {
|
||||||
|
Some(list) => list.split(',').filter(|x| !x.is_empty()).collect(),
|
||||||
|
None => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn videos_html(req : tide::Request<()>) -> tide::Result {
|
||||||
|
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
let query : FeedQuery = req.query()?;
|
||||||
|
let channels = parse_list(query.channels.as_deref());
|
||||||
|
let playlists = parse_list(query.playlists.as_deref());
|
||||||
|
|
||||||
|
let mut channel_names = Some(HashMap::new());
|
||||||
|
let mut playlist_names = Some(HashMap::new());
|
||||||
|
let videos_and_entities = youtube::get_videos(
|
||||||
|
channels.as_slice(),
|
||||||
|
playlists.as_slice(),
|
||||||
|
&mut channel_names,
|
||||||
|
&mut playlist_names,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let (mut videos, entities) = match videos_and_entities {
|
||||||
|
Err(err) => return Ok(tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.body(format!("Error: {}", html_escape::encode_text(&err.to_string())))
|
||||||
|
.header("HX-Retarget", "#errors")
|
||||||
|
.build()),
|
||||||
|
Ok(x) => x,
|
||||||
|
};
|
||||||
|
|
||||||
|
let newline_regex = regex::Regex::new(r"\r\n|\n|\r|➫").unwrap();
|
||||||
|
|
||||||
|
let mut html = String::with_capacity(300 * videos.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while let Some(video) = videos.pop() {
|
||||||
|
if i >= FEED_LIMIT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
|
||||||
|
let _ = write!(html, r#"
|
||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
me {{
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
}}
|
||||||
|
me h2 {{ font-size: 15pt; }}
|
||||||
|
</style>
|
||||||
|
<lite-youtube videoid="{id}" params="rel=0"></lite-youtube>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p><em>{channel}</em></p>
|
||||||
|
<details><summary>Description</summary><p>{description}</p></details>
|
||||||
|
</div>
|
||||||
|
"#,
|
||||||
|
id = html_escape::encode_quoted_attribute(&video.id),
|
||||||
|
title = html_escape::encode_quoted_attribute(&video.title),
|
||||||
|
channel = html_escape::encode_quoted_attribute(&video.channel_name),
|
||||||
|
description = newline_regex.replace_all(html_escape::encode_quoted_attribute(&video.description).as_ref(), "</p><p>"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add in the OOB swaps for chip IDs
|
||||||
|
for (id, name) in entities {
|
||||||
|
let _ = write!(html, r#"
|
||||||
|
<span hx-swap-oob="outerHTML:span.chip[data-id='{}']" class="chip" data-id="{}" onclick="deleteTag(this.parentNode, this)">{}</span>
|
||||||
|
"#, id, id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.body(minify(html.as_bytes()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
response.insert_header(
|
||||||
|
"HX-Push-Url",
|
||||||
|
if !channels.is_empty() || !playlists.is_empty() {
|
||||||
|
format!("/?{}", req.url().query().unwrap_or(""))
|
||||||
|
} else { String::from("/") }
|
||||||
|
);
|
||||||
|
|
||||||
|
if !channels.is_empty() || !playlists.is_empty() {
|
||||||
|
let mut cookie = http::Cookie::new("input-parameters", req.url().query().unwrap_or("").to_owned());
|
||||||
|
cookie.set_path("/");
|
||||||
|
cookie.set_same_site(Some(http::cookies::SameSite::Strict));
|
||||||
|
cookie.set_expires(Some(time::OffsetDateTime::now_utc() + time::Duration::days(365)));
|
||||||
|
response.insert_cookie(cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async fn home(req : tide::Request<()>) -> tide::Result {
|
||||||
|
let mut query : FeedQuery = req.query()?;
|
||||||
|
if query.channels.as_ref().map(|x| x.is_empty()).unwrap_or(true) && query.playlists.as_ref().map(|x| x.is_empty()).unwrap_or(true) {
|
||||||
|
let query_string = req.cookie("input-parameters").unwrap_or(http::Cookie::new("input-parameters", ""));
|
||||||
|
query = serde_qs::from_str(query_string.value())?;
|
||||||
|
};
|
||||||
|
|
||||||
|
let hlbs = handlebars::Handlebars::new();
|
||||||
|
|
||||||
|
let home = get_page!("index.html");
|
||||||
|
|
||||||
|
let mut data = BTreeMap::new();
|
||||||
|
data.insert("channels", query.channels.unwrap_or("".to_string()));
|
||||||
|
data.insert("playlists", query.playlists.unwrap_or("".to_string()));
|
||||||
|
|
||||||
|
let html = hlbs.render_template(home.as_ref(), &data).unwrap();
|
||||||
|
|
||||||
|
Ok(tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.body(minify(html.as_bytes()))
|
||||||
|
.content_type(http::mime::HTML)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rss_redirect(req : tide::Request<()>) -> tide::Result {
|
||||||
|
Ok(tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.header("Hx-Redirect", format!("/rss?{}", req.url().query().unwrap_or("")))
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rss(req : tide::Request<()>) -> tide::Result {
|
||||||
|
let query: FeedQuery = req.query()?;
|
||||||
|
let channels = parse_list(query.channels.as_deref());
|
||||||
|
let playlists = parse_list(query.playlists.as_deref());
|
||||||
|
|
||||||
|
let (mut videos, _) = youtube::get_videos(
|
||||||
|
channels.as_slice(),
|
||||||
|
playlists.as_slice(),
|
||||||
|
&mut None,
|
||||||
|
&mut None,
|
||||||
|
).await.unwrap();
|
||||||
|
|
||||||
|
let mut rss_feed = rss::ChannelBuilder::default()
|
||||||
|
.title("Minimal YouTube Feed".to_owned())
|
||||||
|
.managing_editor(Some("contact@aaronmanning.net".to_owned()))
|
||||||
|
.link("https://minimal-youtube.com".to_owned())
|
||||||
|
.description("A minimal RSS feed with embedded YouTube videos from an arbitrary collection of channels and/or playlists.".to_owned())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut entries = 0;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
while let Some(video) = videos.pop() {
|
||||||
|
if entries >= FEED_LIMIT {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
entries += 1;
|
||||||
|
|
||||||
|
let description =
|
||||||
|
format!(r#"
|
||||||
|
<p style="white-space: pre-wrap">{}</p>"#,
|
||||||
|
html_escape::encode_text(&video.description),
|
||||||
|
)
|
||||||
|
// For some reason the official RSS feeds don't use genuine new lines. This replaces
|
||||||
|
// them back in.
|
||||||
|
.replace('➫', "<br />");
|
||||||
|
|
||||||
|
items.push(rss::ItemBuilder::default()
|
||||||
|
.content(Some(format!(r#"
|
||||||
|
<iframe src="https://www.youtube-nocookie.com/embed/{id}?autoplay=0&rel=0" frameborder="0" loading="lazy" gesture="media" allow="autoplay; fullscreen" allowautoplay="true" allowfullscreen="true" width="728" height="409"></iframe>
|
||||||
|
"#,
|
||||||
|
id = html_escape::encode_quoted_attribute(&video.id),
|
||||||
|
)))
|
||||||
|
.description(Some(description))
|
||||||
|
.title(Some(video.title))
|
||||||
|
.link(Some(format!("https://www.youtube.com/watch?v={}", urlencoding::encode(&video.id))))
|
||||||
|
.guid(Some(rss::Guid { value : video.id, permalink : false }))
|
||||||
|
.pub_date(Some(video.date.format("%a, %d %b %Y %H:%M:%S %Z").to_string()))
|
||||||
|
.author(Some(video.channel_name))
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rss_feed.items = items;
|
||||||
|
|
||||||
|
Ok(tide::Response::builder(tide::StatusCode::Ok)
|
||||||
|
.body(rss_feed.to_string())
|
||||||
|
.content_type(http::mime::XML)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>Minimal YouTube</title>
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
|
<!-- Libraries -->
|
||||||
|
<script src="/assets/htmx.js"></script>
|
||||||
|
<script src="/assets/css-scope-inline.js"></script>
|
||||||
|
<script type="module" src="/assets/lite-youtube.js"></script>
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="stylesheet" href="/assets/fonts/nunito-sans.css" />
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script>
|
||||||
|
const setupChips = function(selector) {
|
||||||
|
const element = document.getElementById(selector);
|
||||||
|
|
||||||
|
resetChips(element);
|
||||||
|
|
||||||
|
const hidden = element.querySelector(".hidden-input");
|
||||||
|
const visible = element.querySelector(".visible-input");
|
||||||
|
const addButton = element.querySelector(".add-button");
|
||||||
|
|
||||||
|
visible.addEventListener('click', function() {
|
||||||
|
visible.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
var initial = hidden.getAttribute('data-initial');
|
||||||
|
|
||||||
|
// Set the initial data correctly
|
||||||
|
if (initial === null) { initial = ""; }
|
||||||
|
|
||||||
|
initial = initial.split(',').map(item => item.trim()).filter(item => item !== '');
|
||||||
|
initial.forEach(function(string) {
|
||||||
|
addChip(element, string);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If data is left in the text box when the feed is generated, the data should
|
||||||
|
// be included as a chip.
|
||||||
|
document.getElementById('generate-feed-button').addEventListener('click', function(e) {
|
||||||
|
var inputText = visible.value.trim();
|
||||||
|
|
||||||
|
visible.value = "";
|
||||||
|
if(inputText != "")
|
||||||
|
addChip(element, inputText);
|
||||||
|
});
|
||||||
|
|
||||||
|
visible.addEventListener('keydown', function(e) {
|
||||||
|
var inputText = visible.value.trim();
|
||||||
|
|
||||||
|
if ([13 /* Enter */, 32 /* Space */].includes(e.keyCode)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Reset the text box
|
||||||
|
visible.value = "";
|
||||||
|
if(inputText != "")
|
||||||
|
addChip(element, inputText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addButton.addEventListener('click', function(e) {
|
||||||
|
var inputText = visible.value.trim();
|
||||||
|
|
||||||
|
// Reset the text box
|
||||||
|
visible.value = "";
|
||||||
|
if(inputText != "")
|
||||||
|
addChip(element, inputText);
|
||||||
|
});
|
||||||
|
|
||||||
|
visible.addEventListener('keydown', function(e) {
|
||||||
|
var inputText = visible.value.trim();
|
||||||
|
|
||||||
|
if ([13 /* Enter */, 32 /* Space */].includes(e.keyCode)) {
|
||||||
|
e.preventDefault();
|
||||||
|
// Reset the text box
|
||||||
|
visible.value = "";
|
||||||
|
if(inputText != "")
|
||||||
|
addChip(element, inputText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetChips = function(element) {
|
||||||
|
const hidden = element.querySelector(".hidden-input");
|
||||||
|
hidden.value = '';
|
||||||
|
element.chips = [];
|
||||||
|
|
||||||
|
spans = element.getElementsByClassName('chip');
|
||||||
|
for(var i = 0; i < spans.length; ++i) {
|
||||||
|
spans[i].remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addChip = function(element, string) {
|
||||||
|
const hidden = element.querySelector(".hidden-input");
|
||||||
|
const visible = element.querySelector(".visible-input");
|
||||||
|
|
||||||
|
if (element.chips.includes(string))
|
||||||
|
return;
|
||||||
|
|
||||||
|
element.chips.push(string);
|
||||||
|
|
||||||
|
var chip = document.createElement('span');
|
||||||
|
chip.classList.add('chip');
|
||||||
|
chip.innerText = string;
|
||||||
|
chip.setAttribute('data-id', string);
|
||||||
|
|
||||||
|
element.insertBefore(chip, visible.parentNode);
|
||||||
|
hidden.value = element.chips.join(',');
|
||||||
|
|
||||||
|
chip.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteTag(this.parentNode, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTag = function(element, chip) {
|
||||||
|
const hidden = element.querySelector(".hidden-input");
|
||||||
|
chip.remove();
|
||||||
|
element.chips.splice(element.chips.indexOf(chip.getAttribute('data-id')), 1);
|
||||||
|
hidden.value = element.chips.join(',');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Ideally I would like to apply these to the YouTube embed, but
|
||||||
|
can't for security reasons. Need to look in to alternative. -->
|
||||||
|
<style>
|
||||||
|
.videowall-endscreen,
|
||||||
|
.ytp-pause-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<style>
|
||||||
|
me { font-family: Nunito Sans, sans; }
|
||||||
|
me { margin: 0; color: white; background-color: #202020; }
|
||||||
|
summary { cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
me a { color: #2ecc71; }
|
||||||
|
me { display: flex; flex-direction: column; min-height: 100vh; max-width: 1000px; margin: auto; justify-content: space-between; }
|
||||||
|
|
||||||
|
me footer {
|
||||||
|
margin: 0px auto 0px auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<main>
|
||||||
|
<style>
|
||||||
|
me { padding: 2em; display: flex; flex-flow: column wrap; gap: 2em; }
|
||||||
|
me #main-text { margin: 0; }
|
||||||
|
me h1 { margin-top: 0; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Minimal YouTube</h1>
|
||||||
|
<p id="main-text">
|
||||||
|
This tool is designed to allow you to access the YouTube channels and playlists you love in the way that the subscription feed is supposed to work: a reverse chronological order feed of videos without comments, video stats, recommendations or any other distractions. Try the <a href="?channels=UCIvTu9cuf-NZFzHPJZPH23Q%2CUCYO_jab_esuFRV4b17AJtAw%2CUCoxcjq-8xIDTYp3uz647V5A%2CUC2C_jShtL725hvbm1arSV9w%2CUCUHW94eEFW7hkUMVaZz4eDg&playlists=PL76q_HJHeE9ZkFe4tbNzhoe9knJZ1nHuz">example feed</a> and read the instructions below to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
me { display: flex; flex-flow: column wrap; gap: 1em; }
|
||||||
|
me > details { padding: 0.8em; background-color: #2b2b2b; }
|
||||||
|
me > details > p { padding-left: 1em; }
|
||||||
|
me > details > * { padding-right: 1em; }
|
||||||
|
</style>
|
||||||
|
<details>
|
||||||
|
<summary>Instructions</summary>
|
||||||
|
<p>
|
||||||
|
First, find the IDs of the YouTube channels and playlists you are interested in.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>For channels, you can do this by going to the main page of the channel and opening up the about section just below the channel name. Then click <code>Share channel</code> and <code>Copy channel ID</code>. Alternatively, tools such as <a href="https://www.streamweasels.com/tools/youtube-channel-id-and-user-id-convertor/">this one</a> will fetch the channel ID from the handle or username.</li>
|
||||||
|
<li>For playlists, simply go to the main page of the playlist which displays the full list of videos, and pull out the value after <code>list=</code> from the URL.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
For each ID, type it into the corresponding input box and then click enter (or space bar) to add it to the list. You can always click on it again to remove it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Then click <code>Generate Feed</code> and enjoy. At this point you can click <code>Get RSS Feed</code> if you prefer to watch videos from an RSS reader, and either way you may now bookmark the page to keep all of your settings saved for next time.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Feedback</summary>
|
||||||
|
<p>
|
||||||
|
This website was made as a small side project based on my own personal desired features. If you have any feedback then please <a href="https://aaronmanning.net/contact">get in touch</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can read about some of the technical aspects of this website <a href="https://aaronmanning.net/blog/minimal%20youtube%20and%20the%20locality%20of%20behaviour%20stack.html">here</a>.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input section -->
|
||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
me { display: flex; flex-direction: column; gap: 0.5em; }
|
||||||
|
|
||||||
|
/* Input section and chips layout */
|
||||||
|
.input-wrapper input {
|
||||||
|
background-color: #2b2b2b;
|
||||||
|
color: white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid white;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.5em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.input-wrapper {
|
||||||
|
padding: 0.5em;
|
||||||
|
border: 1px solid white;
|
||||||
|
}
|
||||||
|
.input-wrapper .chip {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: rgba(46, 204, 113, 0.3);
|
||||||
|
color: #6aeba0;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0 0.2em;
|
||||||
|
margin: 0 0.4em 0.4em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red hover on chip */
|
||||||
|
.input-wrapper .chip:hover { cursor: pointer; color: #e74c3c; background-color: rgba(192, 57, 43, 0.3); transition: 30ms; }
|
||||||
|
|
||||||
|
/* Add button and input bar layout */
|
||||||
|
.input-and-add-button { display: flex; flex-direction: row; gap: 0.5em; }
|
||||||
|
.input-and-add-button > button { width: 2em; font-size: 1.5em; }
|
||||||
|
|
||||||
|
/* All buttons in input section styling */
|
||||||
|
me button { background-color: #2b2b2b; color: white; border: 1px solid white; }
|
||||||
|
me button:hover { background-color: black; cursor: pointer; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="channels-input" class="input-wrapper">
|
||||||
|
<div class="input-and-add-button">
|
||||||
|
<input class="visible-input" type="text" placeholder="Channel IDs">
|
||||||
|
<button class="add-button">+</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="hidden-input"
|
||||||
|
type="text"
|
||||||
|
name="channels"
|
||||||
|
data-initial="{{channels}}"
|
||||||
|
hidden="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div id="playlists-input" class="input-wrapper">
|
||||||
|
<div class="input-and-add-button">
|
||||||
|
<input class="visible-input" type="text" placeholder="Playlist IDs">
|
||||||
|
<button class="add-button">+</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="hidden-input"
|
||||||
|
type="text"
|
||||||
|
name="playlists"
|
||||||
|
data-initial="{{playlists}}"
|
||||||
|
hidden="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<style>
|
||||||
|
me { display: flex; flex-direction: row; column-gap: 0.5em; }
|
||||||
|
me > button { padding: 0.8em; flex: 1 1 0px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="generate-feed-button"
|
||||||
|
hx-swap="innerHtml"
|
||||||
|
hx-target="#videos"
|
||||||
|
hx-get="/api/videos-list"
|
||||||
|
hx-sync="this:replace"
|
||||||
|
hx-trigger="load,click"
|
||||||
|
hx-indicator="#videos-indicator"
|
||||||
|
hx-include="input[name='channels'],input[name='playlists']"
|
||||||
|
hx-on::before-request="document.getElementById('errors').innerHTML = ''"
|
||||||
|
>Web Feed</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
hx-get="/api/rss-redirect"
|
||||||
|
hx-include="input[name='channels'],input[name='playlists']"
|
||||||
|
>RSS Feed</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Indicator for video feed loading -->
|
||||||
|
<div id="videos-indicator" class="htmx-indicator" >
|
||||||
|
<style>
|
||||||
|
me { opacity: 0; text-align: center; position: absolute; }
|
||||||
|
me.htmx-request { opacity: 1; transition: opacity 100ms ease-in; position: unset; }
|
||||||
|
</style>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#errors { color: red; }
|
||||||
|
#errors:empty { position: absolute; }
|
||||||
|
</style>
|
||||||
|
<div id="errors"><!-- htmx --></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#videos {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 1em;
|
||||||
|
max-width: 1000px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#videos:empty { position: absolute; }
|
||||||
|
</style>
|
||||||
|
<div id="videos"><!-- htmx --></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
setupChips('channels-input');
|
||||||
|
setupChips('playlists-input');
|
||||||
|
</script>
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<p>
|
||||||
|
Made with ❤️, <a href="https://github.com/http-rs/tide">Tide</a>, <a href="https://github.com/gnat/css-scope-inline">Css Scope Inline</a>, and <a href="https://htmx.org">htmx</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
use std::thread;
|
||||||
|
use std::collections::{BinaryHeap, HashMap, BTreeMap};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
|
||||||
|
use tide::log::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub (crate) enum ItemType {
|
||||||
|
Channel,
|
||||||
|
Playlist,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests the RSS feed from YouTube for the desired item.
|
||||||
|
async fn get_feed(
|
||||||
|
id : &str,
|
||||||
|
item : ItemType,
|
||||||
|
client : &reqwest::Client
|
||||||
|
) -> Result<reqwest::Response, reqwest::Error> {
|
||||||
|
let url = match item {
|
||||||
|
ItemType::Channel => format!("https://www.youtube.com/feeds/videos.xml?channel_id={}", urlencoding::encode(id)),
|
||||||
|
ItemType::Playlist => format!("https://www.youtube.com/feeds/videos.xml?playlist_id={}", urlencoding::encode(id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let request = client.get(url)
|
||||||
|
.build()?;
|
||||||
|
client.execute(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub (crate) struct Video {
|
||||||
|
// Note: Date must be listed first here in the order of fields in order
|
||||||
|
// for the derived implementation of `Ord` to be correct.
|
||||||
|
pub (crate) date : chrono::DateTime<chrono::Utc>,
|
||||||
|
pub (crate) id : String,
|
||||||
|
pub (crate) title : String,
|
||||||
|
pub (crate) description : String,
|
||||||
|
pub (crate) channel_name : String,
|
||||||
|
pub (crate) thumbnail : String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests all the videos in all official RSS feeds associated with the
|
||||||
|
/// provided channel IDs and playlist IDs in the form of a `BinaryHeap<Video>`
|
||||||
|
pub (crate) async fn get_videos(
|
||||||
|
// Collection of channel IDs to fetch videos from.
|
||||||
|
channel_ids : &[&str],
|
||||||
|
// Collection of playlist IDs to fetch videos from.
|
||||||
|
playlist_ids : &[&str],
|
||||||
|
// Map from channel IDs to name
|
||||||
|
channel_names : &mut Option<HashMap<String, String>>,
|
||||||
|
// Map from playlist IDs to name
|
||||||
|
playlist_names : &mut Option<HashMap<String, String>>,
|
||||||
|
) -> Result<(BinaryHeap<Video>, BTreeMap<String, String>), anyhow::Error> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let client = &client;
|
||||||
|
|
||||||
|
let thread_count = match thread::available_parallelism() {
|
||||||
|
Ok(threads) => threads.into(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Thread estimation error: {}", err);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
let responses =
|
||||||
|
futures::stream::iter(
|
||||||
|
channel_ids.iter().map(|x| (x, ItemType::Channel))
|
||||||
|
.chain(playlist_ids.iter().map(|x| (x, ItemType::Playlist)))
|
||||||
|
.map(|(id, item_type)| async move {
|
||||||
|
(*id, item_type, get_feed(id, item_type, client).await)
|
||||||
|
})
|
||||||
|
// For some very strange reason this is necessary.
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
)
|
||||||
|
.buffer_unordered(usize::max(channel_ids.len(), thread_count))
|
||||||
|
.collect::<Vec<(&str, ItemType, Result<reqwest::Response, reqwest::Error>)>>()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut videos = BinaryHeap::new();
|
||||||
|
let mut entities = BTreeMap::new();
|
||||||
|
|
||||||
|
for (id, item_type, response) in responses {
|
||||||
|
|
||||||
|
let response = response
|
||||||
|
.with_context(|| format!("failed to request feed for entity {}", id))?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
404 => anyhow::bail!("bad entity ID {}", id),
|
||||||
|
status if status != 200 => anyhow::bail!("recieved unexpected status code of {} from youtube", status),
|
||||||
|
_ => (),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rss = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("could not read youtube response text")?;
|
||||||
|
|
||||||
|
let rss = feed_rs::parser::parse(rss.as_bytes())
|
||||||
|
.context("rss feed parse error")?;
|
||||||
|
|
||||||
|
let feed_title = rss.title.context("missing feed title")?.content.clone();
|
||||||
|
entities.insert(id.to_owned(), feed_title.clone());
|
||||||
|
|
||||||
|
let _ = match item_type {
|
||||||
|
ItemType::Channel => channel_names.as_mut().map(|x| x.insert(id.to_owned(), feed_title)),
|
||||||
|
ItemType::Playlist => playlist_names.as_mut().map(|x| x.insert(id.to_owned(), feed_title)),
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in rss.entries {
|
||||||
|
let feed_rs::model::Entry { media, published, id, title, authors, .. } = entry;
|
||||||
|
|
||||||
|
let date = published
|
||||||
|
.context("missing publish date")?;
|
||||||
|
let id = id.strip_prefix("yt:video:")
|
||||||
|
.context("missing video id")?
|
||||||
|
.to_owned();
|
||||||
|
let title = title.map(|x| x.content)
|
||||||
|
.context("missing entry title")?;
|
||||||
|
let channel_name =
|
||||||
|
authors.first().map(|author| author.name.to_string())
|
||||||
|
.context("missing channel name")?;
|
||||||
|
|
||||||
|
let feed_rs::model::MediaObject { description, thumbnails, .. } = media.into_iter().next()
|
||||||
|
.context("missing media entry")?;
|
||||||
|
|
||||||
|
let description = description.map(|description| {
|
||||||
|
let feed_rs::model::Text { content, .. } = description;
|
||||||
|
content
|
||||||
|
}).context("missing description")?;
|
||||||
|
|
||||||
|
let thumbnail = thumbnails.first().map(|thumbnail| {
|
||||||
|
thumbnail.image.clone().uri
|
||||||
|
}).context("missing thumbnail")?;
|
||||||
|
|
||||||
|
videos.push(Video { date, id, title, description, channel_name, thumbnail, });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((videos, entities))
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user