commit 1df261605d32d2eaacd6b56b0d7d40a8a9a0d08a
parent 89c9fa25ffa7bd3929b60e02c4704b82808fd40c
Author: brookjeynes <me@brookjeynes.dev>
Date: Sun, 14 Jun 2026 17:48:18 +1000
feat: add MusicBrainz IDs
Signed-off-by: brookjeynes <me@brookjeynes.dev>
Diffstat:
9 files changed, 95 insertions(+), 60 deletions(-)
diff --git a/internal/db/album.go b/internal/db/album.go
@@ -12,10 +12,10 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) {
var releaseDateStr string
err := e.QueryRow(`
- select id, title, latin_title, release_date
+ select id, musicbrainz_release_id, title, latin_title, release_date
from albums
where id = ?
- `, id).Scan(&album.ID, &album.Title, &album.LatinTitle, &releaseDateStr)
+ `, id).Scan(&album.ID, &album.MusicBrainzReleaseID, &album.Title, &album.LatinTitle, &releaseDateStr)
if err != nil {
return domain.Album{}, fmt.Errorf("failed to get album: %w", err)
}
@@ -71,7 +71,7 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) {
artistMap := make(map[domain.ArtistID]domain.Artist, len(artistIDs))
{
rows, err := e.Query(`
- select id, name, latin_name, disambiguation, bio
+ select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio
from artists
where id in (`+placeholders+`)
`, artistIDs...)
@@ -81,7 +81,7 @@ func Album(e Execer, id domain.AlbumID) (domain.Album, error) {
for rows.Next() {
var a domain.Artist
- if err := rows.Scan(&a.ID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil {
+ if err := rows.Scan(&a.ID, &a.MusicBrainzArtistID, &a.Name, &a.LatinName, &a.Disambiguation, &a.Bio); err != nil {
_ = rows.Close()
return domain.Album{}, fmt.Errorf("failed to scan artist: %w", err)
}
@@ -155,11 +155,12 @@ func UpdateAlbum(e Execer, album domain.EditAlbum) error {
result, err := e.Exec(`
update albums
set
+ musicbrainz_release_id = ?,
title = ?,
latin_title = ?,
release_date = ?
where id = ?
- `, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), album.ID)
+ `, album.MusicBrainzReleaseID, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), album.ID)
if err != nil {
return fmt.Errorf("failed to update album: %w", err)
}
@@ -224,13 +225,14 @@ func UpdateAlbum(e Execer, album domain.EditAlbum) error {
func InsertAlbum(e Execer, album domain.NewAlbum) error {
result, err := e.Exec(`
insert into albums (
+ musicbrainz_release_id,
title,
latin_title,
release_date,
created_at
)
- values (?, ?, ?, ?)
- `, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339))
+ values (?, ?, ?, ?, ?)
+ `, album.MusicBrainzReleaseID, album.Title, album.LatinTitle, album.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("failed to insert album: %w", err)
}
@@ -268,7 +270,7 @@ func SearchAlbum(e Execer, albumName string) ([]domain.Album, error) {
wildcard := "%" + albumName + "%"
rows, err := e.Query(`
- select id, title, latin_title, release_date
+ select id, musicbrainz_release_id, title, latin_title, release_date
from albums
where title like ? collate nocase
or latin_title like ? collate nocase
@@ -287,7 +289,7 @@ func SearchAlbum(e Execer, albumName string) ([]domain.Album, error) {
var album domain.Album
var releaseDateStr string
- if err := rows.Scan(&album.ID, &album.Title, &album.LatinTitle, &releaseDateStr); err != nil {
+ if err := rows.Scan(&album.ID, &album.MusicBrainzReleaseID, &album.Title, &album.LatinTitle, &releaseDateStr); err != nil {
return nil, fmt.Errorf("failed to scan album row: %w", err)
}
diff --git a/internal/db/artist.go b/internal/db/artist.go
@@ -11,10 +11,10 @@ func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []doma
var artist domain.Artist
err := e.QueryRow(`
- select id, name, latin_name, disambiguation, bio
+ select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio
from artists
where id = ?
- `, id).Scan(&artist.ID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio)
+ `, id).Scan(&artist.ID, &artist.MusicBrainzArtistID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio)
if err != nil {
return domain.Artist{}, nil, nil, nil, fmt.Errorf("failed to get artist: %w", err)
}
@@ -142,14 +142,15 @@ func Artist(e Execer, id domain.ArtistID) (domain.Artist, []domain.Album, []doma
func InsertArtist(e Execer, artist domain.Artist) error {
_, err := e.Exec(`
insert into artists (
+ musicbrainz_artist_id,
name,
latin_name,
disambiguation,
bio,
created_at
)
- values (?, ?, ?, ?, ?)
- `, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, time.Now().Format(time.RFC3339))
+ values (?, ?, ?, ?, ?, ?)
+ `, artist.MusicBrainzArtistID, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, time.Now().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("failed to insert artist: %w", err)
}
@@ -161,12 +162,13 @@ func UpdateArtist(e Execer, artist domain.Artist) error {
_, err := e.Exec(`
update artists
set
+ musicbrainz_artist_id = ?,
name = ?,
latin_name = ?,
disambiguation = ?,
bio = ?
where id = ?
- `, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, artist.ID)
+ `, artist.MusicBrainzArtistID, artist.Name, artist.LatinName, artist.Disambiguation, artist.Bio, artist.ID)
if err != nil {
return fmt.Errorf("failed to update artist: %w", err)
}
@@ -182,7 +184,7 @@ func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) {
wildcard := "%" + artistName + "%"
rows, err := e.Query(`
- select id, name, latin_name, disambiguation, bio
+ select id, musicbrainz_artist_id, name, latin_name, disambiguation, bio
from artists
where name like ? collate nocase
or latin_name like ? collate nocase
@@ -199,7 +201,7 @@ func SearchArtist(e Execer, artistName string) ([]domain.Artist, error) {
var results []domain.Artist
for rows.Next() {
var artist domain.Artist
- if err := rows.Scan(&artist.ID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio); err != nil {
+ if err := rows.Scan(&artist.ID, &artist.MusicBrainzArtistID, &artist.Name, &artist.LatinName, &artist.Disambiguation, &artist.Bio); err != nil {
return nil, fmt.Errorf("failed to scan artist row: %w", err)
}
results = append(results, artist)
diff --git a/internal/db/db.go b/internal/db/db.go
@@ -61,6 +61,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) {
_, err = tx.ExecContext(ctx, `
create table if not exists songs (
id integer primary key autoincrement,
+ musicbrainz_recording_id text,
title text not null,
latin_title text,
lyrics text not null,
@@ -75,6 +76,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) {
_, err = tx.ExecContext(ctx, `
create table if not exists artists (
id integer primary key autoincrement,
+ musicbrainz_artist_id text,
name text not null,
latin_name text,
disambiguation text,
@@ -89,6 +91,7 @@ func New(ctx context.Context, dbPath string) (*DB, error) {
_, err = tx.ExecContext(ctx, `
create table if not exists albums (
id integer primary key autoincrement,
+ musicbrainz_release_id text,
title text not null,
latin_title text,
release_date text not null,
diff --git a/internal/db/song.go b/internal/db/song.go
@@ -12,10 +12,10 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
var releaseDateStr string
err := e.QueryRow(`
- select id, title, latin_title, lyrics, release_date
+ select id, musicbrainz_recording_id, title, latin_title, lyrics, release_date
from songs
where id = ?
- `, id).Scan(&song.ID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr)
+ `, id).Scan(&song.ID, &song.MusicBrainzRecordingID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr)
if err != nil {
return domain.Song{}, fmt.Errorf("failed to get song: %w", err)
}
@@ -29,6 +29,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
rows, err := e.Query(`
select
a.id,
+ a.musicbrainz_artist_id,
a.name,
a.latin_name,
a.disambiguation,
@@ -48,6 +49,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
var artist domain.SongArtist
if err := rows.Scan(
&artist.Artist.ID,
+ &artist.Artist.MusicBrainzArtistID,
&artist.Artist.Name,
&artist.Artist.LatinName,
&artist.Artist.Disambiguation,
@@ -71,6 +73,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
rows, err := e.Query(`
select
a.id,
+ a.musicbrainz_release_id,
a.title,
a.latin_title,
a.release_date,
@@ -89,6 +92,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
var albumReleaseDateStr string
if err := rows.Scan(
&album.Album.ID,
+ &album.Album.MusicBrainzReleaseID,
&album.Album.Title,
&album.Album.LatinTitle,
&albumReleaseDateStr,
@@ -144,7 +148,7 @@ func Song(e Execer, id domain.SongID) (domain.Song, error) {
func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
rows, err := e.Query(`
- select id, title, latin_title, lyrics, release_date
+ select id, musicbrainz_recording_id, title, latin_title, lyrics, release_date
from songs
order by created_at desc
limit ?
@@ -158,7 +162,7 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
var song domain.Song
var releaseDateStr string
- if err := rows.Scan(&song.ID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr); err != nil {
+ if err := rows.Scan(&song.ID, &song.MusicBrainzRecordingID, &song.Title, &song.LatinTitle, &song.Lyrics, &releaseDateStr); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("failed to scan recent song: %w", err)
}
@@ -195,6 +199,7 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
select
sa.song_id,
a.id,
+ a.musicbrainz_artist_id,
a.name,
a.latin_name,
a.disambiguation,
@@ -216,6 +221,7 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
if err := rows.Scan(
&songID,
&artist.Artist.ID,
+ &artist.Artist.MusicBrainzArtistID,
&artist.Artist.Name,
&artist.Artist.LatinName,
&artist.Artist.Disambiguation,
@@ -243,6 +249,7 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
select
sa.song_id,
a.id,
+ a.musicbrainz_release_id,
a.title,
a.latin_title,
a.release_date,
@@ -263,6 +270,7 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
if err := rows.Scan(
&songID,
&album.Album.ID,
+ &album.Album.MusicBrainzReleaseID,
&album.Album.Title,
&album.Album.LatinTitle,
&albumReleaseDateStr,
@@ -295,14 +303,15 @@ func RecentSongs(e Execer, limit int) ([]domain.Song, error) {
func InsertSong(e Execer, song domain.NewSong) error {
result, err := e.Exec(`
insert into songs (
+ musicbrainz_recording_id,
title,
latin_title,
lyrics,
release_date,
created_at
)
- values (?, ?, ?, ?, ?)
- `, song.Title, song.LatinTitle, song.Lyrics, song.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339))
+ values (?, ?, ?, ?, ?, ?)
+ `, song.MusicBrainzRecordingID, song.Title, song.LatinTitle, song.Lyrics, song.ReleaseDate.Format(time.RFC3339), time.Now().Format(time.RFC3339))
if err != nil {
return fmt.Errorf("failed to insert song: %w", err)
}
diff --git a/internal/domain/album.go b/internal/domain/album.go
@@ -5,11 +5,12 @@ import "time"
type AlbumID int64
type NewAlbum struct {
- ID AlbumID
- Title string
- LatinTitle *string
- ReleaseDate time.Time
- Artists []NewAlbumArtist
+ ID AlbumID
+ MusicBrainzReleaseID *MusicBrainzID
+ Title string
+ LatinTitle *string
+ ReleaseDate time.Time
+ Artists []NewAlbumArtist
}
type NewAlbumArtist struct {
@@ -19,12 +20,13 @@ type NewAlbumArtist struct {
}
type EditAlbum struct {
- ID AlbumID
- Title string
- LatinTitle *string
- ReleaseDate time.Time
- Artists []NewAlbumArtist
- Songs []EditAlbumSong
+ ID AlbumID
+ MusicBrainzReleaseID *MusicBrainzID
+ Title string
+ LatinTitle *string
+ ReleaseDate time.Time
+ Artists []NewAlbumArtist
+ Songs []EditAlbumSong
}
type EditAlbumSong struct {
@@ -33,12 +35,13 @@ type EditAlbumSong struct {
}
type Album struct {
- ID AlbumID
- Title string
- LatinTitle *string
- ReleaseDate time.Time
- Artists []AlbumArtist
- Songs []AlbumSong
+ ID AlbumID
+ MusicBrainzReleaseID *MusicBrainzID
+ Title string
+ LatinTitle *string
+ ReleaseDate time.Time
+ Artists []AlbumArtist
+ Songs []AlbumSong
}
type AlbumArtist struct {
diff --git a/internal/domain/artist.go b/internal/domain/artist.go
@@ -10,9 +10,10 @@ const (
)
type Artist struct {
- ID ArtistID
- Name string
- LatinName *string
- Disambiguation *string
- Bio *string
+ ID ArtistID
+ MusicBrainzArtistID *MusicBrainzID
+ Name string
+ LatinName *string
+ Disambiguation *string
+ Bio *string
}
diff --git a/internal/domain/musicbrainz.go b/internal/domain/musicbrainz.go
@@ -0,0 +1,10 @@
+package domain
+
+type MusicBrainzID string
+
+func MusicBrainzIDIsValid(id string) bool {
+ if !IsValidMusicBrainzIDRegex.MatchString(id) {
+ return false
+ }
+ return true
+}
diff --git a/internal/domain/regex.go b/internal/domain/regex.go
@@ -5,13 +5,16 @@ import "regexp"
const (
noTrailingOrLeadingSpacePattern = `\S(.*\S)?`
validSortNameCharsPattern = `[a-zA-ZÀ-ÖØ-öø-ÿ0-9 ',.\-&!\(\)]`
+ musicBrainzIDPattern = `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`
)
// Go regexes (server-side validation)
var (
NoTrailingOrLeadingSpaceRegex = regexp.MustCompile(`^` + noTrailingOrLeadingSpacePattern + `$`)
IsValidSortNameRegex = regexp.MustCompile(`^` + validSortNameCharsPattern + `+$`)
+ IsValidMusicBrainzIDRegex = regexp.MustCompile(`^` + musicBrainzIDPattern + `$`)
)
// HTML pattern (client-side validation with lookahead)
const IsValidSortNamePattern = `(?=^` + noTrailingOrLeadingSpacePattern + `$)` + validSortNameCharsPattern + `+$`
+const IsValidMusicBrainzIDPattern = musicBrainzIDPattern
diff --git a/internal/domain/song.go b/internal/domain/song.go
@@ -7,14 +7,15 @@ import (
type SongID int64
type NewSong struct {
- ID SongID
- Title string
- LatinTitle *string
- Lyrics string
- ReleaseDate time.Time
- Artists []NewSongArtist
- Albums []NewSongAlbum
- Genres []Genre
+ ID SongID
+ MusicBrainzRecordingID *MusicBrainzID
+ Title string
+ LatinTitle *string
+ Lyrics string
+ ReleaseDate time.Time
+ Artists []NewSongArtist
+ Albums []NewSongAlbum
+ Genres []Genre
}
type NewSongArtist struct {
@@ -29,14 +30,15 @@ type NewSongAlbum struct {
}
type Song struct {
- ID SongID
- Title string
- LatinTitle *string
- Lyrics string
- ReleaseDate time.Time
- Artists []SongArtist
- Albums []SongAlbum
- Genres []Genre
+ ID SongID
+ MusicBrainzRecordingID *MusicBrainzID
+ Title string
+ LatinTitle *string
+ Lyrics string
+ ReleaseDate time.Time
+ Artists []SongArtist
+ Albums []SongAlbum
+ Genres []Genre
}
type SongArtist struct {