feat(locallibrary): tag editor remote lookup (MusicBrainz + Discogs)

Complete the tag editor with remote metadata auto-fill.

- qbz-integrations: add frontend-agnostic `discogs` (client + DTOs, Tauri-free)
  and `remote_metadata` (unified DTOs + the pure MusicBrainz/Discogs ->
  RemoteAlbum* converters). Copied from src-tauri so the Slint frontend can do
  remote lookup without depending on the Tauri binary; src-tauri is left fully
  untouched (the production build keeps its own copy — no Tauri-build risk).
- tag_editor.rs: search_remote (provider QbzSelect; MusicBrainzClient::new
  search_releases_extended / DiscogsClient search_releases, generation-guarded),
  select_result, apply_remote (get_release_with_tracks / get_release_metadata ->
  full metadata; overwrites album fields + positional per-track title merge;
  track-count-mismatch + rate-limit toasts), open_in_browser.
- TagEditorModal: a remote-lookup box (provider + Search, a horizontal result
  card strip with select highlight, Apply + open-in-browser). ADR-008: square
  badges, no pills.
- state.slint: RemoteResultItem struct + remote fields/actions on TagEditor*.

LocalLibrary Slint port is now feature-complete (scan/folders, artists images,
tag editor manual+remote, tracks bulk). BUILT BLIND — runtime smoke-test pending.

cc
This commit is contained in:
vicrodh committed 2026-05-29 15:32:16 -06:00
1 parent 5a076187e7
commit 3c2f46b756
8 files changed
+1723

No files matched your search

