feat: v0.0.5

This commit is contained in:
Gabe Farrell 2025-06-15 19:09:44 -04:00
parent 4c4ebc593d
commit 242a82ad8c
36 changed files with 694 additions and 174 deletions

View file

@ -64,6 +64,7 @@ type DB interface {
CountAlbums(ctx context.Context, period Period) (int64, error)
CountArtists(ctx context.Context, period Period) (int64, error)
CountTimeListened(ctx context.Context, period Period) (int64, error)
CountTimeListenedToItem(ctx context.Context, opts TimeListenedOpts) (int64, error)
CountUsers(ctx context.Context) (int64, error)
// Search
SearchArtists(ctx context.Context, q string) ([]*models.Artist, error)
@ -71,8 +72,8 @@ type DB interface {
SearchTracks(ctx context.Context, q string) ([]*models.Track, error)
// Merge
MergeTracks(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32) error
MergeArtists(ctx context.Context, fromId, toId int32) error
MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error
MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error
// Etc
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
GetImageSource(ctx context.Context, image uuid.UUID) (string, error)

View file

@ -138,3 +138,10 @@ type ListenActivityOpts struct {
ArtistID int32
TrackID int32
}
type TimeListenedOpts struct {
Period Period
AlbumID int32
ArtistID int32
TrackID int32
}

View file

@ -57,6 +57,14 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
AlbumID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Album{
ID: row.ID,
MbzID: row.MusicBrainzID,
@ -64,6 +72,7 @@ func (d *Psql) GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Albu
Image: row.Image,
VariousArtists: row.VariousArtists,
ListenCount: count,
TimeListened: seconds,
}, nil
}

View file

@ -47,21 +47,16 @@ func testDataForRelease(t *testing.T) {
}
func TestGetAlbum(t *testing.T) {
testDataForRelease(t)
testDataForTopItems(t)
ctx := context.Background()
// Insert test data
rg, err := store.SaveAlbum(ctx, db.SaveAlbumOpts{
Title: "Test Release Group",
ArtistIDs: []int32{1},
})
require.NoError(t, err)
// Test GetAlbum by ID
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID})
result, err := store.GetAlbum(ctx, db.GetAlbumOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, rg.ID, result.ID)
assert.Equal(t, "Test Release Group", result.Title)
assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Release One", result.Title)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetAlbum with insufficient information
_, err = store.GetAlbum(ctx, db.GetAlbumOpts{})

View file

@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
// this function sucks because sqlc keeps making new types for rows that are the same
func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) {
l := logger.FromContext(ctx)
if opts.ID != 0 {
@ -32,13 +33,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
}, nil
} else if opts.MusicBrainzID != uuid.Nil {
l.Debug().Msgf("Fetching artist from DB with MusicBrainz ID %s", opts.MusicBrainzID)
@ -54,13 +63,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
TimeListened: seconds,
ListenCount: count,
}, nil
} else if opts.Name != "" {
l.Debug().Msgf("Fetching artist from DB with name '%s'", opts.Name)
@ -76,13 +93,21 @@ func (d *Psql) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Ar
if err != nil {
return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
ArtistID: row.ID,
})
if err != nil {
return nil, err
}
return &models.Artist{
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
ID: row.ID,
MbzID: row.MusicBrainzID,
Name: row.Name,
Aliases: row.Aliases,
Image: row.Image,
ListenCount: count,
TimeListened: seconds,
}, nil
} else {
return nil, errors.New("insufficient information to get artist")

View file

@ -13,30 +13,33 @@ import (
)
func TestGetArtist(t *testing.T) {
testDataForTopItems(t)
ctx := context.Background()
mbzId := uuid.MustParse("00000000-0000-0000-0000-000000000001")
// Insert test data
artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
Name: "Test Artist",
MusicBrainzID: mbzId,
})
require.NoError(t, err)
// Test GetArtist by ID
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: artist.ID})
result, err := store.GetArtist(ctx, db.GetArtistOpts{ID: 1})
require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID)
assert.Equal(t, "Test Artist", result.Name)
assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist by Name
result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: artist.Name})
result, err = store.GetArtist(ctx, db.GetArtistOpts{Name: "Artist One"})
require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID)
assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist by MusicBrainzID
result, err = store.GetArtist(ctx, db.GetArtistOpts{MusicBrainzID: mbzId})
require.NoError(t, err)
assert.Equal(t, artist.ID, result.ID)
assert.EqualValues(t, 1, result.ID)
assert.Equal(t, "Artist One", result.Name)
assert.EqualValues(t, 4, result.ListenCount)
assert.EqualValues(t, 400, result.TimeListened)
// Test GetArtist with insufficient information
_, err = store.GetArtist(ctx, db.GetArtistOpts{})

View file

