Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ func Load() (*Config, error) {
v.SetDefault("vibes.max_tokens", 4000) // Enough for track list + explanations
v.SetDefault("vibes.timeout_seconds", 120) // 2 minutes
v.SetDefault("vibes.prompts_directory", "") // Falls back to metadata.ai.prompts_directory
v.SetDefault("vibes.min_match_confidence", 0.7) // Lower than playlist sync for more matches
v.SetDefault("vibes.min_match_confidence", 0.7) // Same as playlist sync default (ADR-0014)

// Metadata enrichment defaults
v.SetDefault("metadata.enabled", true)
Expand Down
38 changes: 34 additions & 4 deletions internal/services/playlist_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ func (s *PlaylistSyncService) Register(factory providers.Factory) {
// SPEC playlist-sync-navidrome REQ-PLSYNC-032 (remotePlaylistID stored on playlist entity),
// SPEC playlist-sync-navidrome REQ-PLSYNC-060 (SyncEvent audit logging)
func (s *PlaylistSyncService) SyncPlaylistToNavidrome(ctx context.Context, playlistID int) error {
return s.syncPlaylistToNavidrome(ctx, playlistID, nil)
}

// syncPlaylistToNavidrome performs the sync. libraryIndex may be nil (it is
// loaded on demand); callers syncing many playlists in one tick (issue #330)
// pass a shared index so the user's library is loaded once per tick instead
// of once per playlist.
func (s *PlaylistSyncService) syncPlaylistToNavidrome(ctx context.Context, playlistID int, libraryIndex *LibraryIndex) error {
startTime := time.Now()

s.logger.Info("starting playlist sync to Navidrome",
Expand Down Expand Up @@ -187,11 +195,17 @@ func (s *PlaylistSyncService) SyncPlaylistToNavidrome(ctx context.Context, playl
"playlist_id", playlistID,
"source_track_count", len(sourceTracks))

matchResults, err := s.trackMatcher.MatchTracks(ctx, u.ID, sourceTracks)
if err != nil {
return s.handleSyncError(ctx, pl, u, fmt.Errorf("failed to match tracks: %w", err))
// Load the library index on demand if the caller didn't supply a shared
// one (or supplied one built for a different user).
if libraryIndex == nil || libraryIndex.UserID != u.ID {
libraryIndex, err = s.trackMatcher.LoadLibraryIndex(ctx, u.ID)
if err != nil {
return s.handleSyncError(ctx, pl, u, fmt.Errorf("failed to load library index: %w", err))
}
}

matchResults := s.trackMatcher.MatchTracksWithIndex(libraryIndex, sourceTracks)

// Filter to only matched tracks (we can only add tracks that exist in Navidrome)
var matchedTracks []providers.Track
matchedCount := 0
Expand Down Expand Up @@ -432,10 +446,26 @@ func (s *PlaylistSyncService) SyncAllEnabledPlaylists(ctx context.Context, userI
return nil
}

// Governing: ADR-0014; issue #330 — load the user's library once per sync
// tick and share the index across all playlists, instead of re-running the
// full library query per playlist.
//
// If the tick-level load fails, do NOT abort the tick: fall back to
// per-playlist loading (libraryIndex == nil) so each playlist still goes
// through handleSyncError — sync_status=error, SyncEvent audit logging,
// and UI notification per REQ-PLSYNC-060 — instead of failing invisibly.
libraryIndex, err := s.trackMatcher.LoadLibraryIndex(ctx, userID)
if err != nil {
s.logger.Error("failed to load shared library index for sync tick, falling back to per-playlist loading",
"user_id", userID,
"error", err)
libraryIndex = nil
}

var syncErrors []error
successCount := 0
for _, pl := range playlists {
if err := s.SyncPlaylistToNavidrome(ctx, pl.ID); err != nil {
if err := s.syncPlaylistToNavidrome(ctx, pl.ID, libraryIndex); err != nil {
s.logger.Error("failed to sync playlist",
"user_id", userID,
"playlist_id", pl.ID,
Expand Down
92 changes: 92 additions & 0 deletions internal/services/playlist_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ package services_test

import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync/atomic"
"testing"
"time"

"fmt"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"

"spotter/ent"
"spotter/ent/enttest"
"spotter/ent/playlist"
"spotter/ent/syncevent"
user_ent "spotter/ent/user"
"spotter/internal/config"
"spotter/internal/events"
Expand Down Expand Up @@ -715,3 +722,88 @@ func TestPlaylistSyncService_RebuildPlaylistSync_NoExistingNavidromeID(t *testin
require.NoError(t, err)
assert.Equal(t, "nav-playlist-123", updatedPl.NavidromePlaylistID)
}

// libraryQueryFailingDriver wraps an Ent driver and, when enabled, fails any
// read query against the "tracks" table (the library query issued by
// TrackMatcher.LoadLibraryIndex) while letting every other statement through,
// so playlist updates and SyncEvent writes still succeed.
type libraryQueryFailingDriver struct {
dialect.Driver
fail atomic.Bool
failedQueries atomic.Int64
}

func (d *libraryQueryFailingDriver) Query(ctx context.Context, query string, args, v any) error {
if d.fail.Load() && (strings.Contains(query, "`tracks`") || strings.Contains(query, `"tracks"`)) {
d.failedQueries.Add(1)
return errors.New("injected library query failure")
}
return d.Driver.Query(ctx, query, args, v)
}

// Governing: SPEC playlist-sync-navidrome REQ-PLSYNC-060 (SyncEvent audit logging on failure)
// Issue #330 / PR review follow-up: when the tick-level LoadLibraryIndex in
// SyncAllEnabledPlaylists fails, the tick must NOT abort before any playlist
// gets error handling. It falls back to per-playlist loading so each due
// playlist still reaches handleSyncError: sync_status=error, a
// playlist_sync_failed SyncEvent, and a UI notification.
func TestPlaylistSyncService_SyncAllEnabledPlaylists_LibraryIndexLoadFailure(t *testing.T) {
ctx := context.Background()

drv, err := entsql.Open("sqlite3", "file:libfailtick?mode=memory&cache=shared&_fk=1")
require.NoError(t, err)
failing := &libraryQueryFailingDriver{Driver: drv}
client := ent.NewClient(ent.Driver(failing))
t.Cleanup(func() { client.Close() })
require.NoError(t, client.Schema.Create(ctx))

logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := &config.Config{}
cfg.PlaylistSync.MinMatchConfidence = 0.7
bus := events.NewBus()
svc := services.NewPlaylistSyncService(client, cfg, logger, bus)
svc.Register(mockPlaylistSyncerFactory(&mockPlaylistSyncer{
providerType: providers.TypeNavidrome,
syncedID: "nav-playlist-tickfail",
}))

user := createTestUserWithNavidromeAuth(t, client)
pl1 := createTestPlaylistForSync(t, client, user, "spotify", true)
createTestPlaylistTracksForSync(t, client, pl1)
pl2 := createTestPlaylistForSync(t, client, user, "spotify", true)
createTestPlaylistTracksForSync(t, client, pl2)

failing.fail.Store(true)
err = svc.SyncAllEnabledPlaylists(ctx, user.ID)
failing.fail.Store(false)

// The tick itself reports the per-playlist failures, not the index error.
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to sync 2 playlists")

// Proof the fallback ran: one failed tick-level load, then one failed
// per-playlist load for each of the two playlists (1 + 2 = 3).
assert.Equal(t, int64(3), failing.failedQueries.Load(),
"expected tick-level load plus one per-playlist fallback load per playlist")

// REQ-PLSYNC-060: every due playlist ends in sync_status=error with the
// library-index error recorded.
for _, plID := range []int{pl1.ID, pl2.ID} {
updated, getErr := client.Playlist.Get(ctx, plID)
require.NoError(t, getErr)
assert.Equal(t, playlist.SyncStatusError, updated.SyncStatus,
"playlist %d must end with sync_status=error", plID)
assert.Contains(t, updated.SyncError, "failed to load library index",
"playlist %d must record the library index error", plID)
}

// ...and every due playlist gets a playlist_sync_failed SyncEvent.
failedEvents, err := client.SyncEvent.Query().
Where(syncevent.EventTypeEQ(syncevent.EventTypePlaylistSyncFailed)).
All(ctx)
require.NoError(t, err)
require.Len(t, failedEvents, 2)
combinedMetadata := failedEvents[0].Metadata + failedEvents[1].Metadata
assert.Contains(t, combinedMetadata, fmt.Sprintf(`"playlist_id":%d`, pl1.ID))
assert.Contains(t, combinedMetadata, fmt.Sprintf(`"playlist_id":%d`, pl2.ID))
}
Loading