feat: queue, channel view, downloads/saved pages, repeat/loop, accessibility, perf, multithreading
This commit is contained in:
@@ -4,12 +4,18 @@
|
||||
// in ../frontend calls these via window.__TAURI__.core.invoke(...). Returns the
|
||||
// exact JSON shapes the UI expects:
|
||||
// yt_search { query } -> { ok, results:[…] }
|
||||
// yt_channel { channel } -> { ok, channel, channelUrl, results:[…] }
|
||||
// yt_streams { videoId } -> { ok, data:{ meta, audioUrl, qualities[] } }
|
||||
// store_load {} -> playlists / history / settings
|
||||
// store_save { data } -> { ok }
|
||||
//
|
||||
// Unlike the size-limited zero-native bridge, Tauri's IPC has no fixed buffer,
|
||||
// but we still return only the slim fields the UI needs.
|
||||
//
|
||||
// Threading: the yt-dlp calls and the offline downloads are blocking and can
|
||||
// take seconds. They are dispatched onto a background thread pool with
|
||||
// `tauri::async_runtime::spawn_blocking` so the WebView/main thread stays
|
||||
// responsive and multiple downloads can run concurrently.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
@@ -20,6 +26,7 @@ use std::process::Command;
|
||||
use tauri::Manager;
|
||||
|
||||
const SEARCH_LIMIT: u32 = 25;
|
||||
const CHANNEL_LIMIT: u32 = 60;
|
||||
|
||||
// yt-dlp.exe is baked straight into this binary at compile time, so the standalone
|
||||
// `ytplayer.exe` is fully self-contained — no sibling file and nothing on PATH
|
||||
@@ -100,15 +107,71 @@ fn str_or<'a>(v: &'a Value, key: &str, fallback: &'a str) -> &'a str {
|
||||
v.get(key).and_then(|x| x.as_str()).unwrap_or(fallback)
|
||||
}
|
||||
|
||||
/// Pick the channel display name from a yt-dlp record (channel, then uploader).
|
||||
fn pick_channel(v: &Value) -> &str {
|
||||
let c = str_or(v, "channel", "");
|
||||
if c.is_empty() {
|
||||
str_or(v, "uploader", "")
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a usable channel URL (channel_url, then uploader_url).
|
||||
fn pick_channel_url(v: &Value) -> &str {
|
||||
let u = str_or(v, "channel_url", "");
|
||||
if u.is_empty() {
|
||||
str_or(v, "uploader_url", "")
|
||||
} else {
|
||||
u
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick a channel id (channel_id, then uploader_id).
|
||||
fn pick_channel_id(v: &Value) -> &str {
|
||||
let id = str_or(v, "channel_id", "");
|
||||
if id.is_empty() {
|
||||
str_or(v, "uploader_id", "")
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one yt-dlp flat-playlist record into the slim card the UI expects.
|
||||
fn slim_entry(j: &Value) -> Option<Value> {
|
||||
let id = str_or(j, "id", "");
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"id": id,
|
||||
"title": str_or(j, "title", "(untitled)"),
|
||||
"channel": pick_channel(j),
|
||||
"channelId": pick_channel_id(j),
|
||||
"channelUrl": pick_channel_url(j),
|
||||
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
|
||||
let q = query.trim();
|
||||
let q = query.trim().to_string();
|
||||
if q.is_empty() {
|
||||
return json!({ "ok": false, "error": "empty query" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_search_blocking(&app, &q))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_search_blocking(app: &tauri::AppHandle, q: &str) -> Value {
|
||||
let search = format!("ytsearch{}:{}", SEARCH_LIMIT, q);
|
||||
let out = match run_ytdlp(
|
||||
&app,
|
||||
app,
|
||||
&[
|
||||
&search,
|
||||
"--dump-json",
|
||||
@@ -131,37 +194,117 @@ async fn yt_search(app: tauri::AppHandle, query: String) -> Value {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let id = str_or(&j, "id", "");
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
if let Some(card) = slim_entry(&j) {
|
||||
results.push(card);
|
||||
}
|
||||
let channel = {
|
||||
let c = str_or(&j, "channel", "");
|
||||
if c.is_empty() {
|
||||
str_or(&j, "uploader", "")
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
results.push(json!({
|
||||
"id": id,
|
||||
"title": str_or(&j, "title", "(untitled)"),
|
||||
"channel": channel,
|
||||
"duration": j.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
||||
}));
|
||||
}
|
||||
|
||||
json!({ "ok": true, "results": results })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel — list a channel's recent uploads
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_channel(app: tauri::AppHandle, channel: String) -> Value {
|
||||
let c = channel.trim().to_string();
|
||||
if c.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing channel" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_channel_blocking(&app, &c))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_channel_blocking(app: &tauri::AppHandle, channel: &str) -> Value {
|
||||
// Accept either a full channel/uploader URL or a bare channel id. Always
|
||||
// resolve to the "/videos" tab so we list uploads, not the channel home.
|
||||
let base = if channel.starts_with("http") {
|
||||
channel.trim_end_matches('/').to_string()
|
||||
} else if channel.starts_with('@') {
|
||||
format!("https://www.youtube.com/{}", channel)
|
||||
} else if channel.starts_with("UC") {
|
||||
format!("https://www.youtube.com/channel/{}", channel)
|
||||
} else {
|
||||
format!("https://www.youtube.com/@{}", channel)
|
||||
};
|
||||
let url = if base.ends_with("/videos") {
|
||||
base
|
||||
} else {
|
||||
format!("{}/videos", base)
|
||||
};
|
||||
|
||||
let end = CHANNEL_LIMIT.to_string();
|
||||
let out = match run_ytdlp(
|
||||
app,
|
||||
&[
|
||||
&url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-warnings",
|
||||
"--ignore-errors",
|
||||
"--playlist-end",
|
||||
&end,
|
||||
],
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut name = String::new();
|
||||
let mut chan_url = String::new();
|
||||
for line in out.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let j: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if name.is_empty() {
|
||||
let n = pick_channel(&j);
|
||||
if !n.is_empty() {
|
||||
name = n.to_string();
|
||||
}
|
||||
}
|
||||
if chan_url.is_empty() {
|
||||
let u = pick_channel_url(&j);
|
||||
if !u.is_empty() {
|
||||
chan_url = u.to_string();
|
||||
}
|
||||
}
|
||||
if let Some(card) = slim_entry(&j) {
|
||||
results.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"channel": name,
|
||||
"channelUrl": chan_url,
|
||||
"results": results,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Streams
|
||||
// ============================================================================
|
||||
#[tauri::command]
|
||||
async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
if video_id.is_empty() {
|
||||
let id = video_id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing videoId" });
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || yt_streams_blocking(&app, &id))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn yt_streams_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||
let out = match run_ytdlp(&app, &["-J", "--no-warnings", &url]) {
|
||||
let out = match run_ytdlp(app, &["-J", "--no-warnings", &url]) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
@@ -240,22 +383,15 @@ async fn yt_streams(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
.cmp(&a["height"].as_i64().unwrap_or(0))
|
||||
});
|
||||
|
||||
let channel = {
|
||||
let c = str_or(&info, "channel", "");
|
||||
if c.is_empty() {
|
||||
str_or(&info, "uploader", "")
|
||||
} else {
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
json!({
|
||||
"ok": true,
|
||||
"data": {
|
||||
"meta": {
|
||||
"id": video_id,
|
||||
"title": str_or(&info, "title", "(untitled)"),
|
||||
"channel": channel,
|
||||
"channel": pick_channel(&info),
|
||||
"channelId": pick_channel_id(&info),
|
||||
"channelUrl": pick_channel_url(&info),
|
||||
"duration": info.get("duration").and_then(|x| x.as_f64()).unwrap_or(0.0),
|
||||
"thumbnail": format!("https://i.ytimg.com/vi/{}/hqdefault.jpg", video_id),
|
||||
},
|
||||
@@ -308,16 +444,26 @@ fn file_size(p: &PathBuf) -> u64 {
|
||||
|
||||
/// Download a video into the cache as a single progressive mp4 (audio+video in
|
||||
/// one file, so it needs no ffmpeg muxing and plays offline). Idempotent.
|
||||
///
|
||||
/// Async + spawn_blocking so several saves can run in parallel without blocking
|
||||
/// the UI thread.
|
||||
#[tauri::command]
|
||||
fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
if video_id.is_empty() {
|
||||
async fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
let id = video_id.trim().to_string();
|
||||
if id.is_empty() {
|
||||
return json!({ "ok": false, "error": "missing videoId" });
|
||||
}
|
||||
let dir = match cache_dir(&app) {
|
||||
tauri::async_runtime::spawn_blocking(move || cache_download_blocking(&app, &id))
|
||||
.await
|
||||
.unwrap_or_else(|e| json!({ "ok": false, "error": e.to_string() }))
|
||||
}
|
||||
|
||||
fn cache_download_blocking(app: &tauri::AppHandle, video_id: &str) -> Value {
|
||||
let dir = match cache_dir(app) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return json!({ "ok": false, "error": e }),
|
||||
};
|
||||
if let Some(p) = cached_file(&dir, &video_id) {
|
||||
if let Some(p) = cached_file(&dir, video_id) {
|
||||
return json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) });
|
||||
}
|
||||
let url = format!("https://www.youtube.com/watch?v={}", video_id);
|
||||
@@ -326,7 +472,7 @@ fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
// Prefer a progressive mp4 (single file with audio); fall back to best single
|
||||
// file. Avoids ffmpeg by not requesting separate streams that need merging.
|
||||
let res = run_ytdlp(
|
||||
&app,
|
||||
app,
|
||||
&[
|
||||
"--no-playlist",
|
||||
"--no-warnings",
|
||||
@@ -340,7 +486,7 @@ fn cache_download(app: tauri::AppHandle, video_id: String) -> Value {
|
||||
if let Err(e) = res {
|
||||
return json!({ "ok": false, "error": e });
|
||||
}
|
||||
match cached_file(&dir, &video_id) {
|
||||
match cached_file(&dir, video_id) {
|
||||
Some(p) => json!({ "ok": true, "cached": true, "path": p.to_string_lossy(), "size": file_size(&p) }),
|
||||
None => json!({ "ok": false, "error": "download finished but no file was produced" }),
|
||||
}
|
||||
@@ -453,6 +599,7 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
yt_search,
|
||||
yt_channel,
|
||||
yt_streams,
|
||||
store_load,
|
||||
store_save,
|
||||
|
||||
Reference in New Issue
Block a user