Перейти к содержанию

Двухэтапное разрешение запроса

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, если:

score(query, candidate) >= 2.0

ИЛИ применяется одно из двух исключений:

  1. Lyric override (trust_lyric_source): запрос длинный (>20 символов), не содержит разделителя "Исполнитель - Название", и хотя бы один candidate получен из lyric-источника (Brave). Используется для запросов-строчек из текстов песен, где текст запроса намеренно не является названием трека.

  2. 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:

const resolved = await resolveQuery(rawQuery);
// resolved: { canonical, candidates, intent, elapsed_ms }