Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ yt-dlp = { git = "https://github.com/Guilherme-j10/yt-dlp" }
serde_json = "1.0.149"
dirs = "6.0.0"
once_cell = "1.21.4"
parking_lot = "0.12"
zip = "8.6"
# Decodes yt-dlp output: on Windows it's in the ANSI codepage (cp1251), not UTF-8.
encoding_rs = "0.8"
Expand Down
96 changes: 96 additions & 0 deletions src-tauri/src/functions/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//! In-memory TTL cache for video/playlist metadata, keyed by a normalized
//! URL. Re-pasting or revisiting the same URL within the TTL window skips
//! the yt-dlp subprocess spawn entirely instead of re-fetching.
//!
//! Only successful fetches are cached — a transient failure (e.g. YouTube's
//! anti-bot captcha) must not "stick" for the TTL window, so callers only
//! call `insert` on the Ok path.

use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};

const TTL: Duration = Duration::from_secs(5 * 60);

pub struct TtlCache<T: Clone> {
entries: Mutex<HashMap<String, (Instant, T)>>,
}

impl<T: Clone> TtlCache<T> {
fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}

pub fn get(&self, key: &str) -> Option<T> {
let mut map = self.entries.lock();
match map.get(key) {
Some((inserted, value)) if inserted.elapsed() < TTL => Some(value.clone()),
Some(_) => {
map.remove(key);
None
}
None => None,
}
}

pub fn insert(&self, key: String, value: T) {
self.entries.lock().insert(key, (Instant::now(), value));
}
}

/// Declares a lazily-initialized, process-wide `TtlCache<$ty>` static named
/// `$name`.
macro_rules! ttl_cache {
($name:ident, $ty:ty) => {
pub static $name: LazyLock<TtlCache<$ty>> = LazyLock::new(TtlCache::new);
};
}

ttl_cache!(VIDEO_INFO_CACHE, crate::functions::get_info::VideoInfo);
ttl_cache!(TWITCH_INFO_CACHE, crate::functions::twitch::TwitchVideoInfo);
ttl_cache!(PLAYLIST_INFO_CACHE, crate::functions::playlist::PlaylistInfo);

/// Normalizes a URL to a stable cache key by dropping every query
/// parameter except the ones callers explicitly keep. Falls back to the
/// original (trimmed) URL if it doesn't parse — a cache miss, never a
/// crash.
fn normalize(url: &str, keep: &[&str]) -> String {
match reqwest::Url::parse(url.trim()) {
Ok(mut parsed) => {
let kept: Vec<(String, String)> = parsed
.query_pairs()
.filter(|(k, _)| keep.contains(&k.as_ref()))
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if kept.is_empty() {
parsed.set_query(None);
} else {
let qs = kept
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
parsed.set_query(Some(&qs));
}
parsed.set_fragment(None);
parsed.to_string()
}
Err(_) => url.trim().to_string(),
}
}

/// Cache key for a single video (YouTube `v=`/`youtu.be` path, or a Twitch
/// VOD/clip path) — strips tracking params (`si=`, `t=`, …) so re-pasting
/// a shared link still hits the cache.
pub fn video_key(url: &str) -> String {
normalize(url, &["v"])
}

/// Cache key for a playlist (YouTube `list=`, or a Twitch channel's videos
/// page, which carries no relevant query params).
pub fn playlist_key(url: &str) -> String {
normalize(url, &["list"])
}
35 changes: 29 additions & 6 deletions src-tauri/src/functions/get_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ fn client() -> &'static Client {
})
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
pub struct VideoInfo {
pub title: String,
pub author_name: String,
Expand All @@ -29,6 +29,11 @@ pub struct VideoInfo {

#[tauri::command]
pub async fn get_youtube_info(app: tauri::AppHandle, url: String) -> Result<VideoInfo, String> {
let key = crate::functions::cache::video_key(&url);
if let Some(cached) = crate::functions::cache::VIDEO_INFO_CACHE.get(&key) {
return Ok(cached);
}

let oembed_url = format!("https://www.youtube.com/oembed?url={}&format=json", url);

// oembed (fast title/author/thumbnail) and a single -J call (duration +
Expand All @@ -38,7 +43,6 @@ pub async fn get_youtube_info(app: tauri::AppHandle, url: String) -> Result<Vide
// instead of failing outright.
let (oembed_res, meta) =
tokio::join!(client().get(&oembed_url).send(), fetch_yt_meta(&url, Some(app)));

let oembed_json = match oembed_res {
Ok(res) if res.status().is_success() => res.json::<serde_json::Value>().await.ok(),
_ => None,
Expand Down Expand Up @@ -76,7 +80,7 @@ pub async fn get_youtube_info(app: tauri::AppHandle, url: String) -> Result<Vide
return Err(meta.error.unwrap_or_else(|| "Failed to fetch video info".into()));
}

Ok(VideoInfo {
let info = VideoInfo {
title,
author_name,
thumbnail_url,
Expand All @@ -85,19 +89,32 @@ pub async fn get_youtube_info(app: tauri::AppHandle, url: String) -> Result<Vide
audio_tracks: meta.audio_tracks,
video_codecs: meta.video_codecs,
audio_codecs: meta.audio_codecs,
})
};
// Only cache a complete result — oembed can succeed (title/thumbnail)
// while the yt-dlp metadata call independently fails, leaving
// duration: None. Caching that would lock in a bogus "00:00:00" max
// clip length for the full TTL instead of letting the next request retry.
if info.duration.is_some() {
crate::functions::cache::VIDEO_INFO_CACHE.insert(key, info.clone());
}
Ok(info)
}

#[tauri::command]
pub async fn get_twitch_info(url: String) -> Result<TwitchVideoInfo, String> {
let key = crate::functions::cache::video_key(&url);
if let Some(cached) = crate::functions::cache::TWITCH_INFO_CACHE.get(&key) {
return Ok(cached);
}

let json = fetch_json(&url).await?;

let is_live = json["is_live"].as_bool().unwrap_or(false);
let audio_tracks = crate::functions::youtube::parse_audio_langs(&json);
let video_codecs = crate::functions::youtube::parse_video_codecs(&json);
let audio_codecs = crate::functions::youtube::parse_audio_codecs(&json);

Ok(TwitchVideoInfo {
let info = TwitchVideoInfo {
title: json["title"].as_str().unwrap_or("Twitch VOD").into(),
channel: json["uploader"]
.as_str()
Expand All @@ -116,5 +133,11 @@ pub async fn get_twitch_info(url: String) -> Result<TwitchVideoInfo, String> {
audio_tracks,
video_codecs,
audio_codecs,
})
};
// Live entries have no fixed duration/view-count — don't lock in
// ephemeral data for the TTL window.
if !is_live {
crate::functions::cache::TWITCH_INFO_CACHE.insert(key, info.clone());
}
Ok(info)
}
1 change: 1 addition & 0 deletions src-tauri/src/functions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod cache;
pub mod dependencies;
pub mod get_info;
pub mod playlist;
Expand Down
11 changes: 9 additions & 2 deletions src-tauri/src/functions/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ fn is_twitch_videos_page(u: &str) -> bool {

#[tauri::command]
pub async fn get_playlist_info(url: String) -> Result<PlaylistInfo, String> {
let key = crate::functions::cache::playlist_key(&url);
if let Some(cached) = crate::functions::cache::PLAYLIST_INFO_CACHE.get(&key) {
return Ok(cached);
}

let args: Vec<String> = vec![
"--flat-playlist".into(),
"--print".into(),
Expand Down Expand Up @@ -108,12 +113,14 @@ pub async fn get_playlist_info(url: String) -> Result<PlaylistInfo, String> {

let total = if count == 0 { entries.len() as u64 } else { count };

Ok(PlaylistInfo {
let info = PlaylistInfo {
title: playlist_title,
uploader,
count: total,
entries,
})
};
crate::functions::cache::PLAYLIST_INFO_CACHE.insert(key, info.clone());
Ok(info)
}

fn make_entry(id: &str, title: &str, duration: &str, url: &str) -> PlaylistEntry {
Expand Down
Loading
Loading