first commit

This commit is contained in:
Tomas Dvorak
2026-04-13 17:46:58 +02:00
commit 6e8fedf534
234 changed files with 53808 additions and 0 deletions
@@ -0,0 +1,200 @@
package memory
import (
"context"
"slices"
"sync"
"time"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
)
type Store struct {
mu sync.RWMutex
tracks map[string]recommendation.Track
interactions []recommendation.Interaction
controls map[string]recommendation.UserControls
providerCache map[string]provider.CacheEntry
importJobs map[string]provider.ImportJob
enrichments map[string]provider.TrackEnrichment
}
func New() *Store {
return &Store{
tracks: make(map[string]recommendation.Track),
controls: make(map[string]recommendation.UserControls),
providerCache: make(map[string]provider.CacheEntry),
importJobs: make(map[string]provider.ImportJob),
enrichments: make(map[string]provider.TrackEnrichment),
}
}
func (s *Store) Ping(context.Context) error {
return nil
}
func (s *Store) UpsertTrack(_ context.Context, track recommendation.Track) error {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now().UTC()
existing := s.tracks[track.ID]
if track.CreatedAt.IsZero() {
track.CreatedAt = existing.CreatedAt
}
if track.CreatedAt.IsZero() {
track.CreatedAt = now
}
track.UpdatedAt = now
s.tracks[track.ID] = track
return nil
}
func (s *Store) UpsertTracks(ctx context.Context, tracks []recommendation.Track) error {
for _, track := range tracks {
if err := s.UpsertTrack(ctx, track); err != nil {
return err
}
}
return nil
}
func (s *Store) GetTracksByIDs(_ context.Context, ids []string) ([]recommendation.Track, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]recommendation.Track, 0, len(ids))
for _, id := range ids {
if track, ok := s.tracks[id]; ok {
out = append(out, track)
}
}
return out, nil
}
func (s *Store) RecordInteraction(_ context.Context, interaction recommendation.Interaction) error {
s.mu.Lock()
defer s.mu.Unlock()
if interaction.OccurredAt.IsZero() {
interaction.OccurredAt = time.Now().UTC()
}
s.interactions = append(s.interactions, interaction)
return nil
}
func (s *Store) GetControls(_ context.Context, userID string) (recommendation.UserControls, error) {
s.mu.RLock()
defer s.mu.RUnlock()
controls, ok := s.controls[userID]
if !ok {
return recommendation.UserControls{UserID: userID, AllowExplicit: true}, nil
}
return controls, nil
}
func (s *Store) UpsertControls(_ context.Context, controls recommendation.UserControls) error {
s.mu.Lock()
defer s.mu.Unlock()
s.controls[controls.UserID] = controls
return nil
}
func (s *Store) Snapshot(_ context.Context, userID string) (recommendation.CatalogSnapshot, error) {
s.mu.RLock()
defer s.mu.RUnlock()
tracks := make([]recommendation.Track, 0, len(s.tracks))
for _, track := range s.tracks {
tracks = append(tracks, track)
}
slices.SortFunc(tracks, func(a, b recommendation.Track) int {
if a.ID < b.ID {
return -1
}
if a.ID > b.ID {
return 1
}
return 0
})
interactions := slices.Clone(s.interactions)
controls, ok := s.controls[userID]
if !ok {
controls = recommendation.UserControls{UserID: userID, AllowExplicit: true}
}
return recommendation.CatalogSnapshot{
Tracks: tracks,
Interactions: interactions,
Controls: controls,
}, nil
}
func (s *Store) GetProviderCache(_ context.Context, providerName, itemType, itemID, market string) (provider.CacheEntry, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
entry, ok := s.providerCache[providerCacheKey(providerName, itemType, itemID, market)]
if !ok {
return provider.CacheEntry{}, false, nil
}
return cloneCacheEntry(entry), true, nil
}
func (s *Store) UpsertProviderCache(_ context.Context, entry provider.CacheEntry) error {
s.mu.Lock()
defer s.mu.Unlock()
s.providerCache[providerCacheKey(entry.Provider, entry.ItemType, entry.ItemID, entry.Market)] = cloneCacheEntry(entry)
return nil
}
func (s *Store) ProviderCacheStats(context.Context) (provider.CacheStats, error) {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now().UTC()
stats := provider.CacheStats{Entries: int64(len(s.providerCache))}
for _, entry := range s.providerCache {
if entry.Fresh(now) {
stats.FreshEntries++
} else {
stats.StaleEntries++
}
}
return stats, nil
}
func (s *Store) CreateImportJob(_ context.Context, job provider.ImportJob) error {
s.mu.Lock()
defer s.mu.Unlock()
s.importJobs[job.ID] = job
return nil
}
func (s *Store) FinishImportJob(_ context.Context, job provider.ImportJob) error {
s.mu.Lock()
defer s.mu.Unlock()
s.importJobs[job.ID] = job
return nil
}
func (s *Store) UpsertTrackEnrichment(_ context.Context, enrichment provider.TrackEnrichment) error {
s.mu.Lock()
defer s.mu.Unlock()
s.enrichments[enrichment.TrackID+":"+enrichment.Provider] = cloneEnrichment(enrichment)
return nil
}
func providerCacheKey(providerName, itemType, itemID, market string) string {
return providerName + "\x00" + itemType + "\x00" + itemID + "\x00" + market
}
func cloneCacheEntry(entry provider.CacheEntry) provider.CacheEntry {
if len(entry.Payload) > 0 {
entry.Payload = slices.Clone(entry.Payload)
}
return entry
}
func cloneEnrichment(enrichment provider.TrackEnrichment) provider.TrackEnrichment {
if len(enrichment.Payload) > 0 {
enrichment.Payload = slices.Clone(enrichment.Payload)
}
return enrichment
}
@@ -0,0 +1,33 @@
package memory
import (
"context"
"time"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
)
func SeedDemo(store *Store) {
ctx := context.Background()
now := time.Now().UTC()
tracks := []recommendation.Track{
{ID: "trk-neon-dawn", Title: "Neon Dawn", Artist: "The Arrays", Genres: []string{"synthpop", "indie"}, Popularity: 0.72, Features: recommendation.AudioFeatures{Danceability: 0.74, Energy: 0.70, Loudness: -6, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.15, Liveness: 0.12, Valence: 0.67, Tempo: 118, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-static-heart", Title: "Static Heart", Artist: "The Arrays", Genres: []string{"synthpop"}, Popularity: 0.62, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.66, Loudness: -8, Speechiness: 0.04, Acousticness: 0.18, Instrumentalness: 0.20, Liveness: 0.10, Valence: 0.58, Tempo: 116, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-glass-road", Title: "Glass Road", Artist: "North Index", Genres: []string{"indie", "rock"}, Popularity: 0.51, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.78, Loudness: -5, Speechiness: 0.06, Acousticness: 0.20, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.54, Tempo: 132, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-late-platform", Title: "Late Platform", Artist: "North Index", Genres: []string{"indie", "ambient"}, Popularity: 0.37, Features: recommendation.AudioFeatures{Danceability: 0.44, Energy: 0.38, Loudness: -13, Speechiness: 0.03, Acousticness: 0.66, Instrumentalness: 0.70, Liveness: 0.09, Valence: 0.35, Tempo: 92, TimeSignature: 4, Key: 11, Mode: 0}, DiscoveryAllowed: true},
{ID: "trk-velvet-motor", Title: "Velvet Motor", Artist: "Signal Choir", Genres: []string{"pop-punk", "rock"}, Popularity: 0.49, Features: recommendation.AudioFeatures{Danceability: 0.60, Energy: 0.88, Loudness: -4, Speechiness: 0.07, Acousticness: 0.09, Instrumentalness: 0.02, Liveness: 0.21, Valence: 0.62, Tempo: 148, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true, CommercialBoost: 0.02},
{ID: "trk-blue-hour", Title: "Blue Hour", Artist: "Mira Vale", Genres: []string{"acoustic", "folk"}, Popularity: 0.43, Features: recommendation.AudioFeatures{Danceability: 0.38, Energy: 0.31, Loudness: -14, Speechiness: 0.04, Acousticness: 0.86, Instrumentalness: 0.12, Liveness: 0.11, Valence: 0.42, Tempo: 82, TimeSignature: 4, Key: 7, Mode: 0}, DiscoveryAllowed: true},
}
_ = store.UpsertTracks(ctx, tracks)
for _, interaction := range []recommendation.Interaction{
{UserID: "demo-user", TrackID: "trk-neon-dawn", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "demo-user", TrackID: "trk-static-heart", Type: recommendation.InteractionSave, OccurredAt: now.Add(-48 * time.Hour)},
{UserID: "demo-user", TrackID: "trk-blue-hour", Type: recommendation.InteractionSkip, OccurredAt: now.Add(-12 * time.Hour)},
{UserID: "neighbor-a", TrackID: "trk-neon-dawn", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
{UserID: "neighbor-a", TrackID: "trk-glass-road", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "neighbor-b", TrackID: "trk-static-heart", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
{UserID: "neighbor-b", TrackID: "trk-velvet-motor", Type: recommendation.InteractionLike, OccurredAt: now.Add(-18 * time.Hour)},
} {
_ = store.RecordInteraction(ctx, interaction)
}
}
@@ -0,0 +1,81 @@
package memory
import (
"context"
"time"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
)
// SeedLargeCatalog creates a diverse catalog for realistic recommendations
func SeedLargeCatalog(store *Store) {
ctx := context.Background()
now := time.Now().UTC()
// Diverse catalog with many tracks across genres
tracks := []recommendation.Track{
// Electronic / Dance (similar to Avicii)
{ID: "25FTMokYEbEWHEdss5JLZS", Title: "The Nights", Artist: "Avicii", Genres: []string{"electronic", "dance", "edm"}, Popularity: 0.85, Features: recommendation.AudioFeatures{Danceability: 0.72, Energy: 0.78, Loudness: -5, Speechiness: 0.05, Acousticness: 0.15, Instrumentalness: 0.10, Liveness: 0.20, Valence: 0.70, Tempo: 126, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-wake-me-up", Title: "Wake Me Up", Artist: "Avicii", Genres: []string{"electronic", "dance", "country"}, Popularity: 0.90, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.82, Loudness: -4, Speechiness: 0.06, Acousticness: 0.22, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.65, Tempo: 124, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-levels", Title: "Levels", Artist: "Avicii", Genres: []string{"electronic", "dance", "edm"}, Popularity: 0.88, Features: recommendation.AudioFeatures{Danceability: 0.75, Energy: 0.85, Loudness: -3, Speechiness: 0.04, Acousticness: 0.08, Instrumentalness: 0.20, Liveness: 0.25, Valence: 0.72, Tempo: 128, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-hey-brother", Title: "Hey Brother", Artist: "Avicii", Genres: []string{"electronic", "dance", "country"}, Popularity: 0.82, Features: recommendation.AudioFeatures{Danceability: 0.65, Energy: 0.80, Loudness: -5, Speechiness: 0.07, Acousticness: 0.30, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.60, Tempo: 125, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-waiting-for-love", Title: "Waiting For Love", Artist: "Avicii", Genres: []string{"electronic", "dance", "pop"}, Popularity: 0.83, Features: recommendation.AudioFeatures{Danceability: 0.70, Energy: 0.78, Loudness: -5, Speechiness: 0.05, Acousticness: 0.18, Instrumentalness: 0.12, Liveness: 0.20, Valence: 0.68, Tempo: 126, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
// Pop / Dance
{ID: "trk-counting-stars", Title: "Counting Stars", Artist: "OneRepublic", Genres: []string{"pop", "rock"}, Popularity: 0.87, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.75, Loudness: -6, Speechiness: 0.08, Acousticness: 0.20, Instrumentalness: 0.02, Liveness: 0.15, Valence: 0.55, Tempo: 122, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-uptown-funk", Title: "Uptown Funk", Artist: "Bruno Mars", Genres: []string{"pop", "funk"}, Popularity: 0.89, Features: recommendation.AudioFeatures{Danceability: 0.82, Energy: 0.88, Loudness: -4, Speechiness: 0.10, Acousticness: 0.05, Instrumentalness: 0.02, Liveness: 0.30, Valence: 0.90, Tempo: 115, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-happy", Title: "Happy", Artist: "Pharrell Williams", Genres: []string{"pop", "soul"}, Popularity: 0.86, Features: recommendation.AudioFeatures{Danceability: 0.78, Energy: 0.82, Loudness: -5, Speechiness: 0.12, Acousticness: 0.10, Instrumentalness: 0.03, Liveness: 0.22, Valence: 0.95, Tempo: 160, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-cant-hold-us", Title: "Can't Hold Us", Artist: "Macklemore", Genres: []string{"hip-hop", "pop"}, Popularity: 0.84, Features: recommendation.AudioFeatures{Danceability: 0.72, Energy: 0.86, Loudness: -4, Speechiness: 0.20, Acousticness: 0.15, Instrumentalness: 0.02, Liveness: 0.28, Valence: 0.75, Tempo: 146, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-i-gotta-feeling", Title: "I Gotta Feeling", Artist: "Black Eyed Peas", Genres: []string{"pop", "dance"}, Popularity: 0.85, Features: recommendation.AudioFeatures{Danceability: 0.76, Energy: 0.80, Loudness: -5, Speechiness: 0.08, Acousticness: 0.12, Instrumentalness: 0.05, Liveness: 0.25, Valence: 0.80, Tempo: 128, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
// Alternative / Indie
{ID: "trk-do-i-wanna-know", Title: "Do I Wanna Know?", Artist: "Arctic Monkeys", Genres: []string{"alternative", "indie"}, Popularity: 0.80, Features: recommendation.AudioFeatures{Danceability: 0.58, Energy: 0.68, Loudness: -8, Speechiness: 0.05, Acousticness: 0.35, Instrumentalness: 0.25, Liveness: 0.12, Valence: 0.35, Tempo: 85, TimeSignature: 4, Key: 9, Mode: 0}, DiscoveryAllowed: true},
{ID: "trk-somebody-told-me", Title: "Somebody Told Me", Artist: "The Killers", Genres: []string{"alternative", "rock"}, Popularity: 0.82, Features: recommendation.AudioFeatures{Danceability: 0.62, Energy: 0.85, Loudness: -5, Speechiness: 0.06, Acousticness: 0.08, Instrumentalness: 0.02, Liveness: 0.20, Valence: 0.65, Tempo: 138, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-mr-brightside", Title: "Mr. Brightside", Artist: "The Killers", Genres: []string{"alternative", "rock"}, Popularity: 0.88, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.90, Loudness: -4, Speechiness: 0.09, Acousticness: 0.05, Instrumentalness: 0.02, Liveness: 0.25, Valence: 0.60, Tempo: 148, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-take-me-out", Title: "Take Me Out", Artist: "Franz Ferdinand", Genres: []string{"alternative", "indie"}, Popularity: 0.78, Features: recommendation.AudioFeatures{Danceability: 0.65, Energy: 0.82, Loudness: -5, Speechiness: 0.04, Acousticness: 0.15, Instrumentalness: 0.05, Liveness: 0.18, Valence: 0.55, Tempo: 105, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
// Rock
{ID: "trk-boulevard-of-broken-dreams", Title: "Boulevard of Broken Dreams", Artist: "Green Day", Genres: []string{"rock", "punk"}, Popularity: 0.86, Features: recommendation.AudioFeatures{Danceability: 0.52, Energy: 0.78, Loudness: -5, Speechiness: 0.04, Acousticness: 0.12, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.40, Tempo: 167, TimeSignature: 4, Key: 0, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-knights-of-cydonia", Title: "Knights of Cydonia", Artist: "Muse", Genres: []string{"rock", "alternative"}, Popularity: 0.81, Features: recommendation.AudioFeatures{Danceability: 0.45, Energy: 0.92, Loudness: -3, Speechiness: 0.08, Acousticness: 0.05, Instrumentalness: 0.35, Liveness: 0.22, Valence: 0.45, Tempo: 137, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-smells-like-teen-spirit", Title: "Smells Like Teen Spirit", Artist: "Nirvana", Genres: []string{"rock", "grunge"}, Popularity: 0.87, Features: recommendation.AudioFeatures{Danceability: 0.48, Energy: 0.95, Loudness: -2, Speechiness: 0.07, Acousticness: 0.03, Instrumentalness: 0.10, Liveness: 0.30, Valence: 0.35, Tempo: 117, TimeSignature: 4, Key: 4, Mode: 0}, DiscoveryAllowed: true},
// Acoustic / Folk
{ID: "trk-ho-hey", Title: "Ho Hey", Artist: "The Lumineers", Genres: []string{"folk", "acoustic"}, Popularity: 0.79, Features: recommendation.AudioFeatures{Danceability: 0.58, Energy: 0.55, Loudness: -10, Speechiness: 0.06, Acousticness: 0.75, Instrumentalness: 0.05, Liveness: 0.18, Valence: 0.55, Tempo: 80, TimeSignature: 4, Key: 7, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-riptide", Title: "Riptide", Artist: "Vance Joy", Genres: []string{"folk", "indie"}, Popularity: 0.83, Features: recommendation.AudioFeatures{Danceability: 0.62, Energy: 0.48, Loudness: -11, Speechiness: 0.08, Acousticness: 0.72, Instrumentalness: 0.08, Liveness: 0.12, Valence: 0.60, Tempo: 102, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-let-her-go", Title: "Let Her Go", Artist: "Passenger", Genres: []string{"folk", "acoustic"}, Popularity: 0.80, Features: recommendation.AudioFeatures{Danceability: 0.52, Energy: 0.42, Loudness: -12, Speechiness: 0.05, Acousticness: 0.80, Instrumentalness: 0.05, Liveness: 0.15, Valence: 0.35, Tempo: 75, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
// Synth / New Wave
{ID: "trk-neon-dawn", Title: "Neon Dawn", Artist: "The Arrays", Genres: []string{"synthpop", "indie"}, Popularity: 0.72, Features: recommendation.AudioFeatures{Danceability: 0.74, Energy: 0.70, Loudness: -6, Speechiness: 0.05, Acousticness: 0.12, Instrumentalness: 0.15, Liveness: 0.12, Valence: 0.67, Tempo: 118, TimeSignature: 4, Key: 2, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-static-heart", Title: "Static Heart", Artist: "The Arrays", Genres: []string{"synthpop"}, Popularity: 0.62, Features: recommendation.AudioFeatures{Danceability: 0.68, Energy: 0.66, Loudness: -8, Speechiness: 0.04, Acousticness: 0.18, Instrumentalness: 0.20, Liveness: 0.10, Valence: 0.58, Tempo: 116, TimeSignature: 4, Key: 4, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-glass-road", Title: "Glass Road", Artist: "North Index", Genres: []string{"indie", "rock"}, Popularity: 0.51, Features: recommendation.AudioFeatures{Danceability: 0.55, Energy: 0.78, Loudness: -5, Speechiness: 0.06, Acousticness: 0.20, Instrumentalness: 0.08, Liveness: 0.18, Valence: 0.54, Tempo: 132, TimeSignature: 4, Key: 9, Mode: 1}, DiscoveryAllowed: true},
{ID: "trk-velvet-motor", Title: "Velvet Motor", Artist: "Signal Choir", Genres: []string{"pop-punk", "rock"}, Popularity: 0.49, Features: recommendation.AudioFeatures{Danceability: 0.60, Energy: 0.88, Loudness: -4, Speechiness: 0.07, Acousticness: 0.09, Instrumentalness: 0.02, Liveness: 0.21, Valence: 0.62, Tempo: 148, TimeSignature: 4, Key: 5, Mode: 1}, DiscoveryAllowed: true, CommercialBoost: 0.02},
}
_ = store.UpsertTracks(ctx, tracks)
// Add collaborative interactions to create taste neighborhoods
interactions := []recommendation.Interaction{
// User who likes electronic/dance
{UserID: "user-electronic", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "user-electronic", TrackID: "trk-wake-me-up", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
{UserID: "user-electronic", TrackID: "trk-levels", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
// User who likes pop
{UserID: "user-pop", TrackID: "trk-counting-stars", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "user-pop", TrackID: "trk-uptown-funk", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
{UserID: "user-pop", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-36 * time.Hour)},
// User who likes alternative
{UserID: "user-alt", TrackID: "trk-do-i-wanna-know", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "user-alt", TrackID: "trk-somebody-told-me", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
// Cross-genre listener
{UserID: "user-mixed", TrackID: "25FTMokYEbEWHEdss5JLZS", Type: recommendation.InteractionLike, OccurredAt: now.Add(-24 * time.Hour)},
{UserID: "user-mixed", TrackID: "trk-boulevard-of-broken-dreams", Type: recommendation.InteractionLike, OccurredAt: now.Add(-48 * time.Hour)},
{UserID: "user-mixed", TrackID: "trk-riptide", Type: recommendation.InteractionLike, OccurredAt: now.Add(-72 * time.Hour)},
}
for _, interaction := range interactions {
_ = store.RecordInteraction(ctx, interaction)
}
}
@@ -0,0 +1,337 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: catalog.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getControls = `-- name: GetControls :one
select user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks
from user_controls
where user_id = $1
`
type GetControlsRow struct {
UserID string `json:"user_id"`
AllowExplicit bool `json:"allow_explicit"`
ExcludedTracks []byte `json:"excluded_tracks"`
ExcludedArtists []byte `json:"excluded_artists"`
ExcludedGenres []byte `json:"excluded_genres"`
PostponedTracks []byte `json:"postponed_tracks"`
}
func (q *Queries) GetControls(ctx context.Context, userID string) (GetControlsRow, error) {
row := q.db.QueryRow(ctx, getControls, userID)
var i GetControlsRow
err := row.Scan(
&i.UserID,
&i.AllowExplicit,
&i.ExcludedTracks,
&i.ExcludedArtists,
&i.ExcludedGenres,
&i.PostponedTracks,
)
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
select id, title, artist, album, genres, release_date, duration_ms, popularity,
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
from tracks
where id = any($1::text[])
order by id
`
type GetTracksByIDsRow struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Genres []byte `json:"genres"`
ReleaseDate string `json:"release_date"`
DurationMs int32 `json:"duration_ms"`
Popularity float64 `json:"popularity"`
Explicit bool `json:"explicit"`
Features []byte `json:"features"`
External []byte `json:"external"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CommercialBoost float64 `json:"commercial_boost"`
QualityPenalty float64 `json:"quality_penalty"`
DiscoveryAllowed bool `json:"discovery_allowed"`
}
func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []string) ([]GetTracksByIDsRow, error) {
rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetTracksByIDsRow
for rows.Next() {
var i GetTracksByIDsRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Artist,
&i.Album,
&i.Genres,
&i.ReleaseDate,
&i.DurationMs,
&i.Popularity,
&i.Explicit,
&i.Features,
&i.External,
&i.CreatedAt,
&i.UpdatedAt,
&i.CommercialBoost,
&i.QualityPenalty,
&i.DiscoveryAllowed,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentInteractions = `-- name: ListRecentInteractions :many
select user_id, track_id, type, weight, occurred_at, context, completed_ms
from interactions
where occurred_at >= now() - interval '365 days'
order by occurred_at desc
limit 250000
`
type ListRecentInteractionsRow struct {
UserID string `json:"user_id"`
TrackID string `json:"track_id"`
Type string `json:"type"`
Weight float64 `json:"weight"`
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
Context []byte `json:"context"`
CompletedMs int32 `json:"completed_ms"`
}
func (q *Queries) ListRecentInteractions(ctx context.Context) ([]ListRecentInteractionsRow, error) {
rows, err := q.db.Query(ctx, listRecentInteractions)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRecentInteractionsRow
for rows.Next() {
var i ListRecentInteractionsRow
if err := rows.Scan(
&i.UserID,
&i.TrackID,
&i.Type,
&i.Weight,
&i.OccurredAt,
&i.Context,
&i.CompletedMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracks = `-- name: ListTracks :many
select id, title, artist, album, genres, release_date, duration_ms, popularity,
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
from tracks
order by id
`
type ListTracksRow struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Genres []byte `json:"genres"`
ReleaseDate string `json:"release_date"`
DurationMs int32 `json:"duration_ms"`
Popularity float64 `json:"popularity"`
Explicit bool `json:"explicit"`
Features []byte `json:"features"`
External []byte `json:"external"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
CommercialBoost float64 `json:"commercial_boost"`
QualityPenalty float64 `json:"quality_penalty"`
DiscoveryAllowed bool `json:"discovery_allowed"`
}
func (q *Queries) ListTracks(ctx context.Context) ([]ListTracksRow, error) {
rows, err := q.db.Query(ctx, listTracks)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksRow
for rows.Next() {
var i ListTracksRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.Artist,
&i.Album,
&i.Genres,
&i.ReleaseDate,
&i.DurationMs,
&i.Popularity,
&i.Explicit,
&i.Features,
&i.External,
&i.CreatedAt,
&i.UpdatedAt,
&i.CommercialBoost,
&i.QualityPenalty,
&i.DiscoveryAllowed,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recordInteraction = `-- name: RecordInteraction :exec
insert into interactions (user_id, track_id, type, weight, occurred_at, context, completed_ms)
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
`
type RecordInteractionParams struct {
UserID string `json:"user_id"`
TrackID string `json:"track_id"`
Type string `json:"type"`
Weight float64 `json:"weight"`
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
Column6 []byte `json:"column_6"`
CompletedMs int32 `json:"completed_ms"`
}
func (q *Queries) RecordInteraction(ctx context.Context, arg RecordInteractionParams) error {
_, err := q.db.Exec(ctx, recordInteraction,
arg.UserID,
arg.TrackID,
arg.Type,
arg.Weight,
arg.OccurredAt,
arg.Column6,
arg.CompletedMs,
)
return err
}
const upsertControls = `-- name: UpsertControls :exec
insert into user_controls (user_id, allow_explicit, excluded_tracks, excluded_artists, excluded_genres, postponed_tracks)
values ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb)
on conflict (user_id) do update set
allow_explicit = excluded.allow_explicit,
excluded_tracks = excluded.excluded_tracks,
excluded_artists = excluded.excluded_artists,
excluded_genres = excluded.excluded_genres,
postponed_tracks = excluded.postponed_tracks,
updated_at = now()
`
type UpsertControlsParams struct {
UserID string `json:"user_id"`
AllowExplicit bool `json:"allow_explicit"`
Column3 []byte `json:"column_3"`
Column4 []byte `json:"column_4"`
Column5 []byte `json:"column_5"`
Column6 []byte `json:"column_6"`
}
func (q *Queries) UpsertControls(ctx context.Context, arg UpsertControlsParams) error {
_, err := q.db.Exec(ctx, upsertControls,
arg.UserID,
arg.AllowExplicit,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Column6,
)
return err
}
const upsertTrack = `-- name: UpsertTrack :exec
insert into tracks (
id, title, artist, album, genres, release_date, duration_ms, popularity,
explicit, features, external, commercial_boost, quality_penalty, discovery_allowed
) values (
$1, $2, $3, $4, $5::jsonb, $6, $7, $8,
$9, $10::jsonb, $11::jsonb, $12, $13, $14
)
on conflict (id) do update set
title = excluded.title,
artist = excluded.artist,
album = excluded.album,
genres = excluded.genres,
release_date = excluded.release_date,
duration_ms = excluded.duration_ms,
popularity = excluded.popularity,
explicit = excluded.explicit,
features = excluded.features,
external = excluded.external,
commercial_boost = excluded.commercial_boost,
quality_penalty = excluded.quality_penalty,
discovery_allowed = excluded.discovery_allowed,
updated_at = now()
`
type UpsertTrackParams struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Column5 []byte `json:"column_5"`
ReleaseDate string `json:"release_date"`
DurationMs int32 `json:"duration_ms"`
Popularity float64 `json:"popularity"`
Explicit bool `json:"explicit"`
Column10 []byte `json:"column_10"`
Column11 []byte `json:"column_11"`
CommercialBoost float64 `json:"commercial_boost"`
QualityPenalty float64 `json:"quality_penalty"`
DiscoveryAllowed bool `json:"discovery_allowed"`
}
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) error {
_, err := q.db.Exec(ctx, upsertTrack,
arg.ID,
arg.Title,
arg.Artist,
arg.Album,
arg.Column5,
arg.ReleaseDate,
arg.DurationMs,
arg.Popularity,
arg.Explicit,
arg.Column10,
arg.Column11,
arg.CommercialBoost,
arg.QualityPenalty,
arg.DiscoveryAllowed,
)
return err
}
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
@@ -0,0 +1,86 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
package db
import (
"github.com/jackc/pgx/v5/pgtype"
)
type ImportJob struct {
ID string `json:"id"`
Provider string `json:"provider"`
SourceType string `json:"source_type"`
SourceValue string `json:"source_value"`
Market string `json:"market"`
Status string `json:"status"`
ImportedTracks int32 `json:"imported_tracks"`
UpdatedTracks int32 `json:"updated_tracks"`
Skipped int32 `json:"skipped"`
Warnings []byte `json:"warnings"`
StartedAt pgtype.Timestamptz `json:"started_at"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
type Interaction struct {
ID int64 `json:"id"`
UserID string `json:"user_id"`
TrackID string `json:"track_id"`
Type string `json:"type"`
Weight float64 `json:"weight"`
OccurredAt pgtype.Timestamptz `json:"occurred_at"`
Context []byte `json:"context"`
CompletedMs int32 `json:"completed_ms"`
}
type ProviderCache struct {
Provider string `json:"provider"`
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
Market string `json:"market"`
Payload []byte `json:"payload"`
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
LastError pgtype.Text `json:"last_error"`
}
type Track struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
Genres []byte `json:"genres"`
ReleaseDate string `json:"release_date"`
DurationMs int32 `json:"duration_ms"`
Popularity float64 `json:"popularity"`
Explicit bool `json:"explicit"`
Features []byte `json:"features"`
External []byte `json:"external"`
CommercialBoost float64 `json:"commercial_boost"`
QualityPenalty float64 `json:"quality_penalty"`
DiscoveryAllowed bool `json:"discovery_allowed"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type TrackEnrichment struct {
TrackID string `json:"track_id"`
Provider string `json:"provider"`
MusicbrainzRecordingID string `json:"musicbrainz_recording_id"`
MusicbrainzArtistID string `json:"musicbrainz_artist_id"`
Isrc string `json:"isrc"`
Payload []byte `json:"payload"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type UserControl struct {
UserID string `json:"user_id"`
AllowExplicit bool `json:"allow_explicit"`
ExcludedTracks []byte `json:"excluded_tracks"`
ExcludedArtists []byte `json:"excluded_artists"`
ExcludedGenres []byte `json:"excluded_genres"`
PostponedTracks []byte `json:"postponed_tracks"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
@@ -0,0 +1,216 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: provider.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const createImportJob = `-- name: CreateImportJob :exec
insert into import_jobs (id, provider, source_type, source_value, market, status, imported_tracks, updated_tracks, skipped, warnings, started_at)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)
`
type CreateImportJobParams struct {
ID string `json:"id"`
Provider string `json:"provider"`
SourceType string `json:"source_type"`
SourceValue string `json:"source_value"`
Market string `json:"market"`
Status string `json:"status"`
ImportedTracks int32 `json:"imported_tracks"`
UpdatedTracks int32 `json:"updated_tracks"`
Skipped int32 `json:"skipped"`
Column10 []byte `json:"column_10"`
StartedAt pgtype.Timestamptz `json:"started_at"`
}
func (q *Queries) CreateImportJob(ctx context.Context, arg CreateImportJobParams) error {
_, err := q.db.Exec(ctx, createImportJob,
arg.ID,
arg.Provider,
arg.SourceType,
arg.SourceValue,
arg.Market,
arg.Status,
arg.ImportedTracks,
arg.UpdatedTracks,
arg.Skipped,
arg.Column10,
arg.StartedAt,
)
return err
}
const finishImportJob = `-- name: FinishImportJob :exec
update import_jobs
set status = $2,
imported_tracks = $3,
updated_tracks = $4,
skipped = $5,
warnings = $6::jsonb,
finished_at = $7
where id = $1
`
type FinishImportJobParams struct {
ID string `json:"id"`
Status string `json:"status"`
ImportedTracks int32 `json:"imported_tracks"`
UpdatedTracks int32 `json:"updated_tracks"`
Skipped int32 `json:"skipped"`
Column6 []byte `json:"column_6"`
FinishedAt pgtype.Timestamptz `json:"finished_at"`
}
func (q *Queries) FinishImportJob(ctx context.Context, arg FinishImportJobParams) error {
_, err := q.db.Exec(ctx, finishImportJob,
arg.ID,
arg.Status,
arg.ImportedTracks,
arg.UpdatedTracks,
arg.Skipped,
arg.Column6,
arg.FinishedAt,
)
return err
}
const getProviderCache = `-- name: GetProviderCache :one
select provider, item_type, item_id, market, payload, fetched_at, expires_at, coalesce(last_error, '') as last_error
from provider_cache
where provider = $1 and item_type = $2 and item_id = $3 and market = $4
`
type GetProviderCacheParams struct {
Provider string `json:"provider"`
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
Market string `json:"market"`
}
type GetProviderCacheRow struct {
Provider string `json:"provider"`
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
Market string `json:"market"`
Payload []byte `json:"payload"`
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
LastError string `json:"last_error"`
}
func (q *Queries) GetProviderCache(ctx context.Context, arg GetProviderCacheParams) (GetProviderCacheRow, error) {
row := q.db.QueryRow(ctx, getProviderCache,
arg.Provider,
arg.ItemType,
arg.ItemID,
arg.Market,
)
var i GetProviderCacheRow
err := row.Scan(
&i.Provider,
&i.ItemType,
&i.ItemID,
&i.Market,
&i.Payload,
&i.FetchedAt,
&i.ExpiresAt,
&i.LastError,
)
return i, err
}
const providerCacheStats = `-- name: ProviderCacheStats :one
select count(*)::bigint as entries,
count(*) filter (where expires_at > now())::bigint as fresh_entries,
count(*) filter (where expires_at <= now())::bigint as stale_entries
from provider_cache
`
type ProviderCacheStatsRow struct {
Entries int64 `json:"entries"`
FreshEntries int64 `json:"fresh_entries"`
StaleEntries int64 `json:"stale_entries"`
}
func (q *Queries) ProviderCacheStats(ctx context.Context) (ProviderCacheStatsRow, error) {
row := q.db.QueryRow(ctx, providerCacheStats)
var i ProviderCacheStatsRow
err := row.Scan(&i.Entries, &i.FreshEntries, &i.StaleEntries)
return i, err
}
const upsertProviderCache = `-- name: UpsertProviderCache :exec
insert into provider_cache (provider, item_type, item_id, market, payload, fetched_at, expires_at, last_error)
values ($1, $2, $3, $4, $5::jsonb, $6, $7, nullif($8, ''))
on conflict (provider, item_type, item_id, market) do update set
payload = excluded.payload,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at,
last_error = excluded.last_error
`
type UpsertProviderCacheParams struct {
Provider string `json:"provider"`
ItemType string `json:"item_type"`
ItemID string `json:"item_id"`
Market string `json:"market"`
Column5 []byte `json:"column_5"`
FetchedAt pgtype.Timestamptz `json:"fetched_at"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
Column8 interface{} `json:"column_8"`
}
func (q *Queries) UpsertProviderCache(ctx context.Context, arg UpsertProviderCacheParams) error {
_, err := q.db.Exec(ctx, upsertProviderCache,
arg.Provider,
arg.ItemType,
arg.ItemID,
arg.Market,
arg.Column5,
arg.FetchedAt,
arg.ExpiresAt,
arg.Column8,
)
return err
}
const upsertTrackEnrichment = `-- name: UpsertTrackEnrichment :exec
insert into track_enrichment (track_id, provider, musicbrainz_recording_id, musicbrainz_artist_id, isrc, payload, updated_at)
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
on conflict (track_id, provider) do update set
musicbrainz_recording_id = excluded.musicbrainz_recording_id,
musicbrainz_artist_id = excluded.musicbrainz_artist_id,
isrc = excluded.isrc,
payload = excluded.payload,
updated_at = excluded.updated_at
`
type UpsertTrackEnrichmentParams struct {
TrackID string `json:"track_id"`
Provider string `json:"provider"`
MusicbrainzRecordingID string `json:"musicbrainz_recording_id"`
MusicbrainzArtistID string `json:"musicbrainz_artist_id"`
Isrc string `json:"isrc"`
Column6 []byte `json:"column_6"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) UpsertTrackEnrichment(ctx context.Context, arg UpsertTrackEnrichmentParams) error {
_, err := q.db.Exec(ctx, upsertTrackEnrichment,
arg.TrackID,
arg.Provider,
arg.MusicbrainzRecordingID,
arg.MusicbrainzArtistID,
arg.Isrc,
arg.Column6,
arg.UpdatedAt,
)
return err
}
@@ -0,0 +1,444 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/provider"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/recommendation"
"github.com/tdvorak/spotifyrecalg/apps/backend/internal/storage/postgres/db"
)
type Store struct {
pool *pgxpool.Pool
queries *db.Queries
}
func New(pool *pgxpool.Pool) *Store {
return &Store{pool: pool, queries: db.New(pool)}
}
func (s *Store) Ping(ctx context.Context) error {
return s.pool.Ping(ctx)
}
func (s *Store) UpsertTrack(ctx context.Context, track recommendation.Track) error {
params, err := upsertTrackParams(track)
if err != nil {
return err
}
return s.queries.UpsertTrack(ctx, params)
}
func (s *Store) UpsertTracks(ctx context.Context, tracks []recommendation.Track) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
queries := s.queries.WithTx(tx)
for _, track := range tracks {
params, err := upsertTrackParams(track)
if err != nil {
return err
}
if err := queries.UpsertTrack(ctx, params); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (s *Store) GetTracksByIDs(ctx context.Context, ids []string) ([]recommendation.Track, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
select id, title, artist, album, genres, release_date, duration_ms, popularity,
explicit, features, external, created_at, updated_at, commercial_boost, quality_penalty, discovery_allowed
from tracks
where id = any($1)
order by id`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
tracks := make([]recommendation.Track, 0, len(ids))
for rows.Next() {
track, err := scanTrack(rows)
if err != nil {
return nil, err
}
tracks = append(tracks, track)
}
return tracks, rows.Err()
}
func upsertTrackParams(track recommendation.Track) (db.UpsertTrackParams, error) {
features, err := json.Marshal(track.Features)
if err != nil {
return db.UpsertTrackParams{}, fmt.Errorf("marshal features: %w", err)
}
genres, err := json.Marshal(track.Genres)
if err != nil {
return db.UpsertTrackParams{}, fmt.Errorf("marshal genres: %w", err)
}
external, err := json.Marshal(track.External)
if err != nil {
return db.UpsertTrackParams{}, fmt.Errorf("marshal external ids: %w", err)
}
return db.UpsertTrackParams{
ID: track.ID,
Title: track.Title,
Artist: track.Artist,
Album: track.Album,
Column5: genres,
ReleaseDate: track.ReleaseDate,
DurationMs: int32(track.DurationMS),
Popularity: track.Popularity,
Explicit: track.Explicit,
Column10: features,
Column11: external,
CommercialBoost: track.CommercialBoost,
QualityPenalty: track.QualityPenalty,
DiscoveryAllowed: track.DiscoveryAllowed,
}, nil
}
func (s *Store) RecordInteraction(ctx context.Context, interaction recommendation.Interaction) error {
if interaction.OccurredAt.IsZero() {
interaction.OccurredAt = time.Now().UTC()
}
contextJSON, err := json.Marshal(interaction.Context)
if err != nil {
return fmt.Errorf("marshal interaction context: %w", err)
}
return s.queries.RecordInteraction(ctx, db.RecordInteractionParams{
UserID: interaction.UserID,
TrackID: interaction.TrackID,
Type: string(interaction.Type),
Weight: interaction.Weight,
OccurredAt: pgtype.Timestamptz{Time: interaction.OccurredAt, Valid: true},
Column6: contextJSON,
CompletedMs: int32(interaction.CompletedMS),
})
}
func (s *Store) GetControls(ctx context.Context, userID string) (recommendation.UserControls, error) {
row, err := s.queries.GetControls(ctx, userID)
if errors.Is(err, pgx.ErrNoRows) {
return recommendation.UserControls{UserID: userID, AllowExplicit: true}, nil
}
if err != nil {
return recommendation.UserControls{}, err
}
controls := recommendation.UserControls{UserID: row.UserID, AllowExplicit: row.AllowExplicit}
if err := unmarshalStringSlice(row.ExcludedTracks, &controls.ExcludedTracks); err != nil {
return recommendation.UserControls{}, err
}
if err := unmarshalStringSlice(row.ExcludedArtists, &controls.ExcludedArtists); err != nil {
return recommendation.UserControls{}, err
}
if err := unmarshalStringSlice(row.ExcludedGenres, &controls.ExcludedGenres); err != nil {
return recommendation.UserControls{}, err
}
if err := unmarshalStringSlice(row.PostponedTracks, &controls.PostponedTracks); err != nil {
return recommendation.UserControls{}, err
}
return controls, nil
}
func (s *Store) UpsertControls(ctx context.Context, controls recommendation.UserControls) error {
excludedTracks, err := json.Marshal(controls.ExcludedTracks)
if err != nil {
return err
}
excludedArtists, err := json.Marshal(controls.ExcludedArtists)
if err != nil {
return err
}
excludedGenres, err := json.Marshal(controls.ExcludedGenres)
if err != nil {
return err
}
postponedTracks, err := json.Marshal(controls.PostponedTracks)
if err != nil {
return err
}
return s.queries.UpsertControls(ctx, db.UpsertControlsParams{
UserID: controls.UserID,
AllowExplicit: controls.AllowExplicit,
Column3: excludedTracks,
Column4: excludedArtists,
Column5: excludedGenres,
Column6: postponedTracks,
})
}
func (s *Store) Snapshot(ctx context.Context, userID string) (recommendation.CatalogSnapshot, error) {
tracks, err := s.listTracks(ctx)
if err != nil {
return recommendation.CatalogSnapshot{}, err
}
interactions, err := s.listRecentInteractions(ctx)
if err != nil {
return recommendation.CatalogSnapshot{}, err
}
controls, err := s.GetControls(ctx, userID)
if err != nil {
return recommendation.CatalogSnapshot{}, err
}
return recommendation.CatalogSnapshot{
Tracks: tracks,
Interactions: interactions,
Controls: controls,
}, nil
}
func (s *Store) listTracks(ctx context.Context) ([]recommendation.Track, error) {
rows, err := s.queries.ListTracks(ctx)
if err != nil {
return nil, err
}
tracks := make([]recommendation.Track, 0, len(rows))
for _, row := range rows {
track, err := trackFromListRow(row)
if err != nil {
return nil, err
}
tracks = append(tracks, track)
}
return tracks, nil
}
func trackFromListRow(row db.ListTracksRow) (recommendation.Track, error) {
track := recommendation.Track{
ID: row.ID,
Title: row.Title,
Artist: row.Artist,
Album: row.Album,
ReleaseDate: row.ReleaseDate,
DurationMS: int(row.DurationMs),
Popularity: row.Popularity,
Explicit: row.Explicit,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
CommercialBoost: row.CommercialBoost,
QualityPenalty: row.QualityPenalty,
DiscoveryAllowed: row.DiscoveryAllowed,
}
if err := unmarshalStringSlice(row.Genres, &track.Genres); err != nil {
return recommendation.Track{}, err
}
if err := json.Unmarshal(row.Features, &track.Features); err != nil {
return recommendation.Track{}, err
}
if err := unmarshalStringMap(row.External, &track.External); err != nil {
return recommendation.Track{}, err
}
return track, nil
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanTrack(scanner rowScanner) (recommendation.Track, error) {
var (
genres, features, external []byte
createdAt, updatedAt pgtype.Timestamptz
track recommendation.Track
)
if err := scanner.Scan(
&track.ID,
&track.Title,
&track.Artist,
&track.Album,
&genres,
&track.ReleaseDate,
&track.DurationMS,
&track.Popularity,
&track.Explicit,
&features,
&external,
&createdAt,
&updatedAt,
&track.CommercialBoost,
&track.QualityPenalty,
&track.DiscoveryAllowed,
); err != nil {
return recommendation.Track{}, err
}
track.CreatedAt = createdAt.Time
track.UpdatedAt = updatedAt.Time
if err := unmarshalStringSlice(genres, &track.Genres); err != nil {
return recommendation.Track{}, err
}
if err := json.Unmarshal(features, &track.Features); err != nil {
return recommendation.Track{}, err
}
if err := unmarshalStringMap(external, &track.External); err != nil {
return recommendation.Track{}, err
}
return track, nil
}
func (s *Store) GetProviderCache(ctx context.Context, providerName, itemType, itemID, market string) (provider.CacheEntry, bool, error) {
var entry provider.CacheEntry
err := s.pool.QueryRow(ctx, `
select provider, item_type, item_id, market, payload, fetched_at, expires_at, coalesce(last_error, '')
from provider_cache
where provider = $1 and item_type = $2 and item_id = $3 and market = $4`,
providerName, itemType, itemID, market,
).Scan(&entry.Provider, &entry.ItemType, &entry.ItemID, &entry.Market, &entry.Payload, &entry.FetchedAt, &entry.ExpiresAt, &entry.LastError)
if errors.Is(err, pgx.ErrNoRows) {
return provider.CacheEntry{}, false, nil
}
if err != nil {
return provider.CacheEntry{}, false, err
}
return entry, true, nil
}
func (s *Store) UpsertProviderCache(ctx context.Context, entry provider.CacheEntry) error {
_, err := s.pool.Exec(ctx, `
insert into provider_cache (provider, item_type, item_id, market, payload, fetched_at, expires_at, last_error)
values ($1, $2, $3, $4, $5::jsonb, $6, $7, nullif($8, ''))
on conflict (provider, item_type, item_id, market) do update set
payload = excluded.payload,
fetched_at = excluded.fetched_at,
expires_at = excluded.expires_at,
last_error = excluded.last_error`,
entry.Provider,
entry.ItemType,
entry.ItemID,
entry.Market,
emptyObjectIfNil(entry.Payload),
entry.FetchedAt,
entry.ExpiresAt,
entry.LastError,
)
return err
}
func (s *Store) ProviderCacheStats(ctx context.Context) (provider.CacheStats, error) {
var stats provider.CacheStats
err := s.pool.QueryRow(ctx, `
select count(*)::bigint,
count(*) filter (where expires_at > now())::bigint,
count(*) filter (where expires_at <= now())::bigint
from provider_cache`,
).Scan(&stats.Entries, &stats.FreshEntries, &stats.StaleEntries)
return stats, err
}
func (s *Store) CreateImportJob(ctx context.Context, job provider.ImportJob) error {
warnings, err := json.Marshal(job.Warnings)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
insert into import_jobs (id, provider, source_type, source_value, market, status, imported_tracks, updated_tracks, skipped, warnings, started_at)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11)`,
job.ID, job.Provider, job.SourceType, job.SourceValue, job.Market, job.Status,
job.ImportedTracks, job.UpdatedTracks, job.Skipped, warnings, job.StartedAt,
)
return err
}
func (s *Store) FinishImportJob(ctx context.Context, job provider.ImportJob) error {
warnings, err := json.Marshal(job.Warnings)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
update import_jobs
set status = $2,
imported_tracks = $3,
updated_tracks = $4,
skipped = $5,
warnings = $6::jsonb,
finished_at = $7
where id = $1`,
job.ID, job.Status, job.ImportedTracks, job.UpdatedTracks, job.Skipped, warnings, job.FinishedAt,
)
return err
}
func (s *Store) UpsertTrackEnrichment(ctx context.Context, enrichment provider.TrackEnrichment) error {
_, err := s.pool.Exec(ctx, `
insert into track_enrichment (track_id, provider, musicbrainz_recording_id, musicbrainz_artist_id, isrc, payload, updated_at)
values ($1, $2, $3, $4, $5, $6::jsonb, $7)
on conflict (track_id, provider) do update set
musicbrainz_recording_id = excluded.musicbrainz_recording_id,
musicbrainz_artist_id = excluded.musicbrainz_artist_id,
isrc = excluded.isrc,
payload = excluded.payload,
updated_at = excluded.updated_at`,
enrichment.TrackID,
enrichment.Provider,
enrichment.MusicBrainzRecordingID,
enrichment.MusicBrainzArtistID,
enrichment.ISRC,
emptyObjectIfNil(enrichment.Payload),
enrichment.UpdatedAt,
)
return err
}
func emptyObjectIfNil(payload []byte) []byte {
if len(payload) == 0 {
return []byte(`{}`)
}
return payload
}
func (s *Store) listRecentInteractions(ctx context.Context) ([]recommendation.Interaction, error) {
rows, err := s.queries.ListRecentInteractions(ctx)
if err != nil {
return nil, err
}
interactions := make([]recommendation.Interaction, 0, len(rows))
for _, row := range rows {
interaction := recommendation.Interaction{
UserID: row.UserID,
TrackID: row.TrackID,
Type: recommendation.InteractionType(row.Type),
Weight: row.Weight,
OccurredAt: row.OccurredAt.Time,
CompletedMS: int(row.CompletedMs),
}
if len(row.Context) > 0 {
if err := json.Unmarshal(row.Context, &interaction.Context); err != nil {
return nil, err
}
}
interactions = append(interactions, interaction)
}
return interactions, nil
}
func unmarshalStringSlice(raw []byte, out *[]string) error {
if len(raw) == 0 {
*out = nil
return nil
}
return json.Unmarshal(raw, out)
}
func unmarshalStringMap(raw []byte, out *map[string]string) error {
if len(raw) == 0 || string(raw) == "null" {
*out = nil
return nil
}
return json.Unmarshal(raw, out)
}