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:
8 files changed
+1723
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user