feat: MusicBrainz verification badge + MBID links in track details

Track details dialog now shows:

1. Green checkmark badge next to the track title when the recording
   has a MusicBrainz ID (hover: 'Metadata verified by MusicBrainz')

2. MusicBrainz section at the bottom with clickable MBID links for:
   - Recording (track) → musicbrainz.org/recording/{mbid}
   - Release Group (album) → musicbrainz.org/release-group/{mbid}
   - Artist → musicbrainz.org/artist/{mbid}

   Links open in the system browser. Only shown for entities that
   have MBIDs from audio file tags.

Backend: GetTrackMBIDs(filePath) Wails binding queries recording,
release_group, and artist mbid columns via a single JOIN query.
Frontend: loaded async when the dialog opens, non-blocking.
This commit is contained in:
2026-03-28 10:11:52 -04:00
parent 54b074eae7
commit 9a841cd173
4 changed files with 190 additions and 18 deletions
+44
View File
@@ -38,6 +38,9 @@ type Track struct {
FileSize int64 FileSize int64
PlayCount int64 PlayCount int64
LastPlayed string LastPlayed string
RecordingMBID string
ArtistMBID string
ReleaseGroupMBID string
} }
// genreDelimiter is the separator used by GROUP_CONCAT in the // genreDelimiter is the separator used by GROUP_CONCAT in the
@@ -96,6 +99,47 @@ func mapTrackRow(
} }
} }
// TrackMBIDs holds MusicBrainz identifiers for a track, resolved
// from the recording, release group, and artist tables.
type TrackMBIDs struct {
RecordingMBID string `json:"recordingMbid"`
ReleaseGroupMBID string `json:"releaseGroupMbid"`
ArtistMBID string `json:"artistMbid"`
}
// GetTrackMBIDs returns the MusicBrainz IDs for the track at the
// given file path. Returns empty strings for entities without MBIDs.
func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs {
rows, err := l.db.QueryContext(`
SELECT
COALESCE(r.mbid, '') AS recording_mbid,
COALESCE(rg.mbid, '') AS release_group_mbid,
COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
WHERE af.file_path = ?
LIMIT 1
`, filePath)
if err != nil {
return TrackMBIDs{}
}
defer func() { _ = rows.Close() }()
var result TrackMBIDs
if rows.Next() {
_ = rows.Scan(&result.RecordingMBID, &result.ReleaseGroupMBID, &result.ArtistMBID)
}
return result
}
// Artist represents an artist in the library. // Artist represents an artist in the library.
type Artist struct { type Artist struct {
ID int64 ID int64
@@ -18,6 +18,8 @@ import {
BatchWriteTrackTags, BatchWriteTrackTags,
CancelBatchWrite, CancelBatchWrite,
} from '@go/tagwriter/TagWriter'; } from '@go/tagwriter/TagWriter';
import { GetTrackMBIDs } from '@go/library/Library';
import type { TrackMBIDs } from '@go/library/Library';
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil'; import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
import { libraryStore } from '../../store/library-store'; import { libraryStore } from '../../store/library-store';
import { EventsOn, EventsOff } from '@runtime/runtime'; import { EventsOn, EventsOff } from '@runtime/runtime';
@@ -98,6 +100,7 @@ export class TrackDetails extends LitElement {
failures: Array<{ filePath: string; error: string }>; failures: Array<{ filePath: string; error: string }>;
} | null = null; } | null = null;
@state() private showConfirmation = false; @state() private showConfirmation = false;
@state() private trackMBIDs: TrackMBIDs | null = null;
@query('wa-dialog') @query('wa-dialog')
private dialog!: HTMLElement & { open: boolean }; private dialog!: HTMLElement & { open: boolean };
@@ -117,9 +120,13 @@ export class TrackDetails extends LitElement {
this.editing = false; this.editing = false;
this.editValues = {}; this.editValues = {};
this.errorMessage = ''; this.errorMessage = '';
this.trackMBIDs = null;
this.cleanupPendingCoverArt(); this.cleanupPendingCoverArt();
this.resetBatchState(); this.resetBatchState();
// Load MBIDs asynchronously.
this.loadMBIDs(track.FilePath);
this.updateComplete.then(() => { this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true; if (this.dialog) this.dialog.open = true;
}); });
@@ -316,6 +323,39 @@ export class TrackDetails extends LitElement {
font-style: italic; font-style: italic;
} }
/* MusicBrainz badge + links */
.mb-verified-badge {
color: #1db954;
font-size: 14px;
margin-left: 6px;
vertical-align: middle;
cursor: help;
}
.mb-section-header {
display: flex;
align-items: center;
gap: 6px;
}
.mb-icon {
color: #1db954;
font-size: 14px;
}
.mb-link {
font-size: var(--yj-text-xs);
color: var(--yj-text-secondary, #b3b3b3);
text-decoration: none;
word-break: break-all;
font-family: monospace;
}
.mb-link:hover {
color: #1db954;
text-decoration: underline;
}
/* Edit mode inputs */ /* Edit mode inputs */
.meta-input { .meta-input {
width: 100%; width: 100%;
@@ -725,6 +765,7 @@ export class TrackDetails extends LitElement {
<div class="metadata-grid"> <div class="metadata-grid">
${this.renderAudioProperties(t)} ${this.renderAudioProperties(t)}
</div> </div>
${this.renderMusicBrainzSection()}
<div class="action-bar"> <div class="action-bar">
${this.renderActions()} ${this.renderActions()}
</div> </div>
@@ -1297,6 +1338,14 @@ export class TrackDetails extends LitElement {
<label class="main-field-label">Title</label> <label class="main-field-label">Title</label>
<span class="title"> <span class="title">
${t.TrackName || this.fileNameFromPath(t.FilePath)} ${t.TrackName || this.fileNameFromPath(t.FilePath)}
${this.trackMBIDs?.recordingMbid
? html`<span
class="mb-verified-badge"
title="Metadata verified by MusicBrainz"
>
<wa-icon name="circle-check"></wa-icon>
</span>`
: nothing}
</span> </span>
</div> </div>
<div class="main-field-group"> <div class="main-field-group">
@@ -1420,6 +1469,61 @@ export class TrackDetails extends LitElement {
); );
} }
private renderMusicBrainzSection() {
if (!this.trackMBIDs) return nothing;
const mbids = this.trackMBIDs;
const links: Array<{ label: string; mbid: string; type: string }> = [];
if (mbids.recordingMbid) {
links.push({
label: 'Recording',
mbid: mbids.recordingMbid,
type: 'recording',
});
}
if (mbids.releaseGroupMbid) {
links.push({
label: 'Release Group',
mbid: mbids.releaseGroupMbid,
type: 'release-group',
});
}
if (mbids.artistMbid) {
links.push({
label: 'Artist',
mbid: mbids.artistMbid,
type: 'artist',
});
}
if (links.length === 0) return nothing;
return html`
<div class="section-header mb-section-header">
<wa-icon name="circle-check" class="mb-icon"></wa-icon>
MusicBrainz
</div>
<div class="metadata-grid">
${links.map(
(l) => html`
<span class="meta-label">${l.label}</span>
<a
class="mb-link"
href="https://musicbrainz.org/${l.type}/${l.mbid}"
target="_blank"
title="View on MusicBrainz"
>
${l.mbid}
</a>
`,
)}
</div>
`;
}
private renderField(f: MetadataField) { private renderField(f: MetadataField) {
const display = const display =
this.getEditValue(f.key, f.value) || f.value; this.getEditValue(f.key, f.value) || f.value;
@@ -1885,6 +1989,18 @@ export class TrackDetails extends LitElement {
this.cleanupPendingCoverArt(); this.cleanupPendingCoverArt();
} }
private async loadMBIDs(filePath: string): Promise<void> {
try {
const mbids = await GetTrackMBIDs(filePath);
if (mbids.recordingMbid || mbids.releaseGroupMbid || mbids.artistMbid) {
this.trackMBIDs = mbids;
}
} catch {
// Non-critical — MBIDs just won't show.
}
}
private cleanupPendingCoverArt(): void { private cleanupPendingCoverArt(): void {
if (this.pendingCoverArt?.previewUrl) { if (this.pendingCoverArt?.previewUrl) {
URL.revokeObjectURL( URL.revokeObjectURL(
+8
View File
@@ -83,3 +83,11 @@ export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
export function SetScanHooks(arg1:library.ScanHooks):Promise<void>; export function SetScanHooks(arg1:library.ScanHooks):Promise<void>;
export function SoftScanAllLibraries():Promise<void>; export function SoftScanAllLibraries():Promise<void>;
export interface TrackMBIDs {
recordingMbid: string;
releaseGroupMbid: string;
artistMbid: string;
}
export function GetTrackMBIDs(arg1:string):Promise<TrackMBIDs>;
+4
View File
@@ -161,3 +161,7 @@ export function SetScanHooks(arg1) {
export function SoftScanAllLibraries() { export function SoftScanAllLibraries() {
return window['go']['library']['Library']['SoftScanAllLibraries'](); return window['go']['library']['Library']['SoftScanAllLibraries']();
} }
export function GetTrackMBIDs(arg1) {
return window['go']['library']['Library']['GetTrackMBIDs'](arg1);
}