+36
View File
@@ -4187,6 +4187,42 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
let weak = window.as_weak();
window
.global::<TagEditorActions>()
.on_set_provider(move |i| {
if let Some(w) = weak.upgrade() {
w.global::<TagEditorState>().set_remote_provider_index(i);
}
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<TagEditorActions>()
.on_search_remote(move || tag_editor::search_remote(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
window
.global::<TagEditorActions>()
.on_select_result(move |id| tag_editor::select_result(weak.clone(), id.to_string()));
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<TagEditorActions>()
.on_apply_remote(move || tag_editor::apply_remote(weak.clone(), handle.clone()));
}
{
let weak = window.as_weak();
window
.global::<TagEditorActions>()
.on_open_in_browser(move || tag_editor::open_in_browser(weak.clone()));
}
// Local Library — Albums tab controls (search / sort re-query page 1;
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
+206
View File
@@ -138,6 +138,14 @@ fn populate(
s.set_write_progress_current(0);
s.set_write_progress_total(0);
s.set_tracks(ModelRc::new(VecModel::from(rows)));
// Reset remote-lookup state.
s.set_remote_provider_index(0);
s.set_remote_searching(false);
s.set_remote_loading(false);
s.set_remote_results(ModelRc::new(VecModel::from(Vec::<crate::RemoteResultItem>::new())));
s.set_selected_result_id("".into());
s.set_show_remote_panel(false);
s.set_has_searched(false);
s.set_open(true);
}
@@ -379,6 +387,204 @@ pub fn save_tags(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
});
}
// ============================== Remote lookup =============================
/// Remote search/apply generation — a newer request supersedes a slow one.
static REMOTE_GEN: AtomicU64 = AtomicU64::new(0);
fn map_search(r: &qbz_integrations::RemoteAlbumSearchResult) -> crate::RemoteResultItem {
let provider = if matches!(r.provider, qbz_integrations::RemoteProvider::Discogs) {
"discogs"
} else {
"musicbrainz"
};
crate::RemoteResultItem {
provider: provider.into(),
provider_id: r.provider_id.clone().into(),
title: r.title.clone().into(),
artist: r.artist.clone().into(),
year: r.year.unwrap_or(0) as i32,
has_year: r.year.is_some(),
track_count: r.track_count.unwrap_or(0) as i32,
has_track_count: r.track_count.is_some(),
country: r.country.clone().unwrap_or_default().into(),
format: r.format.clone().unwrap_or_default().into(),
label: r.label.clone().unwrap_or_default().into(),
catalog_number: r.catalog_number.clone().unwrap_or_default().into(),
}
}
/// Search the selected provider (MusicBrainz/Discogs) for the current album
/// title + artist. Generation-guarded so a slow reply can't clobber a newer one.
pub fn search_remote(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let Some(w) = weak.upgrade() else {
return;
};
let s = w.global::<TagEditorState>();
let title = s.get_album_title().trim().to_string();
let artist = s.get_album_artist().trim().to_string();
let provider = s.get_remote_provider_index();
if title.is_empty() && artist.is_empty() {
crate::toast::error_weak(&weak, "Enter a title or artist to search");
return;
}
let gen = REMOTE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
s.set_remote_searching(true);
handle.spawn(async move {
let results: Result<Vec<crate::RemoteResultItem>, String> = if provider == 1 {
let dc = qbz_integrations::DiscogsClient::new();
dc.search_releases(&artist, &title, None, 12).await.map(|v| {
v.iter()
.map(|r| map_search(&qbz_integrations::discogs_extended_to_search_result(r)))
.collect()
})
} else {
let mb = qbz_integrations::MusicBrainzClient::new();
mb.search_releases_extended(&title, &artist, None, 12)
.await
.map(|resp| {
resp.releases
.iter()
.map(|r| map_search(&qbz_integrations::musicbrainz_release_to_search_result(r)))
.collect()
})
.map_err(|e| e.to_string())
};
let _ = weak.upgrade_in_event_loop(move |w| {
if REMOTE_GEN.load(Ordering::SeqCst) != gen {
return;
}
let s = w.global::<TagEditorState>();
s.set_remote_searching(false);
s.set_has_searched(true);
match results {
Ok(items) => {
let empty = items.is_empty();
s.set_remote_results(ModelRc::new(VecModel::from(items)));
s.set_show_remote_panel(!empty);
}
Err(e) => {
s.set_show_remote_panel(false);
crate::toast::error(&w, format!("Search failed: {e}"));
}
}
});
});
}
/// Mark a result card selected.
pub fn select_result(weak: Weak<AppWindow>, provider_id: String) {
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<TagEditorState>().set_selected_result_id(provider_id.into());
});
}
/// Fetch the selected result's full metadata and apply it (album fields +
/// positional per-track titles). Generation-guarded.
pub fn apply_remote(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
let Some(w) = weak.upgrade() else {
return;
};
let s = w.global::<TagEditorState>();
let id = s.get_selected_result_id().to_string();
if id.is_empty() {
return;
}
let provider = s.get_remote_provider_index();
let gen = REMOTE_GEN.fetch_add(1, Ordering::SeqCst) + 1;
s.set_remote_loading(true);
handle.spawn(async move {
let meta: Result<qbz_integrations::RemoteAlbumMetadata, String> = if provider == 1 {
match id.parse::<u64>() {
Ok(rid) => {
let dc = qbz_integrations::DiscogsClient::new();
dc.get_release_metadata(rid)
.await
.map(|m| qbz_integrations::discogs_full_to_metadata(&m))
}
Err(_) => Err("Invalid Discogs release id".to_string()),
}
} else {
let mb = qbz_integrations::MusicBrainzClient::new();
mb.get_release_with_tracks(&id)
.await
.map(|r| qbz_integrations::musicbrainz_full_to_metadata(&r))
.map_err(|e| e.to_string())
};
let _ = weak.upgrade_in_event_loop(move |w| {
if REMOTE_GEN.load(Ordering::SeqCst) != gen {
return;
}
let s = w.global::<TagEditorState>();
s.set_remote_loading(false);
match meta {
Ok(m) => {
s.set_album_title(m.title.clone().into());
s.set_album_artist(m.artist.clone().into());
if let Some(y) = m.year {
s.set_year_input(y.to_string().into());
}
if let Some(g) = m.genres.first() {
s.set_genre(g.clone().into());
}
if let Some(c) = m.catalog_number.as_ref() {
s.set_catalog_number(c.clone().into());
}
if m.disc_count > 0 {
s.set_album_total_discs(m.disc_count as i32);
}
// Positional per-track title merge.
let model = s.get_tracks();
let local_n = model.row_count();
let n = local_n.min(m.tracks.len());
for i in 0..n {
if let Some(mut row) = model.row_data(i) {
row.title = m.tracks[i].title.clone().into();
model.set_row_data(i, row);
}
}
s.set_show_remote_panel(false);
let remote_n = m.tracks.len();
if remote_n > 0 && remote_n != local_n {
crate::toast::warning(
&w,
"Track count differs from the result; titles applied by position",
);
}
}
Err(e) => {
let lower = e.to_lowercase();
if lower.contains("429") || lower.contains("rate") {
crate::toast::error(&w, "Rate limited, try again shortly");
} else {
crate::toast::error(&w, "Failed to fetch metadata");
}
}
}
});
});
}
/// Open the selected result's provider page in the system browser.
pub fn open_in_browser(weak: Weak<AppWindow>) {
let Some(w) = weak.upgrade() else {
return;
};
let s = w.global::<TagEditorState>();
let id = s.get_selected_result_id().to_string();
if id.is_empty() {
return;
}
let url = if s.get_remote_provider_index() == 1 {
format!("https://www.discogs.com/release/{id}")
} else {
format!("https://musicbrainz.org/release/{id}")
};
let _ = open::that(url);
}
/// Re-fetch the album's tracks and re-apply the detail view, and reset the
/// LocalLibrary browse models so the tabs re-fetch on next visit. Avoids a
/// full library reload (the 16K-track freeze).
@@ -12,6 +12,7 @@ import { Radius } from "../foundation/radius.slint";
import { TagEditorState, TagEditorActions } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { QbzSelect } from "QbzSelect.slint";
import { LoadingSpinner } from "LoadingSpinner.slint";
import { LineEdit, ScrollView } from "std-widgets.slint";
// A labeled field block (label above the control).
@@ -144,6 +145,197 @@ export component TagEditorModal inherits Rectangle {
font-size: Typography.legal;
}
// Remote metadata lookup (MusicBrainz / Discogs).
Rectangle {
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
VerticalLayout {
padding: 10px;
spacing: 8px;
// Provider + search.
HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 150px;
sm: true;
options: ["MusicBrainz", "Discogs"];
current-index: TagEditorState.remote_provider_index;
selected(i) => {
TagEditorActions.set-provider(i);
}
}
}
Rectangle {
width: search-row.preferred-width + 26px;
height: 32px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: search-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
search-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
spacing: 6px;
if TagEditorState.remote_searching: VerticalLayout {
alignment: center;
LoadingSpinner { size: 14px; }
}
if !TagEditorState.remote_searching: VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-secondary;
}
}
VerticalLayout {
alignment: center;
Text {
text: TagEditorState.remote_searching ? @tr("Searching…") : @tr("Search");
color: Theme.text-secondary;
font-size: Typography.legal;
font-weight: Typography.medium;
}
}
}
search-ta := TouchArea {
mouse-cursor: TagEditorState.remote_searching ? default : pointer;
clicked => {
if (!TagEditorState.remote_searching) {
TagEditorActions.search-remote();
}
}
}
}
Rectangle { horizontal-stretch: 1; }
}
// No-results note.
if TagEditorState.has_searched && !TagEditorState.remote_searching
&& TagEditorState.remote_results.length == 0: Text {
text: @tr("No results, try a different search");
color: Theme.text-muted;
font-size: Typography.legal;
}
// Results — horizontal scroll of selectable cards.
if TagEditorState.show_remote_panel
&& TagEditorState.remote_results.length > 0: Rectangle {
height: 116px;
ScrollView {
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
HorizontalLayout {
spacing: 8px;
padding-bottom: 6px;
for r in TagEditorState.remote_results: Rectangle {
width: 200px;
border-radius: Radius.sm;
border-width: 1px;
property <bool> sel: r.provider_id == TagEditorState.selected_result_id;
border-color: self.sel ? Theme.accent : Theme.border-subtle;
background: self.sel ? Theme.surface-hover : Theme.surface-card;
VerticalLayout {
padding: 8px;
spacing: 3px;
Text {
text: r.title;
color: Theme.text-primary;
font-size: Typography.legal;
font-weight: Typography.semibold;
overflow: elide;
}
Text {
text: r.artist;
color: Theme.text-secondary;
font-size: Typography.legal;
overflow: elide;
}
Text {
text: (r.has-year ? "\{r.year}" : "—")
+ (r.has-track-count ? " · \{r.track-count} " + @tr("tracks") : "")
+ (r.country != "" ? " · " + r.country : "");
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
Text {
text: r.format != "" ? r.format : (r.label != "" ? r.label : "");
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
card-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
TagEditorActions.select-result(r.provider_id);
}
}
}
}
}
}
// Apply + open-in-browser (when a result is selected).
if TagEditorState.selected_result_id != "": HorizontalLayout {
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
// Open in browser.
Rectangle {
width: 32px;
height: 32px;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: ext-ta.has-hover ? Theme.surface-hover : Theme.surface-card;
QbzIcon {
source: @image-url("../assets/icons/external-link.svg");
width: 14px;
height: 14px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-secondary;
}
ext-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
TagEditorActions.open-in-browser();
}
}
}
// Apply.
Rectangle {
width: apply-label.preferred-width + 28px;
height: 32px;
border-radius: Radius.sm;
background: apply-ta.has-hover && !TagEditorState.remote_loading
? Theme.accent-hover : Theme.accent;
opacity: TagEditorState.remote_loading ? 0.6 : 1.0;
apply-label := Text {
text: TagEditorState.remote_loading ? @tr("Loading…") : @tr("Apply");
color: Theme.accent-text;
font-size: Typography.legal;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
apply-ta := TouchArea {
mouse-cursor: TagEditorState.remote_loading ? default : pointer;
clicked => {
if (!TagEditorState.remote_loading) {
TagEditorActions.apply-remote();
}
}
}
}
}
}
}
// Track table.
Text {
text: @tr("Tracks");
+30
View File
@@ -628,6 +628,23 @@ export struct TagTrackEdit {
track_number: string, // raw text (LineEdit); controller parses on save ("" = unset)
}
// One remote (MusicBrainz/Discogs) search-result card. Option fields map to
// a 0/"" sentinel + a has_* companion where the zero value is valid.
export struct RemoteResultItem {
provider: string, // "musicbrainz" | "discogs"
provider_id: string, // selection key + open-in-browser
title: string,
artist: string,
year: int,
has_year: bool,
track_count: int,
has_track_count: bool,
country: string,
format: string,
label: string,
catalog_number: string,
}
export global TagEditorState {
in-out property <bool> open: false;
in property <string> album_group_key; // == LocalAlbum.id == directory_path
@@ -644,12 +661,25 @@ export global TagEditorState {
in-out property <bool> saving: false;
in-out property <int> write_progress_current: 0;
in-out property <int> write_progress_total: 0;
// Remote lookup (MusicBrainz / Discogs).
in-out property <int> remote_provider_index: 0; // 0 musicbrainz, 1 discogs
in-out property <bool> remote_searching: false;
in-out property <bool> remote_loading: false; // apply (get full) in flight
in-out property <[RemoteResultItem]> remote_results: [];
in-out property <string> selected_result_id: ""; // "" = none
in-out property <bool> show_remote_panel: false;
in-out property <bool> has_searched: false;
}
export global TagEditorActions {
callback close();
callback save();
callback set-persistence(int); // QbzSelect index -> mode (controller maps)
callback set-provider(int); // provider QbzSelect index
callback search-remote();
callback select-result(string /* provider_id */);
callback apply-remote();
callback open-in-browser();
}
// Global toast state, driven by crate::toast. The Toast overlay (mounted