chore: initial public commit

This commit is contained in:
Gabe Farrell 2025-06-11 19:45:39 -04:00
commit fc9054b78c
250 changed files with 32809 additions and 0 deletions

57
internal/mbz/artist.go Normal file
View file

@ -0,0 +1,57 @@
package mbz
import (
"context"
"errors"
"slices"
"github.com/gabehf/koito/internal/logger"
"github.com/google/uuid"
)
type MusicBrainzArtist struct {
Name string `json:"name"`
SortName string `json:"sort_name"`
Gender string `json:"gender"`
Area MusicBrainzArea `json:"area"`
Aliases []MusicBrainzArtistAlias `json:"aliases"`
}
type MusicBrainzArtistAlias struct {
Name string `json:"name"`
Type string `json:"type"`
Primary bool `json:"primary"`
}
const artistAliasFmtStr = "%s/ws/2/artist/%s?inc=aliases"
func (c *MusicBrainzClient) getArtist(ctx context.Context, id uuid.UUID) (*MusicBrainzArtist, error) {
var mbzArtist *MusicBrainzArtist
err := c.getEntity(ctx, artistAliasFmtStr, id, mbzArtist)
if err != nil {
return nil, err
}
return mbzArtist, nil
}
// Returns the artist name at index 0, and all primary aliases after.
func (c *MusicBrainzClient) GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error) {
l := logger.FromContext(ctx)
artist, err := c.getArtist(ctx, id)
if err != nil {
return nil, err
}
if artist == nil {
return nil, errors.New("artist could not be found by musicbrainz")
}
used := make(map[string]bool)
ret := make([]string, 1)
ret[0] = artist.Name
used[artist.Name] = true
for _, alias := range artist.Aliases {
if alias.Primary && !slices.Contains(ret, alias.Name) {
l.Debug().Msgf("Found primary alias '%s' for artist '%s'", alias.Name, artist.Name)
ret = append(ret, alias.Name)
}
}
return ret, nil
}

92
internal/mbz/mbz.go Normal file
View file

@ -0,0 +1,92 @@
// package mbz provides functions for interacting with the musicbrainz api
package mbz
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/queue"
"github.com/google/uuid"
)
type MusicBrainzArea struct {
Name string `json:"name"`
Iso3166_1Codes []string `json:"iso-3166-1-codes"`
}
type MusicBrainzClient struct {
url string
userAgent string
requestQueue *queue.RequestQueue
}
type MusicBrainzCaller interface {
GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error)
GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error)
GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error)
GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error)
GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error)
Shutdown()
}
func NewMusicBrainzClient() *MusicBrainzClient {
ret := new(MusicBrainzClient)
ret.url = cfg.MusicBrainzUrl()
ret.userAgent = "Koito/0.0.1 (contact@koito.app)"
ret.requestQueue = queue.NewRequestQueue(1, 1)
return ret
}
func (c *MusicBrainzClient) Shutdown() {
c.requestQueue.Shutdown()
}
func (c *MusicBrainzClient) getEntity(ctx context.Context, fmtStr string, id uuid.UUID, result any) error {
l := logger.FromContext(ctx)
url := fmt.Sprintf(fmtStr, c.url, id.String())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
l.Debug().Msg("Adding MusicBrainz request to queue")
body, err := c.queue(ctx, req)
if err != nil {
l.Debug().Err(err)
return err
}
err = json.Unmarshal(body, result)
if err != nil {
l.Debug().Err(err)
return err
}
return nil
}
func (c *MusicBrainzClient) queue(ctx context.Context, req *http.Request) ([]byte, error) {
l := logger.FromContext(ctx)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Accept", "application/json")
resultChan := c.requestQueue.Enqueue(func(client *http.Client, done chan<- queue.RequestResult) {
resp, err := client.Do(req)
if err != nil {
l.Debug().Err(err).Str("url", req.RequestURI).Msg("Failed to contact MusicBrainz")
done <- queue.RequestResult{Err: err}
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
done <- queue.RequestResult{Body: body, Err: err}
})
result := <-resultChan
return result.Body, result.Err
}

93
internal/mbz/mock.go Normal file
View file

@ -0,0 +1,93 @@
package mbz
import (
"context"
"fmt"
"slices"
"github.com/google/uuid"
)
// implements a mock caller
type MbzMockCaller struct {
Artists map[uuid.UUID]*MusicBrainzArtist
ReleaseGroups map[uuid.UUID]*MusicBrainzReleaseGroup
Releases map[uuid.UUID]*MusicBrainzRelease
Tracks map[uuid.UUID]*MusicBrainzTrack
}
func (m *MbzMockCaller) GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error) {
releaseGroup, exists := m.ReleaseGroups[id]
if !exists {
return nil, fmt.Errorf("release group with ID %s not found", id)
}
return releaseGroup, nil
}
func (m *MbzMockCaller) GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error) {
release, exists := m.Releases[id]
if !exists {
return nil, fmt.Errorf("release group with ID %s not found", id)
}
return release, nil
}
func (m *MbzMockCaller) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
rg, exists := m.ReleaseGroups[RGID]
if !exists {
return nil, fmt.Errorf("release with ID %s not found", RGID)
}
var titles []string
for _, release := range rg.Releases {
if !slices.Contains(titles, release.Title) {
titles = append(titles, release.Title)
}
}
return titles, nil
}
func (m *MbzMockCaller) GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error) {
track, exists := m.Tracks[id]
if !exists {
return nil, fmt.Errorf("track with ID %s not found", id)
}
return track, nil
}
func (m *MbzMockCaller) GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error) {
name := m.Artists[id].Name
ss := make([]string, len(m.Artists[id].Aliases)+1)
ss[0] = name
for i, alias := range m.Artists[id].Aliases {
ss[i+1] = alias.Name
}
return ss, nil
}
func (m *MbzMockCaller) Shutdown() {}
type MbzErrorCaller struct{}
func (m *MbzErrorCaller) GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error) {
return nil, fmt.Errorf("error: GetReleaseGroup not implemented")
}
func (m *MbzErrorCaller) GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error) {
return nil, fmt.Errorf("error: GetRelease not implemented")
}
func (m *MbzErrorCaller) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("error: GetReleaseTitles not implemented")
}
func (m *MbzErrorCaller) GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error) {
return nil, fmt.Errorf("error: GetTrack not implemented")
}
func (m *MbzErrorCaller) GetArtistPrimaryAliases(ctx context.Context, id uuid.UUID) ([]string, error) {
return nil, fmt.Errorf("error: GetArtistPrimaryAliases not implemented")
}
func (m *MbzErrorCaller) Shutdown() {}

