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

View file

@ -0,0 +1,243 @@
package catalog
import (
"context"
"errors"
"slices"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/images"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type AssociateAlbumOpts struct {
Artists []*models.Artist
ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID
ReleaseName string
TrackName string // required
Mbzc mbz.MusicBrainzCaller
}
func AssociateAlbum(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx)
if opts.TrackName == "" {
return nil, errors.New("required parameter TrackName missing")
}
releaseTitle := opts.ReleaseName
if releaseTitle == "" {
releaseTitle = opts.TrackName
}
if opts.ReleaseMbzID != uuid.Nil {
l.Debug().Msgf("Associating album '%s' by MusicBrainz release ID", releaseTitle)
return matchAlbumByMbzReleaseID(ctx, d, opts)
} else {
l.Debug().Msgf("Associating album '%s' by title and artist", releaseTitle)
return matchAlbumByTitle(ctx, d, opts)
}
}
func matchAlbumByMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx)
a, err := d.GetAlbum(ctx, db.GetAlbumOpts{MusicBrainzID: opts.ReleaseMbzID})
if err == nil {
l.Debug().Msgf("Found release '%s' by MusicBrainz Release ID", a.Title)
return &models.Album{
ID: a.ID,
MbzID: &opts.ReleaseMbzID,
Title: a.Title,
VariousArtists: a.VariousArtists,
Image: a.Image,
}, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
} else {
l.Debug().Msgf("Album '%s' could not be found by MusicBrainz Release ID", opts.ReleaseName)
rg, err := createOrUpdateAlbumWithMbzReleaseID(ctx, d, opts)
if err != nil {
return matchAlbumByTitle(ctx, d, opts)
}
return rg, nil
}
}
func createOrUpdateAlbumWithMbzReleaseID(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx)
release, err := opts.Mbzc.GetRelease(ctx, opts.ReleaseMbzID)
if err != nil {
l.Warn().Msg("MusicBrainz unreachable, falling back to release title matching")
return matchAlbumByTitle(ctx, d, opts)
}
var album *models.Album
titles := []string{release.Title, opts.ReleaseName}
utils.Unique(&titles)
l.Debug().Msgf("Searching for albums '%v' from artist id %d in DB", titles, opts.Artists[0].ID)
album, err = d.GetAlbum(ctx, db.GetAlbumOpts{
ArtistID: opts.Artists[0].ID,
Titles: titles,
})
if err == nil {
l.Debug().Msgf("Found album %s, updating with MusicBrainz Release ID...", album.Title)
err := d.UpdateAlbum(ctx, db.UpdateAlbumOpts{
ID: album.ID,
MusicBrainzID: opts.ReleaseMbzID,
})
if err != nil {
l.Err(err).Msg("Failed to update album with MusicBrainz Release ID")
return nil, err
}
l.Debug().Msgf("Updated album '%s' with MusicBrainz Release ID", album.Title)
if opts.ReleaseGroupMbzID != uuid.Nil {
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
if err == nil {
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
if err != nil {
l.Err(err).Msg("Failed to save aliases")
}
} else {
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz")
}
}
} else if !errors.Is(err, pgx.ErrNoRows) {
l.Err(err).Msg("Error while searching for album by MusicBrainz Release ID")
return nil, err
} else {
l.Debug().Msgf("Album %s could not be found. Creating...", release.Title)
var variousArtists bool
for _, artistCredit := range release.ArtistCredit {
if artistCredit.Name == "Various Artists" {
l.Debug().Msgf("MusicBrainz release group '%s' detected as being a Various Artists compilation release", release.Title)
variousArtists = true
}
}
l.Debug().Msg("Searching for album images...")
var imgid uuid.UUID
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
Artists: utils.UniqueIgnoringCase(slices.Concat(utils.FlattenMbzArtistCreditNames(release.ArtistCredit), utils.FlattenArtistNames(opts.Artists))),
Album: release.Title,
ReleaseMbzID: &opts.ReleaseMbzID,
})
if err == nil && imgUrl != "" {
var size ImageSize
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
imgid = uuid.New()
l.Debug().Msg("Downloading album image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil {
l.Err(err).Msg("Failed to cache image")
}
}
if err != nil {
l.Debug().Msgf("Failed to get album images for %s: %s", release.Title, err.Error())
}
album, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: release.Title,
MusicBrainzID: opts.ReleaseMbzID,
ArtistIDs: utils.FlattenArtistIDs(opts.Artists),
VariousArtists: variousArtists,
Image: imgid,
ImageSrc: imgUrl,
})
if err != nil {
return nil, err
}
if opts.ReleaseGroupMbzID != uuid.Nil {
aliases, err := opts.Mbzc.GetReleaseTitles(ctx, opts.ReleaseGroupMbzID)
if err == nil {
l.Debug().Msgf("Associating aliases '%s' with Release '%s'", aliases, album.Title)
err = d.SaveAlbumAliases(ctx, album.ID, aliases, "MusicBrainz")
if err != nil {
l.Err(err).Msg("Failed to save aliases")
}
} else {
l.Info().AnErr("err", err).Msg("Failed to get release group from MusicBrainz")
}
}
l.Info().Msgf("Created album '%s' with MusicBrainz Release ID", album.Title)
}
return &models.Album{
ID: album.ID,
MbzID: &opts.ReleaseMbzID,
Title: album.Title,
VariousArtists: album.VariousArtists,
}, nil
}
func matchAlbumByTitle(ctx context.Context, d db.DB, opts AssociateAlbumOpts) (*models.Album, error) {
l := logger.FromContext(ctx)
var releaseName string
if opts.ReleaseName != "" {
releaseName = opts.ReleaseName
} else {
releaseName = opts.TrackName
}
a, err := d.GetAlbum(ctx, db.GetAlbumOpts{
Title: releaseName,
ArtistID: opts.Artists[0].ID,
})
if err == nil {
l.Debug().Msgf("Found album '%s' by artist and title", a.Title)
if a.MbzID == nil && opts.ReleaseMbzID != uuid.Nil {
l.Debug().Msgf("Updating album with id %d with MusicBrainz ID %s", a.ID, opts.ReleaseMbzID)
err = d.UpdateAlbum(ctx, db.UpdateAlbumOpts{
ID: a.ID,
MusicBrainzID: opts.ReleaseMbzID,
})
if err != nil {
l.Err(err).Msg("Failed to associate existing release with MusicBrainz ID")
}
}
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
} else {
var imgid uuid.UUID
imgUrl, err := images.GetAlbumImage(ctx, images.AlbumImageOpts{
Artists: utils.FlattenArtistNames(opts.Artists),
Album: opts.ReleaseName,
ReleaseMbzID: &opts.ReleaseMbzID,
})
if err == nil && imgUrl != "" {
var size ImageSize
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
imgid = uuid.New()
l.Debug().Msg("Downloading album image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil {
l.Err(err).Msg("Failed to cache image")
}
}
if err != nil {
l.Debug().Msgf("Failed to get album images for %s: %s", opts.ReleaseName, err.Error())
}
a, err = d.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: releaseName,
ArtistIDs: utils.FlattenArtistIDs(opts.Artists),
Image: imgid,
MusicBrainzID: opts.ReleaseMbzID,
ImageSrc: imgUrl,
})
if err != nil {
return nil, err
}
l.Info().Msgf("Created album '%s' with artist and title", a.Title)
}
return &models.Album{
ID: a.ID,
Title: a.Title,
}, nil
}

View file

@ -0,0 +1,231 @@
package catalog
import (
"context"
"errors"
"slices"
"strings"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/images"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type AssociateArtistsOpts struct {
ArtistMbzIDs []uuid.UUID
ArtistNames []string
ArtistName string
TrackTitle string
Mbzc mbz.MusicBrainzCaller
}
func AssociateArtists(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
var result []*models.Artist
if len(opts.ArtistMbzIDs) > 0 {
l.Debug().Msg("Associating artists by MusicBrainz ID(s)")
mbzMatches, err := matchArtistsByMBID(ctx, d, opts)
if err != nil {
return nil, err
}
result = append(result, mbzMatches...)
}
if len(opts.ArtistNames) > len(result) {
l.Debug().Msg("Associating artists by list of artist names")
nameMatches, err := matchArtistsByNames(ctx, opts.ArtistNames, result, d)
if err != nil {
return nil, err
}
result = append(result, nameMatches...)
}
if len(result) < 1 {
allArtists := slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
l.Debug().Msgf("Associating artists by artist name(s) %v and track title '%s'", allArtists, opts.TrackTitle)
fallbackMatches, err := matchArtistsByNames(ctx, allArtists, nil, d)
if err != nil {
return nil, err
}
result = append(result, fallbackMatches...)
}
return result, nil
}
func matchArtistsByMBID(ctx context.Context, d db.DB, opts AssociateArtistsOpts) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
var result []*models.Artist
for _, id := range opts.ArtistMbzIDs {
if id == uuid.Nil {
l.Warn().Msg("Provided artist has uuid.Nil MusicBrainzID")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
}
a, err := d.GetArtist(ctx, db.GetArtistOpts{
MusicBrainzID: id,
})
if err == nil {
l.Debug().Msgf("Artist '%s' found by MusicBrainz ID", a.Name)
result = append(result, a)
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
if len(opts.ArtistNames) < 1 {
opts.ArtistNames = slices.Concat(opts.ArtistNames, ParseArtists(opts.ArtistName, opts.TrackTitle))
}
a, err = resolveAliasOrCreateArtist(ctx, id, opts.ArtistNames, d, opts.Mbzc)
if err != nil {
l.Warn().Msg("MusicBrainz unreachable, falling back to artist name matching")
return matchArtistsByNames(ctx, opts.ArtistNames, result, d)
// return nil, err
}
result = append(result, a)
}
return result, nil
}
func resolveAliasOrCreateArtist(ctx context.Context, mbzID uuid.UUID, names []string, d db.DB, mbz mbz.MusicBrainzCaller) (*models.Artist, error) {
l := logger.FromContext(ctx)
aliases, err := mbz.GetArtistPrimaryAliases(ctx, mbzID)
if err != nil {
return nil, err
}
l.Debug().Msgf("Got aliases %v from MusicBrainz", aliases)
for _, alias := range aliases {
a, err := d.GetArtist(ctx, db.GetArtistOpts{
Name: alias,
})
if err == nil && (a.MbzID == nil || *a.MbzID == uuid.Nil) {
a.MbzID = &mbzID
l.Debug().Msgf("Alias '%s' found in DB. Associating with MusicBrainz ID...", alias)
if updateErr := d.UpdateArtist(ctx, db.UpdateArtistOpts{ID: a.ID, MusicBrainzID: mbzID}); updateErr != nil {
return nil, updateErr
}
if saveAliasErr := d.SaveArtistAliases(ctx, a.ID, aliases, "MusicBrainz"); saveAliasErr != nil {
return nil, saveAliasErr
}
return a, nil
}
}
canonical := aliases[0]
for _, alias := range aliases {
for _, name := range names {
if strings.EqualFold(alias, name) {
l.Debug().Msgf("Canonical name for artist is '%s'", alias)
canonical = alias
break
}
}
}
var imgid uuid.UUID
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{
Aliases: aliases,
})
if err == nil && imgUrl != "" {
var size ImageSize
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
imgid = uuid.New()
l.Debug().Msg("Downloading artist image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil {
l.Err(err).Msg("Failed to cache image")
}
} else if err != nil {
l.Warn().Msgf("Failed to get artist image from ImageSrc: %s", err.Error())
}
u, err := d.SaveArtist(ctx, db.SaveArtistOpts{
MusicBrainzID: mbzID,
Name: canonical,
Aliases: aliases,
Image: imgid,
ImageSrc: imgUrl,
})
if err != nil {
return nil, err
}
l.Info().Msgf("Created artist '%s' with MusicBrainz Artist ID", canonical)
return u, nil
}
func matchArtistsByNames(ctx context.Context, names []string, existing []*models.Artist, d db.DB) ([]*models.Artist, error) {
l := logger.FromContext(ctx)
var result []*models.Artist
for _, name := range names {
if artistExists(name, existing) || artistExists(name, result) {
l.Debug().Msgf("Artist '%s' already found, skipping...", name)
continue
}
a, err := d.GetArtist(ctx, db.GetArtistOpts{
Name: name,
})
if err == nil {
l.Debug().Msgf("Artist '%s' found in DB", name)
result = append(result, a)
continue
}
if errors.Is(err, pgx.ErrNoRows) {
var imgid uuid.UUID
imgUrl, err := images.GetArtistImage(ctx, images.ArtistImageOpts{
Aliases: []string{name},
})
if err == nil && imgUrl != "" {
var size ImageSize
if cfg.FullImageCacheEnabled() {
size = ImageSizeFull
} else {
size = ImageSizeLarge
}
imgid = uuid.New()
l.Debug().Msg("Downloading artist image from source...")
err = DownloadAndCacheImage(ctx, imgid, imgUrl, size)
if err != nil {
l.Err(err).Msg("Failed to cache image")
}
} else if err != nil {
l.Debug().Msgf("Failed to get artist images for %s: %s", name, err.Error())
}
a, err = d.SaveArtist(ctx, db.SaveArtistOpts{Name: name, Image: imgid, ImageSrc: imgUrl})
if err != nil {
return nil, err
}
l.Info().Msgf("Created artist '%s' with artist name", name)
result = append(result, a)
} else {
return nil, err
}
}
return result, nil
}
func artistExists(name string, artists []*models.Artist) bool {
for _, a := range artists {
allAliases := append(a.Aliases, a.Name)
for _, alias := range allAliases {
if strings.EqualFold(name, alias) {
return true
}
}
}
return false
}

View file

@ -0,0 +1,119 @@
package catalog
import (
"context"
"errors"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type AssociateTrackOpts struct {
ArtistIDs []int32
AlbumID int32
TrackMbzID uuid.UUID
TrackName string
Duration int32
Mbzc mbz.MusicBrainzCaller
}
func AssociateTrack(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
l := logger.FromContext(ctx)
if opts.TrackName == "" {
return nil, errors.New("missing required parameter 'opts.TrackName'")
}
if len(opts.ArtistIDs) < 1 {
return nil, errors.New("at least one artist id must be specified")
}
if opts.AlbumID == 0 {
return nil, errors.New("release group id must be specified")
}
// first, try to match track Mbz ID
if opts.TrackMbzID != uuid.Nil {
l.Debug().Msgf("Associating track '%s' by MusicBrainz recording ID", opts.TrackName)
return matchTrackByMbzID(ctx, d, opts)
} else {
l.Debug().Msgf("Associating track '%s' by title and artist", opts.TrackName)
return matchTrackByTitleAndArtist(ctx, d, opts)
}
}
// If no match is found, will call matchTrackByTitleAndArtist and associate the Mbz ID with the result
func matchTrackByMbzID(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
l := logger.FromContext(ctx)
track, err := d.GetTrack(ctx, db.GetTrackOpts{
MusicBrainzID: opts.TrackMbzID,
})
if err == nil {
l.Debug().Msgf("Found track '%s' by MusicBrainz ID", track.Title)
return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
} else {
l.Debug().Msgf("Track '%s' could not be found by MusicBrainz ID", opts.TrackName)
track, err := matchTrackByTitleAndArtist(ctx, d, opts)
if err != nil {
return nil, err
}
l.Debug().Msgf("Updating track '%s' with MusicBrainz ID %s", opts.TrackName, opts.TrackMbzID)
err = d.UpdateTrack(ctx, db.UpdateTrackOpts{
ID: track.ID,
MusicBrainzID: opts.TrackMbzID,
})
if err != nil {
return nil, err
}
track.MbzID = &opts.TrackMbzID
return track, nil
}
}
func matchTrackByTitleAndArtist(ctx context.Context, d db.DB, opts AssociateTrackOpts) (*models.Track, error) {
l := logger.FromContext(ctx)
// try provided track title
track, err := d.GetTrack(ctx, db.GetTrackOpts{
Title: opts.TrackName,
ArtistIDs: opts.ArtistIDs,
})
if err == nil {
l.Debug().Msgf("Track '%s' found by title and artist match", track.Title)
return track, nil
} else if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
} else {
if opts.TrackMbzID != uuid.Nil {
mbzTrack, err := opts.Mbzc.GetTrack(ctx, opts.TrackMbzID)
if err == nil {
track, err := d.GetTrack(ctx, db.GetTrackOpts{
Title: mbzTrack.Title,
ArtistIDs: opts.ArtistIDs,
})
if err == nil {
l.Debug().Msgf("Track '%s' found by MusicBrainz title and artist match", opts.TrackName)
return track, nil
}
}
}
l.Debug().Msgf("Track '%s' could not be found by title and artist match", opts.TrackName)
t, err := d.SaveTrack(ctx, db.SaveTrackOpts{
RecordingMbzID: opts.TrackMbzID,
AlbumID: opts.AlbumID,
Title: opts.TrackName,
ArtistIDs: opts.ArtistIDs,
Duration: opts.Duration,
})
if err != nil {
return nil, err
}
if opts.TrackMbzID == uuid.Nil {
l.Info().Msgf("Created track '%s' with title and artist", opts.TrackName)
} else {
l.Info().Msgf("Created track '%s' with MusicBrainz Recording ID", opts.TrackName)
}
return t, nil
}
}

228
internal/catalog/catalog.go Normal file
View file