@ -2,6 +2,7 @@ package psql
import (
"context"
"errors"
"time"
"github.com/gabehf/koito/internal/db"
@ -68,3 +69,41 @@ func (p *Psql) CountTimeListened(ctx context.Context, period db.Period) (int64,
}
return count, nil
}
func (p *Psql) CountTimeListenedToItem(ctx context.Context, opts db.TimeListenedOpts) (int64, error) {
t2 := time.Now()
t1 := db.StartTimeFromPeriod(opts.Period)
if opts.ArtistID > 0 {
count, err := p.q.CountTimeListenedToArtist(ctx, repository.CountTimeListenedToArtistParams{
ListenedAt: t1,
ListenedAt_2: t2,
ArtistID: opts.ArtistID,
})
if err != nil {
return 0, err
}
return count, nil
} else if opts.AlbumID > 0 {
count, err := p.q.CountTimeListenedToRelease(ctx, repository.CountTimeListenedToReleaseParams{
ListenedAt: t1,
ListenedAt_2: t2,
ReleaseID: opts.AlbumID,
})
if err != nil {
return 0, err
}
return count, nil
} else if opts.TrackID > 0 {
count, err := p.q.CountTimeListenedToTrack(ctx, repository.CountTimeListenedToTrackParams{
ListenedAt: t1,
ListenedAt_2: t2,
ID: opts.TrackID,
})
if err != nil {
return 0, err
}
return count, nil
}
return 0, errors.New("an id must be provided")
}

View file

@ -74,3 +74,33 @@ func TestCountTimeListened(t *testing.T) {
truncateTestData(t)
}
func TestCountTimeListenedToArtist(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, ArtistID: 1})
require.NoError(t, err)
assert.EqualValues(t, 400, count)
truncateTestData(t)
}
func TestCountTimeListenedToAlbum(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, AlbumID: 2})
require.NoError(t, err)
assert.EqualValues(t, 300, count)
truncateTestData(t)
}
func TestCountTimeListenedToTrack(t *testing.T) {
ctx := context.Background()
testDataForTopItems(t)
period := db.PeriodAllTime
count, err := store.CountTimeListenedToItem(ctx, db.TimeListenedOpts{Period: period, TrackID: 3})
require.NoError(t, err)
assert.EqualValues(t, 200, count)
truncateTestData(t)
}

View file

