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:
+62
-18
@@ -20,24 +20,27 @@ var (
|
||||
|
||||
// Track represents a playable audio file in the library.
|
||||
type Track struct {
|
||||
TrackName string
|
||||
ArtistName string
|
||||
TrackLength string
|
||||
FilePath string
|
||||
TrackNumber int64
|
||||
DiscNumber int64
|
||||
Album string
|
||||
Genre []string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
PlayCount int64
|
||||
LastPlayed string
|
||||
TrackName string
|
||||
ArtistName string
|
||||
TrackLength string
|
||||
FilePath string
|
||||
TrackNumber int64
|
||||
DiscNumber int64
|
||||
Album string
|
||||
Genre []string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
PlayCount int64
|
||||
LastPlayed string
|
||||
RecordingMBID string
|
||||
ArtistMBID string
|
||||
ReleaseGroupMBID string
|
||||
}
|
||||
|
||||
// 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.
|
||||
type Artist struct {
|
||||
ID int64
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
BatchWriteTrackTags,
|
||||
CancelBatchWrite,
|
||||
} 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 { libraryStore } from '../../store/library-store';
|
||||
import { EventsOn, EventsOff } from '@runtime/runtime';
|
||||
@@ -98,6 +100,7 @@ export class TrackDetails extends LitElement {
|
||||
failures: Array<{ filePath: string; error: string }>;
|
||||
} | null = null;
|
||||
@state() private showConfirmation = false;
|
||||
@state() private trackMBIDs: TrackMBIDs | null = null;
|
||||
|
||||
@query('wa-dialog')
|
||||
private dialog!: HTMLElement & { open: boolean };
|
||||
@@ -117,9 +120,13 @@ export class TrackDetails extends LitElement {
|
||||
this.editing = false;
|
||||
this.editValues = {};
|
||||
this.errorMessage = '';
|
||||
this.trackMBIDs = null;
|
||||
this.cleanupPendingCoverArt();
|
||||
this.resetBatchState();
|
||||
|
||||
// Load MBIDs asynchronously.
|
||||
this.loadMBIDs(track.FilePath);
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
});
|
||||
@@ -316,6 +323,39 @@ export class TrackDetails extends LitElement {
|
||||
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 */
|
||||
.meta-input {
|
||||
width: 100%;
|
||||
@@ -725,6 +765,7 @@ export class TrackDetails extends LitElement {
|
||||
<div class="metadata-grid">
|
||||
${this.renderAudioProperties(t)}
|
||||
</div>
|
||||
${this.renderMusicBrainzSection()}
|
||||
<div class="action-bar">
|
||||
${this.renderActions()}
|
||||
</div>
|
||||
@@ -1297,6 +1338,14 @@ export class TrackDetails extends LitElement {
|
||||
<label class="main-field-label">Title</label>
|
||||
<span class="title">
|
||||
${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>
|
||||
</div>
|
||||
<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) {
|
||||
const display =
|
||||
this.getEditValue(f.key, f.value) || f.value;
|
||||
@@ -1885,6 +1989,18 @@ export class TrackDetails extends LitElement {
|
||||
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 {
|
||||
if (this.pendingCoverArt?.previewUrl) {
|
||||
URL.revokeObjectURL(
|
||||
|
||||
+8
@@ -83,3 +83,11 @@ export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
||||
export function SetScanHooks(arg1:library.ScanHooks):Promise<void>;
|
||||
|
||||
export function SoftScanAllLibraries():Promise<void>;
|
||||
|
||||
export interface TrackMBIDs {
|
||||
recordingMbid: string;
|
||||
releaseGroupMbid: string;
|
||||
artistMbid: string;
|
||||
}
|
||||
|
||||
export function GetTrackMBIDs(arg1:string):Promise<TrackMBIDs>;
|
||||
|
||||
@@ -161,3 +161,7 @@ export function SetScanHooks(arg1) {
|
||||
export function SoftScanAllLibraries() {
|
||||
return window['go']['library']['Library']['SoftScanAllLibraries']();
|
||||
}
|
||||
|
||||
export function GetTrackMBIDs(arg1) {
|
||||
return window['go']['library']['Library']['GetTrackMBIDs'](arg1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user