From e222d48179bf140147ce0d70c8e484738dbd43d9 Mon Sep 17 00:00:00 2001 From: "Jonas H." Date: Wed, 1 Jul 2026 20:27:59 +0200 Subject: [PATCH] make better fuzzy search --- cli/sjnmusic | 258 +++++--- daemon/src/main.rs | 1 + daemon/src/server.rs | 51 +- daemon/src/youtube.rs | 927 ++++++++++++++++++++++++++++ gui-rs/src/app.rs | 15 +- gui-rs/src/daemon.rs | 133 ++++ gui-rs/src/views/search_download.rs | 190 +++++- 7 files changed, 1476 insertions(+), 99 deletions(-) create mode 100644 daemon/src/youtube.rs diff --git a/cli/sjnmusic b/cli/sjnmusic index 6f7a31c..2bb6746 100755 --- a/cli/sjnmusic +++ b/cli/sjnmusic @@ -183,6 +183,176 @@ def try_parse(payload): return None +def print_candidate(idx, c): + """Pretty-print one ranked candidate row for the picker.""" + score = c.get("score") + score_str = f"score={score:+d}" if score is not None else "" + flags = " ".join(c.get("flags") or []) + flag_str = f" [{flags}]" if flags else "" + print( + f"[{idx}] {c.get('title', '?')} -- " + f"{c.get('uploader') or '(unknown)':24} " + f"({fmt_secs(c.get('duration_secs')):>5}) " + f"{score_str}{flag_str}" + ) + + +def interactive_pick(candidates): + """Show the picker; return the user's choice (or candidates[0]).""" + for i, c in enumerate(candidates, 1): + print_candidate(i, c) + try: + raw = input("Pick a song [1]: ").strip() + except EOFError: + print( + "error: no input available; re-run with --auto or supply a piped number", + file=sys.stderr, + ) + sys.exit(2) + if raw == "": + return candidates[0] + try: + idx = int(raw) - 1 + except ValueError: + print(f"error: not a number: {raw!r}", file=sys.stderr) + sys.exit(2) + if idx < 0 or idx >= len(candidates): + print( + f"error: choice out of range (1-{len(candidates)})", + file=sys.stderr, + ) + sys.exit(2) + return candidates[idx] + + +def legacy_pick(results, force_auto): + """Legacy /search/yt picker (no scoring fields).""" + if force_auto: + return results[0] + return interactive_pick(results) + + +def do_init(args, host, port, q, force_pick, force_auto, limit): + """ + Drive the `init` subcommand through the new ranked pipeline. + + Default behaviour: `GET /pick?q=…` and let the daemon either + auto-select (when the top score beats the runner-up by margin) or + surface ranked candidates for the picker. Falls back to the legacy + `/search/yt` picker if the daemon doesn't speak `/pick` (older + builds), so the CLI stays forward/backward compatible. + + Returns the chosen candidate dict (with at least a 'url' key) so + `main()` can dispatch the actual `/init` POST uniformly. + """ + pick_query = {"q": q} + if limit is not None and limit > 0: + pick_query["limit"] = str(limit) + # 3 parallel yt-dlp fetches can easily take 30+ seconds each + # on a slow network; the daemon drops its state mutex before + # invoking them, but the network round-trips themselves are + # unchanged. Generous timeout to avoid ghost-failing. + pick_status, pick_payload = call( + host, port, "GET", "/pick", query=pick_query, timeout=180 + ) + pick_obj = try_parse(pick_payload) + if pick_status != 200 or pick_obj is None or "kind" not in pick_obj: + # Fall back to legacy `/search/yt` if the daemon is too old + # to know about `/pick`. Keeps the CLI working across daemon + # upgrades in either direction. + legacy_status, legacy_payload = call( + host, port, "GET", "/search/yt", + query=pick_query, timeout=120, + ) + legacy_obj = try_parse(legacy_payload) or {} + legacy_results = legacy_obj.get("results") or [] + if legacy_status != 200: + return (legacy_status, legacy_payload) + if not legacy_results: + print( + f"error: no yt-dlp results for {q!r}", + file=sys.stderr, + ) + sys.exit(1) + return ( + 200, + json.dumps({"chosen": legacy_pick(legacy_results, force_auto)}), + ) + + kind = pick_obj["kind"] + if kind == "empty": + print( + f"error: no yt-dlp results for {q!r}: " + f"{pick_obj.get('message', '')}", + file=sys.stderr, + ) + sys.exit(1) + if kind == "auto": + # Daemon is confident — surface the choice so the user sees + # what was picked, then download. + score = pick_obj.get("score", 0) + print( + f"[auto-pick score={score}] " + f"{pick_obj.get('title', '?')} -- " + f"{pick_obj.get('uploader', '(unknown)')} " + f"({fmt_secs(pick_obj.get('duration_secs'))})" + ) + if force_pick: + # `--pick` overrides the auto decision: ask the user. + ranked_status, ranked_payload = call( + host, port, "GET", "/search/yt/ranked", + query=pick_query, timeout=180, + ) + ranked_obj = try_parse(ranked_payload) or {} + ranked_results = ranked_obj.get("results") or [] + if ranked_status != 200 or not ranked_results: + print_response_args(args, ranked_status, ranked_payload) + sys.exit(1) + return ( + 200, + json.dumps({"chosen": interactive_pick(ranked_results)}), + ) + return ( + 200, + json.dumps({ + "chosen": { + "url": pick_obj.get("url"), + "title": pick_obj.get("title"), + "uploader": pick_obj.get("uploader"), + "score": score, + "duration_secs": pick_obj.get("duration_secs"), + "flags": pick_obj.get("flags", []), + }, + }), + ) + if kind == "needs_choice": + candidates = pick_obj.get("candidates") or [] + if not candidates: + print( + f"error: no yt-dlp results for {q!r}", + file=sys.stderr, + ) + sys.exit(1) + if force_auto: + chosen = candidates[0] + print( + f"[auto-pick score={chosen.get('score', 0)}] " + f"{chosen.get('title', '?')} -- " + f"{chosen.get('uploader', '(unknown)')} " + f"({fmt_secs(chosen.get('duration_secs'))})" + ) + else: + chosen = interactive_pick(candidates) + return (200, json.dumps({"chosen": chosen})) + print(f"error: unknown /pick kind {kind!r}", file=sys.stderr) + sys.exit(1) + + +def print_response_args(args, status, payload): + """Helper for subroutes that already pre-formatted a payload.""" + print_response(args, status, payload) + + def print_response(args, status, payload): if args.json: print(f"HTTP {status}\n{payload}") @@ -315,82 +485,20 @@ def main(argv=None): sys.exit(2) force_pick = getattr(args, "pick", False) force_auto = getattr(args, "auto", False) - # `stdin_isatty` controls whether we *auto*-select (non-interactive) - # versus *prompt* the user. `--pick` overrides a non-TTY stdin so - # scripts can still drive the picker via a piped index if they want. - interactive = force_pick or ( - sys.stdin.isatty() and not force_auto + limit = getattr(args, "limit", None) + + # Drive the ranked pipeline (do_init returns the chosen + # candidate as a JSON envelope so we don't need separate + # branches below for `/init`). + _, envelope_payload = do_init( + args, host, port, q, force_pick, force_auto, limit, + ) + chosen_obj = try_parse(envelope_payload) or {} + chosen = chosen_obj.get("chosen") or {} + status, payload = call( + host, port, "POST", "/init", + body={"name": q, "url": chosen.get("url") or ""}, ) - if not interactive: - # Legacy / scripted path: top result only, no round-trip for - # the candidate list. The daemon falls back to ytsearch1:NAME - # when no URL is supplied. - status, payload = call( - host, port, "POST", "/init", body={"song": q} - ) - else: - limit = getattr(args, "limit", None) - query = {"q": q} - if limit is not None and limit > 0: - query["limit"] = str(limit) - # yt-dlp searches can easily take 30+ seconds on a slow network - # (the daemon calls `ytsearchN:QUERY -j` which hits YouTube). - # The default 15 s `urllib` timeout short-circuits the picker - # before the daemon even returns, so give this one endpoint a - # generous budget. All other endpoints keep the 15 s default. - status, payload = call( - host, port, "GET", "/search/yt", query=query, timeout=120 - ) - obj = try_parse(payload) - results = (obj or {}).get("results") or [] - if status != 200: - print_response(args, status, payload) - sys.exit(1) - if not results: - print( - f"error: no yt-dlp results for {q!r}", - file=sys.stderr, - ) - sys.exit(1) - for i, r in enumerate(results, 1): - uploader = r.get("uploader") or "(unknown)" - print( - f"[{i}] {r.get('title', '?')} -- {uploader} " - f"({fmt_secs(r.get('duration_secs'))})" - ) - try: - raw = input("Pick a song [1]: ").strip() - except EOFError: - print( - "error: no input available; re-run with --auto or supply a piped number", - file=sys.stderr, - ) - sys.exit(2) - if raw == "": - chosen_idx = 0 - else: - try: - chosen_idx = int(raw) - 1 - except ValueError: - print( - f"error: not a number: {raw!r}", - file=sys.stderr, - ) - sys.exit(2) - if chosen_idx < 0 or chosen_idx >= len(results): - print( - f"error: choice out of range (1-{len(results)})", - file=sys.stderr, - ) - sys.exit(2) - chosen = results[chosen_idx] - status, payload = call( - host, - port, - "POST", - "/init", - body={"name": q, "url": chosen.get("url") or ""}, - ) elif args.cmd == "init-batch": status, payload = call( host, port, "POST", "/init/batch", body={"songs": args.queries} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index ff7c004..ecd4d2a 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -6,6 +6,7 @@ mod mpris; mod paths; mod server; mod state; +mod youtube; fn main() { // Initialise logging first so subsequent startup steps are visible. diff --git a/daemon/src/server.rs b/daemon/src/server.rs index 3a6c5c3..ffb2fa7 100644 --- a/daemon/src/server.rs +++ b/daemon/src/server.rs @@ -6,6 +6,7 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; use crate::audio::AudioCmd; use crate::state::{init_async, init_batch, DaemonState, RepeatMode}; +use crate::youtube::{pick_best, rank_query, AUTO_PICK_MARGIN}; #[derive(Serialize)] struct ApiError { @@ -83,7 +84,9 @@ fn handle( "GET /songs?q=", "GET /search?q=... (best match) or POST /search {query}", "GET /search/all?q=... (every match above threshold)", - "GET /search/yt?q=...&limit=N (yt-dlp candidates for picker)", + "GET /search/yt?q=...&limit=N (raw yt-dlp candidates for picker)", + "GET /search/yt/ranked?q=...&limit=N (scored + sorted candidates; re-ranks 3 query variants)", + "GET /pick?q=...&limit=N&margin=N (auto-pick when top score > runner-up by margin, otherwise returns candidates)", "GET /queue", "GET /now-playing", "GET /downloads", @@ -200,6 +203,48 @@ fn handle( Err(e) => error_response(e), }, + Route::SearchYtRanked => match query_or_body(&query_params, &body_text) { + Ok(q) => { + let limit = query_params + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(state.search_count); + // Same lock-drop reasoning as `/search/yt` — parallel + // yt-dlp invocations can reach 30s+ each before they + // return, and we don't want to keep the state mutex for + // the duration. + drop(state); + match rank_query(&q, limit) { + Ok(ranked) => json_response(200, &serde_json::json!({ + "query": q, + "limit": limit, + "results": ranked, + })), + Err(e) => error_response(e), + } + } + Err(e) => error_response(e), + }, + + Route::Pick => match query_or_body(&query_params, &body_text) { + Ok(q) => { + let limit = query_params + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(state.search_count); + let margin = query_params + .get("margin") + .and_then(|s| s.parse::().ok()) + .unwrap_or(AUTO_PICK_MARGIN); + drop(state); + match pick_best(&q, limit, margin) { + Ok(resp) => json_response(200, &resp), + Err(e) => error_response(e), + } + } + Err(e) => error_response(e), + }, + Route::Queue => json_response(200, &serde_json::json!({ "queue": state.queue, "current": state.current, @@ -483,6 +528,8 @@ enum Route { Search, SearchAll, SearchYt, + SearchYtRanked, + Pick, Queue, NowPlaying, Downloads, @@ -521,6 +568,8 @@ fn route_for(method: &Method, path: &str) -> Route { (Method::Get, "/songs") => Route::Songs, (m, "/search") if matches!(m, Method::Get | Method::Post) => Route::Search, (Method::Get, "/search/all") => Route::SearchAll, + (Method::Get, "/search/yt/ranked") => Route::SearchYtRanked, + (Method::Get, "/pick") => Route::Pick, (Method::Get, "/search/yt") => Route::SearchYt, (Method::Get, "/queue") => Route::Queue, (Method::Get, "/now-playing") => Route::NowPlaying, diff --git a/daemon/src/youtube.rs b/daemon/src/youtube.rs new file mode 100644 index 0000000..6c8f7fb --- /dev/null +++ b/daemon/src/youtube.rs @@ -0,0 +1,927 @@ +//! YouTube candidate ranking + auto-pick heuristics. +//! +//! Wraps `DaemonState::search_yt_sync` with a layer that pushes the most +//! likely "official" track to the top of the candidate list, so the user +//! rarely needs to manually pick a result. +//! +//! ## Why +//! +//! `yt-dlp -j ytsearch5:" "` returns whatever YouTube's +//! own search-order decides (closely tied to view count, recency, and +//! relative engagement) — which means a 5-year-old fan-upload with +//! "Lyrics" in the title often outranks the official 2024 upload from +//! the artist's own channel. The ranking heuristics here re-score +//! every candidate with explicit signals so the official track wins +//! in the common case. +//! +//! ## Pipeline +//! +//! 1. [`query_variants`] expands `<artist> <title>` into 3 ytsearch +//! queries: raw, "official video", "official audio". Each variant +//! biases toward the channel pattern YouTube's official uploads +//! follow ("Artist - Topic", "VEVO", "(Official)"). +//! 2. [`rank_query`] runs every variant **in parallel** (each spawns +//! its own yt-dlp subprocess), merges, dedupes, scores, and returns +//! the top-`limit` sorted by score. +//! 3. [`pick_best`] adds the auto-pick decision on top: when the top +//! score beats the runner-up by [`AUTO_PICK_MARGIN`] points, the +//! caller can proceed straight to download without surfacing a +//! picker. +//! +//! Scoring constants live next to the scorer so the spec in the repo +//! docs and the code can never drift apart — adjust the numbers here +//! when refining the heuristics. + +use std::collections::HashSet; +use std::thread; + +use log::{debug, warn}; +use serde::{Deserialize, Serialize}; +use strsim::jaro_winkler; + +use crate::state::{DaemonState, YtCandidate}; + +// ===================================================================== +// Scoring constants — tuned to the spec in /docs/INTERFACE.md (search +// ranking heuristics). Pull these into your editor and read them next +// to `score_candidate` when adjusting values. +// ===================================================================== + +/// Official-artist-channel marker. Bigger than every other positive +/// signal combined so a real official upload will always outrank even +/// the most popular fan upload. +pub const OFFICIAL_CHANNEL_BOOST: i32 = 100; +/// "Official Video" / "Official Music Video" / "Official Audio" phrase +/// in the title. Stacks with `OFFICIAL_CHANNEL_BOOST`. +pub const OFFICIAL_PHRASE_BOOST: i32 = 30; +/// All (non-trivial) tokens from the parsed query appear in the +/// candidate title. Stops "Best Of" compilations and audio-twin +/// decoys from outscoring the real track. +pub const TITLE_INCLUDES_QUERY_BOOST: i32 = 40; +/// Duration sits in the 2–4 minute "ideal" band. +pub const DURATION_IDEAL_BOOST: i32 = 20; +/// Duration sits in the 1.5–6 minute "acceptable" band (excluding +/// ideal). +pub const DURATION_ACCEPTABLE_BOOST: i32 = 10; +/// Duration is over 8 minutes (audiobooks, full live sets). +pub const DURATION_LONG_PENALTY: i32 = -30; +/// Duration is suspiciously short (< 1 minute). +pub const DURATION_SHORT_PENALTY: i32 = -15; +/// yt-dlp didn't report a duration at all (livestream, region-locked). +pub const DURATION_UNKNOWN_PENALTY: i32 = -5; + +/// Tokens like "Lyrics", "Lyric Video" in the title. +pub const LYRICS_PENALTY: i32 = -40; +/// Reaction channels (Reaction / Reacts / First Listen). +pub const REACTION_PENALTY: i32 = -80; +/// "Remix" / "Edit" / "Mashup" — skipped when the user typed "remix". +pub const REMIX_PENALTY: i32 = -50; +/// "Live" / "Concert" — skipped when the user typed "live". +pub const LIVE_PENALTY: i32 = -40; +/// Nightcore / Bass Boosted / Slowed / Reverb. These are spam-flag +/// genres; skipping is unconditional because that's not how anybody +/// searches for music they actually want. +pub const SPAM_PENALTY: i32 = -80; +/// "Instrumental" — skipped when the user typed "instrumental". +pub const INSTRUMENTAL_PENALTY: i32 = -60; +/// "Karaoke" — always penalised. +pub const KARAOKE_PENALTY: i32 = -60; +/// Requested artist does not fuzzy-match the candidate's +/// uploader / title at all. +pub const ARTIST_MISMATCH_PENALTY: i32 = -60; +/// Small penalty applied to fan uploads when at least one +/// genuinely-official upload (`VEVO`, `- Topic`, `(Official)`) +/// exists in the candidate set. Disappears when no official +/// candidate was found at all, so genuinely obscure tracks aren't +/// over-penalised. Spec: "Fan uploads: Small penalty unless no +/// official upload exists." +pub const FAN_UPLOAD_PENALTY: i32 = -10; + +/// Below this Jaro-Winkler similarity the artist match is treated as +/// "missing" → `ARTIST_MISMATCH_PENALTY`. +pub const ARTIST_MATCH_REJECT: f64 = 0.65; + +// Duration thresholds (seconds). Spec bands: +// ideal = 2–4 min → +20 +// acceptable = 2–6 min, excluding ideal → +10 +// long = > 8 min → penalty +pub const IDEAL_SONG_SECS: f64 = 120.0; +pub const IDEAL_SONG_SECS_MAX: f64 = 240.0; +pub const MIN_SONG_SECS: f64 = 120.0; +pub const MAX_SONG_SECS: f64 = 360.0; +pub const LONG_SONG_SECS: f64 = 480.0; +pub const VERY_SHORT_SECS: f64 = 60.0; + +/// Margin (in score points) by which the top candidate must beat the +/// runner-up before `pick_best` decides to auto-select. Matches the +/// "best by ≥30" example in the spec. +pub const AUTO_PICK_MARGIN: i32 = 30; + +/// Extra candidates fetched per variant beyond what the caller asked +/// for, to leave headroom after dedupe + scoring. +pub const PER_VARIANT_LIMIT_BUMP: usize = 2; +/// Lower bound on per-variant ytsearch limit even when the caller asks +/// for fewer. +pub const MIN_PER_VARIANT_LIMIT: usize = 3; + +// ===================================================================== +// ParsedQuery — split `<artist> <title>` into proper halves so we can +// do artist-matching without trusting only the title. +// ===================================================================== + +/// Parsed-out (artist, title, raw) for ranking and artist-matching. +/// `artist` is `None` when the query can't be split cleanly; the +/// scorer just skips the artist mismatch penalty in that case rather +/// than guessing. +#[derive(Clone, Debug)] +pub struct ParsedQuery { + pub artist: Option<String>, + pub title: String, + /// The full input the user typed, kept verbatim so we can detect + /// `remix`, `live`, `instrumental`, etc. user-intent modifiers. + pub raw: String, +} + +impl ParsedQuery { + /// Try common separators in order. Falls back to "all of the + /// query is the title, no artist info" when nothing matches. + /// Returns `None` for empty/whitespace input so the ranker + /// can short-circuit (ytsearch has nothing useful to ask for). + /// + /// Accepts both spaced and bare separators so copy-pasted + /// metadata like `Artist:Title` (no surrounding spaces) and + /// `Artist - Title` both parse cleanly. Tokenizing beyond + /// the first occurrence is intentional — callers can always + /// pass the raw query if the heuristic misfires. + pub fn parse(query: &str) -> Option<Self> { + let q = query.trim(); + if q.is_empty() { + return None; + } + for sep in [" - ", " | "] { + if let Some(idx) = q.find(sep) { + let a = q[..idx].trim(); + let t = q[idx + sep.len()..].trim(); + if !a.is_empty() && !t.is_empty() { + return Some(Self { + artist: Some(a.to_string()), + title: t.to_string(), + raw: q.to_string(), + }); + } + } + } + // Colon: any of ` : `, ` :`, `: `, or bare `:` — handlers + // trim afterwards, so whitespace variants collapse to the + // same split. + if let Some(idx) = q.find(':') { + let a = q[..idx].trim(); + let t = q[idx + 1..].trim(); + if !a.is_empty() && !t.is_empty() { + return Some(Self { + artist: Some(a.to_string()), + title: t.to_string(), + raw: q.to_string(), + }); + } + } + Some(Self { + artist: None, + title: q.to_string(), + raw: q.to_string(), + }) + } +} + +// ===================================================================== +// Query variants — expand a query into 3 ytsearch calls that together +// surface both the user's own-channel uploads and YouTube's +// auto-generated "Topic" channels. +// ===================================================================== + +/// Expand `<artist> <title>` into a small batch of ytsearch-friendly +/// queries. The first element is always the raw query (so dedup-by-id +/// can collapse matches across variants). Subsequent entries bias +/// toward the "tagged Artist's <Title> [Official…]" pattern that +/// official uploads follow. +pub fn query_variants(raw: &str) -> Vec<String> { + let raw = raw.trim(); + if raw.is_empty() { + return Vec::new(); + } + let stripped = strip_official_suffix(raw); + let mut out = vec![ + stripped.to_string(), + format!("{stripped} official video"), + format!("{stripped} official audio"), + ]; + let mut seen = HashSet::new(); + out.retain(|v| seen.insert(v.clone())); + out +} + +/// Strip a trailing "official video" / "official music video" / +/// "official audio" suffix from the query so re-adding it doesn't +/// double the term. +fn strip_official_suffix(s: &str) -> &str { + let lower = s.to_ascii_lowercase(); + for suf in ["official music video", "official video", "official audio"] { + if let Some(rest) = lower.strip_suffix(suf) { + let trimmed = rest.trim_end_matches(|c: char| c == '-' || c.is_whitespace()); + return s[..trimmed.len()].trim_end(); + } + } + s +} + +// ===================================================================== +// ScoreBreakdown — every scoring field, kept around for the GUI/CLI +// to render "why was this picked" tooltips. +// ===================================================================== + +/// Per-candidate score breakdown that the ranker and the GUI/CLI both +/// read. `total` is what sorting + auto-pick decisions use; the rest +/// are surfaced as badges. +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +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, +} + +/// YtCandidate + score + flags. `score` is the breakdown `total` so +/// consumers can sort cheaply; the full `breakdown` is included for +/// the GUI's "why was this picked?" tooltip. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RankedCandidate { + #[serde(flatten)] + pub base: YtCandidate, + pub score: i32, + /// Full score breakdown so the GUI/CLI can render "official +35", + /// "lyrics -40" etc. `None` if the scorer short-circuited. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub breakdown: Option<ScoreBreakdown>, + /// Detected badges (`official`, `lyrics`, `live`, `reaction`, + /// `remix`, `nightcore`, `slowed`, `bass-boosted`, `reverb`, + /// `instrumental`, `karaoke`, `long`, `short`, + /// `artist-mismatch`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub flags: Vec<String>, +} + +// ===================================================================== +// score_candidate — the heart of the ranking. Returns (breakdown, flags). +// ===================================================================== + +/// Score a single yt-dlp candidate against the parsed query. +/// Mutates everyone-but-flags of `ScoreBreakdown::total` at the end. +pub fn score_candidate(pq: &ParsedQuery, c: &YtCandidate) -> (ScoreBreakdown, Vec<String>) { + let mut s = ScoreBreakdown::default(); + let mut flags: Vec<String> = Vec::new(); + + let raw_lower = pq.raw.to_ascii_lowercase(); + let title_lower = c.title.to_ascii_lowercase(); + let uploader_lower = c.uploader.to_ascii_lowercase(); + + // ---- Positive signals ---------------------------------------- + + // Official artist channel. + // YouTube's auto-generated "Topic" channels (e.g. "Artist - Topic") + // are official uploads by definition — the copyright holder + // registered them via YouTube's Content ID. VEVO is the legacy + // official channel provider. "(Official)" / "Official Artist + // Channel" appear in the uploader string when YouTube has flagged + // the channel. + let official_markers = ["vevo", " - topic", "(official)", "official artist channel"]; + if official_markers.iter().any(|kw| uploader_lower.contains(kw)) { + s.official_channel = OFFICIAL_CHANNEL_BOOST; + flags.push("official".to_string()); + } + + // Title contains ALL non-trivial tokens from the parsed query. + let mut required: Vec<String> = pq + .title + .split_whitespace() + .filter(|w| w.len() > 2) + .map(|w| w.to_ascii_lowercase()) + .collect(); + if let Some(a) = &pq.artist { + for w in a.split_whitespace().filter(|w| w.len() > 2) { + required.push(w.to_ascii_lowercase()); + } + } + if !required.is_empty() && required.iter().all(|t| title_lower.contains(t)) { + s.title_match = TITLE_INCLUDES_QUERY_BOOST; + } + + // "Official Video" / "Official Music Video" / "Official Audio" in title. + let official_phrases = ["official music video", "official video", "official audio"]; + if official_phrases.iter().any(|p| title_lower.contains(p)) { + s.official_phrase = OFFICIAL_PHRASE_BOOST; + if !flags.iter().any(|f| f == "official") { + // Only push the "official-upload" tag if we didn't already + // tag the channel as official — keeps the badge list short. + flags.push("official-upload".to_string()); + } + } + + // Artist matching: requested artist vs uploader / title / channel. + // `pick_uploader` in state.rs prefers the channel string, so the + // `uploader` field IS the channel-name-equivalent for most videos. + if let Some(artist) = &pq.artist { + let artist_lower = artist.to_ascii_lowercase(); + if !artist_lower.is_empty() { + let sim_uploader = jaro_winkler(&artist_lower, &uploader_lower); + let sim_title = jaro_winkler(&artist_lower, &title_lower); + let best = sim_uploader.max(sim_title); + if best < ARTIST_MATCH_REJECT { + s.artist_match = ARTIST_MISMATCH_PENALTY; + flags.push("artist-mismatch".to_string()); + } + } + } + + // Duration scoring. + let dur = c.duration_secs; + if dur <= 0.0 { + s.duration = DURATION_UNKNOWN_PENALTY; + } else if dur >= IDEAL_SONG_SECS && dur <= IDEAL_SONG_SECS_MAX { + s.duration = DURATION_IDEAL_BOOST; + } else if dur >= MIN_SONG_SECS && dur <= MAX_SONG_SECS { + s.duration = DURATION_ACCEPTABLE_BOOST; + } else if dur > LONG_SONG_SECS { + s.duration = DURATION_LONG_PENALTY; + flags.push("long".to_string()); + } else if dur < VERY_SHORT_SECS { + s.duration = DURATION_SHORT_PENALTY; + flags.push("short".to_string()); + } + + // ---- Negative signals ----------------------------------------- + // Each has a corresponding "user opted in" guard so a search for + // "song remix" doesn't penalise the remix itself. + + let user_wants = |kw: &str| raw_lower.contains(kw); + let title_has = |terms: &[&str]| terms.iter().any(|t| title_lower.contains(t)); + + if !user_wants("lyric") && title_has(&["lyric", "lyrics"]) { + s.lyrics = LYRICS_PENALTY; + flags.push("lyrics".to_string()); + } + if !user_wants("reaction") && title_has(&["reaction", "reacts", "first listen"]) { + s.reaction = REACTION_PENALTY; + flags.push("reaction".to_string()); + } + if !user_wants("remix") && title_has(&["remix", "edit", "mashup"]) { + s.remix = REMIX_PENALTY; + flags.push("remix".to_string()); + } + if !user_wants("live") && title_has(&["live", "concert"]) { + s.live = LIVE_PENALTY; + flags.push("live".to_string()); + } + if title_has(&["nightcore"]) { + s.spam += SPAM_PENALTY; + flags.push("nightcore".to_string()); + } + if title_has(&["bass boosted"]) { + s.spam += SPAM_PENALTY; + flags.push("bass-boosted".to_string()); + } + if title_has(&["slowed"]) { + s.spam += SPAM_PENALTY; + flags.push("slowed".to_string()); + } + if title_has(&["reverb"]) { + s.spam += SPAM_PENALTY; + flags.push("reverb".to_string()); + } + if !user_wants("instrumental") && title_has(&["instrumental"]) { + s.instrumental = INSTRUMENTAL_PENALTY; + flags.push("instrumental".to_string()); + } + if title_has(&["karaoke"]) { + s.karaoke = KARAOKE_PENALTY; + flags.push("karaoke".to_string()); + } + + // `fan_upload` is updated by `apply_fan_upload_penalty` after + // scoring; deliberately omitted from the initial total sum so + // the post-pass owns the bookkeeping and syncs `rc.score = b.total`. + s.total = s.official_channel + + s.official_phrase + + s.title_match + + s.artist_match + + s.duration + + s.lyrics + + s.reaction + + s.remix + + s.live + + s.spam + + s.instrumental + + s.karaoke; + (s, flags) +} + +// ===================================================================== +// dedupe_candidates — drop exact-id duplicates first, then near-by +// title + uploader copies so VEVO + Topic + "Full Album" copies of +// the same track collapse into one ranked row. +// ===================================================================== + +pub fn dedupe_candidates(candidates: Vec<YtCandidate>) -> Vec<YtCandidate> { + let mut out: Vec<YtCandidate> = Vec::new(); + for c in candidates { + if out.iter().any(|existing| existing.id == c.id) { + continue; + } + let dup = out.iter().any(|existing| { + let t_sim = jaro_winkler( + &existing.title.to_ascii_lowercase(), + &c.title.to_ascii_lowercase(), + ); + let u_sim = jaro_winkler( + &existing.uploader.to_ascii_lowercase(), + &c.uploader.to_ascii_lowercase(), + ); + t_sim > 0.85 && u_sim > 0.70 + }); + if !dup { + out.push(c); + } + } + out +} + +// ===================================================================== +// Fan-upload penalty — small -10 to non-official candidates when at +// least one official upload exists in the result set. Spec line: +// "Fan uploads: Small penalty unless no official upload exists." +// ===================================================================== + +/// Apply `FAN_UPLOAD_PENALTY` to every candidate that is NOT marked +/// as an official channel upload, when the result set contains at +/// least one such official candidate. No-op otherwise. +pub fn apply_fan_upload_penalty(ranked: &mut [RankedCandidate]) { + let has_official = ranked.iter().any(|rc| { + rc.flags + .iter() + .any(|f| f == "official" || f == "official-upload") + }); + if !has_official { + return; + } + for rc in ranked.iter_mut() { + let is_official = rc + .flags + .iter() + .any(|f| f == "official" || f == "official-upload"); + if is_official { + continue; + } + // Single-source the bookkeeping: mutate the breakdown first, + // then sync `rc.score` to it so the two never drift. + if let Some(b) = rc.breakdown.as_mut() { + b.fan_upload += FAN_UPLOAD_PENALTY; + b.total += FAN_UPLOAD_PENALTY; + rc.score = b.total; + } else { + rc.score += FAN_UPLOAD_PENALTY; + } + if !rc.flags.iter().any(|f| f == "fan-upload") { + rc.flags.push("fan-upload".to_string()); + } + } +} + +// ===================================================================== +// rank_query — fetch all variants in parallel, merge, dedupe, score. +// ===================================================================== + +/// Run every variant in parallel, merge + dedupe + score, return the +/// top-`limit` candidates sorted by score descending. An empty query +/// returns an empty list (no error — the ranker is happy to be +/// no-op'd). yt-dlp subprocess failures are logged and the surviving +/// variants still produce results. +pub fn rank_query(query: &str, limit: usize) -> Result<Vec<RankedCandidate>, String> { + let pq = match ParsedQuery::parse(query) { + Some(pq) => pq, + None => return Ok(Vec::new()), + }; + let variants = query_variants(&pq.raw); + if variants.is_empty() { + return Ok(Vec::new()); + } + + let per_variant_limit = (limit + PER_VARIANT_LIMIT_BUMP).max(MIN_PER_VARIANT_LIMIT); + debug!( + "rank_query: query={:?}, {} variants, per-variant limit {}", + query, + variants.len(), + per_variant_limit + ); + + // Spawn one thread per variant. Each thread calls yt-dlp + // independently and returns its parsed candidate list. yt-dlp + // subprocesses don't share state, so this is safe and skips the + // 3x latency hit of fetching variants serially. + let threads: Vec<_> = variants + .iter() + .map(|v| { + let v = v.clone(); + thread::Builder::new() + .name(format!("sjnmusic-rank-{}", short_token(&v))) + .spawn(move || { + DaemonState::search_yt_sync(&v, per_variant_limit).unwrap_or_default() + }) + }) + .collect(); + + let mut all: Vec<YtCandidate> = Vec::new(); + for t in threads { + match t { + Ok(handle) => match handle.join() { + Ok(mut r) => all.append(&mut r), + Err(_) => warn!("rank_query: variant fetcher thread panicked"), + }, + Err(e) => warn!("rank_query: spawn failed: {e}"), + } + } + + let deduped = dedupe_candidates(all); + debug!( + "rank_query: {} unique candidates after dedupe (from {} raw)", + deduped.len(), + variants.len() * per_variant_limit + ); + + let mut ranked: Vec<RankedCandidate> = deduped + .into_iter() + .map(|c| { + let (score, flags) = score_candidate(&pq, &c); + RankedCandidate { + base: c, + score: score.total, + breakdown: Some(score), + flags, + } + }) + .collect(); + + // Fan-upload penalty pass: small -10 to non-official candidates + // when at least one official upload exists. Spec signal: "Fan + // uploads: Small penalty unless no official upload exists." + apply_fan_upload_penalty(&mut ranked); + + // Stable-sort descending by score → yt-dlp's original order is + // the tiebreaker so the most-viewcount-heavy track wins among + // equals (e.g. all four variants returning the same video). + ranked.sort_by(|a, b| b.score.cmp(&a.score)); + + if limit > 0 && ranked.len() > limit { + ranked.truncate(limit); + } + Ok(ranked) +} + +/// Truncate a query for use in a thread name. yt-dlp may spit out +/// queries that confuse thread-name limits otherwise. +fn short_token(s: &str) -> String { + s.chars() + .filter(|c| c.is_alphanumeric()) + .take(20) + .collect::<String>() + .to_ascii_lowercase() +} + +// ===================================================================== +// PickResponse — the wire shape of the /pick endpoint. Tagged enum so +// CLI/GUI can branch on `kind` without merging two response shapes. +// ===================================================================== + +/// Response from the `/pick` endpoint. Tagged enum → JSON like +/// `{"kind": "auto", ...}` or `{"kind": "needs_choice", ...}` so the +/// CLI can branch on the discriminator alone. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PickResponse { + /// Single confident pick — caller can proceed straight to + /// `/init` with `url` and skip showing the picker. + Auto { + url: String, + title: String, + uploader: String, + score: i32, + duration_secs: f64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + flags: Vec<String>, + }, + /// Two or more candidates are close in score — caller should + /// show a picker. + NeedsChoice { + candidates: Vec<RankedCandidate>, + top_score: i32, + runner_up_score: i32, + margin: i32, + }, + /// yt-dlp returned nothing usable; caller should report an error. + Empty { query: String, message: String }, +} + +/// Decide whether to auto-pick or surface the picker. Uses +/// [`rank_query`] under the hood. `limit` controls how many ranked +/// candidates the picker case carries; `margin` is the auto-pick +/// threshold (defaults to [`AUTO_PICK_MARGIN`]). +pub fn pick_best(query: &str, limit: usize, margin: i32) -> Result<PickResponse, String> { + // Fetch a couple extra candidates so the runner-up comparison has + // real signal rather than fighting with tail-end noise. + let fetch = (limit.max(2) + 2).max(5); + let ranked = rank_query(query, fetch)?; + + if ranked.is_empty() { + return Ok(PickResponse::Empty { + query: query.to_string(), + message: format!("no yt-dlp results for {query:?}"), + }); + } + + let top = &ranked[0]; + if ranked.len() == 1 { + return Ok(PickResponse::Auto { + url: top.base.url.clone(), + title: top.base.title.clone(), + uploader: top.base.uploader.clone(), + score: top.score, + duration_secs: top.base.duration_secs, + flags: top.flags.clone(), + }); + } + + let runner_up = ranked[1].score; + let actual_margin = top.score - runner_up; + if actual_margin >= margin { + return Ok(PickResponse::Auto { + url: top.base.url.clone(), + title: top.base.title.clone(), + uploader: top.base.uploader.clone(), + score: top.score, + duration_secs: top.base.duration_secs, + flags: top.flags.clone(), + }); + } + + // Detach the borrow on `ranked` before moving it into `trimmed` — + // otherwise Rust insists the auto branches above have already + // borrowed the very same slot we're trying to move into the + // NeedsChoice payload. + let top_score = top.score; + let trimmed = if limit > 0 && ranked.len() > limit { + ranked[..limit].to_vec() + } else { + ranked + }; + Ok(PickResponse::NeedsChoice { + candidates: trimmed, + top_score, + runner_up_score: runner_up, + margin: actual_margin, + }) +} + +// ===================================================================== +// Tests. +// +// Pure-function logic tests; the IO-heavy paths (rank_query, pick_best) +// are covered manually via the daemon's `/search/yt/ranked` and +// `/pick` endpoints, since mocking yt-dlp in-process adds more complexity +// than the integration value is worth. The scorer / dedupe / parser +// paths are deterministic and below. +// ===================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + fn cand(title: &str, uploader: &str, dur: f64) -> YtCandidate { + YtCandidate { + id: title.to_string(), + title: title.to_string(), + uploader: uploader.to_string(), + duration_secs: dur, + url: format!("https://example/{title}"), + thumbnail: None, + } + } + + #[test] + fn parses_artist_title_with_dash_separator() { + let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap(); + assert_eq!(pq.artist.as_deref(), Some("Haftbefehl")); + assert_eq!(pq.title, "RADW"); + } + + #[test] + fn parses_artist_title_with_colon_separator() { + let pq = ParsedQuery::parse("Bonez MC: Erde").unwrap(); + assert_eq!(pq.artist.as_deref(), Some("Bonez MC")); + assert_eq!(pq.title, "Erde"); + } + + #[test] + fn parses_no_separator_artist_is_none() { + let pq = ParsedQuery::parse("Just A Title").unwrap(); + assert_eq!(pq.artist, None); + assert_eq!(pq.title, "Just A Title"); + } + + #[test] + fn empty_query_returns_none() { + assert!(ParsedQuery::parse(" ").is_none()); + assert!(ParsedQuery::parse("").is_none()); + } + + #[test] + fn query_variants_strips_duplicate_official_suffix() { + let v = query_variants("Haftbefehl - RADW official audio"); + // First entry has the suffix removed; the rest re-add it. The + // trailing "official audio" duplication is collapsed. + assert!(v[0] == "Haftbefehl - RADW"); + assert!(v.contains(&"Haftbefehl - RADW official video".to_string())); + assert_eq!(v.len(), 3); + } + + #[test] + fn query_variants_empty_returns_empty() { + assert!(query_variants("").is_empty()); + assert!(query_variants(" ").is_empty()); + } + + #[test] + fn official_topic_channel_boosts_hard() { + let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap(); + let c = cand("Haftbefehl - RADW", "Haftbefehl - Topic", 200.0); + let (s, flags) = score_candidate(&pq, &c); + // Official +100, title match +40, duration ideal +20 = 160. + assert_eq!(s.total, 100 + 40 + 20); + assert!(flags.contains(&"official".to_string())); + } + + #[test] + fn lyrics_video_gets_lyric_penalty() { + let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap(); + let c = cand("Haftbefehl - RADW (Lyric Video)", "Random User", 200.0); + let (s, _flags) = score_candidate(&pq, &c); + // No official, title match +40, duration +20, lyrics -40 = 20. + assert_eq!(s.total, 40 + 20 - 40); + assert_eq!(s.lyrics, LYRICS_PENALTY); + } + + #[test] + fn lyrics_penalty_skipped_when_user_typed_lyric() { + let pq = ParsedQuery::parse("Haftbefehl - RADW lyric video").unwrap(); + let c = cand("Haftbefehl - RADW (Lyric Video)", "Random User", 200.0); + let (s, _flags) = score_candidate(&pq, &c); + // No official, title match +40, duration +20 = 60. + assert_eq!(s.total, 40 + 20); + assert_eq!(s.lyrics, 0); + } + + #[test] + fn reaction_video_heavily_penalised() { + let pq = ParsedQuery::parse("Haftbefehl - RADW").unwrap(); + let c = cand( + "Haftbefehl - RADW REACTION! First Listen", + "Reaction Channel", + 300.0, + ); + let (s, _flags) = score_candidate(&pq, &c); + // Title contains all terms, duration acceptable, but reaction. + assert_eq!(s.total, 40 + 10 - 80); + assert_eq!(s.reaction, REACTION_PENALTY); + } + + #[test] + fn artist_mismatch_penalises_dos_hermanos_for_2hermanoz_query() { + let pq = ParsedQuery::parse("2hermanoz - Narcos").unwrap(); + let c = cand("Narcos", "DOS HERMANOS - Topic", 200.0); + let (s, _flags) = score_candidate(&pq, &c); + // Official +100, duration +20, but artist doesn't match → -60. + assert_eq!(s.total, 100 + 20 - 60); + assert_eq!(s.artist_match, ARTIST_MISMATCH_PENALTY); + } + + #[test] + fn nightcore_spam_penalty() { + let pq = ParsedQuery::parse("Some Song").unwrap(); + let c = cand("Some Song - Nightcore", "Spam Uploader", 180.0); + let (s, _flags) = score_candidate(&pq, &c); + // Title match +40, duration ideal +20, spam -80 = -20. + assert_eq!(s.total, 40 + 20 - 80); + assert_eq!(s.spam, SPAM_PENALTY); + } + + #[test] + fn very_long_audiobook_penalised() { + let pq = ParsedQuery::parse("Some Song").unwrap(); + // Title deliberately omits "song" so title_match stays 0; the + // only signal active is the long-duration penalty. + let c = cand("Some Long Audiobook Podcast", "User", 7200.0); + let (s, _flags) = score_candidate(&pq, &c); + assert_eq!(s.total, DURATION_LONG_PENALTY); + } + + #[test] + fn fan_upload_penalty_applied_when_official_present() { + let mut ranked = vec![ + RankedCandidate { + base: cand("Haftbefehl - RADW", "HaftbefehlVEVO", 200.0), + score: 160, + breakdown: None, + flags: vec!["official".into()], + }, + RankedCandidate { + base: cand("Haftbefehl - RADW", "Random User", 200.0), + score: 60, + breakdown: None, + flags: vec![], + }, + ]; + apply_fan_upload_penalty(&mut ranked); + // Fan upload: 60 - 10 = 50, with a "fan-upload" flag attached. + assert_eq!(ranked[1].score, 50); + assert!(ranked[1].flags.iter().any(|f| f == "fan-upload")); + // Official: untouched. + assert_eq!(ranked[0].score, 160); + assert!(!ranked[0].flags.iter().any(|f| f == "fan-upload")); + } + + #[test] + fn fan_upload_penalty_skipped_when_no_official() { + let mut ranked = vec![ + RankedCandidate { + base: cand("Some Song", "Random User", 200.0), + score: 60, + breakdown: None, + flags: vec![], + }, + RankedCandidate { + base: cand("Another Song", "Other User", 200.0), + score: 60, + breakdown: None, + flags: vec![], + }, + ]; + apply_fan_upload_penalty(&mut ranked); + assert_eq!(ranked[0].score, 60); + assert_eq!(ranked[1].score, 60); + } + + #[test] + fn dedupe_keeps_distinct_videos() { + let mut v = vec![ + cand("Haftbefehl - RADW", "VEVO", 180.0), + cand("Haftbefehl - RADW", "VEVO", 180.0), // exact dup id-less: dedups by title+uploader + cand("Bonez MC - Spray", "Bonez MC - Topic", 200.0), + ]; + v = dedupe_candidates(v); + assert_eq!(v.len(), 2); + } + + #[test] + fn dedupe_id_dedupes_with_same_id() { + let mut a = cand("Some Song", "Topic Channel", 200.0); + a.id = "abc123".to_string(); + let mut b = cand("Some Song (Official)", "Topic Channel", 200.0); + b.id = "abc123".to_string(); + let mut c = cand("Other Song", "Topic Channel", 200.0); + c.id = "zzz999".to_string(); + let v = dedupe_candidates(vec![a, b, c]); + // a and b share id → only one survives; c is distinct. + assert_eq!(v.len(), 2); + assert!(v.iter().any(|c| c.id == "zzz999")); + } +} diff --git a/gui-rs/src/app.rs b/gui-rs/src/app.rs index fb3dc6a..5a2fba9 100644 --- a/gui-rs/src/app.rs +++ b/gui-rs/src/app.rs @@ -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, diff --git a/gui-rs/src/daemon.rs b/gui-rs/src/daemon.rs index b5977e3..5ff0532 100644 --- a/gui-rs/src/daemon.rs +++ b/gui-rs/src/daemon.rs @@ -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") } diff --git a/gui-rs/src/views/search_download.rs b/gui-rs/src/views/search_download.rs index 8746c77..5d73b1e 100644 --- a/gui-rs/src/views/search_download.rs +++ b/gui-rs/src/views/search_download.rs @@ -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!(