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
+62 -18
View File
@@ -20,24 +20,27 @@ var (
// Track represents a playable audio file in the library. // Track represents a playable audio file in the library.
type Track struct { type Track struct {
TrackName string TrackName string
ArtistName string ArtistName string
TrackLength string TrackLength string
FilePath string FilePath string
TrackNumber int64 TrackNumber int64
DiscNumber int64 DiscNumber int64
Album string Album string
Genre []string Genre []string
Year int64 Year int64
Composer string Composer string
FileType string FileType string
SampleRate int64 SampleRate int64
BitDepth int64 BitDepth int64
Channels int64 Channels int64
Bitrate int64 Bitrate int64
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);
}