make better fuzzy search
This commit is contained in:
+11
-4
@@ -17,7 +17,7 @@ use std::sync::Arc;
|
||||
use eframe::egui;
|
||||
use eframe::egui::TextureHandle;
|
||||
|
||||
use crate::daemon::{DaemonClient, DownloadJob, HistoryEntry, NowPlaying, Playlist, QueueSnapshot, Song, Stats, YtCandidate};
|
||||
use crate::daemon::{DaemonClient, DownloadJob, HistoryEntry, NowPlaying, Playlist, QueueSnapshot, RankedCandidate, Song, Stats};
|
||||
use crate::state::{AppState, Route, Theme, TickMessage, Toast, ToastLevel};
|
||||
use crate::thumbnails::ImageMessage;
|
||||
use crate::tray::TraySignals;
|
||||
@@ -48,7 +48,7 @@ pub struct SJNMusicApp {
|
||||
// them at the top of every `update()` so a quick typing burst can't
|
||||
// strand a result behind stale state.
|
||||
pub library_results_bus: Arc<std::sync::Mutex<Option<Vec<Song>>>>,
|
||||
pub search_results_bus: Arc<std::sync::Mutex<Option<Vec<YtCandidate>>>>,
|
||||
pub search_results_bus: Arc<std::sync::Mutex<Option<Vec<RankedCandidate>>>>,
|
||||
|
||||
// Downloads view holds an explicit override so per-view refresh
|
||||
// overrides the polled snapshot without us racing the worker.
|
||||
@@ -72,9 +72,14 @@ pub struct SJNMusicApp {
|
||||
|
||||
pub search_q: String,
|
||||
pub search_limit: usize,
|
||||
pub search_results: Vec<YtCandidate>,
|
||||
pub search_results: Vec<RankedCandidate>,
|
||||
pub search_loading: bool,
|
||||
pub search_just_downloaded: Option<String>,
|
||||
pub search_margin: i32,
|
||||
/// Set while a Smart-Pick "auto" or "needs_choice" decision is in
|
||||
/// flight so the UI can show "Picking…" instead of stale
|
||||
/// `search_results` rows.
|
||||
pub search_picking: bool,
|
||||
|
||||
pub history_data: Vec<HistoryEntry>,
|
||||
pub history_loading: bool,
|
||||
@@ -146,10 +151,12 @@ impl SJNMusicApp {
|
||||
new_playlist_name: String::new(),
|
||||
|
||||
search_q: String::new(),
|
||||
search_limit: 3,
|
||||
search_limit: 9,
|
||||
search_results: Vec::new(),
|
||||
search_loading: false,
|
||||
search_just_downloaded: None,
|
||||
search_margin: 30,
|
||||
search_picking: false,
|
||||
|
||||
history_data: Vec::new(),
|
||||
history_loading: false,
|
||||
|
||||
@@ -47,6 +47,89 @@ pub struct YtCandidate {
|
||||
pub thumbnail: Option<String>,
|
||||
}
|
||||
|
||||
/// Per-signal score that `GET /search/yt/ranked` attaches to every
|
||||
/// candidate. The daemon ranks by `score` and surfaces this
|
||||
/// breakdown for the GUI to render as badges / tooltips so the user
|
||||
/// sees *why* a particular result won.
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct ScoreBreakdown {
|
||||
pub total: i32,
|
||||
#[serde(default)]
|
||||
pub official_channel: i32,
|
||||
#[serde(default)]
|
||||
pub official_phrase: i32,
|
||||
#[serde(default)]
|
||||
pub title_match: i32,
|
||||
#[serde(default)]
|
||||
pub artist_match: i32,
|
||||
#[serde(default)]
|
||||
pub duration: i32,
|
||||
#[serde(default)]
|
||||
pub fan_upload: i32,
|
||||
#[serde(default)]
|
||||
pub lyrics: i32,
|
||||
#[serde(default)]
|
||||
pub reaction: i32,
|
||||
#[serde(default)]
|
||||
pub remix: i32,
|
||||
#[serde(default)]
|
||||
pub live: i32,
|
||||
#[serde(default)]
|
||||
pub spam: i32,
|
||||
#[serde(default)]
|
||||
pub instrumental: i32,
|
||||
#[serde(default)]
|
||||
pub karaoke: i32,
|
||||
}
|
||||
|
||||
/// `GET /search/yt/ranked` candidate: a `YtCandidate` + score +
|
||||
/// detected flags (`official`, `lyrics`, `live`, `reaction`,
|
||||
/// `remix`, `nightcore`, etc.). Sorted server-side by `score`
|
||||
/// descending.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct RankedCandidate {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub uploader: String,
|
||||
pub duration_secs: f64,
|
||||
pub url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
pub score: i32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breakdown: Option<ScoreBreakdown>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub flags: Vec<String>,
|
||||
}
|
||||
|
||||
/// `GET /pick` response. Tagged enum so the GUI can branch on the
|
||||
/// `kind` discriminator:
|
||||
/// - `auto` → proceeds straight to `/init` with `url`,
|
||||
/// - `needs_choice` → shows the picker (`candidates`),
|
||||
/// - `empty` → surfaces the error to the user.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum PickResponse {
|
||||
Auto {
|
||||
url: String,
|
||||
title: String,
|
||||
uploader: String,
|
||||
score: i32,
|
||||
duration_secs: f64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
flags: Vec<String>,
|
||||
},
|
||||
NeedsChoice {
|
||||
candidates: Vec<RankedCandidate>,
|
||||
top_score: i32,
|
||||
runner_up_score: i32,
|
||||
margin: i32,
|
||||
},
|
||||
Empty {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// One persistent playlist with its member songs.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Playlist {
|
||||
@@ -165,6 +248,13 @@ pub struct SearchYtResult {
|
||||
pub results: Vec<YtCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SearchRankedResult {
|
||||
pub query: String,
|
||||
pub limit: usize,
|
||||
pub results: Vec<RankedCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HistoryList {
|
||||
pub history: Vec<HistoryEntry>,
|
||||
@@ -350,6 +440,49 @@ impl DaemonClient {
|
||||
.results)
|
||||
}
|
||||
|
||||
pub fn search_yt_ranked(
|
||||
&self,
|
||||
q: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RankedCandidate>, DaemonError> {
|
||||
// `/search/yt/ranked` runs up to 3 variant `ytsearch` calls in
|
||||
// parallel inside the daemon (raw, "official video",
|
||||
// "official audio"), then dedupes and scores each candidate.
|
||||
// Worst-case latency is dominated by the slowest single
|
||||
// variant fetch, so we still budget ~180 s here.
|
||||
Ok(self
|
||||
.get_with_timeout::<SearchRankedResult>(
|
||||
&format!(
|
||||
"/search/yt/ranked?q={}&limit={}",
|
||||
urlencoding::encode(q),
|
||||
limit
|
||||
),
|
||||
Duration::from_secs(180),
|
||||
)?
|
||||
.results)
|
||||
}
|
||||
|
||||
/// Auto-pick: returns either a single confidently-selected URL
|
||||
/// or the ranked candidates for the picker. Same high timeout
|
||||
/// as `search_yt_ranked` since the daemon does the same work.
|
||||
pub fn pick(
|
||||
&self,
|
||||
q: &str,
|
||||
limit: usize,
|
||||
margin: Option<i32>,
|
||||
) -> Result<PickResponse, DaemonError> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
parts.push(format!("q={}", urlencoding::encode(q)));
|
||||
parts.push(format!("limit={limit}"));
|
||||
if let Some(m) = margin {
|
||||
parts.push(format!("margin={m}"));
|
||||
}
|
||||
self.get_with_timeout::<PickResponse>(
|
||||
&format!("/pick?{}", parts.join("&")),
|
||||
Duration::from_secs(180),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn volume_info(&self) -> Result<VolumeInfo, DaemonError> {
|
||||
self.get("/volume")
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user