[READ-ONLY] Mirror of https://github.com/probablykasper/kadium. App for staying ontop of YouTube channels' uploads kadium.kasper.space
linux macos notifications tauri windows youtube
0

Configure Feed

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

Show videos on videos page

Kasper (Nov 5, 2021, 1:21 AM +0100) de002dce af61838a

+235 -77
+2 -1
src-tauri/migrations/1_videos.sql
··· 9 9 thumbnailMaxres BOOLEAN NOT NULL, 10 10 channelName TEXT NOT NULL, 11 11 channelId TEXT NOT NULL, 12 - unread BOOLEAN NOT NULL DEFAULT 1 12 + unread BOOLEAN NOT NULL DEFAULT 1, 13 + archived BOOLEAN NOT NULL DEFAULT 0 13 14 );
+14 -14
src-tauri/src/api.rs
··· 42 42 /// Lists the fields we use only. Documentation: 43 43 /// https://developers.google.com/youtube/v3/docs/videos#properties 44 44 #[derive(Deserialize, Debug)] 45 - #[serde(rename_all = "camelCase")] 45 + #[allow(non_snake_case)] 46 46 pub struct Video { 47 47 pub id: String, 48 - pub content_details: ContentDetails, 49 - pub live_streaming_details: Option<LiveStreamingDetails>, 48 + pub contentDetails: ContentDetails, 49 + pub liveStreamingDetails: Option<LiveStreamingDetails>, 50 50 pub snippet: Snippet, 51 51 } 52 52 #[derive(Deserialize, Debug)] ··· 54 54 pub duration: String, 55 55 } 56 56 #[derive(Deserialize, Debug)] 57 - #[serde(rename_all = "camelCase")] 57 + #[allow(non_snake_case)] 58 58 pub struct LiveStreamingDetails { 59 - pub scheduled_start_time: String, 59 + pub scheduledStartTime: String, 60 60 } 61 61 62 62 #[derive(Deserialize, Debug)] 63 - #[serde(rename_all = "camelCase")] 63 + #[allow(non_snake_case)] 64 64 pub struct Snippet { 65 - pub published_at: String, 65 + pub publishedAt: String, 66 66 pub title: String, 67 67 pub description: String, 68 68 pub thumbnails: Thumbnails, 69 - pub channel_id: String, 70 - pub channel_title: String, 69 + pub channelId: String, 70 + pub channelTitle: String, 71 71 } 72 72 /// default, medium and high always exist: 73 73 /// default 120x90: https://i.ytimg.com/vi/___ID___/default.jpg ··· 110 110 /// an hour off... But it's almost 2 hours off, if not 2.5 hours. 111 111 /// :/ 112 112 #[derive(Deserialize, Debug)] 113 - #[serde(rename_all = "camelCase")] 113 + #[allow(non_snake_case)] 114 114 pub struct Playlist { 115 - pub content_details: ContentDetails, 115 + pub contentDetails: ContentDetails, 116 116 } 117 117 #[derive(Deserialize, Debug)] 118 - #[serde(rename_all = "camelCase")] 118 + #[allow(non_snake_case)] 119 119 pub struct ContentDetails { 120 - pub video_published_at: String, 121 - pub video_id: String, 120 + pub videoPublishedAt: String, 121 + pub videoId: String, 122 122 } 123 123 }
+13 -15
src-tauri/src/background.rs
··· 205 205 // check which videos are new 206 206 let mut new_ids: Vec<String> = Vec::new(); 207 207 for fetched_video in uploads.items { 208 - let fetched_id = &fetched_video.content_details.video_id; 208 + let fetched_id = &fetched_video.contentDetails.videoId; 209 209 if existing_ids.contains(fetched_id) { 210 - println!("Existing ID: {}", fetched_id); 211 210 continue; 212 211 } 213 212 214 - let published_str = fetched_video.content_details.video_published_at; 213 + let published_str = fetched_video.contentDetails.videoPublishedAt; 215 214 let published_time = parse_datetime(&published_str)?.timestamp_millis(); 216 215 if published_time < channel.from_time { 217 - println!("Too old ID: {}", fetched_id); 218 216 continue; 219 217 } 220 218 221 - new_ids.push(fetched_video.content_details.video_id); 219 + new_ids.push(fetched_video.contentDetails.videoId); 222 220 } 223 221 224 222 if new_ids.len() == 0 { ··· 238 236 let mut videos_to_add: Vec<db::Video> = Vec::new(); 239 237 for video in videos.items { 240 238 // skip future livestreams 241 - if let Some(live_streaming_details) = &video.live_streaming_details { 242 - let start_time = &live_streaming_details.scheduled_start_time; 239 + if let Some(live_streaming_details) = &video.liveStreamingDetails { 240 + let start_time = &live_streaming_details.scheduledStartTime; 243 241 let start_timestamp = parse_datetime(&start_time)?; 244 242 if start_timestamp > chrono::Utc::now() { 245 243 continue; // skip future livestreams 246 244 } 247 245 } 248 - let publish_time = parse_datetime(&video.snippet.published_at)?; 249 - let duration_ms = parse_absolute_duration(&video.content_details.duration)?; 246 + let publish_time = parse_datetime(&video.snippet.publishedAt)?; 247 + let duration_ms = parse_absolute_duration(&video.contentDetails.duration)?; 250 248 videos_to_add.push(db::Video { 251 249 id: video.id, 252 250 title: video.snippet.title, 253 251 description: video.snippet.description, 254 - publish_time_ms: publish_time.timestamp_millis(), 255 - duration_ms: duration_ms, 256 - thumbnail_standard: video.snippet.thumbnails.standard.is_some(), 257 - thumbnail_maxres: video.snippet.thumbnails.maxres.is_some(), 258 - channel_id: video.snippet.channel_id, 259 - channel_name: video.snippet.channel_title, 252 + publishTimeMs: publish_time.timestamp_millis(), 253 + durationMs: duration_ms, 254 + thumbnailStandard: video.snippet.thumbnails.standard.is_some(), 255 + thumbnailMaxres: video.snippet.thumbnails.maxres.is_some(), 256 + channelId: video.snippet.channelId, 257 + channelName: video.snippet.channelTitle, 260 258 unread: true, 261 259 }); 262 260 }
+17 -9
src-tauri/src/data.rs
··· 1 1 use crate::settings::{Channel, Settings, VersionedSettings}; 2 - use crate::{background, throw}; 2 + use crate::{background, db, throw}; 3 3 use serde::Serialize; 4 4 use serde_json::Value; 5 5 use sqlx::SqlitePool; 6 6 use std::path::PathBuf; 7 - use std::sync::{Arc, Mutex}; 7 + use std::sync::Arc; 8 8 use tauri::{command, Config, State}; 9 + use tokio::sync::Mutex; 9 10 10 11 #[derive(Clone)] 11 12 pub struct AppPaths { ··· 56 57 } 57 58 58 59 #[command] 59 - pub fn get_settings(data: State<ArcData>) -> Result<Value, String> { 60 - let mut data = data.0.lock().unwrap(); 60 + pub async fn get_videos(data: State<'_, ArcData>) -> Result<Value, String> { 61 + let data = data.0.lock().await; 62 + let videos = db::get_videos(&data.db_pool).await?; 63 + to_json(&videos) 64 + } 65 + 66 + #[command] 67 + pub async fn get_settings(data: State<'_, ArcData>) -> Result<Value, String> { 68 + let mut data = data.0.lock().await; 61 69 to_json(&data.settings()) 62 70 } 63 71 64 72 #[command] 65 - pub fn set_channels(channels: Vec<Channel>, data: State<ArcData>) -> Result<(), String> { 66 - let mut data = data.0.lock().unwrap(); 73 + pub async fn set_channels(channels: Vec<Channel>, data: State<'_, ArcData>) -> Result<(), String> { 74 + let mut data = data.0.lock().await; 67 75 data.settings().channels = channels; 68 76 data.save_settings()?; 69 77 Ok(()) 70 78 } 71 79 72 80 #[command] 73 - pub fn set_general_settings( 81 + pub async fn set_general_settings( 74 82 api_key: String, 75 83 max_concurrent_requests: u32, 76 - data: State<ArcData>, 84 + data: State<'_, ArcData>, 77 85 ) -> Result<(), String> { 78 - let mut data = data.0.lock().unwrap(); 86 + let mut data = data.0.lock().await; 79 87 data.settings().api_key = api_key; 80 88 data.settings().max_concurrent_requests = max_concurrent_requests; 81 89 data.save_settings()?;
+42 -14
src-tauri/src/db.rs
··· 1 1 use crate::api::playlist_items; 2 2 use crate::throw; 3 3 use log; 4 + use serde::Serialize; 4 5 use sqlx::migrate::MigrateDatabase; 5 6 use sqlx::{ConnectOptions, Row, Sqlite, SqlitePool}; 6 7 ··· 15 16 Err(e) => throw!("Could not create database: {}", e), 16 17 } 17 18 } 18 - println!("Exists: {}", exists); 19 19 20 20 let mut connect_options = sqlx::sqlite::SqliteConnectOptions::new().filename(&path); 21 21 connect_options.log_statements(log::LevelFilter::Info); ··· 46 46 let query_str = format!("SELECT id FROM videos WHERE id IN ({});", id_placeholders); 47 47 let mut query = sqlx::query(&query_str); 48 48 for video in videos { 49 - query = query.bind(&video.content_details.video_id); 49 + query = query.bind(&video.contentDetails.videoId); 50 50 } 51 51 let rows = match query.fetch_all(pool).await { 52 52 Ok(rows) => rows, ··· 62 62 Ok(existing_ids) 63 63 } 64 64 65 + #[derive(Debug, Serialize)] 66 + #[allow(non_snake_case)] 65 67 pub struct Video { 66 68 pub id: String, 67 69 pub title: String, 68 70 pub description: String, 69 - pub publish_time_ms: i64, 71 + pub publishTimeMs: i64, 70 72 /// SQLite does not support unsigned integers 71 - pub duration_ms: i64, 72 - pub thumbnail_standard: bool, 73 - pub thumbnail_maxres: bool, 74 - pub channel_id: String, 75 - pub channel_name: String, 73 + pub durationMs: i64, 74 + pub thumbnailStandard: bool, 75 + pub thumbnailMaxres: bool, 76 + pub channelId: String, 77 + pub channelName: String, 76 78 pub unread: bool, 77 79 } 80 + impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Video { 81 + fn from_row(row: &sqlx::sqlite::SqliteRow) -> sqlx::Result<Self> { 82 + Ok(Video { 83 + id: row.try_get("id")?, 84 + title: row.try_get("title")?, 85 + description: row.try_get("description")?, 86 + publishTimeMs: row.try_get("publishTimeMs")?, 87 + /// SQLite does not support unsigned integers 88 + durationMs: row.try_get("durationMs")?, 89 + thumbnailStandard: row.try_get("thumbnailStandard")?, 90 + thumbnailMaxres: row.try_get("thumbnailMaxres")?, 91 + channelId: row.try_get("channelId")?, 92 + channelName: row.try_get("channelName")?, 93 + unread: row.try_get("unread")?, 94 + }) 95 + } 96 + } 78 97 79 98 pub async fn insert_video(video: &Video, pool: &SqlitePool) -> Result<(), String> { 80 99 let query_str = format!( ··· 85 104 .bind(&video.id) 86 105 .bind(&video.title) 87 106 .bind(&video.description) 88 - .bind(&video.publish_time_ms) 89 - .bind(&video.duration_ms) 90 - .bind(&video.thumbnail_standard) 91 - .bind(&video.thumbnail_maxres) 92 - .bind(&video.channel_id) 93 - .bind(&video.channel_name); 107 + .bind(&video.publishTimeMs) 108 + .bind(&video.durationMs) 109 + .bind(&video.thumbnailStandard) 110 + .bind(&video.thumbnailMaxres) 111 + .bind(&video.channelId) 112 + .bind(&video.channelName); 94 113 let rows_affected = match query.execute(pool).await { 95 114 Ok(result_rows) => result_rows.rows_affected(), 96 115 Err(e) => throw!("Error saving video: {}", e), ··· 100 119 } 101 120 Ok(()) 102 121 } 122 + 123 + pub async fn get_videos(pool: &SqlitePool) -> Result<Vec<Video>, String> { 124 + let query = sqlx::query_as("SELECT * FROM videos"); 125 + let videos: Vec<Video> = match query.fetch_all(pool).await { 126 + Ok(videos) => videos, 127 + Err(e) => throw!("Error getting videos: {}", e), 128 + }; 129 + Ok(videos) 130 + }
+15 -4
src-tauri/src/main.rs
··· 10 10 use std::thread; 11 11 use tauri::api::{dialog, shell}; 12 12 use tauri::{command, CustomMenuItem, Submenu, Window, WindowBuilder, WindowUrl}; 13 + use tokio::runtime::Runtime; 13 14 14 15 mod api; 15 16 mod background; ··· 112 113 113 114 const MAIN_WIN: &str = "main"; 114 115 115 - #[tokio::main] 116 - async fn main() { 116 + fn main() { 117 117 let ctx = tauri::generate_context!(); 118 118 119 119 // macOS "App Nap" periodically pauses our app when it's in the background. ··· 121 121 macos_app_nap::prevent(); 122 122 123 123 let app_paths = AppPaths::from_tauri_config(&ctx.config()); 124 - let (loaded_data, note) = match load_data(&app_paths).await { 124 + 125 + let data_load_result = match Runtime::new() { 126 + Ok(runtime) => runtime.block_on(async { 127 + // load data in separate async thread. If main() is an async runtime, 128 + // tauri will crash on drag-and-drop 129 + return load_data(&app_paths).await; 130 + }), 131 + Err(e) => Err(e.to_string()), 132 + }; 133 + 134 + let (loaded_data, note) = match data_load_result { 125 135 Ok(v) => v, 126 136 Err(e) => { 127 137 error_popup_main_thread(&e); ··· 138 148 let app = tauri::Builder::default() 139 149 .invoke_handler(tauri::generate_handler![ 140 150 error_popup, 151 + data::get_videos, 141 152 data::get_settings, 142 153 data::set_channels, 143 154 data::set_general_settings, ··· 159 170 .decorations(true) 160 171 .always_on_top(false) 161 172 .inner_size(900.0, 800.0) 162 - .min_inner_size(440.0, 150.0) 173 + .min_inner_size(400.0, 150.0) 163 174 .fullscreen(false); 164 175 return (win, webview); 165 176 })
+16 -8
src/App.svelte
··· 1 1 <script lang="ts"> 2 - import ChannelsPage from './lib/Channels.svelte' 3 - import SettingsPage from './lib/Settings.svelte' 2 + import ChannelsPage from './routes/Channels.svelte' 3 + import SettingsPage from './routes/Settings.svelte' 4 4 import { checkShortcut } from './lib/general' 5 5 import { loadSettings, settings, useSampleSettings } from './lib/data' 6 6 import { Route, active, router } from 'tinro' 7 + import VideosPage from './routes/Videos.svelte' 7 8 8 9 function go(e: MouseEvent) { 9 10 if (e.target instanceof HTMLElement) { ··· 40 41 <button on:click={useSampleSettings}>Check out sample data?</button> 41 42 {:else} 42 43 <nav> 43 - <a on:mousedown={go} use:active data-exact href="/">Videos</a> 44 - <a on:mousedown={go} use:active href="/channels">Channels</a> 45 - <a on:mousedown={go} use:active href="/settings">Settings</a> 44 + <a on:mousedown={go} use:active data-exact href="/"><button>Videos</button></a> 45 + <a on:mousedown={go} use:active href="/channels"><button>Channels</button></a> 46 + <a on:mousedown={go} use:active href="/settings"><button>Settings</button></a> 46 47 </nav> 47 48 <div class="page"> 48 - <Route path="/">Videos page</Route> 49 + <Route path="/"><VideosPage /></Route> 49 50 <Route path="/channels"><ChannelsPage channels={$settings.channels} /></Route> 50 51 <Route path="/settings"> 51 52 <SettingsPage ··· 80 81 overflow: auto 81 82 height: calc(100% - $nav-height) 82 83 a 84 + background-color: transparent 85 + border: none 83 86 display: inline-block 84 - font-size: 16px 85 87 margin-right: 15px 86 88 text-decoration: none 87 - padding: 6px 0px 88 89 color: hsl(210, 100%, 55%) 89 90 &:hover 90 91 color: hsl(210, 100%, 45%) 91 92 &:global(.active) 92 93 color: hsl(216, 30%, 93%) 94 + button 95 + background-color: transparent 96 + border: none 97 + font-size: 16px 98 + color: inherit 99 + margin: 0px 100 + padding: 6px 0px 93 101 </style>
+5 -5
src/lib/Channels.svelte src/routes/Channels.svelte
··· 1 1 <script lang="ts"> 2 - import Link from './Link.svelte' 3 - import type { Channel } from './data' 4 - import Tags from './Tags.svelte' 5 - import { runCmd } from './general' 2 + import Link from '../lib/Link.svelte' 3 + import type { Channel } from '../lib/data' 4 + import Tags from '../lib/Tags.svelte' 5 + import { runCmd } from '../lib/general' 6 6 7 7 export let channels: Channel[] 8 8 ··· 21 21 <div class="content"> 22 22 <!-- <span>{channel.id}</span> --> 23 23 <span>Check for videos after {new Date(channel.from_time).toLocaleString()}</span> 24 - <span>Minutes between refreshes: {channel.minutes_between_refreshes}</span> 24 + <span>Minutes between refreshes: {channel.refresh_rate}</span> 25 25 </div> 26 26 <Tags bind:value={channel.tags} on:update={saveChannels} /> 27 27 </div>
+3 -3
src/lib/Settings.svelte src/routes/Settings.svelte
··· 1 1 <script lang="ts"> 2 - import Button from './Button.svelte' 2 + import Button from '../lib/Button.svelte' 3 3 import { router } from 'tinro' 4 - import { runCmd } from './general' 5 - import { loadSettings } from './data' 4 + import { runCmd } from '../lib/general' 5 + import { loadSettings } from '../lib/data' 6 6 7 7 export let apiKey: string 8 8 export let maxConcurrentRequests: number
+1 -2
src/lib/Tags.svelte
··· 2 2 import { createEventDispatcher } from 'svelte' 3 3 import { checkShortcut } from './general' 4 4 5 - let tagsEl: HTMLDivElement 6 5 let inputEl: HTMLInputElement 7 6 8 7 let editing = false ··· 96 95 } 97 96 </script> 98 97 99 - <div class="tags" bind:this={tagsEl}> 98 + <div class="tags"> 100 99 <div class="label">Tags</div> 101 100 {#each value as tag, i} 102 101 {#if i === value.length - 1 && editing}
+3 -2
src/lib/data.ts
··· 8 8 icon: string 9 9 uploads_playlist_id: string 10 10 from_time: number 11 - minutes_between_refreshes: number 11 + /// Milliseconds between refreshes 12 + refresh_rate: number 12 13 tags: string[] 13 14 } 14 15 ··· 38 39 id: 'UCp4csaOD64mSzPxbfuzJcuA', 39 40 name: 'Chuckle Sandwich ' + i, 40 41 uploads_playlist_id: 'UUp4csaOD64mSzPxbfuzJcuA', 41 - minutes_between_refreshes: 60, 42 + refresh_rate: 60, 42 43 tags: ['Chungus'], 43 44 }) 44 45 }
+104
src/routes/Videos.svelte
··· 1 + <script lang="ts"> 2 + import { runCmd } from '../lib/general' 3 + 4 + type Video = { 5 + id: string 6 + title: string 7 + description: string 8 + publishTimeMs: number 9 + durationMs: number 10 + thumbnailStandard: boolean 11 + thumbnailMaxres: boolean 12 + channelId: string 13 + channelName: string 14 + unread: boolean 15 + } 16 + 17 + let videos: Video[] = [] 18 + async function getVideos() { 19 + videos = await runCmd('get_videos') 20 + } 21 + const months = [ 22 + 'Jan', 23 + 'Feb', 24 + 'Mar', 25 + 'Apr', 26 + 'May', 27 + 'Jun', 28 + 'Jul', 29 + 'Aug', 30 + 'Sep', 31 + 'Oct', 32 + 'Nov', 33 + 'Dec', 34 + ] 35 + function formatDate(timestamp: number) { 36 + let ts = new Date(timestamp) 37 + return ts.getDay() + ' ' + months[ts.getMonth()] + ' ' + ts.getFullYear() 38 + } 39 + getVideos() 40 + </script> 41 + 42 + <main class="selectable"> 43 + {#each videos as video} 44 + <div class="box"> 45 + <!-- <img src="https://i.ytimg.com/vi/{video.id}/hqdefault.jpg" alt="" /> --> 46 + <!-- <img src="https://i.ytimg.com/vi/{video.id}/sddefault.jpg" alt="" /> --> 47 + <a target="_blank" href="https://youtube.com/watch?v={video.id}"> 48 + <button> 49 + <img src="https://i.ytimg.com/vi/{video.id}/maxresdefault.jpg" alt="" /> 50 + <p class="title selectable">{video.title}</p> 51 + </button> 52 + </a> 53 + <p class="channel"> 54 + <a target="_blank" href="https://www.youtube.com/channel/{video.channelId}"> 55 + <button class="selectable">{video.channelName}</button> 56 + </a> 57 + </p> 58 + <p class="channel selectable">{formatDate(video.publishTimeMs)}</p> 59 + </div> 60 + {/each} 61 + </main> 62 + 63 + <style lang="sass"> 64 + .selectable 65 + user-select: text 66 + -webkit-user-select: text 67 + main 68 + width: 100% 69 + box-sizing: border-box 70 + display: grid 71 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)) 72 + flex-wrap: wrap 73 + grid-gap: 15px 74 + padding: 20px 75 + .box 76 + max-width: 280px 77 + margin: 0px auto 78 + user-select: none 79 + -webkit-user-select: none 80 + img 81 + width: 100% 82 + p 83 + margin: 0px 84 + a 85 + text-decoration: none 86 + color: inherit 87 + &:focus 88 + border-color: hsl(210, 100%, 55%) 89 + button 90 + background-color: transparent 91 + border: none 92 + margin: 0px 93 + padding: 0px 94 + text-align: left 95 + cursor: pointer 96 + p.title 97 + font-size: 13px 98 + font-weight: 500 99 + color: #ffffff 100 + p.channel 101 + font-size: 11.5px 102 + opacity: 0.8 103 + margin-top: 2px 104 + </style>