@ -2,6 +2,7 @@ package psql
import (
"context"
"fmt"
"github.com/gabehf/koito/internal/logger"
"github.com/gabehf/koito/internal/repository"
@ -14,7 +15,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeTracks: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -23,7 +24,7 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
TrackID_2: toId,
})
if err != nil {
return err
return fmt.Errorf("MergeTracks: %w", err)
}
err = qtx.CleanOrphanedEntries(ctx)
if err != nil {
@ -33,13 +34,13 @@ func (d *Psql) MergeTracks(ctx context.Context, fromId, toId int32) error {
return tx.Commit(ctx)
}
func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32, replaceImage bool) error {
l := logger.FromContext(ctx)
l.Info().Msgf("Merging album %d into album %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeAlbums: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -48,7 +49,21 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
ReleaseID_2: toId,
})
if err != nil {
return err
return fmt.Errorf("MergeAlbums: %w", err)
}
if replaceImage {
old, err := qtx.GetRelease(ctx, fromId)
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
err = qtx.UpdateReleaseImage(ctx, repository.UpdateReleaseImageParams{
ID: toId,
Image: old.Image,
ImageSource: old.ImageSource,
})
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
}
err = qtx.CleanOrphanedEntries(ctx)
if err != nil {
@ -58,13 +73,13 @@ func (d *Psql) MergeAlbums(ctx context.Context, fromId, toId int32) error {
return tx.Commit(ctx)
}
func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32, replaceImage bool) error {
l := logger.FromContext(ctx)
l.Info().Msgf("Merging artist %d into artist %d", fromId, toId)
tx, err := d.conn.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
l.Err(err).Msg("Failed to begin transaction")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
defer tx.Rollback(ctx)
qtx := d.q.WithTx(tx)
@ -74,7 +89,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist tracks")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.DeleteConflictingArtistReleases(ctx, repository.DeleteConflictingArtistReleasesParams{
ArtistID: fromId,
@ -82,7 +97,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to delete conflicting artist releases")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.UpdateArtistTracks(ctx, repository.UpdateArtistTracksParams{
ArtistID: fromId,
@ -90,7 +105,7 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to update artist tracks")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
err = qtx.UpdateArtistReleases(ctx, repository.UpdateArtistReleasesParams{
ArtistID: fromId,
@ -98,12 +113,26 @@ func (d *Psql) MergeArtists(ctx context.Context, fromId, toId int32) error {
})
if err != nil {
l.Err(err).Msg("Failed to update artist releases")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
if replaceImage {
old, err := qtx.GetArtist(ctx, fromId)
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
err = qtx.UpdateArtistImage(ctx, repository.UpdateArtistImageParams{
ID: toId,
Image: old.Image,
ImageSource: old.ImageSource,
})
if err != nil {
return fmt.Errorf("MergeAlbums: %w", err)
}
}
err = qtx.CleanOrphanedEntries(ctx)
if err != nil {
l.Err(err).Msg("Failed to clean orphaned entries")
return err
return fmt.Errorf("MergeArtists: %w", err)
}
return tx.Commit(ctx)
}

View file

@ -12,9 +12,9 @@ func setupTestDataForMerge(t *testing.T) {
truncateTestData(t)
// Insert artists
err := store.Exec(context.Background(),
`INSERT INTO artists (musicbrainz_id)
VALUES ('00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000002')`)
`INSERT INTO artists (musicbrainz_id, image, image_source)
VALUES ('00000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000000', 'source.com'),
('00000000-0000-0000-0000-000000000002', NULL, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -25,9 +25,9 @@ func setupTestDataForMerge(t *testing.T) {
// Insert albums
err = store.Exec(context.Background(),
`INSERT INTO releases (musicbrainz_id)
VALUES ('11111111-1111-1111-1111-111111111111'),
('22222222-2222-2222-2222-222222222222')`)
`INSERT INTO releases (musicbrainz_id, image, image_source)
VALUES ('11111111-1111-1111-1111-111111111111', '20000000-0000-0000-0000-000000000000', 'source.com'),
('22222222-2222-2222-2222-222222222222', NULL, NULL)`)
require.NoError(t, err)
err = store.Exec(context.Background(),
@ -90,11 +90,15 @@ func TestMergeAlbums(t *testing.T) {
setupTestDataForMerge(t)
// Merge Album 1 into Album 2
err := store.MergeAlbums(ctx, 1, 2)
err := store.MergeAlbums(ctx, 1, 2, true)
require.NoError(t, err)
// Verify image was replaced
count, err := store.Count(ctx, `SELECT COUNT(*) FROM releases WHERE image = '20000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
require.NoError(t, err)
assert.Equal(t, 1, count, "expected merged release to contain image information")
// Verify tracks are updated
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM tracks WHERE release_id = 2`)
require.NoError(t, err)
assert.Equal(t, 2, count, "expected all tracks to be merged into Album 2")
@ -107,11 +111,15 @@ func TestMergeArtists(t *testing.T) {
setupTestDataForMerge(t)
// Merge Artist 1 into Artist 2
err := store.MergeArtists(ctx, 1, 2)
err := store.MergeArtists(ctx, 1, 2, true)
require.NoError(t, err)
// Verify image was replaced
count, err := store.Count(ctx, `SELECT COUNT(*) FROM artists WHERE image = '10000000-0000-0000-0000-000000000000' AND image_source = 'source.com'`)
require.NoError(t, err)
assert.Equal(t, 1, count, "expected merged artist to contain image information")
// Verify artist associations are updated
var count int
count, err = store.Count(ctx, `SELECT COUNT(*) FROM artist_tracks WHERE artist_id = 2`)
require.NoError(t, err)
assert.Equal(t, 2, count, "expected all tracks to be associated with Artist 2")

View file

@ -72,10 +72,19 @@ func (d *Psql) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Trac
TrackID: track.ID,
})
if err != nil {
l.Err(err).Msgf("Failed to get listen count for track with id %d", track.ID)
return nil, err
}
seconds, err := d.CountTimeListenedToItem(ctx, db.TimeListenedOpts{
Period: db.PeriodAllTime,
TrackID: track.ID,
})
if err != nil {
return nil, err
}
track.ListenCount = count
track.TimeListened = seconds
return &track, nil
}

View file

@ -44,9 +44,9 @@ func testDataForTracks(t *testing.T) {
// Insert tracks
err = store.Exec(context.Background(),
`INSERT INTO tracks (musicbrainz_id, release_id)
VALUES ('11111111-1111-1111-1111-111111111111', 1),
('22222222-2222-2222-2222-222222222222', 2)`)
`INSERT INTO tracks (musicbrainz_id, release_id, duration)
VALUES ('11111111-1111-1111-1111-111111111111', 1, 100),
('22222222-2222-2222-2222-222222222222', 2, 100)`)
require.NoError(t, err)
// Insert track aliases
@ -61,6 +61,12 @@ func testDataForTracks(t *testing.T) {
`INSERT INTO artist_tracks (artist_id, track_id)
VALUES (1, 1), (2, 2)`)
require.NoError(t, err)
// Associate tracks with artists
err = store.Exec(context.Background(),
`INSERT INTO listens (user_id, track_id, listened_at)
VALUES (1, 1, NOW()), (1, 2, NOW())`)
require.NoError(t, err)
}
func TestGetTrack(t *testing.T) {
@ -73,12 +79,14 @@ func TestGetTrack(t *testing.T) {
assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title)
assert.Equal(t, uuid.MustParse("11111111-1111-1111-1111-111111111111"), *track.MbzID)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by MusicBrainzID
track, err = store.GetTrack(ctx, db.GetTrackOpts{MusicBrainzID: uuid.MustParse("22222222-2222-2222-2222-222222222222")})
require.NoError(t, err)
assert.Equal(t, int32(2), track.ID)
assert.Equal(t, "Track Two", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack by Title and ArtistIDs
track, err = store.GetTrack(ctx, db.GetTrackOpts{
@ -88,6 +96,7 @@ func TestGetTrack(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, int32(1), track.ID)
assert.Equal(t, "Track One", track.Title)
assert.EqualValues(t, 100, track.TimeListened)
// Test GetTrack with insufficient information
_, err = store.GetTrack(ctx, db.GetTrackOpts{})