92
internal/mbz/release.go Normal file
View file

@ -0,0 +1,92 @@
package mbz
import (
"context"
"slices"
"github.com/google/uuid"
)
type MusicBrainzReleaseGroup struct {
Title string `json:"title"`
Type string `json:"primary_type"`
ArtistCredit []MusicBrainzArtistCredit `json:"artist-credit"`
Releases []MusicBrainzRelease `json:"releases"`
}
type MusicBrainzRelease struct {
Title string `json:"title"`
ID string `json:"id"`
ArtistCredit []MusicBrainzArtistCredit `json:"artist-credit"`
Status string `json:"status"`
TextRepresentation TextRepresentation `json:"text-representation"`
}
type MusicBrainzArtistCredit struct {
Artist MusicBrainzArtist `json:"artist"`
Name string `json:"name"`
}
type TextRepresentation struct {
Language string `json:"language"`
Script string `json:"script"`
}
const releaseGroupFmtStr = "%s/ws/2/release-group/%s?inc=releases+artists"
const releaseFmtStr = "%s/ws/2/release/%s?inc=artists"
func (c *MusicBrainzClient) GetReleaseGroup(ctx context.Context, id uuid.UUID) (*MusicBrainzReleaseGroup, error) {
mbzRG := new(MusicBrainzReleaseGroup)
err := c.getEntity(ctx, releaseGroupFmtStr, id, mbzRG)
if err != nil {
return nil, err
}
return mbzRG, nil
}
func (c *MusicBrainzClient) GetRelease(ctx context.Context, id uuid.UUID) (*MusicBrainzRelease, error) {
mbzRelease := new(MusicBrainzRelease)
err := c.getEntity(ctx, releaseFmtStr, id, mbzRelease)
if err != nil {
return nil, err
}
return mbzRelease, nil
}
func (c *MusicBrainzClient) GetReleaseTitles(ctx context.Context, RGID uuid.UUID) ([]string, error) {
releaseGroup, err := c.GetReleaseGroup(ctx, RGID)
if err != nil {
return nil, err
}
var titles []string
for _, release := range releaseGroup.Releases {
if !slices.Contains(titles, release.Title) {
titles = append(titles, release.Title)
}
}
return titles, nil
}
func ReleaseGroupToTitles(rg *MusicBrainzReleaseGroup) []string {
var titles []string
for _, release := range rg.Releases {
if !slices.Contains(titles, release.Title) {
titles = append(titles, release.Title)
}
}
return titles
}
// Searches for Pseudo-Releases of release groups with Latin script, and returns them as an array
func (c *MusicBrainzClient) GetLatinTitles(ctx context.Context, id uuid.UUID) ([]string, error) {
rg, err := c.GetReleaseGroup(ctx, id)
if err != nil {
return nil, err
}
titles := make([]string, 0)
for _, r := range rg.Releases {
if r.Status == "Pseudo-Release" && r.TextRepresentation.Script == "Latn" { // not a typo
titles = append(titles, r.Title)
}
}
return titles, nil
}

23
internal/mbz/track.go Normal file
View file

@ -0,0 +1,23 @@
package mbz
import (
"context"
"github.com/google/uuid"
)
type MusicBrainzTrack struct {
Title string `json:"title"`
}
const recordingFmtStr = "%s/ws/2/recording/%s"
// Returns the artist name at index 0, and all primary aliases after.
func (c *MusicBrainzClient) GetTrack(ctx context.Context, id uuid.UUID) (*MusicBrainzTrack, error) {
track := new(MusicBrainzTrack)
err := c.getEntity(ctx, recordingFmtStr, id, track)
if err != nil {
return nil, err
}
return track, nil
}