From f2a703ed528934a0797aa9c6af8bfca9877d1c45 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Tue, 24 Mar 2026 22:47:39 -0400 Subject: [PATCH] feat: show English alias for non-Latin script artists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract primary English alias from MusicBrainz artist data when the canonical name uses non-Latin script (CJK, Cyrillic, etc.). Display it as the primary name in search results and artist detail header, with the native script name as a subtitle beneath. Example: 山下達郎 now shows 'Tatsuro Yamashita' prominently with '山下達郎' as a subtitle. Artists with Latin names are unchanged. --- backend/explore/musicbrainz.go | 45 +++- backend/explore/types.go | 1 + .../explore-artist-details.ts | 23 +- .../components/explore-view/explore-view.ts | 21 +- frontend/wailsjs/go/models.ts | 201 ++++++++++++++++++ 5 files changed, 283 insertions(+), 8 deletions(-) diff --git a/backend/explore/musicbrainz.go b/backend/explore/musicbrainz.go index ebe0f38..dcf4831 100644 --- a/backend/explore/musicbrainz.go +++ b/backend/explore/musicbrainz.go @@ -5,6 +5,7 @@ import ( "encoding/json" "log/slog" "time" + "unicode" "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/musicbrainzws2" @@ -336,7 +337,7 @@ func clampLimit(limit int) int { // --------------------------------------------------------------------------- func convertArtist(a musicbrainzws2.Artist) MBArtist { - return MBArtist{ + out := MBArtist{ MBID: string(a.ID), Name: a.Name, SortName: a.SortName, @@ -345,6 +346,15 @@ func convertArtist(a musicbrainzws2.Artist) MBArtist { Disambiguation: a.Disambiguation, Score: a.Score, } + + // Extract the primary English alias when the canonical name + // is non-Latin (CJK, Cyrillic, etc.). This lets the frontend + // show "Tatsuro Yamashita" alongside "山下達郎". + if !isLatinScript(a.Name) { + out.EnglishName = primaryEnglishAlias(a.Aliases) + } + + return out } func convertArtists(artists []musicbrainzws2.Artist) []MBArtist { @@ -356,6 +366,39 @@ func convertArtists(artists []musicbrainzws2.Artist) []MBArtist { return out } +// primaryEnglishAlias returns the primary English alias name from +// a slice of aliases, or "" if none exists. +func primaryEnglishAlias(aliases []musicbrainzws2.Alias) string { + // Prefer primary English alias. + for _, a := range aliases { + if a.Locale == "en" && a.IsPrimary { + return a.Name + } + } + + // Fall back to any English alias. + for _, a := range aliases { + if a.Locale == "en" { + return a.Name + } + } + + return "" +} + +// isLatinScript returns true if the string consists primarily of +// Latin characters, digits, and common punctuation. Returns false +// for CJK, Cyrillic, Arabic, etc. +func isLatinScript(s string) bool { + for _, r := range s { + if unicode.IsLetter(r) && !unicode.In(r, unicode.Latin) { + return false + } + } + + return true +} + func convertReleaseGroup(rg musicbrainzws2.ReleaseGroup) MBReleaseGroup { return MBReleaseGroup{ MBID: string(rg.ID), diff --git a/backend/explore/types.go b/backend/explore/types.go index 71d663a..51d7c8a 100644 --- a/backend/explore/types.go +++ b/backend/explore/types.go @@ -20,6 +20,7 @@ type MBArtist struct { MBID string `json:"mbid"` Name string `json:"name"` SortName string `json:"sortName"` + EnglishName string `json:"englishName,omitempty"` Type string `json:"type"` Country string `json:"country"` Disambiguation string `json:"disambiguation"` diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index 5965a78..e02dcf8 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -169,6 +169,15 @@ export class ExploreArtistDetails extends LitElement { line-height: 1.2; } + .artist-native-name { + font-size: var(--yj-text-md); + color: var(--yj-text-secondary, #b3b3b3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; + } + .artist-meta { font-size: var(--yj-text-md); color: var(--yj-text-secondary, #b3b3b3); @@ -611,6 +620,11 @@ export class ExploreArtistDetails extends LitElement { return name.charAt(0).toUpperCase(); } + /** English name if available, otherwise the native name. */ + private get displayName(): string { + return this.artist?.englishName || this.artistName; + } + /** * Group release groups by type, returning entries in the * canonical order: Albums → EP → Single → Other Albums → ...rest. @@ -695,12 +709,15 @@ export class ExploreArtistDetails extends LitElement { class="artist-avatar" style="background: hsl(${hue}, 45%, 35%)" > - ${this.getInitial(this.artistName)} + ${this.getInitial(this.displayName)}
-

- ${this.artistName} +

+ ${this.displayName}

+ ${this.artist?.englishName + ? html`
${this.artist.name}
` + : nothing} ${this.renderArtistMeta()}
diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index 5ce4947..0b6a2d0 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -303,6 +303,16 @@ export class ExploreView extends LitElement { width: 100%; } + .artist-native-name { + color: var(--yj-text-secondary, #aaa); + font-size: var(--yj-text-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + margin-top: -4px; + } + .artist-disambiguation { color: var(--yj-text-tertiary, #888); font-size: var(--yj-text-xs); @@ -769,7 +779,7 @@ export class ExploreView extends LitElement { class="artist-avatar" style="background: hsl(${hue}, 45%, 35%)" > - ${a.name.charAt(0).toUpperCase()} + ${(a.englishName || a.name).charAt(0).toUpperCase()}
${a.name}
@@ -826,11 +836,14 @@ export class ExploreView extends LitElement { class="artist-avatar" style="background: hsl(${hue}, 45%, 35%)" > - ${a.name.charAt(0).toUpperCase()} + ${(a.englishName || a.name).charAt(0).toUpperCase()}
-
- ${a.name} +
+ ${a.englishName || a.name}
+ ${a.englishName + ? html`
${a.name}
` + : nothing} ${a.disambiguation ? html`
${a.disambiguation} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b35f67d..45e7717 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,204 @@ +export namespace explore { + + export class LBSimilarArtist { + artistMbid: string; + name: string; + score: number; + + static createFrom(source: any = {}) { + return new LBSimilarArtist(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.artistMbid = source["artistMbid"]; + this.name = source["name"]; + this.score = source["score"]; + } + } + export class LBTopRecording { + recordingMbid: string; + artistName: string; + trackName: string; + totalListenCount: number; + + static createFrom(source: any = {}) { + return new LBTopRecording(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.recordingMbid = source["recordingMbid"]; + this.artistName = source["artistName"]; + this.trackName = source["trackName"]; + this.totalListenCount = source["totalListenCount"]; + } + } + export class MBArtist { + mbid: string; + name: string; + sortName: string; + englishName?: string; + type: string; + country: string; + disambiguation: string; + score: number; + + static createFrom(source: any = {}) { + return new MBArtist(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mbid = source["mbid"]; + this.name = source["name"]; + this.sortName = source["sortName"]; + this.englishName = source["englishName"]; + this.type = source["type"]; + this.country = source["country"]; + this.disambiguation = source["disambiguation"]; + this.score = source["score"]; + } + } + export class MBRecording { + mbid: string; + title: string; + length: number; + artistCredit: string; + score: number; + + static createFrom(source: any = {}) { + return new MBRecording(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mbid = source["mbid"]; + this.title = source["title"]; + this.length = source["length"]; + this.artistCredit = source["artistCredit"]; + this.score = source["score"]; + } + } + export class MBTrack { + position: number; + discNumber: number; + title: string; + length: number; + mbid: string; + + static createFrom(source: any = {}) { + return new MBTrack(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.position = source["position"]; + this.discNumber = source["discNumber"]; + this.title = source["title"]; + this.length = source["length"]; + this.mbid = source["mbid"]; + } + } + export class MBRelease { + mbid: string; + title: string; + date: string; + country: string; + status: string; + tracks?: MBTrack[]; + + static createFrom(source: any = {}) { + return new MBRelease(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mbid = source["mbid"]; + this.title = source["title"]; + this.date = source["date"]; + this.country = source["country"]; + this.status = source["status"]; + this.tracks = this.convertValues(source["tracks"], MBTrack); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class MBReleaseGroup { + mbid: string; + title: string; + primaryType: string; + secondaryTypes?: string[]; + firstReleaseDate: string; + artistCredit: string; + + static createFrom(source: any = {}) { + return new MBReleaseGroup(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.mbid = source["mbid"]; + this.title = source["title"]; + this.primaryType = source["primaryType"]; + this.secondaryTypes = source["secondaryTypes"]; + this.firstReleaseDate = source["firstReleaseDate"]; + this.artistCredit = source["artistCredit"]; + } + } + export class MBSearchResult { + artists?: MBArtist[]; + releaseGroups?: MBReleaseGroup[]; + recordings?: MBRecording[]; + + static createFrom(source: any = {}) { + return new MBSearchResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.artists = this.convertValues(source["artists"], MBArtist); + this.releaseGroups = this.convertValues(source["releaseGroups"], MBReleaseGroup); + this.recordings = this.convertValues(source["recordings"], MBRecording); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace library { export class Album {