mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
243
internal/catalog/associate_album.go
Normal file
243
internal/catalog/associate_album.go
Normal 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
|
||||
}
|
||||
231
internal/catalog/associate_artists.go
Normal file
231
internal/catalog/associate_artists.go
Normal 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
|
||||
}
|
||||
119
internal/catalog/associate_track.go
Normal file
119
internal/catalog/associate_track.go
Normal 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
228
internal/catalog/catalog.go
Normal 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
|
||||
}
|
||||
366
internal/catalog/catalog_test.go
Normal file
366
internal/catalog/catalog_test.go
Normal 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
266
internal/catalog/images.go
Normal 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
|
||||
}
|
||||
74
internal/catalog/images_test.go
Normal file
74
internal/catalog/images_test.go
Normal 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)
|
||||
}
|
||||
858
internal/catalog/submit_listen_test.go
Normal file
858
internal/catalog/submit_listen_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue