Двухэтапное разрешение запроса¶
Resolver преобразует сырой пользовательский ввод в структурированную пару (artist, title) до того, как запрос попадёт к какому-либо провайдеру поиска. Это кардинально улучшает качество результатов: вместо того чтобы отправлять "нон стоп молли" в полнотекстовый поиск BitTorrent-трекера, мы отправляем "Пошлая Молли Нон стоп".
Источник: src-tauri/src/resolver/mod.rs, src-tauri/src/resolver/types.rs
Зачем это нужно¶
Файловый поиск (RuTracker, SoulSeek) работает по точным строкам внутри имён файлов и торрентов. Пользовательские запросы бывают самыми разными:
- Строчки из текстов песен: "смотрю в иллюминатор я вижу море огней"
- Транслитерация: "paranoid android radiohead" → исполнитель "Radiohead", название "Paranoid Android"
- Разговорные формы: "нон стоп молли" → исполнитель "Пошлая Молли", название "Нон стоп"
- Смешение алфавитов: "perviy klass sukiny deti" → исполнитель "1.Kla$", название "Сукины дети"
Resolver устраняет этот разрыв, обращаясь к поисковому индексу веб-масштаба (Genius через Brave Search), чтобы получить канонические метаданные, а затем передаёт чистую строку "Artist Title" провайдерам.
Результат: ResolveResult¶
pub struct ResolveResult {
pub query: String, // сырой пользовательский ввод, возвращается как есть
pub canonical: Option<ArtistTitle>, // наиболее вероятная пара (artist, title) — null если resolver не справился
pub candidates: Vec<TrackCandidate>,// до ~10 вариантов, лучший первый
pub intent: Intent, // Track / Artist / Album / Lyric / Raw
pub elapsed_ms: u64,
}
pub struct ArtistTitle {
pub artist: String,
pub title: String, // пустая строка для результатов с intent Artist
}
pub enum Intent {
Track, // однозначно идентифицирована одна конкретная песня
Artist, // запрос ~= имя исполнителя, возвращено несколько треков
Album, // кластер треков из одного альбома
Lyric, // LRCLIB совпал + запрос похож на строку из текста песни
Raw, // ничего полезного не разрешено — провайдеры ищут по сырой строке
}
Фронтенд использует intent, чтобы решить, какой UI-чип показывать ("Трек", "Исполнитель" и т.д.) и куда направлять: на страницу исполнителя или на список треков.
Архитектура¶
Используется только один источник: Brave Search с ограничением site:genius.com. iTunes и LRCLIB были исключены:
- iTunes ранжировал результаты по глобальной популярности и выдавал неправильные треки, когда пользователь искал другую песню того же исполнителя
- LRCLIB был заблокирован на сетевом уровне на основной машине разработки и неизменно завершался по таймауту через 3 секунды, добавляя 3 секунды к каждому запросу
Путь через Brave обычно завершается за 700–1200 мс при нормальном соединении. Когда Brave возвращает 429, resolver решает Argon2id proof-of-work challenge и повторяет запрос (см. Brave Search & PoW).
resolve_query(raw_query)
↓
sources::brave::lookup(client, query, 10, dlog)
↓ Vec<(artist, title, MatchKind)>
merge_candidates(...)
↓ Vec<TrackCandidate> (дедуплицировано по нормализованному ключу)
rank by match_score(query, candidate)
↓
pick canonical (score ≥ 2.0 OR lyric/script-mismatch override)
↓
classify_intent(query, candidates, has_canonical)
↓
ResolveResult { canonical, candidates, intent, elapsed_ms }
Объединение кандидатов¶
merge_candidates дедуплицирует по ключу (kind, norm(artist), norm(title)). norm() — это приведение к нижнему регистру с удалением не-буквенно-цифровых символов (см. resolver/norm.rs):
pub fn norm(s: &str) -> String {
s.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(|c| c.to_lowercase())
.collect()
}
Таким образом, "Пошлая Молли" и "пошлая молли" сводятся к одному ключу (пошлаямолли), исключая дубликаты, когда Brave возвращает одного и того же исполнителя из нескольких заголовков страниц.
Каждый candidate отслеживает, какие источники его сообщили:
pub struct TrackCandidate {
pub artist: String,
pub title: String,
pub kind: MatchKind, // Track / Album / Artist
pub sources: Vec<String>, // например ["brave"]
}
Чем больше источников — тем выше уверенность; это используется как дополнительный критерий при ранжировании.
MatchKind¶
Brave возвращает страницы трёх типов. Парсер в brave.rs классифицирует каждую из них:
| Kind | Пример заголовка | Значение |
|---|---|---|
Track |
"Пошлая Молли - Нон стоп Lyrics | Genius" | Конкретная песня |
Album |
"Пошлая Молли - Незваный гость Lyrics and Tracklist | Genius" | Страница альбома |
Artist |
"Пошлая Молли Lyrics, Songs, and Albums | Genius" | Страница-хаб исполнителя |
Kind передаётся дальше при классификации Intent и при формировании UI-чипа в подсказке поиска.
Правила канонизации¶
Candidate принимается как canonical, если:
ИЛИ применяется одно из двух исключений:
-
Lyric override (
trust_lyric_source): запрос длинный (>20 символов), не содержит разделителя "Исполнитель - Название", и хотя бы один candidate получен из lyric-источника (Brave). Используется для запросов-строчек из текстов песен, где текст запроса намеренно не является названием трека. -
Script-mismatch override: запрос содержит кириллицу, а лучший candidate — только латиница.
match_scoreне умеет сопоставлять разные алфавиты, поэтому возвращает 0 для, например, "параноид андроид" ↔ "Radiohead - Paranoid Android". Исключение позволяет таким парам пройти.
Если ни один candidate не преодолевает порог, canonical = None и intent = Raw. Провайдер тогда ищет по сырой строке.
Определение запроса по одному исполнителю¶
После выбора canonical candidate resolver проверяет, полностью ли сырой запрос содержится в имени исполнителя лучшего candidate (токен за токеном):
let q_tokens_set: HashSet<String> = tokenize(trimmed).into_iter().collect();
let artist_only_query = artist_page_cand.as_ref().is_some_and(|c| {
let stripped = strip_bracket_annotations(&c.artist);
let a_tokens: HashSet<String> = tokenize(&stripped).into_iter().collect();
q_tokens_set.iter().all(|t| a_tokens.contains(t))
});
Если это так, canonical title устанавливается в пустую строку, а intent принудительно переключается на Artist. Это не даёт "Пошлая Молли" (чистый запрос по исполнителю) подхватить первый трек, который Brave случайно вернул для этого исполнителя.
Классификация intent¶
После канонизации:
canonical.kind == Album → Intent::Album
canonical.kind == Artist → Intent::Artist
canonical.kind == Track →
if (запрос похож на строку из текста)
AND (в запросе есть слова, не входящие в artist+title)
→ Intent::Lyric
else
→ Intent::Track
canonical == None → Intent::Raw
Проверка "похоже на строку из текста": char_count > 20 И нет разделителя "Исполнитель - Название" (" - ", " – ", " — ").
Проверка "есть лишние слова": хотя бы один токен запроса отсутствует в объединении tokenize(artist) ∪ tokenize(title). Если каждое слово запроса уже объясняется метаданными, это обычный запрос по треку, а не запрос-строчка.
Tauri-команда¶
Resolver доступен как единственная Tauri-команда:
#[tauri::command]
pub async fn resolve_query(
state: State<'_, ResolverState>,
query: String,
) -> Result<ResolveResult, String>
ResolverState хранит общий reqwest::Client (с пулом соединений между вызовами) и Arc<AppDebugLog>.
Фронтенд вызывает его через тонкую обёртку в src/search/resolver.ts: