Add federated music similarity search
This commit is contained in:
+100
-4
@@ -7,7 +7,7 @@ use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
|
||||
use super::theme;
|
||||
use crate::app::state::{AppState, DevicePresenceSection, FedRow, settings_rows};
|
||||
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
|
||||
|
||||
pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let block = Block::bordered()
|
||||
@@ -28,12 +28,12 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
.areas(inner);
|
||||
|
||||
draw_settings_rows(frame, rows_area, state);
|
||||
draw_status(frame, status_area, state);
|
||||
draw_status_column(frame, status_area, state);
|
||||
return;
|
||||
}
|
||||
|
||||
let rows_height =
|
||||
(settings_rows(state).len() + 8 + device_presence_sections(state).len()) as u16;
|
||||
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16;
|
||||
let [rows_area, _, status_area] = Layout::vertical([
|
||||
Constraint::Length(rows_height.min(inner.height)),
|
||||
Constraint::Length(1),
|
||||
@@ -42,7 +42,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
.areas(inner);
|
||||
|
||||
draw_settings_rows(frame, rows_area, state);
|
||||
draw_status(frame, status_area, state);
|
||||
draw_status_column(frame, status_area, state);
|
||||
}
|
||||
|
||||
fn device_presence_sections(state: &AppState) -> Vec<DevicePresenceSection> {
|
||||
@@ -88,6 +88,39 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
|
||||
y = y.saturating_add(1);
|
||||
|
||||
draw_section(frame, area, state, &mut y, "Similarity Search");
|
||||
let similarity = &state.similarity.settings;
|
||||
for row in SimilarityRow::ALL {
|
||||
let (label, value) = match row {
|
||||
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
|
||||
SimilarityRow::Model => (
|
||||
"Embedding model",
|
||||
crate::similarity::model_by_id(&similarity.model)
|
||||
.map(|model| format!("{} · {}", model.id, model.license))
|
||||
.unwrap_or_else(|| similarity.model.clone()),
|
||||
),
|
||||
SimilarityRow::Profile => (
|
||||
"Preprocessing profile",
|
||||
format!("{} (enter for details)", similarity.profile),
|
||||
),
|
||||
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
|
||||
SimilarityRow::Clear => ("Clear all stored embeddings", "↵".to_string()),
|
||||
};
|
||||
draw_row(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
&mut y,
|
||||
cursor,
|
||||
state.settings_cursor,
|
||||
label,
|
||||
value,
|
||||
);
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
y = y.saturating_add(1);
|
||||
|
||||
draw_section(frame, area, state, &mut y, "Federation");
|
||||
for row in FedRow::ALL {
|
||||
let (label, value) = match row {
|
||||
@@ -366,6 +399,7 @@ fn protocol_label(id: &str) -> &str {
|
||||
"music_dht" => "Music DHT",
|
||||
"catalog" => "Catalog",
|
||||
"audio" => "Audio transfer",
|
||||
"similarity" => "Similarity search",
|
||||
"device_sync" => "Device sync",
|
||||
"jam" => "Jam",
|
||||
other => other,
|
||||
@@ -505,6 +539,68 @@ fn short_id(id: &str) -> String {
|
||||
id.chars().take(12).collect::<String>() + "…"
|
||||
}
|
||||
|
||||
fn draw_status_column(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if area.height < 12 {
|
||||
draw_status(frame, area, state);
|
||||
return;
|
||||
}
|
||||
let [similarity_area, _, federation_area] = Layout::vertical([
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(area);
|
||||
draw_similarity_status(frame, similarity_area, state);
|
||||
draw_status(frame, federation_area, state);
|
||||
}
|
||||
|
||||
fn draw_similarity_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
let status = &state.similarity.status;
|
||||
let progress = if status.total_tracks == 0 {
|
||||
"0 / 0".to_string()
|
||||
} else {
|
||||
format!("{} / {}", status.completed_tracks, status.total_tracks)
|
||||
};
|
||||
let active = status
|
||||
.active_profile
|
||||
.as_deref()
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "not ready".to_string());
|
||||
let target = status
|
||||
.target_profile
|
||||
.as_deref()
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
draw_summary_card(
|
||||
frame,
|
||||
area,
|
||||
state,
|
||||
" Similarity Processing ",
|
||||
vec![
|
||||
status_line("State", status.phase.label().to_string()),
|
||||
status_line("Progress", progress),
|
||||
status_line("Active", active),
|
||||
status_line("Processing", target),
|
||||
status_line(
|
||||
"Stored",
|
||||
format!(
|
||||
"{} vectors / {}",
|
||||
status.stored_vectors,
|
||||
short_bytes_label(status.stored_bytes)
|
||||
),
|
||||
),
|
||||
status_line(
|
||||
"Current / errors",
|
||||
status
|
||||
.current_track
|
||||
.clone()
|
||||
.or_else(|| status.last_error.clone())
|
||||
.unwrap_or_else(|| format!("{} errors", status.failed_tracks)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
|
||||
+6
-1
@@ -890,7 +890,12 @@ fn draw_release(frame: &mut Frame, area: Rect, state: &AppState, id: i64, cursor
|
||||
|
||||
fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
||||
let search = &state.search;
|
||||
let mut title = format!(" Search: {} ", search.query);
|
||||
let prefix = if search.similarity_source.is_some() {
|
||||
"Search similar to"
|
||||
} else {
|
||||
"Search"
|
||||
};
|
||||
let mut title = format!(" {prefix}: {} ", search.query);
|
||||
if search.loading {
|
||||
title.push_str("· searching… ");
|
||||
}
|
||||
|
||||
+70
-3
@@ -25,6 +25,10 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
..
|
||||
}) => draw_edit(frame, state, title, fields, *focus, error.as_deref()),
|
||||
Some(Popup::ConfirmDelete { label, .. }) => draw_confirm_delete(frame, state, label),
|
||||
Some(Popup::SimilarityPrivacyConsent { .. }) => {
|
||||
draw_similarity_privacy_consent(frame, state)
|
||||
}
|
||||
Some(Popup::ConfirmClearEmbeddings) => draw_confirm_clear_embeddings(frame, state),
|
||||
Some(Popup::LibraryFilters { cursor }) => draw_library_filters(frame, state, *cursor),
|
||||
Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
@@ -100,6 +104,51 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_similarity_privacy_consent(frame: &mut Frame, state: &AppState) {
|
||||
let area = centered(frame.area(), 78, 11);
|
||||
let block = Block::bordered()
|
||||
.title(" Similarity search and federation ")
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::strong_border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::raw("To find similar music on other peers, Furumi sends them an"),
|
||||
Line::raw("anonymous numeric embedding of the selected track."),
|
||||
Line::raw(""),
|
||||
Line::raw("It contains no account identity, but a peer may technically"),
|
||||
Line::raw("infer what kind of music you are searching from it."),
|
||||
Line::raw(""),
|
||||
Line::styled("enter/y: agree and enable · n/esc: cancel", theme::dim()),
|
||||
])
|
||||
.wrap(Wrap { trim: true }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_confirm_clear_embeddings(frame: &mut Frame, state: &AppState) {
|
||||
let area = centered(frame.area(), 70, 8);
|
||||
let block = Block::bordered()
|
||||
.title(" Clear embeddings? ")
|
||||
.title_style(theme::header_for(state))
|
||||
.border_style(theme::strong_border_for(state));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(block, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::raw("All model/profile embeddings will be removed from SQLite."),
|
||||
Line::raw("Audio and library metadata stay untouched."),
|
||||
Line::raw("If enabled, processing starts again automatically."),
|
||||
Line::raw(""),
|
||||
Line::styled("enter/y: clear · n/esc: cancel", theme::dim()),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_music_directory_confirmation(frame: &mut Frame, state: &AppState, path: &std::path::Path) {
|
||||
let area = centered(frame.area(), 76, 9);
|
||||
let block = Block::bordered()
|
||||
@@ -873,11 +922,19 @@ fn draw_fed_input(
|
||||
);
|
||||
}
|
||||
|
||||
/// Read-only wrapped text (this peer's federation ticket).
|
||||
/// Read-only wrapped text.
|
||||
fn draw_fed_text(frame: &mut Frame, state: &AppState, title: &str, text: &str) {
|
||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||
let text_width = usize::from(width.saturating_sub(2));
|
||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
||||
let lines_needed = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
UnicodeWidthStr::width(line)
|
||||
.max(1)
|
||||
.div_ceil(text_width.max(1))
|
||||
})
|
||||
.sum::<usize>()
|
||||
.saturating_add(2) as u16;
|
||||
let area = centered(
|
||||
frame.area(),
|
||||
width,
|
||||
@@ -1281,12 +1338,22 @@ fn draw_track_info(
|
||||
);
|
||||
|
||||
let can_share = crate::share::track_can_share(track);
|
||||
let hint = if tracks.len() > 1 && can_share {
|
||||
let can_similar =
|
||||
state.similarity.settings.enabled && track.id >= 0 && !track.file_path.is_empty();
|
||||
let hint = if tracks.len() > 1 && can_share && can_similar {
|
||||
"j/k scroll · h/left previous · l/right next · a artist · s similar · c copy link · esc"
|
||||
} else if tracks.len() > 1 && can_share {
|
||||
"j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close"
|
||||
} else if tracks.len() > 1 && can_similar {
|
||||
"j/k scroll · h/left previous · l/right next · a artist · s similar · esc close"
|
||||
} else if tracks.len() > 1 {
|
||||
"j/k scroll · h/left previous · l/right next · a artist · esc close"
|
||||
} else if can_share && can_similar {
|
||||
"j/k scroll · a artist · s similar · c copy link · esc close"
|
||||
} else if can_share {
|
||||
"j/k scroll · a artist · c copy frid link · esc close"
|
||||
} else if can_similar {
|
||||
"j/k scroll · a artist · s similar · esc close"
|
||||
} else {
|
||||
"j/k scroll · a artist · esc close"
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user