perf: cache prereq versions and resolved stream URLs
The bundled yt-dlp is a PyInstaller one-file binary that unpacks ~37MB on every invocation, costing about eight seconds a call on this machine regardless of signing, xattrs or thinning the universal binary. Homebrew's copy is a Python script and starts instantly, so bundling traded startup speed for self-containment. Softens it where possible: tool versions are read once at startup in the background rather than on every Settings open, and resolved stream URLs are cached for three hours (well inside YouTube's signed-URL lifetime) so replaying a video costs nothing. A first play still pays the startup.
This commit is contained in:
@@ -37,8 +37,17 @@ pub struct AppState {
|
|||||||
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
|
pub children: Arc<Mutex<HashMap<String, tokio::process::Child>>>,
|
||||||
pub download_slots: Arc<Semaphore>,
|
pub download_slots: Arc<Semaphore>,
|
||||||
pub playlists: PlaylistServer,
|
pub playlists: PlaylistServer,
|
||||||
|
/// yt-dlp's PyInstaller bundle costs several seconds to start, so its
|
||||||
|
/// version is read once and kept.
|
||||||
|
pub prereqs: Arc<Mutex<Option<Prereqs>>>,
|
||||||
|
/// Resolved stream URLs, keyed by video and quality cap. YouTube's signed
|
||||||
|
/// URLs last hours, so replaying a video should not pay for yt-dlp again.
|
||||||
|
pub streams: Arc<Mutex<HashMap<(String, Option<u32>), (String, std::time::Instant)>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
|
||||||
|
const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600);
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct RefreshProgress {
|
struct RefreshProgress {
|
||||||
done: usize,
|
done: usize,
|
||||||
@@ -140,8 +149,16 @@ fn label(version: &str, name: &str) -> String {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String> {
|
||||||
|
// The library path can change, so only the tool versions are cached.
|
||||||
let library = state.library.lock().await.clone();
|
let library = state.library.lock().await.clone();
|
||||||
Ok(Prereqs {
|
if let Some(cached) = state.prereqs.lock().await.clone() {
|
||||||
|
return Ok(Prereqs {
|
||||||
|
library_path: library.to_string_lossy().to_string(),
|
||||||
|
..cached
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fresh = Prereqs {
|
||||||
yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")),
|
yt_dlp: version_of("yt-dlp", "--version").await.map(|v| label(&v, "yt-dlp")),
|
||||||
ffmpeg: version_of("ffmpeg", "-version").await.map(|v| {
|
ffmpeg: version_of("ffmpeg", "-version").await.map(|v| {
|
||||||
// ffmpeg's first line is long; keep the useful head of it.
|
// ffmpeg's first line is long; keep the useful head of it.
|
||||||
@@ -149,7 +166,9 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
|
|||||||
label(&head, "ffmpeg")
|
label(&head, "ffmpeg")
|
||||||
}),
|
}),
|
||||||
library_path: library.to_string_lossy().to_string(),
|
library_path: library.to_string_lossy().to_string(),
|
||||||
})
|
};
|
||||||
|
*state.prereqs.lock().await = Some(fresh.clone());
|
||||||
|
Ok(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_channels(path: &str) -> Result<Vec<Channel>, String> {
|
async fn read_channels(path: &str) -> Result<Vec<Channel>, String> {
|
||||||
@@ -227,6 +246,15 @@ pub async fn resolve_stream(
|
|||||||
max_height: Option<u32>,
|
max_height: Option<u32>,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Stream, String> {
|
) -> Result<Stream, String> {
|
||||||
|
let key = (video_id.clone(), max_height);
|
||||||
|
{
|
||||||
|
let mut cache = state.streams.lock().await;
|
||||||
|
cache.retain(|_, (_, at)| at.elapsed() < STREAM_CACHE_TTL);
|
||||||
|
if let Some((cached, _)) = cache.get(&key) {
|
||||||
|
return Ok(Stream { url: Some(cached.clone()), playlist: None });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
let url = format!("https://www.youtube.com/watch?v={video_id}");
|
||||||
|
|
||||||
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
// The HLS master playlist. Every m3u8 format shares the same manifest_url,
|
||||||
@@ -237,6 +265,7 @@ pub async fn resolve_stream(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
let Some(cap) = max_height else {
|
let Some(cap) = max_height else {
|
||||||
|
remember(&state, key, &master).await;
|
||||||
return Ok(Stream { url: Some(master), playlist: None });
|
return Ok(Stream { url: Some(master), playlist: None });
|
||||||
};
|
};
|
||||||
// Fetch and cut it down. If anything about that fails, the adaptive
|
// Fetch and cut it down. If anything about that fails, the adaptive
|
||||||
@@ -244,10 +273,11 @@ pub async fn resolve_stream(
|
|||||||
return match state.http.get(&master).send().await {
|
return match state.http.get(&master).send().await {
|
||||||
Ok(resp) => match resp.text().await {
|
Ok(resp) => match resp.text().await {
|
||||||
Ok(body) => Ok(match filter_master_playlist(&body, cap) {
|
Ok(body) => Ok(match filter_master_playlist(&body, cap) {
|
||||||
Some(filtered) => Stream {
|
Some(filtered) => {
|
||||||
url: Some(state.playlists.publish(filtered).await),
|
let served = state.playlists.publish(filtered).await;
|
||||||
playlist: None,
|
remember(&state, key, &served).await;
|
||||||
},
|
Stream { url: Some(served), playlist: None }
|
||||||
|
}
|
||||||
None => Stream { url: Some(master), playlist: None },
|
None => Stream { url: Some(master), playlist: None },
|
||||||
}),
|
}),
|
||||||
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
|
Err(_) => Ok(Stream { url: Some(master), playlist: None }),
|
||||||
@@ -272,6 +302,18 @@ pub async fn resolve_stream(
|
|||||||
Err("Could not find a playable stream for this video.".into())
|
Err("Could not find a playable stream for this video.".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn remember(
|
||||||
|
state: &State<'_, AppState>,
|
||||||
|
key: (String, Option<u32>),
|
||||||
|
url: &str,
|
||||||
|
) {
|
||||||
|
state
|
||||||
|
.streams
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(key, (url.to_string(), std::time::Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
/// Keeps only the highest video variant at or below `max_height`, along with
|
/// Keeps only the highest video variant at or below `max_height`, along with
|
||||||
/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references).
|
/// every `EXT-X-MEDIA` line (the audio and subtitle groups it references).
|
||||||
///
|
///
|
||||||
@@ -766,6 +808,8 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
children: Arc::new(Mutex::new(HashMap::new())),
|
children: Arc::new(Mutex::new(HashMap::new())),
|
||||||
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
download_slots: Arc::new(Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
||||||
playlists: PlaylistServer::start()?,
|
playlists: PlaylistServer::start()?,
|
||||||
|
prereqs: Arc::new(Mutex::new(None)),
|
||||||
|
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ pub fn run() {
|
|||||||
enable_element_fullscreen(&window);
|
enable_element_fullscreen(&window);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The bundled yt-dlp takes seconds to start, so pay that once in
|
||||||
|
// the background rather than the first time Settings is opened.
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
use tauri::Manager;
|
||||||
|
let state = handle.state::<commands::AppState>();
|
||||||
|
let _ = commands::check_prereqs(state).await;
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
|||||||
Reference in New Issue
Block a user