Subscriptions panel
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Most of the basic functionality

+386 -80
+20 -1
src/db.rs
··· 10 10 creation_datetime TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 11 11 );", 12 12 ), 13 - 13 + M::up( 14 + "CREATE TABLE tag ( 15 + name TEXT NOT NULL 16 + );", 17 + ), 18 + M::up( 19 + "CREATE TABLE subscription_tag ( 20 + subscription_id INTEGER NOT NULL, 21 + tag_id INTEGER NOT NULL, 22 + PRIMARY KEY (subscription_id, tag_id) 23 + );", 24 + ), 25 + M::up( 26 + "ALTER TABLE subscription 27 + ADD COLUMN last_updated TEXT DEFAULT CURRENT_TIMESTAMP;", 28 + ), 29 + M::up( 30 + "ALTER TABLE subscription 31 + ADD COLUMN last_updated TEXT DEFAULT CURRENT_TIMESTAMP;", 32 + ), 14 33 ]; 15 34 16 35 const MIGRATIONS: Migrations<'_> = Migrations::from_slice(MIGRATIONS_SLICE);
+15
src/fragments.rs
··· 1 + use maud::{Markup, html}; 2 + 3 + pub mod video_grid; 4 + 5 + pub fn wrapper(markup: Markup) -> Markup { 6 + html! { 7 + head { 8 + link rel="stylesheet" href="/style.css"; 9 + meta name="color-scheme" content="light dark"; 10 + } 11 + body { 12 + (markup) 13 + } 14 + } 15 + }
+44
src/fragments/video_grid.rs
··· 1 + use maud::{Markup, html}; 2 + use rustypipe::model::VideoItem; 3 + 4 + const MINUTE: u32 = 60; 5 + const HOUR: u32 = 3600; 6 + 7 + pub fn video_grid(videos: Vec<VideoItem>) -> Markup { 8 + html!( 9 + 10 + ol .thumb-grid { 11 + @for vid in videos.into_iter().take(20) { 12 + @let channel = vid.channel.unwrap(); 13 + @let duration = vid.duration.unwrap_or_default(); 14 + li { 15 + vid-thumbnail { 16 + img src=(vid.thumbnail.first().unwrap().url); 17 + 18 + vid-timestamp { 19 + @if (duration / HOUR) > 0 { 20 + (duration / HOUR) 21 + ":" 22 + } 23 + ((duration / MINUTE) % 60) 24 + ":" 25 + (format!("{:02}", duration % MINUTE)) 26 + } 27 + 28 + } 29 + 30 + vid-details-large { 31 + // @let avatar = channel.avatar.first().unwrap(); 32 + // img src=(avatar.url) width=(avatar.width) height=(avatar.height); 33 + vid-title { a href={ "https://youtu.be/" (vid.id)}{(vid.name)} } 34 + } 35 + 36 + vid-details-small { 37 + vid-channel { (channel.name) } 38 + upload-date { (vid.publish_date_txt.unwrap_or_default())} 39 + } 40 + } 41 + } 42 + } 43 + ) 44 + }
+21 -5
src/main.rs
··· 1 - use poem::{EndpointExt as _, Route, Server, endpoint::StaticFileEndpoint, get, listener::TcpListener, web::Data}; 1 + use poem::{ 2 + EndpointExt as _, Route, Server, endpoint::StaticFileEndpoint, get, listener::TcpListener, 3 + web::Data, 4 + }; 2 5 use rusqlite::Connection; 3 - use std::{env, sync::{Arc, Mutex}}; 6 + use std::{ 7 + env, 8 + sync::{Arc, Mutex}, 9 + }; 4 10 5 11 mod db; 12 + mod fragments; 6 13 mod routes; 14 + 15 + pub const NBSP: &str = "\u{00a0}"; 7 16 8 17 // type WrappedConnection = Arc<Mutex<Connection>>; 9 18 // type W = WrappedConnection; ··· 17 26 let conn = Arc::new(Mutex::new(db::connect())); 18 27 19 28 let app = Route::new() 20 - .at("/", poem::get(routes::index::get).post(routes::index::post)) 21 - 22 - .at("/style.css", StaticFileEndpoint::new("./style.css").no_cache(true)) 29 + .at("/", poem::get(routes::index::get)) 30 + .at("/tag", poem::post(routes::tag::post)) 31 + .at("/tag/:id", poem::get(routes::tag::by_id)) 32 + .at("/sub", poem::post(routes::sub::post)) 33 + .at("/sub/tag", poem::post(routes::sub::tag)) 34 + .at("/sub/:id/delete", poem::get(routes::sub::delete)) 35 + .at( 36 + "/style.css", 37 + StaticFileEndpoint::new("./style.css").no_cache(true), 38 + ) 23 39 //.with(CookieJarManager::new()) 24 40 .data(conn.clone()); 25 41
+1
src/routes.rs
··· 1 1 pub mod index; 2 2 pub mod sub; 3 + pub mod tag;
+168 -72
src/routes/index.rs
··· 1 - use std::collections::HashMap; 2 - 1 + use crate::{ 2 + Db, NBSP, 3 + fragments::{video_grid::video_grid, wrapper}, 4 + }; 3 5 use maud::{Markup, html}; 4 - use poem::{IntoResponse as _, Response, handler, web::{Data, Form, Redirect}}; 5 - use rustypipe::{client::RustyPipe, model::{UrlTarget, VideoItem}}; 6 - 7 - use crate::Db; 6 + use poem::{handler, web::Data}; 7 + use rusqlite::Connection; 8 + use rustypipe::{client::RustyPipe, model::VideoItem}; 8 9 9 10 #[derive(Clone)] 10 - struct Subs { 11 + pub struct Sub { 11 12 db_id: i32, 12 13 channel_id: String, 13 14 name: String, 15 + tags: Vec<Tag>, 14 16 } 15 17 18 + #[derive(Clone)] 19 + pub struct Tag { 20 + pub db_id: i32, 21 + pub name: String, 22 + } 16 23 17 - #[handler] 18 - pub async fn post(Data(conn): Db<'_>, Form(form): Form<HashMap<String, String>>) { 19 - let channel = &form["channel"]; 20 - 21 - let rp = RustyPipe::new(); 22 - let UrlTarget::Channel { id } = rp.query().resolve_string(channel, false).await.unwrap() else { unreachable!() }; 24 + fn get_sub_tags(conn: &Connection, subscription_id: i32) -> Vec<Tag> { 25 + let mut stmt = conn 26 + .prepare( 27 + "SELECT t.rowid, t.name FROM subscription s 28 + LEFT JOIN subscription_tag st ON s.rowid = st.subscription_id 29 + LEFT JOIN tag t ON t.rowid = st.tag_id 30 + WHERE s.ROWID = ?1", 31 + ) 32 + .unwrap(); 23 33 24 - let channel = rp.query().channel_videos(&id).await.unwrap(); 25 - { 26 - let conn = conn.lock().unwrap(); 27 - let mut stmt = conn.prepare("INSERT INTO subscription (name, channel_id) VALUES (?1, ?2)").unwrap(); 28 - stmt.execute([channel.name, channel.id]).unwrap(); 34 + let thing: rusqlite::Result<Vec<_>> = stmt 35 + .query_map([subscription_id], |row| { 36 + Ok((row.get(0).unwrap(), row.get(1).unwrap())) 37 + }) 38 + .unwrap() 39 + .collect(); 29 40 30 - } 41 + thing 42 + .unwrap() 43 + .into_iter() 44 + .flat_map(|(id, name)| match (id, name) { 45 + (Some(db_id), Some(name)) => Some(Tag { db_id, name }), 46 + _ => None, 47 + }) 48 + .collect() 31 49 } 32 50 33 - #[handler] 34 - pub async fn get(Data(conn): Db<'_>) -> Markup { 35 - 36 - let subscriptions: rusqlite::Result<Vec<Subs>> = { 37 - let conn = conn.lock().unwrap(); 38 - let mut stmt = conn.prepare("SELECT ROWID, channel_id, name FROM subscription").unwrap(); 51 + pub enum VideoFilter { 52 + AllVideos, 53 + ByTag { id: i32 }, 54 + BySubscription { id: i32 }, 55 + } 39 56 40 - stmt 41 - .query_map([], |row| { 42 - Ok(Subs { 43 - db_id: row.get(0).unwrap(), 44 - channel_id: row.get(1).unwrap(), 45 - name: row.get(2).unwrap(), 46 - }) 47 - }).unwrap() 48 - .collect() 57 + pub fn get_subscriptions(conn: &Connection, filter: VideoFilter) -> rusqlite::Result<Vec<Sub>> { 58 + let map_fn = |row: &rusqlite::Row<'_>| { 59 + let db_id = row.get(0).unwrap(); 60 + Ok(Sub { 61 + db_id, 62 + channel_id: row.get(1).unwrap(), 63 + name: row.get(2).unwrap(), 64 + tags: get_sub_tags(&conn, db_id), 65 + }) 49 66 }; 50 - let subscriptions = subscriptions.unwrap(); 51 67 68 + match filter { 69 + VideoFilter::AllVideos => { 70 + let mut stmt = conn 71 + .prepare("SELECT ROWID, channel_id, name FROM subscription") 72 + .unwrap(); 73 + stmt.query_map([], map_fn).unwrap().collect() 74 + } 75 + VideoFilter::ByTag { id } => { 76 + let mut stmt = conn 77 + .prepare( 78 + "SELECT s.ROWID, s.channel_id, s.name FROM subscription s 79 + LEFT JOIN subscription_tag st ON s.rowid = st.subscription_id 80 + LEFT JOIN tag t ON t.rowid = st.tag_id 81 + WHERE t.ROWID = ?1", 82 + ) 83 + .unwrap(); 84 + stmt.query_map([id], map_fn).unwrap().collect() 85 + } 86 + VideoFilter::BySubscription { id } => todo!(), 87 + } 88 + } 89 + 90 + pub async fn get_videos(subscriptions: &[Sub]) -> Vec<VideoItem> { 52 91 let rp = RustyPipe::new(); 53 92 54 93 let mut videos = vec![]; 55 94 for subscription in subscriptions.clone() { 56 - let vids = rp.query() 57 - .channel_videos_tab(subscription.channel_id, rustypipe::param::ChannelVideoTab::Videos).await.unwrap(); 95 + let vids = rp 96 + .query() 97 + .channel_videos_tab( 98 + subscription.channel_id.clone(), 99 + rustypipe::param::ChannelVideoTab::Videos, 100 + ) 101 + .await 102 + .unwrap(); 58 103 videos.extend(vids.content.items); 59 104 } 60 105 61 106 videos.sort_by(|a, b| { 62 - let make_timestamp = |e: &VideoItem| {e.publish_date.map(|t| t.unix_timestamp()).unwrap_or_default()}; 107 + let make_timestamp = |e: &VideoItem| { 108 + e.publish_date 109 + .map(|t| t.unix_timestamp()) 110 + .unwrap_or_default() 111 + }; 63 112 make_timestamp(a).cmp(&make_timestamp(b)) 64 113 }); 65 114 66 115 videos.reverse(); 67 116 68 - html!{ 69 - link rel="stylesheet" href="/style.css"; 117 + videos 118 + } 70 119 71 - form method="POST" { 120 + #[handler] 121 + pub async fn get(Data(conn): Db<'_>) -> Markup { 122 + let tags: Vec<Tag> = { 123 + let conn = conn.lock().unwrap(); 72 124 73 - label { "sub" input name="channel"; } 74 - input type="submit"; 125 + let mut stmt = conn.prepare("SELECT ROWID, name FROM tag").unwrap(); 126 + 127 + let tags: rusqlite::Result<Vec<Tag>> = stmt 128 + .query_map([], |row| { 129 + Ok(Tag { 130 + db_id: row.get(0).unwrap(), 131 + name: row.get(1).unwrap(), 132 + }) 133 + }) 134 + .unwrap() 135 + .collect(); 136 + 137 + tags.unwrap() 138 + }; 139 + 140 + let subscriptions = { 141 + let conn = conn.lock().unwrap(); 142 + get_subscriptions(&conn, VideoFilter::AllVideos).unwrap() 143 + }; 144 + 145 + let videos = get_videos(&subscriptions[..]).await; 146 + 147 + wrapper(html! { 148 + 149 + nav { 150 + h1.plain.heading { "All Subscriptions" } 75 151 } 152 + hr; 76 153 77 - details { 78 - summary { "All subscriptions" } 154 + details name="grouped" { 155 + summary { "Manage Subscriptions" } 156 + form action="/sub" method="POST" { 157 + 158 + label { 159 + "Channel ID, or username, or @handle " 160 + br; 161 + input name="channel"; 162 + } 163 + br; 164 + input type="submit" value="Subscribe"; 165 + } 79 166 @for sub in subscriptions { 80 167 ul { 81 168 li { 82 169 (sub.name) 83 170 84 - form action="/sub" method="DELETE" { 85 - input type="hidden" name="id" value=(sub.db_id); 171 + br; 172 + div style="border: 1px solid rebeccapurple; display: inline-flex; gap: 1rem;" { 173 + @for tag in sub.tags { 174 + span {(tag.name)} 175 + } 176 + } 177 + form action="/sub/tag" method="POST" { 178 + input type="hidden" name="sub" value=(sub.db_id); 179 + select name="tag" { 180 + @for tag in &tags { 181 + option value=(tag.db_id) { (tag.name) } 182 + } 183 + } 184 + input type="submit" value="Add tag"; 185 + } 186 + 187 + form action={"/sub/" (sub.db_id) "/delete"} method="GET" { 188 + input type="submit" value="Delete"; 86 189 } 87 190 } 88 191 } 89 192 } 90 193 } 91 194 92 - ol .thumb-grid { 93 - @for vid in videos { 94 - @let channel = vid.channel.unwrap(); 95 - @let duration = vid.duration.unwrap_or_default(); 96 - li { 97 - vid-thumbnail { 98 - img src=(vid.thumbnail.first().unwrap().url); 99 - 100 - vid-timestamp { 101 - (duration / 60) 102 - ":" 103 - (duration % 60) 104 - } 105 - 106 - } 195 + details name="grouped" { 196 + summary { "Manage Tags" } 197 + form action="/tag" method="POST" { 107 198 108 - vid-details-large { 109 - // @let avatar = channel.avatar.first().unwrap(); 110 - // img src=(avatar.url) width=(avatar.width) height=(avatar.height); 111 - vid-title { (vid.name) } 112 - } 113 - 114 - vid-details-small { 115 - vid-channel { (channel.name) } 116 - upload-date { (vid.publish_date_txt.unwrap_or_default())} 199 + label { 200 + "Tag name " 201 + br; 202 + input name="name"; 203 + } 204 + br; 205 + input type="submit" value="New tag"; 206 + } 207 + @for tag in tags { 208 + ul { 209 + li { 210 + (tag.name) (NBSP) a href={"/tag/"(tag.db_id)} {"View Posts"} 117 211 } 118 212 } 119 213 } 120 214 } 121 215 122 - } 216 + (video_grid(videos)) 217 + 218 + }) 123 219 }
+44 -2
src/routes/sub.rs
··· 1 - use poem::{handler, web::Data}; 1 + use std::collections::HashMap; 2 + 2 3 use crate::Db; 4 + use poem::{ 5 + handler, 6 + web::{Data, Form, Path, Redirect}, 7 + }; 8 + use rustypipe::{client::RustyPipe, model::UrlTarget}; 3 9 4 10 #[handler] 5 - pub async fn delete(Data(conn): Db<'_>) { 11 + pub async fn tag( 12 + Data(conn): Db<'_>, 13 + Form(form): Form<HashMap<String, String>>, 14 + ) -> poem::web::Redirect { 15 + let tag = &form["tag"]; 16 + let sub = &form["sub"]; 17 + 18 + let conn = conn.lock().unwrap(); 19 + let mut stmt = conn 20 + .prepare("INSERT INTO subscription_tag (subscription_id, tag_id) VALUES (?1, ?2)") 21 + .unwrap(); 22 + stmt.execute([sub, tag]).unwrap(); 6 23 24 + Redirect::see_other("/") 25 + } 26 + 27 + #[handler] 28 + pub async fn delete(Data(conn): Db<'_>) {} 29 + 30 + #[handler] 31 + pub async fn post(Data(conn): Db<'_>, Form(form): Form<HashMap<String, String>>) -> Redirect { 32 + let channel = &form["channel"]; 33 + 34 + let rp = RustyPipe::new(); 35 + let UrlTarget::Channel { id } = rp.query().resolve_string(channel, false).await.unwrap() else { 36 + unreachable!() 37 + }; 38 + 39 + let channel = rp.query().channel_videos(&id).await.unwrap(); 40 + { 41 + let conn = conn.lock().unwrap(); 42 + let mut stmt = conn 43 + .prepare("INSERT INTO subscription (name, channel_id) VALUES (?1, ?2)") 44 + .unwrap(); 45 + stmt.execute([channel.name, channel.id]).unwrap(); 46 + } 47 + 48 + Redirect::see_other("/") 7 49 }
+58
src/routes/tag.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use maud::{Markup, html}; 4 + use poem::{ 5 + handler, 6 + web::{Data, Form, Path, Redirect}, 7 + }; 8 + 9 + use crate::{ 10 + Db, NBSP, 11 + fragments::{video_grid::video_grid, wrapper}, 12 + routes::index::{Tag, VideoFilter, get_subscriptions, get_videos}, 13 + }; 14 + 15 + #[handler] 16 + pub async fn by_id(Path(id): Path<i32>, Data(conn): Db<'_>) -> Markup { 17 + let subscriptions = { 18 + let conn = conn.lock().unwrap(); 19 + get_subscriptions(&conn, VideoFilter::ByTag { id }).unwrap() 20 + }; 21 + 22 + let videos = get_videos(&subscriptions[..]).await; 23 + 24 + let tag: Tag = { 25 + let conn = conn.lock().unwrap(); 26 + 27 + let mut stmt = conn 28 + .prepare("SELECT name FROM tag WHERE rowid = ?1") 29 + .unwrap(); 30 + 31 + stmt.query_one([id], |row| { 32 + Ok(Tag { 33 + db_id: id, 34 + name: row.get(0).unwrap(), 35 + }) 36 + }) 37 + .unwrap() 38 + }; 39 + 40 + wrapper(html! { 41 + nav { 42 + a href="/" { "← Back to Index" } (NBSP) h1.plain.heading { (tag.name) } 43 + } 44 + hr; 45 + (video_grid(videos)) 46 + }) 47 + } 48 + 49 + #[handler] 50 + pub fn post(Data(conn): Db<'_>, Form(form): Form<HashMap<String, String>>) -> Redirect { 51 + let name = &form["name"]; 52 + 53 + let conn = conn.lock().unwrap(); 54 + let mut stmt = conn.prepare("INSERT INTO tag (name) VALUES (?1)").unwrap(); 55 + stmt.execute([name]).unwrap(); 56 + 57 + Redirect::see_other("/") 58 + }
+15
style.css
··· 4 4 box-sizing: border-box; 5 5 } 6 6 7 + h1.plain, 8 + h2.plain, 9 + h3.plain { 10 + font-size: inherit; 11 + font-weight: inherit; 12 + display: inline; 13 + } 14 + 15 + .heading.heading { 16 + font-weight: bold; 17 + text-transform: capitalize; 18 + } 19 + 7 20 ol.thumb-grid { 8 21 display: grid; 9 22 grid-template-columns: repeat(auto-fill, 16rem); 10 23 gap: 1rem; 24 + padding: 0; 25 + justify-content: center; 11 26 12 27 li { 13 28 display: flex;