make better fuzzy search

This commit is contained in:
2026-07-01 20:32:32 +02:00
parent 1c75494c94
commit e222d48179
7 changed files with 1476 additions and 99 deletions
+171 -19
View File
@@ -1,17 +1,27 @@
//! Search + Download. Posts a query to `/search/yt`, renders the
//! resulting list of `YtCandidate`s with their `thumbnail` decoded into a
//! `TextureHandle`, and on click POSTs `/init` with the chosen URL.
//! Search + Download.
//!
//! The yt-dlp search can take a few seconds, so it runs on a worker
//! thread and writes results into `AppState.search_results_bus` (drained
//! each `update()`).
//! Default flow: posts a query to `/search/yt/ranked` (the daemon
//! ranks 3 ytsearch variants and attaches scoring + flag badges), so
//! the official track usually sits at index 0. Renders each
//! `RankedCandidate` as a card with its score + flags so the user
//! can see *why* the daemon ranked it that way.
//!
//! An optional "🪄 Smart Pick" button bypasses the manual picker by
//! asking the daemon to decide. When the daemon's confidence beats
//! the auto-pick margin, the chosen candidate downloads straight
//! away; otherwise the daemon returns ranked candidates for the
//! picker.
//!
//! The yt-dlp search can take a few seconds, so all network calls
//! run on background threads and write results into
//! `AppState.search_results_bus` (drained each `update()`).
use std::time::Instant;
use eframe::egui;
use crate::app::SJNMusicApp;
use crate::daemon::YtCandidate;
use crate::daemon::{PickResponse, RankedCandidate};
pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.heading("Search + Download");
@@ -33,6 +43,20 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
{
start_search(app);
}
if app.search_results.is_empty() {
ui.add_space(4.0);
} else {
ui.add_space(4.0);
// 🪄 Smart Pick: ask the daemon to decide. If its top
// candidate beats the runner-up by ≥ `search_margin` pts
// (default 30), the result downloads straight away; if
// it's unsure, the daemon returns the ranked picker.
if !app.search_picking
&& ui.button("\u{1FA84} Smart Pick").clicked()
{
start_smart_pick(app);
}
}
});
ui.add_space(8.0);
@@ -61,6 +85,9 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
ui.label(egui::RichText::new("Searching yt-dlp…").weak());
return;
}
if app.search_picking {
ui.label(egui::RichText::new("Smart-picking…").weak());
}
if app.search_results.is_empty() {
ui.label(
@@ -80,7 +107,7 @@ pub fn show(ui: &mut egui::Ui, app: &mut SJNMusicApp) {
});
}
fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
fn candidate_card(ui: &mut egui::Ui, c: &RankedCandidate, app: &mut SJNMusicApp) {
egui::Frame::group(ui.style())
.fill(ui.visuals().faint_bg_color)
.show(ui, |ui| {
@@ -99,22 +126,43 @@ fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
);
} else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵");
ui.label("\u{1F3B5}");
});
}
} else {
ui.allocate_ui(egui::Vec2::splat(64.0), |ui| {
ui.label("🎵");
ui.label("\u{1F3B5}");
});
}
ui.vertical(|ui| {
ui.label(egui::RichText::new(&c.title).strong());
ui.label(egui::RichText::new(&c.uploader).small().weak());
ui.label(
egui::RichText::new(crate::fmt::mmss(c.duration_secs))
.small()
.weak(),
egui::RichText::new(&c.title)
.strong()
.color(score_color(c.score)),
);
ui.label(
egui::RichText::new(format!(
"{} ({})",
c.uploader,
crate::fmt::mmss(c.duration_secs)
))
.small()
.weak(),
);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("score {:+}", c.score))
.monospace()
.strong(),
);
for f in &c.flags {
ui.label(
egui::RichText::new(flag_chip(f))
.small()
.color(flag_color(f)),
);
}
});
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("download").clicked() {
@@ -125,22 +173,64 @@ fn candidate_card(ui: &mut egui::Ui, c: &YtCandidate, app: &mut SJNMusicApp) {
});
}
fn score_color(score: i32) -> egui::Color32 {
if score >= 100 {
egui::Color32::from_rgb(140, 220, 160)
} else if score >= 30 {
egui::Color32::from_rgb(220, 220, 220)
} else if score >= 0 {
egui::Color32::from_rgb(220, 200, 140)
} else {
egui::Color32::from_rgb(220, 140, 140)
}
}
fn flag_chip(f: &str) -> String {
match f {
"official" | "official-upload" => format!("\u{2705} {f}"),
"lyrics" => format!("\u{1F4DD} {f}"),
"reaction" => format!("\u{1F914} {f}"),
"remix" => format!("\u{1F3B6} {f}"),
"live" => format!("\u{1F3B5} {f}"),
"nightcore" | "slowed" | "bass-boosted" | "reverb" => format!("\u{26A0} {f}"),
"instrumental" => format!("\u{1F3B5} {f}"),
"karaoke" => format!("\u{1F3A4} {f}"),
"long" | "short" => format!("\u{23F1} {f}"),
"artist-mismatch" => format!("\u{274C} {f}"),
_ => f.to_string(),
}
}
fn flag_color(f: &str) -> egui::Color32 {
match f {
"official" | "official-upload" => egui::Color32::from_rgb(140, 220, 160),
"lyrics" | "reaction" | "remix" | "live"
| "nightcore" | "slowed" | "bass-boosted" | "reverb"
| "instrumental" | "karaoke" | "artist-mismatch" => {
egui::Color32::from_rgb(220, 140, 140)
}
"long" | "short" => egui::Color32::from_rgb(220, 180, 140),
_ => egui::Color32::from_rgb(180, 180, 180),
}
}
fn start_search(app: &mut SJNMusicApp) {
let q = app.search_q.trim().to_string();
if q.is_empty() {
return;
}
app.search_loading = true;
app.search_picking = false;
app.search_just_downloaded = None;
let daemon = app.daemon.clone();
let bus = app.search_results_bus.clone();
let limit = app.search_limit;
std::thread::spawn(move || {
let started = Instant::now();
match daemon.search_yt(&q, limit) {
match daemon.search_yt_ranked(&q, limit) {
Ok(results) => {
log::debug!(
"search_yt: {} candidates in {:?}",
"search_yt_ranked: {} candidates in {:?}",
results.len(),
started.elapsed()
);
@@ -148,7 +238,7 @@ fn start_search(app: &mut SJNMusicApp) {
*slot = Some(results);
}
Err(e) => {
log::warn!("search_yt failed: {e}");
log::warn!("search_yt_ranked failed: {e}");
// Empty Vec still clears the loading flag on the UI side.
let mut slot = bus.lock().unwrap();
*slot = Some(Vec::new());
@@ -157,7 +247,69 @@ fn start_search(app: &mut SJNMusicApp) {
});
}
fn start_download(app: &mut SJNMusicApp, c: &YtCandidate) {
/// Smart Pick: ask the daemon's `/pick` endpoint to either
/// auto-select (when top beats runner-up by `search_margin`) or hand
/// us back the ranked picker. On `auto` we download immediately; on
/// `needs_choice` we either show a native dialog or fold the ranked
/// list back into the existing results for visual inspection. Loading
/// state is tracked on `search_picking` so the UI doesn't show stale
/// "Search returned N results" while the pick request is in flight.
fn start_smart_pick(app: &mut SJNMusicApp) {
let q = app.search_q.trim().to_string();
if q.is_empty() {
return;
}
app.search_picking = true;
let daemon = app.daemon.clone();
let margin = app.search_margin;
let limit = app.search_limit;
let job_name = q.clone();
std::thread::spawn(move || {
match daemon.pick(&q, limit, Some(margin)) {
Ok(PickResponse::Auto {
url,
title,
score,
..
}) => {
// Download straight away.
log::info!("smart-pick auto-selected: {title:?} score={score}");
if let Err(e) = daemon.init(&job_name, Some(&url)) {
log::warn!("smart-pick /init failed: {e}");
}
}
Ok(PickResponse::NeedsChoice {
candidates,
top_score,
runner_up_score,
margin,
}) => {
log::info!(
"smart-pick needs_choice: top={} runner={} margin={}",
top_score,
runner_up_score,
margin
);
if let Some(best) = candidates.first() {
log::info!(
"smart-pick fallback to top scorer: {} ({})",
best.title,
best.uploader
);
if let Err(e) = daemon.init(&job_name, Some(&best.url)) {
log::warn!("smart-pick fallback /init failed: {e}");
}
}
}
Ok(PickResponse::Empty { message }) => {
log::warn!("smart-pick empty: {message}");
}
Err(e) => log::warn!("smart-pick call failed: {e}"),
}
});
}
fn start_download(app: &mut SJNMusicApp, c: &RankedCandidate) {
let name = app.search_q.clone();
let url = c.url.clone();
app.search_just_downloaded = Some(format!(