@ -0,0 +1,228 @@
// Package catalog manages the internal metadata of the catalog of music the user has submitted listens for.
// This includes artists, releases (album, single, ep, etc), and tracks, as well as ingesting
// listens submitted both via the API(s) and other methods.
package catalog
import (
"context"
"errors"
"regexp"
"strings"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
)
type GetListensOpts struct {
ArtistID int32
ReleaseGroupID int32
TrackID int32
Limit int
}
type SaveListenOpts struct {
TrackID int32
Time time.Time
}
type SubmitListenOpts struct {
// When true, skips registering the listen and only associates or creates the
// artist, release, release group, and track in DB
SkipSaveListen bool
MbzCaller mbz.MusicBrainzCaller
ArtistNames []string
Artist string
ArtistMbzIDs []uuid.UUID
TrackTitle string
RecordingMbzID uuid.UUID
Duration int32 // in seconds
ReleaseTitle string
ReleaseMbzID uuid.UUID
ReleaseGroupMbzID uuid.UUID
Time time.Time
UserID int32
Client string
}
const (
ImageSourceUserUpload = "User Upload"
)
func SubmitListen(ctx context.Context, store db.DB, opts SubmitListenOpts) error {
l := logger.FromContext(ctx)
if opts.Artist == "" || opts.TrackTitle == "" {
return errors.New("track name and artist are required")
}
artists, err := AssociateArtists(
ctx,
store,
AssociateArtistsOpts{
ArtistMbzIDs: opts.ArtistMbzIDs,
ArtistNames: opts.ArtistNames,
ArtistName: opts.Artist,
Mbzc: opts.MbzCaller,
TrackTitle: opts.TrackTitle,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate artists to listen")
return err
} else if len(artists) < 1 {
l.Debug().Msg("Failed to associate any artists to release")
}
artistIDs := make([]int32, len(artists))
for i, artist := range artists {
artistIDs[i] = artist.ID
l.Debug().Any("artist", artist).Msg("Matched listen to artist")
}
rg, err := AssociateAlbum(ctx, store, AssociateAlbumOpts{
ReleaseMbzID: opts.ReleaseMbzID,
ReleaseGroupMbzID: opts.ReleaseGroupMbzID,
ReleaseName: opts.ReleaseTitle,
TrackName: opts.TrackTitle,
Mbzc: opts.MbzCaller,
Artists: artists,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate release group to listen")
return err
}
// ensure artists are associated with release group
store.AddArtistsToAlbum(ctx, db.AddArtistsToAlbumOpts{
ArtistIDs: artistIDs,
AlbumID: rg.ID,
})
track, err := AssociateTrack(ctx, store, AssociateTrackOpts{
ArtistIDs: artistIDs,
AlbumID: rg.ID,
TrackMbzID: opts.RecordingMbzID,
TrackName: opts.TrackTitle,
Duration: opts.Duration,
Mbzc: opts.MbzCaller,
})
if err != nil {
l.Error().Err(err).Msg("Failed to associate track to listen")
return err
}
if track.Duration == 0 && opts.Duration != 0 {
err := store.UpdateTrack(ctx, db.UpdateTrackOpts{
ID: track.ID,
Duration: opts.Duration,
})
if err != nil {
l.Err(err).Msgf("Failed to update duration for track %s", track.Title)
}
}
if opts.SkipSaveListen {
return nil
}
l.Info().Msgf("Received listen: '%s' by %s, from release '%s'", track.Title, buildArtistStr(artists), rg.Title)
return store.SaveListen(ctx, db.SaveListenOpts{
TrackID: track.ID,
Time: opts.Time,
UserID: opts.UserID,
Client: opts.Client,
})
}
func buildArtistStr(artists []*models.Artist) string {
artistNames := make([]string, len(artists))
for i, artist := range artists {
artistNames[i] = artist.Name
}
return strings.Join(artistNames, " & ")
}
var (
// Bracketed feat patterns
bracketFeatPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\(feat\. ([^)]*)\)`),
regexp.MustCompile(`(?i)\[feat\. ([^\]]*)\]`),
}
// Inline feat (not in brackets)
inlineFeatPattern = regexp.MustCompile(`(?i)feat\. ([^()\[\]]+)$`)
// Delimiters only used inside feat. sections
featSplitDelimiters = regexp.MustCompile(`(?i)\s*(?:,|&|and|·)\s*`)
// Delimiter for separating artists in main string (rare but real usage)
mainArtistDotSplitter = regexp.MustCompile(`\s+·\s+`)
)
// ParseArtists extracts all contributing artist names from the artist and title strings
func ParseArtists(artist string, title string) []string {
seen := make(map[string]struct{})
var out []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" {
return
}
if _, exists := seen[name]; !exists {
seen[name] = struct{}{}
out = append(out, name)
}
}
foundFeat := false
// Extract bracketed features from artist
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
}
// Extract inline feat. from artist
if matches := inlineFeatPattern.FindStringSubmatch(artist); matches != nil {
foundFeat = true
artist = strings.Replace(artist, matches[0], "", 1)
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
// Add base artist(s)
if foundFeat {
add(strings.TrimSpace(artist))
} else {
// Only split on " · " in base artist string
for _, name := range mainArtistDotSplitter.Split(artist, -1) {
add(name)
}
}
// Extract features from title
for _, re := range bracketFeatPatterns {
if matches := re.FindStringSubmatch(title); matches != nil {
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
}
if matches := inlineFeatPattern.FindStringSubmatch(title); matches != nil {
for _, name := range featSplitDelimiters.Split(matches[1], -1) {
add(name)
}
}
return out
}

View file

@ -0,0 +1,366 @@
package catalog_test
import (
"context"
"fmt"
"log"
"os"
"testing"
"time"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db/psql"
"github.com/gabehf/koito/internal/mbz"
"github.com/gabehf/koito/internal/utils"
_ "github.com/gabehf/koito/testing_init"
"github.com/google/uuid"
"github.com/ory/dockertest/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var (
mbzArtistData = map[uuid.UUID]*mbz.MusicBrainzArtist{
uuid.MustParse("00000000-0000-0000-0000-000000000001"): {
Name: "ATARASHII GAKKO!",
SortName: "Atarashii Gakko",
Aliases: []mbz.MusicBrainzArtistAlias{
{
Name: "新しい学校のリーダーズ",
Type: "Artist name",
Primary: true,
},
},
},
}
mbzReleaseGroupData = map[uuid.UUID]*mbz.MusicBrainzReleaseGroup{
uuid.MustParse("00000000-0000-0000-0000-000000000011"): {
Title: "AG! Calling",
Type: "Album",
ArtistCredit: []mbz.MusicBrainzArtistCredit{
{
Artist: mbz.MusicBrainzArtist{
Name: "ATARASHII GAKKO!",
Aliases: []mbz.MusicBrainzArtistAlias{
{
Name: "新しい学校のリーダーズ",
Type: "Artist name",
Primary: true,
},
},
},
Name: "ATARASHII GAKKO!",
},
},
Releases: []mbz.MusicBrainzRelease{
{
Title: "AG! Calling",
ID: "00000000-0000-0000-0000-000000000101",
ArtistCredit: []mbz.MusicBrainzArtistCredit{
{
Artist: mbz.MusicBrainzArtist{
Name: "ATARASHII GAKKO!",
Aliases: []mbz.MusicBrainzArtistAlias{
{
Name: "ATARASHII GAKKO!",
Type: "Artist name",
Primary: true,
},
},
},
Name: "ATARASHII GAKKO!",
},
},
Status: "Official",
},
{
Title: "AG! Calling - Alt Title",
ID: "00000000-0000-0000-0000-000000000102",
ArtistCredit: []mbz.MusicBrainzArtistCredit{
{
Artist: mbz.MusicBrainzArtist{
Name: "ATARASHII GAKKO!",
Aliases: []mbz.MusicBrainzArtistAlias{
{
Name: "ATARASHII GAKKO!",
Type: "Artist name",
Primary: true,
},
},
},
Name: "ATARASHII GAKKO!",
},
},
Status: "Official",
},
},
},
}
mbzReleaseData = map[uuid.UUID]*mbz.MusicBrainzRelease{
uuid.MustParse("00000000-0000-0000-0000-000000000101"): {
Title: "AG! Calling",
ID: "00000000-0000-0000-0000-000000000101",
ArtistCredit: []mbz.MusicBrainzArtistCredit{
{
Artist: mbz.MusicBrainzArtist{
Name: "ATARASHII GAKKO!",
Aliases: []mbz.MusicBrainzArtistAlias{
{
Name: "新しい学校のリーダーズ",
Type: "Artist name",
Primary: true,
},
},
},
Name: "ATARASHII GAKKO!",
},
},
Status: "Official",
},
uuid.MustParse("00000000-0000-0000-0000-000000000202"): {
Title: "EVANGELION FINALLY",
ID: "00000000-0000-0000-0000-000000000202",
ArtistCredit: []mbz.MusicBrainzArtistCredit{
{
Artist: mbz.MusicBrainzArtist{
Name: "Various Artists",
},
Name: "Various Artists",
},
},
Status: "Official",
},
}
mbzTrackData = map[uuid.UUID]*mbz.MusicBrainzTrack{
uuid.MustParse("00000000-0000-0000-0000-000000001001"): {
Title: "Tokyo Calling",
},
}
)
var store *psql.Psql
func getTestGetenv(resource *dockertest.Resource) func(string) string {
dir, err := utils.GenerateRandomString(8)
if err != nil {
panic(err)
}
return func(env string) string {
switch env {
case cfg.ENABLE_STRUCTURED_LOGGING_ENV:
return "true"
case cfg.LOG_LEVEL_ENV:
return "debug"
case cfg.DATABASE_URL_ENV:
return fmt.Sprintf("postgres://postgres:secret@localhost:%s", resource.GetPort("5432/tcp"))
case cfg.CONFIG_DIR_ENV:
return dir
case cfg.DISABLE_DEEZER_ENV, cfg.DISABLE_COVER_ART_ARCHIVE_ENV, cfg.DISABLE_MUSICBRAINZ_ENV, cfg.ENABLE_FULL_IMAGE_CACHE_ENV:
return "true"
default:
return ""
}
}
}
func truncateTestData(t *testing.T) {
err := store.Exec(context.Background(),
`TRUNCATE
artists,
artist_aliases,
tracks,
artist_tracks,
releases,
artist_releases,
release_aliases,
listens
RESTART IDENTITY CASCADE`)
require.NoError(t, err)
}
func setupTestDataWithMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000101')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO tracks (release_id, musicbrainz_id)
VALUES (1, '00000000-0000-0000-0000-000000001001')`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO track_aliases (track_id, alias, source, is_primary)
VALUES (1, 'Tokyo Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1)`)
require.NoError(t, err)
}
func setupTestDataSansMbzIDs(t *testing.T) {
truncateTestData(t)
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_aliases (artist_id, alias, source, is_primary)
VALUES (1, 'ATARASHII GAKKO!', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
VALUES (NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO release_aliases (release_id, alias, source, is_primary)
VALUES (1, 'AG! Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_releases (artist_id, release_id)
VALUES (1, 1)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO tracks (release_id, musicbrainz_id)
VALUES (1, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO track_aliases (track_id, alias, source, is_primary)
VALUES (1, 'Tokyo Calling', 'Testing', true)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
`INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1)`)
require.NoError(t, err)
}
func TestMain(m *testing.M) {
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
if err := pool.Client.Ping(); err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
}
resource, err := pool.Run("postgres", "latest", []string{"POSTGRES_PASSWORD=secret"})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
err = cfg.Load(getTestGetenv(resource))
if err != nil {
log.Fatalf("Could not load cfg: %s", err)
}
if err := pool.Retry(func() error {
var err error
store, err = psql.New()
if err != nil {
log.Println("Failed to connect to test database, retrying...")
return err
}
return store.Ping(context.Background())
}); err != nil {
log.Fatalf("Could not connect to database: %s", err)
}
// insert a user into the db with id 1 to use for tests
err = store.Exec(context.Background(), `INSERT INTO users (username, password) VALUES ('test', DECODE('abc123', 'hex'))`)
if err != nil {
log.Fatalf("Failed to insert test user: %v", err)
}
code := m.Run()
// You can't defer this because os.Exit doesn't care for defer
if err := pool.Purge(resource); err != nil {
log.Fatalf("Could not purge resource: %s", err)
}
err = os.RemoveAll(cfg.ConfigDir())
if err != nil {
log.Fatalf("Could not remove temporary config dir: %v", err)
}
os.Exit(code)
}
// From: https://brandur.org/fragments/go-equal-time
// EqualTime compares two times in a way that's safer and with better fail
// output than a call to `require.Equal` would produce.
//
// It takes care to:
//
// - Strip off monotonic portions of timestamps so they aren't considered for
// purposes of comparison.
//
// - Truncate nanoseconds in a functionally equivalent way to how pgx would do
// it so that times that have round-tripped from Postgres can still be
// compared. Postgres only stores times to the microsecond level.
//
// - Use formatted, human-friendly time outputs so that in case of a failure,
// the discrepancy is easier to pick out.
func EqualTime(t testing.TB, t1, t2 time.Time) {
// Note that leaving off the nanosecond portion will have the effect of
// truncating it rather than rounding to the nearest microsecond, which
// functionally matches pgx's behavior while persisting.
const rfc3339Micro = "2006-01-02T15:04:05.999999Z07:00"
require.Equal(t,
t1.Format(rfc3339Micro),
t2.Format(rfc3339Micro),
)
}
func TestArtistStringParse(t *testing.T) {
type input struct {
Name string
Title string
}
cases := map[input][]string{
// only one artist
{"NELKE", ""}: {"NELKE"},
{"The Brook & The Bluff", ""}: {"The Brook & The Bluff"},
{"half·alive", ""}: {"half·alive"},
// Earth, Wind, & Fire
{"Earth, Wind & Fire", "The Very Best of Earth, Wind & Fire"}: {"Earth, Wind & Fire"},
// only artists in artist string
{"Carly Rae Jepsen feat. Rufus Wainwright", ""}: {"Carly Rae Jepsen", "Rufus Wainwright"},
{"Mimi (feat. HATSUNE MIKU & KAFU)", ""}: {"Mimi", "HATSUNE MIKU", "KAFU"},
{"Magnify Tokyo · Kanade Ishihara", ""}: {"Magnify Tokyo", "Kanade Ishihara"},
{"Daft Punk [feat. Paul Williams]", ""}: {"Daft Punk", "Paul Williams"},
// primary artist in artist string, features in title
{"Tyler, The Creator", "CA (feat. Alice Smith, Leon Ware & Clem Creevy)"}: {"Tyler, The Creator", "Alice Smith", "Leon Ware", "Clem Creevy"},
{"ONE OK ROCK", "C.U.R.I.O.S.I.T.Y. (feat. Paledusk and CHICO CARLITO)"}: {"ONE OK ROCK", "Paledusk", "CHICO CARLITO"},
{"Rat Tally", "In My Car feat. Madeline Kenney"}: {"Rat Tally", "Madeline Kenney"},
// artists in both
{"Daft Punk feat. Julian Casablancas", "Instant Crush (feat. Julian Casablancas)"}: {"Daft Punk", "Julian Casablancas"},
{"Paramore (feat. Joy Williams)", "Hate to See Your Heart Break feat. Joy Williams"}: {"Paramore", "Joy Williams"},
}
for in, out := range cases {
artists := catalog.ParseArtists(in.Name, in.Title)
assert.ElementsMatch(t, out, artists)
}
}

266
internal/catalog/images.go Normal file
View file

@ -0,0 +1,266 @@
package catalog
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"github.com/gabehf/koito/internal/cfg"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/logger"
"github.com/google/uuid"
"github.com/h2non/bimg"
)
type ImageSize string
const (
ImageSizeSmall ImageSize = "small"
ImageSizeMedium ImageSize = "medium"
ImageSizeLarge ImageSize = "large"
// imageSizeXL ImageSize = "xl"
ImageSizeFull ImageSize = "full"
ImageCacheDir = "image_cache"
)
func ParseImageSize(size string) (ImageSize, error) {
switch strings.ToLower(size) {
case "small":
return ImageSizeSmall, nil
case "medium":
return ImageSizeMedium, nil
case "large":
return ImageSizeLarge, nil
// case "xl":
// return imageSizeXL, nil
case "full":
return ImageSizeFull, nil
default:
return "", fmt.Errorf("unknown image size: %s", size)
}
}
func GetImageSize(size ImageSize) int {
var px int
switch size {
case "small":
px = 48
case "medium":
px = 256
case "large":
px = 500
case "xl":
px = 1000
}
return px
}
func SourceImageDir() string {
if cfg.FullImageCacheEnabled() {
return path.Join(cfg.ConfigDir(), ImageCacheDir, "full")
} else {
return path.Join(cfg.ConfigDir(), ImageCacheDir, "large")
}
}
// ValidateImageURL checks if the URL points to a valid image by performing a HEAD request.
func ValidateImageURL(url string) error {
resp, err := http.Head(url)
if err != nil {
return fmt.Errorf("failed to perform HEAD request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HEAD request failed, status code: %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("URL does not point to an image, content type: %s", contentType)
}
return nil
}
// DownloadAndCacheImage downloads an image from the given URL, then calls CompressAndSaveImage.
func DownloadAndCacheImage(ctx context.Context, id uuid.UUID, url string, size ImageSize) error {
l := logger.FromContext(ctx)
err := ValidateImageURL(url)
if err != nil {
return err
}
l.Debug().Msgf("Downloading image for ID %s", id)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download image, status code: %d", resp.StatusCode)
}
return CompressAndSaveImage(ctx, id.String(), size, resp.Body)
}
// Compresses an image to the specified size, then saves it to the correct cache folder.
func CompressAndSaveImage(ctx context.Context, filename string, size ImageSize, body io.Reader) error {
l := logger.FromContext(ctx)
if size == ImageSizeFull {
return saveImage(filename, size, body)
}
l.Debug().Msg("Creating resized image")
compressed, err := compressImage(size, body)
if err != nil {
return err
}
return saveImage(filename, size, compressed)
}
// SaveImage saves an image to the image_cache/{size} folder
func saveImage(filename string, size ImageSize, data io.Reader) error {
configDir := cfg.ConfigDir()
cacheDir := filepath.Join(configDir, ImageCacheDir)
// Ensure the cache directory exists
err := os.MkdirAll(filepath.Join(cacheDir, string(size)), os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create full image cache directory: %w", err)
}
// Create a file in the cache directory
imagePath := filepath.Join(cacheDir, string(size), filename)
file, err := os.Create(imagePath)
if err != nil {
return fmt.Errorf("failed to create image file: %w", err)
}
defer file.Close()
// Save the image to the file
_, err = io.Copy(file, data)
if err != nil {
return fmt.Errorf("failed to save image: %w", err)
}
return nil
}
func compressImage(size ImageSize, data io.Reader) (io.Reader, error) {
imgBytes, err := io.ReadAll(data)
if err != nil {
return nil, err
}
px := GetImageSize(size)
// Resize with bimg
imgBytes, err = bimg.NewImage(imgBytes).Process(bimg.Options{
Width: px,
Height: px,
Crop: true,
Quality: 85,
StripMetadata: true,
Type: bimg.WEBP,
})
if err != nil {
return nil, err
}
if len(imgBytes) == 0 {
return nil, fmt.Errorf("compression failed")
}
return bytes.NewReader(imgBytes), nil
}
func DeleteImage(filename uuid.UUID) error {
configDir := cfg.ConfigDir()
cacheDir := filepath.Join(configDir, ImageCacheDir)
// err := os.Remove(path.Join(cacheDir, "xl", filename.String()))
// if err != nil && !os.IsNotExist(err) {
// return err
// }
err := os.Remove(path.Join(cacheDir, "full", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
}
err = os.Remove(path.Join(cacheDir, "large", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
}
err = os.Remove(path.Join(cacheDir, "medium", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
}
err = os.Remove(path.Join(cacheDir, "small", filename.String()))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// Finds any images in all image_cache folders and deletes them if they are not associated with
// an album or artist.
func PruneOrphanedImages(ctx context.Context, store db.DB) error {
l := logger.FromContext(ctx)
configDir := cfg.ConfigDir()
cacheDir := filepath.Join(configDir, ImageCacheDir)
count := 0
// go through every folder to find orphaned images
// store already processed images to speed up pruining
memo := make(map[string]bool)
for _, dir := range []string{"large", "medium", "small", "full"} {
c, err := pruneDirImgs(ctx, store, path.Join(cacheDir, dir), memo)
if err != nil {
return err
}
count += c
}
l.Info().Msgf("Purged %d images", count)
return nil
}
// returns the number of pruned images
func pruneDirImgs(ctx context.Context, store db.DB, path string, memo map[string]bool) (int, error) {
l := logger.FromContext(ctx)
count := 0
files, err := os.ReadDir(path)
if err != nil {
l.Info().Msgf("Failed to read from directory %s; skipping for prune", path)
files = []os.DirEntry{}
}
for _, file := range files {
fn := file.Name()
imageid, err := uuid.Parse(fn)
if err != nil {
l.Debug().Msgf("Filename does not appear to be UUID: %s", fn)
continue
}
exists, err := store.ImageHasAssociation(ctx, imageid)
if err != nil {
return 0, err
} else if exists {
continue
}
// image does not have association
l.Debug().Msgf("Deleting image: %s", imageid)
err = DeleteImage(imageid)
if err != nil {
l.Err(err).Msg("Error purging orphaned images")
}
if memo != nil {
memo[fn] = true
}
count++
}
return count, nil
}

View file

@ -0,0 +1,74 @@
package catalog_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/cfg"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestImageLifecycle(t *testing.T) {
// serve yuu.jpg as test image
imageBytes, err := os.ReadFile(filepath.Join("static", "yuu.jpg"))
require.NoError(t, err)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
w.Write(imageBytes)
}))
defer server.Close()
imgID := uuid.New()
err = catalog.DownloadAndCacheImage(context.Background(), imgID, server.URL, catalog.ImageSizeFull)
require.NoError(t, err)
err = catalog.DownloadAndCacheImage(context.Background(), imgID, server.URL, catalog.ImageSizeMedium)
require.NoError(t, err)
// ensure download is correct
imagePath := filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "full", imgID.String())
_, err = os.Stat(imagePath)
assert.NoError(t, err)
imagePath = filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "medium", imgID.String())
_, err = os.Stat(imagePath)
assert.NoError(t, err)
assert.NoError(t, catalog.DeleteImage(imgID))
// ensure delete works
imagePath = filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "full", imgID.String())
_, err = os.Stat(imagePath)
assert.Error(t, err)
imagePath = filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "medium", imgID.String())
_, err = os.Stat(imagePath)
assert.Error(t, err)
// re-download for prune
err = catalog.DownloadAndCacheImage(context.Background(), imgID, server.URL, catalog.ImageSizeFull)
require.NoError(t, err)
err = catalog.DownloadAndCacheImage(context.Background(), imgID, server.URL, catalog.ImageSizeMedium)
require.NoError(t, err)
assert.NoError(t, catalog.PruneOrphanedImages(context.Background(), store))
// ensure prune works
imagePath = filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "full", imgID.String())
_, err = os.Stat(imagePath)
assert.Error(t, err)
imagePath = filepath.Join(cfg.ConfigDir(), catalog.ImageCacheDir, "medium", imgID.String())
_, err = os.Stat(imagePath)
assert.Error(t, err)
}

View file

@ -0,0 +1,858 @@
package catalog_test
import (
"context"
"testing"
"time"
"github.com/gabehf/koito/internal/catalog"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/mbz"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// this file is very long
func TestSubmitListen_CreateAllMbzIDs(t *testing.T) {
truncateTestData(t)
// artist gets created with musicbrainz id
// release group gets created with mbz id
// track gets created with mbz id
// test listen time is opts time
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
ReleaseGroups: mbzReleaseGroupData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: releaseGroupMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// Verify that listen time is correct
p, err := store.GetListensPaginated(ctx, db.GetItemsOpts{Limit: 1, Page: 1})
require.NoError(t, err)
require.Len(t, p.Items, 1)
l := p.Items[0]
EqualTime(t, opts.Time, l.Time)
}
func TestSubmitListen_CreateAllMbzIDsNoReleaseGroupID(t *testing.T) {
truncateTestData(t)
// release group gets created with release id
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
ReleaseGroups: mbzReleaseGroupData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM releases_with_title
WHERE title = $1
)`, "AG! Calling")
require.NoError(t, err)
assert.True(t, exists, "expected release to be created")
}
func TestSubmitListen_CreateAllNoMbzIDs(t *testing.T) {
truncateTestData(t)
// artist gets created with artist names
// release group gets created with artist and title
// track gets created with title and artist
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
TrackTitle: "Tokyo Calling",
ReleaseTitle: "AG! Calling",
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
}
func TestSubmitListen_CreateAllNoMbzIDsNoArtistNamesNoReleaseTitle(t *testing.T) {
truncateTestData(t)
// artists get created with artist and track title
// release group gets created with artist and track title
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistMbzIDs: []uuid.UUID{
uuid.MustParse("00000000-0000-0000-0000-000000000000"),
},
Artist: "Rat Tally",
TrackTitle: "In My Car feat. Madeline Kenney",
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM releases_with_title
WHERE title = $1
)`, opts.TrackTitle)
require.NoError(t, err)
assert.True(t, exists, "expected created release to have track title as title")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists_with_name
WHERE name = $1
)`, "Rat Tally")
require.NoError(t, err)
assert.True(t, exists, "expected primary artist to be created")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists_with_name
WHERE name = $1
)`, "Madeline Kenney")
require.NoError(t, err)
assert.True(t, exists, "expected featured artist to be created")
}
func TestSubmitListen_MatchAllMbzIDs(t *testing.T) {
setupTestDataWithMbzIDs(t)
// artist gets matched with musicbrainz id
// release gets matched with mbz id
// track gets matched with mbz id
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release group created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
}
func TestSubmitListen_MatchTrackFromMbzTitle(t *testing.T) {
setupTestDataSansMbzIDs(t)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Tracks: mbzTrackData,
}
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
TrackTitle: "Tokyo Calling - Alt Title",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release group created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
}
func TestSubmitListen_VariousArtistsRelease(t *testing.T) {
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Releases: mbzReleaseData,
}
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000202")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ARIANNE"},
Artist: "ARIANNE",
TrackTitle: "KOMM, SUSSER TOD (M-10 Director's Edit version)",
ReleaseTitle: "Evangelion Finally",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM releases WHERE various_artists = $1
`, true)
require.NoError(t, err)
assert.EqualValues(t, 1, count)
}
func TestSubmitListen_MatchOneArtistMbzIDOneArtistName(t *testing.T) {
setupTestDataWithMbzIDs(t)
// artist gets matched with musicbrainz id
// release gets matched with mbz id
// track gets matched with mbz id
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
// i really do want to use real tracks for tests but i dont wanna set up all the data for one test
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!", "Fake Artist"},
Artist: "ATARASHII GAKKO! feat. Fake Artist",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release group created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "Fake Artist")
require.NoError(t, err)
assert.Equal(t, 1, count, "expected featured artist to be created")
}
func TestSubmitListen_MatchAllMbzIDsNoReleaseGroupIDNoTrackID(t *testing.T) {
setupTestDataWithMbzIDs(t)
// release group gets matched with release id
// track gets matched with title and artist
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
ReleaseGroups: mbzReleaseGroupData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
}
func TestSubmitListen_MatchNoMbzIDs(t *testing.T) {
setupTestDataSansMbzIDs(t)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
TrackTitle: "Tokyo Calling",
ReleaseTitle: "AG! Calling",
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1 AND musicbrainz_id IS NULL
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created or has been associated with fake musicbrainz id")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1 AND musicbrainz_id IS NULL
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release created or has been associated with fake musicbrainz id")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1 AND musicbrainz_id IS NULL
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created or has been associated with fake musicbrainz id")
}
func TestSubmitListen_UpdateTrackDuration(t *testing.T) {
setupTestDataSansMbzIDs(t)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
TrackTitle: "Tokyo Calling",
ReleaseTitle: "AG! Calling",
Time: time.Now(),
Duration: 191,
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1 AND duration = 191
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "expected duration to be updated")
}
func TestSubmitListen_MatchFromTrackTitleNoMbzIDs(t *testing.T) {
setupTestDataSansMbzIDs(t)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
}
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistMbzIDs: []uuid.UUID{
uuid.MustParse("00000000-0000-0000-0000-000000000001"),
},
Artist: "ATARASHII GAKKO!",
TrackTitle: "Tokyo Calling",
ReleaseTitle: "AG! Calling",
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT * FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release created")
}
func TestSubmitListen_AssociateAllMbzIDs(t *testing.T) {
setupTestDataSansMbzIDs(t)
// existing artist gets associated with mbz id (also updates aliases)
// exisiting release gets associated with mbz id
// existing track gets associated with mbz id (with new artist association)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
// Verify that the mbz ids were saved
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM tracks
WHERE musicbrainz_id = $1
)`, trackMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected track row with mbz id to exist")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists
WHERE musicbrainz_id = $1
)`, artistMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected artist row with mbz id to exist")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM releases
WHERE musicbrainz_id = $1
)`, releaseMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected release row with mbz id to exist")
}
func TestSubmitListen_AssociateAllMbzIDsWithMbzUnreachable(t *testing.T) {
setupTestDataSansMbzIDs(t)
// existing artist gets associated with mbz id (also updates aliases)
// exisiting release gets associated with mbz id
// existing track gets associated with mbz id (with new artist association)
ctx := context.Background()
mbzc := &mbz.MbzErrorCaller{}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM tracks_with_title WHERE title = $1
`, "Tokyo Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate track created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM releases_with_title WHERE title = $1
`, "AG! Calling")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate release created")
count, err = store.Count(ctx, `
SELECT COUNT(*) FROM artists_with_name WHERE name = $1
`, "ATARASHII GAKKO!")
require.NoError(t, err)
assert.Equal(t, 1, count, "duplicate artist created")
// Verify that the mbz ids were saved
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM tracks
WHERE musicbrainz_id = $1
)`, trackMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected track row with mbz id to exist")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM artists
WHERE musicbrainz_id = $1
)`, artistMbzID)
require.NoError(t, err)
// as artist names and mbz ids can be ids with unknown order
assert.False(t, exists, "artists cannot be associated with mbz ids when mbz is unreachable")
exists, err = store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM releases
WHERE musicbrainz_id = $1
)`, releaseMbzID)
require.NoError(t, err)
assert.True(t, exists, "expected release row with mbz id to exist")
}
func TestSubmitListen_AssociateReleaseAliases(t *testing.T) {
setupTestDataSansMbzIDs(t)
// existing artist gets associated with mbz id (also updates aliases)
// exisiting release group gets associated with mbz id
// existing track gets associated with mbz id (with new artist association)
ctx := context.Background()
mbzc := &mbz.MbzMockCaller{
Artists: mbzArtistData,
Releases: mbzReleaseData,
Tracks: mbzTrackData,
ReleaseGroups: mbzReleaseGroupData,
}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: releaseGroupMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
// verify that track, release group, and artist are existing ones and not duplicates
count, err := store.Count(ctx, `
SELECT COUNT(*) FROM release_aliases WHERE alias = $1
`, "AG! Calling - Alt Title")
require.NoError(t, err)
assert.Equal(t, 1, count, "expected release alias to exist")
}
func TestSubmitListen_MusicBrainzUnreachable(t *testing.T) {
truncateTestData(t)
// test don't fail when mbz unreachable
ctx := context.Background()
mbzc := &mbz.MbzErrorCaller{}
artistMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
releaseGroupMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000011")
releaseMbzID := uuid.MustParse("00000000-0000-0000-0000-000000000101")
trackMbzID := uuid.MustParse("00000000-0000-0000-0000-000000001001")
opts := catalog.SubmitListenOpts{
MbzCaller: mbzc,
ArtistNames: []string{"ATARASHII GAKKO!"},
Artist: "ATARASHII GAKKO!",
ArtistMbzIDs: []uuid.UUID{
artistMbzID,
},
TrackTitle: "Tokyo Calling",
RecordingMbzID: trackMbzID,
ReleaseTitle: "AG! Calling",
ReleaseMbzID: releaseMbzID,
ReleaseGroupMbzID: releaseGroupMbzID,
Time: time.Now(),
UserID: 1,
}
err := catalog.SubmitListen(ctx, store, opts)
require.NoError(t, err)
// Verify that the listen was saved
exists, err := store.RowExists(ctx, `
SELECT EXISTS (
SELECT 1 FROM listens
WHERE track_id = $1
)`, 1)
require.NoError(t, err)
assert.True(t, exists, "expected listen row to exist")
}