diff --git a/README.md b/README.md index f988472..ad7593a 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,16 @@ export LAZULI_PASSWORD="your-app-password" ### Commands -| Command | Usage | -| :--- | :--- | +| Command | Usage | +| :------- | :------------------------------------------------------- | | `export` | Parse and merge Last.fm/Spotify exports into a JSON file | -| `import` | Import new records to Bluesky (auto-skips existing) | -| `sync` | Refresh the local cache with records from Bluesky | -| `stats` | Show database status and daily rate limit consumption | -| `failed` | List records that failed to import | -| `retry` | Attempt to re-import failed records | -| `dedupe` | Remove duplicate records from your Bluesky profile | -| `debug` | Dump raw records from Bluesky for troubleshooting | +| `import` | Import new records to Bluesky (auto-skips existing) | +| `sync` | Refresh the local cache with records from Bluesky | +| `stats` | Show database status and daily rate limit consumption | +| `failed` | List records that failed to import | +| `retry` | Attempt to re-import failed records | +| `dedupe` | Remove duplicate records from your Bluesky profile | +| `debug` | Dump raw records from Bluesky for troubleshooting | ### Advanced Options @@ -41,16 +41,15 @@ export LAZULI_PASSWORD="your-app-password" ## Environment Variables -| Variable | Description | -| ----------------- | -------------------------------------------- | -| `LAZULI_HANDLE` | Bluesky handle (e.g., `user.bsky.social`) | -| `LAZULI_PASSWORD` | Bluesky app password | -| `LAZULI_LASTFM` | Path to Last.fm CSV file | -| `LAZULI_SPOTIFY` | Path to Spotify JSON file/directory/zip | -| `LAZULI_MODE` | Import mode: `lastfm`, `spotify`, `combined` | -| `LAZULI_DRY_RUN` | Preview without publishing | -| `LAZULI_VERBOSE` | Enable verbose logging | -| `LAZULI_REVERSE` | Process records in reverse order | +| Variable | Description | +| ----------------- | ----------------------------------------- | +| `LAZULI_HANDLE` | Bluesky handle (e.g., `user.bsky.social`) | +| `LAZULI_PASSWORD` | Bluesky app password | +| `LAZULI_LASTFM` | Path to Last.fm CSV file | +| `LAZULI_SPOTIFY` | Path to Spotify JSON file/directory/zip | +| `LAZULI_DRY_RUN` | Preview without publishing | +| `LAZULI_VERBOSE` | Enable verbose logging | +| `LAZULI_REVERSE` | Process records in reverse order | ## Input Formats @@ -71,6 +70,7 @@ Lazuli is designed to work with your **Extended Streaming History** from Spotify The recommended way to use Spotify data is by passing the **ZIP archive** you receive from Spotify directly. Lazuli will automatically find and parse all streaming history files within it. Lazuli accepts: + - **ZIP archives** containing extended history (Recommended) - Directories containing `Streaming_History_Audio_*.json` files - Single `Streaming_History_Audio_*.json` files diff --git a/kway/merge.go b/kway/merge.go new file mode 100644 index 0000000..a04258d --- /dev/null +++ b/kway/merge.go @@ -0,0 +1,96 @@ +package kway + +import ( + "container/heap" + "time" +) + +// Mergeable defines the interface that types must implement to be used with the generic merge function. +type Mergeable[T any] interface { + Time() time.Time + IsDuplicate(other T, tol time.Duration) (isMatch bool, preferThis bool) +} + +// heapItem represents an item in the merge heap, tracking which source it came from +// and its position within that source. +type heapItem[T Mergeable[T]] struct { + Value T + SourceIdx int + ElementIdx int +} + +// mergeHeap implements a min-heap for mergeable items using the Compare method. +type mergeHeap[T Mergeable[T]] []heapItem[T] + +func (h mergeHeap[T]) Len() int { return len(h) } +func (h mergeHeap[T]) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h mergeHeap[T]) Less(i, j int) bool { return h[i].Value.Time().Before(h[j].Value.Time()) } +func (h *mergeHeap[T]) Push(x any) { *h = append(*h, x.(heapItem[T])) } +func (h *mergeHeap[T]) Pop() any { + old := *h + n := len(old) + item := old[n-1] + *h = old[0 : n-1] + return item +} + +// Merge performs a k-way merge of multiple sorted slices of mergeable items. +// It combines the sources while removing duplicates within the specified tolerance. +// The result is sorted according to the Compare method of the items. +func Merge[T Mergeable[T]](sources [][]T, tolerance time.Duration) []T { + h := &mergeHeap[T]{} + heap.Init(h) + + // Initialize heap with first item from each source + for i, src := range sources { + if len(src) > 0 { + heap.Push(h, heapItem[T]{Value: src[0], SourceIdx: i, ElementIdx: 0}) + } + } + + result := make([]T, 0) + window := make([]T, 0) + + // Process items from the heap + for h.Len() > 0 { + curr := heap.Pop(h).(heapItem[T]) + + // Push the next item from the same source + if curr.ElementIdx+1 < len(sources[curr.SourceIdx]) { + heap.Push(h, heapItem[T]{ + Value: sources[curr.SourceIdx][curr.ElementIdx+1], + SourceIdx: curr.SourceIdx, + ElementIdx: curr.ElementIdx + 1, + }) + } + + currItem := curr.Value + + // Evict items from window that are now older than tolerance relative to currItem + for len(window) > 0 && currItem.Time().Sub(window[0].Time()) > tolerance { + result = append(result, window[0]) + window = window[1:] + } + + // Check for duplicates in window + found := false + for i, existing := range window { + if isMatch, preferCurr := currItem.IsDuplicate(existing, tolerance); isMatch { + if preferCurr { + window[i] = currItem + } + + found = true + break + } + } + + if !found { + window = append(window, currItem) + } + } + + // Flush remaining window + result = append(result, window...) + return result +} diff --git a/kway/merge_test.go b/kway/merge_test.go new file mode 100644 index 0000000..9614351 --- /dev/null +++ b/kway/merge_test.go @@ -0,0 +1,122 @@ +package kway + +import ( + "testing" + "time" +) + +// TestPlayRecord is a simple implementation of Mergeable for testing +type TestPlayRecord struct { + TrackName string + Artist string + time time.Time + Source string // "lastfm" or "spotify" + HasMBID bool +} + +func (r TestPlayRecord) IsDuplicate(other TestPlayRecord, tolerance time.Duration) (bool, bool) { + return r.SameAs(other, tolerance), r.BetterThan(other) +} + +func (r TestPlayRecord) SameAs(other TestPlayRecord, tolerance time.Duration) bool { + if r.TrackName != other.TrackName { + return false + } + if r.Artist != other.Artist { + return false + } + + diff := r.time.Sub(other.time) + if diff < 0 { + diff = -diff + } + return diff <= tolerance +} + +func (r TestPlayRecord) BetterThan(other TestPlayRecord) bool { + if r.Source == "lastfm" && other.Source != "lastfm" { + return true + } + if r.Source != "lastfm" && other.Source == "lastfm" { + return false + } + return r.HasMBID && !other.HasMBID +} + +func (r TestPlayRecord) Time() time.Time { + return r.time +} + +func TestMerge(t *testing.T) { + baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + + // Test case where duplicates are very close in time and should be merged + lastfmRecords := []TestPlayRecord{ + {TrackName: "Song A", Artist: "Artist", time: baseTime, Source: "lastfm"}, + {TrackName: "Song D", Artist: "Artist", time: baseTime.Add(3 * time.Hour), Source: "lastfm"}, + } + + spotifyRecords := []TestPlayRecord{ + {TrackName: "Song A", Artist: "Artist", time: baseTime.Add(3 * time.Minute), Source: "spotify"}, // Duplicate within tolerance + {TrackName: "Song B", Artist: "Artist", time: baseTime.Add(time.Hour), Source: "spotify"}, + {TrackName: "Song C", Artist: "Artist", time: baseTime.Add(2 * time.Hour), Source: "spotify"}, + } + + result := Merge([][]TestPlayRecord{lastfmRecords, spotifyRecords}, 10*time.Minute) + + if len(result) != 4 { + t.Errorf("Expected 4 results, got %d", len(result)) + } + + // Check order - should be sorted by time + expectedOrder := []string{"Song A", "Song B", "Song C", "Song D"} + for i, expected := range expectedOrder { + if i >= len(result) { + t.Errorf("Missing result at position %d", i) + break + } + if result[i].TrackName != expected { + t.Errorf("Result %d should be %s, got %s", i, expected, result[i].TrackName) + } + } + + // Check that LastFM version is preferred for duplicate Song A + if result[0].Source != "lastfm" { + t.Errorf("Duplicate Song A should be from lastfm, got %s", result[0].Source) + } +} + +func TestMergeExactDuplicate(t *testing.T) { + baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) + + lastfmRecords := []TestPlayRecord{ + {TrackName: "Song A", Artist: "Artist", time: baseTime, Source: "lastfm"}, + } + + spotifyRecords := []TestPlayRecord{ + {TrackName: "Song A", Artist: "Artist", time: baseTime, Source: "spotify"}, // Exact duplicate + } + + result := Merge([][]TestPlayRecord{lastfmRecords, spotifyRecords}, 0) + + if len(result) != 1 { + t.Errorf("Expected 1 result, got %d", len(result)) + } + + // Check that LastFM version is preferred + if result[0].Source != "lastfm" { + t.Errorf("Duplicate should be from lastfm, got %s", result[0].Source) + } +} + +func TestMergeEmptySources(t *testing.T) { + result := Merge([][]TestPlayRecord{}, 0) + if len(result) != 0 { + t.Errorf("Expected empty result, got %d items", len(result)) + } + + result = Merge([][]TestPlayRecord{{}, {}}, 0) + if len(result) != 0 { + t.Errorf("Expected empty result from empty sources, got %d items", len(result)) + } +} diff --git a/main.go b/main.go index ec210ee..20776cf 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "time" "tangled.org/karitham.dev/lazuli/cache" + "tangled.org/karitham.dev/lazuli/kway" "tangled.org/karitham.dev/lazuli/sources/lastfm" "tangled.org/karitham.dev/lazuli/sources/spotify" "tangled.org/karitham.dev/lazuli/sync" @@ -486,8 +487,13 @@ func (a *App) runExport(ctx context.Context, cmd *cli.Command) error { } a.log.Info("Loaded Spotify records", slog.Int("count", len(spotifyRecords))) - mergedRecords, stats := sync.MergeRecords(lastfmRecords, spotifyRecords, tolerance) - a.log.Info("Merged records", slog.Int("merged_total", stats.MergedTotal), slog.Int("duplicates_removed", stats.DuplicatesRemoved)) + mergedRecords := kway.Merge([][]sync.PlayRecord{lastfmRecords, spotifyRecords}, tolerance) + + a.log.Info( + "Merged records", + slog.Int("merged_total", len(mergedRecords)), + slog.Int("duplicates_removed", len(lastfmRecords)+len(spotifyRecords)-len(mergedRecords)), + ) if reverse { slices.Reverse(mergedRecords) @@ -506,7 +512,6 @@ func (a *App) runImport(ctx context.Context, cmd *cli.Command) error { lastfmPath := cmd.String("lastfm") spotifyPath := cmd.String("spotify") - modeStr := cmd.String("mode") dryRun := cmd.Bool("dry-run") reverse := cmd.Bool("reverse") fresh := cmd.Bool("fresh") @@ -522,22 +527,9 @@ func (a *App) runImport(ctx context.Context, cmd *cli.Command) error { } } - var mode sync.ImportMode - switch modeStr { - case "lastfm": - mode = sync.ImportModeLastFM - case "spotify": - mode = sync.ImportModeSpotify - case "combined": - mode = sync.ImportModeCombined - default: - return fmt.Errorf("invalid mode: %s (must be lastfm, spotify, or combined)", modeStr) - } - records, totalCount, err := sync.LoadRecordsForImport(ctx, sync.ImportOptions{ LastFMPath: lastfmPath, SpotifyPath: spotifyPath, - Mode: mode, Tolerance: tolerance, LastFMParser: lastfm.Parser{}, SpotifyParser: spotify.Parser{}, @@ -896,12 +888,6 @@ var exportFlags = []cli.Flag{ var importFlags = []cli.Flag{ lastfmFlag, spotifyFlag, - &cli.StringFlag{ - Name: "mode", - Usage: "Import mode: lastfm, spotify, combined (default: combined)", - Value: "combined", - Sources: cli.EnvVars("LAZULI_MODE"), - }, &cli.BoolFlag{ Name: "dry-run", Usage: "Preview without publishing", diff --git a/sync/config.go b/sync/config.go index dfe98e9..c992f79 100644 --- a/sync/config.go +++ b/sync/config.go @@ -18,15 +18,6 @@ const ( var BaseRetryDelay = 2 * time.Second -type ImportMode string - -const ( - ImportModeLastFM ImportMode = "lastfm" - ImportModeSpotify ImportMode = "spotify" - ImportModeCombined ImportMode = "combined" - ImportModeSync ImportMode = "sync" -) - type Config struct { RecordType string `json:"recordType"` ClientAgent string `json:"clientAgent"` @@ -35,7 +26,6 @@ type Config struct { CacheTTL time.Duration `json:"cacheTTL"` CacheVersion int `json:"cacheVersion"` SlingshotResolverURL string `json:"slingshotResolverURL"` - ImportMode ImportMode `json:"importMode"` UserAgent string `json:"userAgent"` } @@ -47,7 +37,6 @@ var DefaultConfig = Config{ CacheTTL: CacheTTL, CacheVersion: CacheVersion, SlingshotResolverURL: SlingshotResolverURL, - ImportMode: ImportModeLastFM, } type PublishResult struct { diff --git a/sync/import.go b/sync/import.go index d7691e3..26c1b36 100644 --- a/sync/import.go +++ b/sync/import.go @@ -9,6 +9,8 @@ import ( "os" "strings" "time" + + "tangled.org/karitham.dev/lazuli/kway" ) type Parser interface { @@ -50,7 +52,6 @@ func ParseInput(ctx context.Context, path string, parser Parser) ([]PlayRecord, type ImportOptions struct { LastFMPath string SpotifyPath string - Mode ImportMode Tolerance time.Duration LastFMParser Parser @@ -61,14 +62,14 @@ func LoadRecordsForImport(ctx context.Context, opts ImportOptions) ([]PlayRecord var lastfmRecords, spotifyRecords []PlayRecord var err error - if opts.Mode == ImportModeLastFM || opts.Mode == ImportModeCombined { + if opts.LastFMPath != "" { lastfmRecords, err = ParseInput(ctx, opts.LastFMPath, opts.LastFMParser) if err != nil { return nil, 0, fmt.Errorf("parse lastfm: %w", err) } } - if opts.Mode == ImportModeSpotify || opts.Mode == ImportModeCombined { + if opts.SpotifyPath != "" { spotifyRecords, err = ParseInput(ctx, opts.SpotifyPath, opts.SpotifyParser) if err != nil { return nil, 0, fmt.Errorf("parse spotify: %w", err) @@ -77,15 +78,7 @@ func LoadRecordsForImport(ctx context.Context, opts ImportOptions) ([]PlayRecord totalInput := len(lastfmRecords) + len(spotifyRecords) - var mergedRecords []PlayRecord - switch opts.Mode { - case ImportModeCombined: - mergedRecords, _ = MergeRecords(lastfmRecords, spotifyRecords, opts.Tolerance) - case ImportModeLastFM: - mergedRecords = lastfmRecords - default: - mergedRecords = spotifyRecords - } + mergedRecords := kway.Merge([][]PlayRecord{lastfmRecords, spotifyRecords}, opts.Tolerance) return mergedRecords, totalInput, nil } diff --git a/sync/import_test.go b/sync/import_test.go index c92ef4f..ed2b858 100644 --- a/sync/import_test.go +++ b/sync/import_test.go @@ -113,7 +113,6 @@ func TestImportE2E(t *testing.T) { // 3. Load Records opts := sync.ImportOptions{ - Mode: sync.ImportModeCombined, Tolerance: 10 * time.Second, LastFMParser: &mockParser{records: []sync.PlayRecord{rec1}}, SpotifyParser: &mockParser{records: []sync.PlayRecord{rec2, rec3}}, diff --git a/sync/record.go b/sync/record.go index ddf48b2..d809d80 100644 --- a/sync/record.go +++ b/sync/record.go @@ -2,7 +2,6 @@ package sync import ( "fmt" - "sort" "strings" "time" "unicode" @@ -71,15 +70,15 @@ func (r PlayRecord) ArtistName() string { return "Unknown Artist" } -func (r PlayRecord) NormalizedArtist() string { +func (r PlayRecord) normalizeArtist() string { return normalizeString(r.ArtistName()) } -func (r PlayRecord) NormalizedTrack() string { +func (r PlayRecord) normalizeTrack() string { return normalizeString(r.TrackName) } -func (r PlayRecord) HasMBID() bool { +func (r PlayRecord) hasMBID() bool { for _, a := range r.Artists { if a.ArtistMbId != "" { return true @@ -88,38 +87,52 @@ func (r PlayRecord) HasMBID() bool { return r.RecordingMbId != "" } -func (r PlayRecord) IsLastFM() bool { +func (r PlayRecord) isLastFM() bool { return r.MusicServiceBaseDomain == MusicServiceLastFM } func (r PlayRecord) BetterThan(other PlayRecord) bool { - if r.IsLastFM() && !other.IsLastFM() { + if r.isLastFM() && !other.isLastFM() { return true } - if !r.IsLastFM() && other.IsLastFM() { + if !r.isLastFM() && other.isLastFM() { return false } // Both same source, prefer the one with MBID - if r.HasMBID() && !other.HasMBID() { + if r.hasMBID() && !other.hasMBID() { return true } return false } +func (r PlayRecord) IsDuplicate(other PlayRecord, tolerance time.Duration) (bool, bool) { + return r.sameAs(other, tolerance), r.BetterThan(other) +} + +func (r PlayRecord) sameAs(other PlayRecord, tolerance time.Duration) bool { + if r.normalizeTrack() != other.normalizeTrack() { + return false + } + if r.normalizeArtist() != other.normalizeArtist() { + return false + } + + diff := r.PlayedTime.Sub(other.PlayedTime.Time) + if diff < 0 { + diff = -diff + } + return diff <= tolerance +} + +func (r PlayRecord) Time() time.Time { + return r.PlayedTime.Time +} + type PlayRecordArtist struct { ArtistName string `json:"artistName"` ArtistMbId string `json:"artistMbId,omitempty"` } -type MergeStats struct { - LastFMTotal int `json:"lastfmTotal"` - SpotifyTotal int `json:"spotifyTotal"` - DuplicatesRemoved int `json:"duplicatesRemoved"` - LastFMUnique int `json:"lastfmUnique"` - SpotifyUnique int `json:"spotifyUnique"` - MergedTotal int `json:"mergedTotal"` -} - const ( MusicServiceLastFM = "last.fm" MusicServiceSpotify = "spotify.com" @@ -151,104 +164,6 @@ func CreateRecordKeys(records []PlayRecord) []string { return keys } -func MergeRecords(lastfm, spotify []PlayRecord, tolerance time.Duration) ([]PlayRecord, MergeStats) { - stats := MergeStats{ - LastFMTotal: len(lastfm), - SpotifyTotal: len(spotify), - } - - // key is normalizedTrack|normalizedArtist|bucket - recordsMap := make(map[string]PlayRecord) - - process := func(records []PlayRecord) { - for _, rec := range records { - track := rec.NormalizedTrack() - artist := rec.NormalizedArtist() - timestamp := rec.PlayedTime.Time - - found := false - if tolerance > 0 { - bucket := timestamp.Unix() / int64(tolerance.Seconds()) - // Check current and adjacent buckets - for b := bucket - 1; b <= bucket+1; b++ { - key := fmt.Sprintf("%s|%s|%d", track, artist, b) - if existing, ok := recordsMap[key]; ok { - diff := timestamp.Sub(existing.PlayedTime.Time) - if diff < 0 { - diff = -diff - } - if diff <= tolerance { - if rec.BetterThan(existing) { - recordsMap[key] = rec - } - stats.DuplicatesRemoved++ - found = true - break - } - } - } - if !found { - key := fmt.Sprintf("%s|%s|%d", track, artist, bucket) - recordsMap[key] = rec - if rec.IsLastFM() { - stats.LastFMUnique++ - } else { - stats.SpotifyUnique++ - } - } - } else { - key := fmt.Sprintf("%s|%s|%s", track, artist, timestamp.Format(time.RFC3339)) - if existing, ok := recordsMap[key]; ok { - if rec.BetterThan(existing) { - recordsMap[key] = rec - } - stats.DuplicatesRemoved++ - } else { - recordsMap[key] = rec - if rec.IsLastFM() { - stats.LastFMUnique++ - } else { - stats.SpotifyUnique++ - } - } - } - } - } - - process(lastfm) - process(spotify) - - result := make([]PlayRecord, 0, len(recordsMap)) - for _, rec := range recordsMap { - result = append(result, rec) - } - - sort.Slice(result, func(i, j int) bool { - if !result[i].PlayedTime.Equal(result[j].PlayedTime.Time) { - return result[i].PlayedTime.Before(result[j].PlayedTime.Time) - } - return result[i].TrackName < result[j].TrackName - }) - - stats.MergedTotal = len(result) - return result, stats -} - -func (r PlayRecord) SameAs(other PlayRecord, tolerance time.Duration) bool { - if r.NormalizedTrack() != other.NormalizedTrack() { - return false - } - if r.NormalizedArtist() != other.NormalizedArtist() { - return false - } - - diff := r.PlayedTime.Sub(other.PlayedTime.Time) - if diff < 0 { - diff = -diff - } - return diff <= tolerance -} - func FilterNew(records []PlayRecord, existing []ExistingRecord, processed map[string]bool) []PlayRecord { existingKeys := make(map[string]bool) for _, rec := range existing { diff --git a/sync/record_test.go b/sync/record_test.go index 2650158..f7b6bd5 100644 --- a/sync/record_test.go +++ b/sync/record_test.go @@ -3,6 +3,8 @@ package sync import ( "testing" "time" + + "tangled.org/karitham.dev/lazuli/kway" ) func TestCreateRecordKey(t *testing.T) { @@ -183,672 +185,255 @@ func TestSelectBetterRecord(t *testing.T) { } } -func TestMergeRecords(t *testing.T) { +func TestMergeRecordsComprehensive(t *testing.T) { baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) tests := []struct { - name string - lastfm []PlayRecord - spotify []PlayRecord - tolerance time.Duration - expectedLen int - expectedDuplicates int - expectedLastFMUnique int - expectedSpotifyUnique int + name string + lastfm []PlayRecord + spotify []PlayRecord + tolerance time.Duration + expectedLen int + expectedMergedTotal int + expectedFirstTrack string + expectedOrder []string // track names in expected order }{ { - name: "empty input both", - lastfm: []PlayRecord{}, - spotify: []PlayRecord{}, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 0, - }, - { - name: "empty input lastfm only", - lastfm: []PlayRecord{}, - spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedLastFMUnique: 0, - expectedSpotifyUnique: 1, - }, - { - name: "empty input spotify only", - lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - }, - spotify: []PlayRecord{}, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedLastFMUnique: 1, - expectedSpotifyUnique: 0, + name: "both slices empty", + lastfm: []PlayRecord{}, + spotify: []PlayRecord{}, + tolerance: 0, + expectedLen: 0, + expectedMergedTotal: 0, }, { - name: "same timestamp merged", + name: "only lastfm records", lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, - spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedDuplicates: 1, - expectedLastFMUnique: 1, - expectedSpotifyUnique: 0, + spotify: []PlayRecord{}, + tolerance: 0, + expectedLen: 2, + expectedMergedTotal: 2, + expectedOrder: []string{"Song A", "Song B"}, }, { - name: "different songs not merged", - lastfm: []PlayRecord{ - { - TrackName: "Song A", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - }, + name: "only spotify records", + lastfm: []PlayRecord{}, spotify: []PlayRecord{ - { - TrackName: "Song B", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 2, - expectedDuplicates: 0, - }, - { - name: "different artists not merged", - lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song X", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "Song Y", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 2, - expectedDuplicates: 0, + tolerance: 0, + expectedLen: 2, + expectedMergedTotal: 2, + expectedOrder: []string{"Song X", "Song Y"}, }, { - name: "zero tolerance same timestamp merged", + name: "zero tolerance no duplicates", lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: 0, - expectedLen: 1, - expectedDuplicates: 1, - }, - { - name: "partial overlap merged", - lastfm: []PlayRecord{ - { - TrackName: "Song A", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - { - TrackName: "Song B", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(1 * time.Hour)}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{ - { - TrackName: "Song A", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - { - TrackName: "Song C", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Hour)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 3, - expectedDuplicates: 1, + tolerance: 0, + expectedLen: 2, + expectedMergedTotal: 2, }, { - name: "case insensitive matching", + name: "zero tolerance exact duplicate", lastfm: []PlayRecord{ - { - TrackName: "SONG", - Artists: []PlayRecordArtist{{ArtistName: "ARTIST"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - { - TrackName: "song", - Artists: []PlayRecordArtist{{ArtistName: "artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedDuplicates: 1, - }, - { - name: "punctuation insensitive matching", - lastfm: []PlayRecord{ - { - TrackName: "Don't Stop!", - Artists: []PlayRecordArtist{{ArtistName: "Queen"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{ - { - TrackName: "Dont Stop", - Artists: []PlayRecordArtist{{ArtistName: "Queen"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedDuplicates: 1, + tolerance: 0, + expectedLen: 1, + expectedMergedTotal: 1, + expectedFirstTrack: "Same Song", }, { - name: "many records no duplicates", + name: "within tolerance duplicate", lastfm: []PlayRecord{ - {TrackName: "Song 1", Artists: []PlayRecordArtist{{ArtistName: "Artist 1"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Song 2", Artists: []PlayRecordArtist{{ArtistName: "Artist 2"}}, PlayedTime: Timestamp{Time: baseTime.Add(1 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Song 3", Artists: []PlayRecordArtist{{ArtistName: "Artist 3"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "Song 4", Artists: []PlayRecordArtist{{ArtistName: "Artist 4"}}, PlayedTime: Timestamp{Time: baseTime.Add(3 * time.Minute)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "Song 5", Artists: []PlayRecordArtist{{ArtistName: "Artist 5"}}, PlayedTime: Timestamp{Time: baseTime.Add(4 * time.Minute)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "Song 6", Artists: []PlayRecordArtist{{ArtistName: "Artist 6"}}, PlayedTime: Timestamp{Time: baseTime.Add(5 * time.Minute)}, MusicServiceBaseDomain: MusicServiceSpotify}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 6, - expectedDuplicates: 0, - expectedLastFMUnique: 3, - expectedSpotifyUnique: 3, - }, - { - name: "zero tolerance 1 second apart not merged", - lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(1 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: 0, - expectedLen: 2, - expectedDuplicates: 0, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, + expectedFirstTrack: "Song", }, { - name: "five minute tolerance 31 seconds apart merged", + name: "outside tolerance no duplicate", lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 1, - expectedDuplicates: 1, - }, - { - name: "one minute tolerance 30 seconds apart merged", - lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(60 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: time.Minute, - expectedLen: 1, - expectedDuplicates: 1, + tolerance: 30 * time.Second, + expectedLen: 2, + expectedMergedTotal: 2, }, { - name: "30 second tolerance 31 seconds apart not merged", + name: "time bucket boundary exact", lastfm: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - { - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - }, - tolerance: 30 * time.Second, - expectedLen: 2, - expectedDuplicates: 0, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + }, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, }, { - name: "many consecutive deduplications", + name: "time bucket boundary crossed", lastfm: []PlayRecord{ - {TrackName: "Written In Stone - KAYTRANADA Remix", Artists: []PlayRecordArtist{{ArtistName: "Robert Glasper"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 0, 55, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Gum In My Mouth", Artists: []PlayRecordArtist{{ArtistName: "Butcher Brown", ArtistMbId: "c0937ba4-6869-456b-afd0-10335ae50245"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 3, 58, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "ab00c566-3038-4e5f-a5e4-264a9baf542c", RecordingMbId: "5d913a35-fbee-403d-8771-4a7e11013889"}, - {TrackName: "Welcome to the World of the Plastic Beach", Artists: []PlayRecordArtist{{ArtistName: "Gorillaz", ArtistMbId: "e21857d5-3256-4547-afb3-4b6ded592596"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 6, 39, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "28ddf022-0a8a-4ecd-bf18-d80af26c3aff", RecordingMbId: "4d3de31d-d25f-3abf-9bd1-8e38b62dd37e"}, - {TrackName: "Already There", Artists: []PlayRecordArtist{{ArtistName: "Taylor McFerrin", ArtistMbId: "7abc2c7b-f47f-4d94-b75f-8cb4ca926899"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 10, 14, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "2113fbb8-c2b3-4aaf-9b33-92845940f82d"}, - {TrackName: "Here We Go Again", Artists: []PlayRecordArtist{{ArtistName: "Buckshot LeFonque", ArtistMbId: "c1085917-1048-4f49-91d8-f7f7625e3545"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 13, 15, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "02437aee-0d9a-3134-806e-c27d799203d2", RecordingMbId: "0d5ee57b-21cb-329d-872c-43569e13c151"}, - {TrackName: "Life's Work", Artists: []PlayRecordArtist{{ArtistName: "LooPRaT"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 15, 41, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "6221803a-4228-42e2-9191-30723b1faa0e", RecordingMbId: "63d324cd-a34b-455b-a8d6-1572766854b9"}, - {TrackName: "Chaser", Artists: []PlayRecordArtist{{ArtistName: "Electric Wire Hustle", ArtistMbId: "77fc277e-f79d-40b1-b5c8-92702c86b760"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 19, 13, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "1e42946f-5a6b-3177-9fab-73a8b3377486", RecordingMbId: "3d356387-45fc-4ffa-8a34-76aae01f6de7"}, - {TrackName: "Burn & Rise", Artists: []PlayRecordArtist{{ArtistName: "Yazmin Lacey", ArtistMbId: "451919df-764c-40cf-9aa2-fcafe599d869"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 23, 58, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "bdba3763-8f61-4598-8a20-e78424dc744b", RecordingMbId: "4d7b93fc-3651-4396-88b9-7bc98eb35e09"}, - {TrackName: "I Want You", Artists: []PlayRecordArtist{{ArtistName: "Robert Glasper", ArtistMbId: "6e8f82ea-9e6d-4fdd-9b32-32feef13186b"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 26, 38, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "092c640b-c864-4e4b-abab-e44f1c0fe681", RecordingMbId: "369d7f97-1a10-4b8f-b867-05f5b64b5edf"}, - {TrackName: "Go On", Artists: []PlayRecordArtist{{ArtistName: "Snoop Dogg", ArtistMbId: "f90e8b26-9e52-4669-a5c9-e28529c47894"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 21, 8, 2, 43, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "e228272d-9b8c-4993-b2ee-ae9a0dbfe816"}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "Lil Girl feat. Fatima", Artists: []PlayRecordArtist{{ArtistName: "Shafiq Husayn"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 0, 55, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 230200}, - {TrackName: "Written In Stone - KAYTRANADA Remix", Artists: []PlayRecordArtist{{ArtistName: "Robert Glasper"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 3, 58, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 182452}, - {TrackName: "Gum In My Mouth", Artists: []PlayRecordArtist{{ArtistName: "Butcher Brown"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 6, 39, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 159853}, - {TrackName: "Welcome to the World of the Plastic Beach (feat. Snoop Dogg and Hypnotic Brass Ensemble)", Artists: []PlayRecordArtist{{ArtistName: "Gorillaz"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 10, 14, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 215506}, - {TrackName: "Already There", Artists: []PlayRecordArtist{{ArtistName: "Taylor McFerrin"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 13, 15, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 179989}, - {TrackName: "Here We Go Again", Artists: []PlayRecordArtist{{ArtistName: "Buckshot LeFonque"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 15, 41, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 146600}, - {TrackName: "Life's Work", Artists: []PlayRecordArtist{{ArtistName: "LOOPRAT"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 19, 13, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 210280}, - {TrackName: "Chaser", Artists: []PlayRecordArtist{{ArtistName: "Electric Wire Hustle"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 23, 58, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 285493}, - {TrackName: "Burn & Rise", Artists: []PlayRecordArtist{{ArtistName: "Yazmin Lacey"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 26, 38, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 158685}, - {TrackName: "I Want You", Artists: []PlayRecordArtist{{ArtistName: "Robert Glasper"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 20, 18, 29, 2, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 141024}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 12, - expectedDuplicates: 8, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + }, + tolerance: 30 * time.Second, + expectedLen: 2, }, { - name: "mixed sources and edge cases", + name: "lastfm priority over spotify", lastfm: []PlayRecord{ - {TrackName: "Roi du nord (Freestyle)", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi", ArtistMbId: "9b205338-4565-4b14-8e4b-94d1abfedfbc"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 37, 30, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "abc"}, - {TrackName: "Rap conscient", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi", ArtistMbId: "9b205338-4565-4b14-8e4b-94d1abfedfbc"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 39, 39, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Saint jack", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi", ArtistMbId: "9b205338-4565-4b14-8e4b-94d1abfedfbc"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 41, 57, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "(I'm a Kadaver) Alakazam", Artists: []PlayRecordArtist{{ArtistName: "Psychedelic Porn Crumpets", ArtistMbId: "11d94660-1963-4020-8762-4c5907e2ea48"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 3, 57, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, RecordingMbId: "xyz"}, - {TrackName: "Teddy Picker", Artists: []PlayRecordArtist{{ArtistName: "Arctic Monkeys", ArtistMbId: "ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 7, 43, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM, ReleaseMbId: "def"}, - {TrackName: "New Gold (feat. Tame Impala and Bootie Brown)", Artists: []PlayRecordArtist{{ArtistName: "Gorillaz", ArtistMbId: "e21857d5-3256-4547-afb3-4b6ded592596"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 10, 24, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "The Pretender", Artists: []PlayRecordArtist{{ArtistName: "Foo Fighters", ArtistMbId: "67f66c07-6e61-4026-ade5-7e782fad3a5d"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 13, 57, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Make It Wit Chu", Artists: []PlayRecordArtist{{ArtistName: "Queens of the Stone Age", ArtistMbId: "7dc8f5bd-9d0b-4087-9f73-dc164950bbd8"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 18, 23, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Broken Boy", Artists: []PlayRecordArtist{{ArtistName: "Cage the Elephant", ArtistMbId: "b41b38d4-ef3e-4f37-8c75-cfe9af999696"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 23, 11, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Take Me Out", Artists: []PlayRecordArtist{{ArtistName: "Franz Ferdinand", ArtistMbId: "aa7a2827-f74b-473c-bd79-03d065835cf7"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 25, 52, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "Roi du nord (Freestyle)", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 39, 37, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 180000}, - {TrackName: "Rap conscient", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 41, 59, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 200000}, - {TrackName: "Saint jack", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 43, 30, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 190000}, - {TrackName: "Ford (Freestyle)", Artists: []PlayRecordArtist{{ArtistName: "Jack Uzi"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 12, 10, 44, 32, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 170000}, - {TrackName: "(I'm a Kadaver) Alakazam", Artists: []PlayRecordArtist{{ArtistName: "Psychedelic Porn Crumpets"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 7, 44, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 228954}, - {TrackName: "Teddy Picker", Artists: []PlayRecordArtist{{ArtistName: "Arctic Monkeys"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 10, 27, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 165000}, - {TrackName: "New Gold (feat. Tame Impala and Bootie Brown)", Artists: []PlayRecordArtist{{ArtistName: "Gorillaz"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 13, 58, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 215149}, - {TrackName: "The Pretender", Artists: []PlayRecordArtist{{ArtistName: "Foo Fighters"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 18, 25, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 269373}, - {TrackName: "Make It Wit Chu", Artists: []PlayRecordArtist{{ArtistName: "Queens of the Stone Age"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 23, 13, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 290493}, - {TrackName: "Broken Boy", Artists: []PlayRecordArtist{{ArtistName: "Cage The Elephant"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 14, 7, 25, 52, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify, MsPlayed: 163200}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 11, - expectedDuplicates: 9, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + }, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, + expectedFirstTrack: "Song", }, { - name: "same song different days not merged", + name: "same source with mbid preferred", lastfm: []PlayRecord{ - {TrackName: "After School", Artists: []PlayRecordArtist{{ArtistName: "Weeekly"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 18, 17, 59, 3, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Blue Flame", Artists: []PlayRecordArtist{{ArtistName: "LE SSERAFIM"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 18, 17, 59, 4, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "After LIKE", Artists: []PlayRecordArtist{{ArtistName: "IVE"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 18, 18, 2, 25, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM, RecordingMbId: "mbid-123"}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, - spotify: []PlayRecord{ - {TrackName: "After School", Artists: []PlayRecordArtist{{ArtistName: "Weeekly"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 18, 18, 2, 26, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "After LIKE", Artists: []PlayRecordArtist{{ArtistName: "IVE"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 18, 18, 5, 21, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 3, - expectedDuplicates: 2, - expectedLastFMUnique: 3, - expectedSpotifyUnique: 0, + spotify: []PlayRecord{}, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, + expectedFirstTrack: "Song", }, { - name: "hyphen vs space dedupe with space-less normalization", + name: "case insensitive duplicate detection", lastfm: []PlayRecord{ - {TrackName: "So This is Love?", Artists: []PlayRecordArtist{{ArtistName: "George Benson"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 14, 32, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Cream Puff War", Artists: []PlayRecordArtist{{ArtistName: "Grateful Dead"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 18, 12, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "V Neck Sweater", Artists: []PlayRecordArtist{{ArtistName: "The Greyboy Allstars"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 20, 43, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Jungle Boogie-in", Artists: []PlayRecordArtist{{ArtistName: "Ghost-Note"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 22, 53, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "song title", Artists: []PlayRecordArtist{{ArtistName: "artist name"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "V-Neck Sweater", Artists: []PlayRecordArtist{{ArtistName: "The Greyboy Allstars"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 20, 43, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "Jungle Boogie-in", Artists: []PlayRecordArtist{{ArtistName: "Ghost-Note"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 10, 22, 53, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 4, - expectedDuplicates: 2, - expectedLastFMUnique: 4, - expectedSpotifyUnique: 0, + {TrackName: "SONG TITLE", Artists: []PlayRecordArtist{{ArtistName: "ARTIST NAME"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + }, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, }, { - name: "korean artist name and diacritics", + name: "multiple duplicates across time buckets", lastfm: []PlayRecord{ - {TrackName: "DAAAAAMMMN", Artists: []PlayRecordArtist{{ArtistName: "김재중"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 16, 51, 1, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Déjà fait", Artists: []PlayRecordArtist{{ArtistName: "Peet"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 17, 0, 10, 43, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "DAAAAAMMMN", Artists: []PlayRecordArtist{{ArtistName: "김재중"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 16, 16, 51, 1, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "Déjà fait", Artists: []PlayRecordArtist{{ArtistName: "Peet"}}, PlayedTime: Timestamp{Time: time.Date(2023, 9, 17, 0, 7, 21, 0, time.UTC)}, MusicServiceBaseDomain: MusicServiceSpotify}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLen: 2, - expectedDuplicates: 2, - expectedLastFMUnique: 2, - expectedSpotifyUnique: 0, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2*time.Minute + 10*time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + }, + tolerance: 30 * time.Second, + expectedLen: 2, + expectedMergedTotal: 2, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - merged, stats := MergeRecords(tt.lastfm, tt.spotify, tt.tolerance) - - if len(merged) != tt.expectedLen { - t.Errorf("len(merged) = %d, want %d", len(merged), tt.expectedLen) - } - if stats.DuplicatesRemoved != tt.expectedDuplicates { - t.Errorf("stats.DuplicatesRemoved = %d, want %d", stats.DuplicatesRemoved, tt.expectedDuplicates) - } - if tt.expectedLastFMUnique > 0 && stats.LastFMUnique != tt.expectedLastFMUnique { - t.Errorf("stats.LastFMUnique = %d, want %d", stats.LastFMUnique, tt.expectedLastFMUnique) - } - if tt.expectedSpotifyUnique > 0 && stats.SpotifyUnique != tt.expectedSpotifyUnique { - t.Errorf("stats.SpotifyUnique = %d, want %d", stats.SpotifyUnique, tt.expectedSpotifyUnique) - } - if stats.LastFMTotal != len(tt.lastfm) { - t.Errorf("stats.LastFMTotal = %d, want %d", stats.LastFMTotal, len(tt.lastfm)) - } - if stats.SpotifyTotal != len(tt.spotify) { - t.Errorf("stats.SpotifyTotal = %d, want %d", stats.SpotifyTotal, len(tt.spotify)) - } - }) - } -} - -func TestMergeRecordsSortedByTime(t *testing.T) { - baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) - - tests := []struct { - name string - lastfm []PlayRecord - spotify []PlayRecord - tolerance time.Duration - expectedOrder []string - }{ { - name: "unsorted input sorted by time", + name: "sorted by time then track name", lastfm: []PlayRecord{ - {TrackName: "Later", Artists: []PlayRecordArtist{{ArtistName: "A"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "First", Artists: []PlayRecordArtist{{ArtistName: "A"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "B Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "Middle", Artists: []PlayRecordArtist{{ArtistName: "A"}}, PlayedTime: Timestamp{Time: baseTime.Add(1 * time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - tolerance: DefaultCrossSourceTolerance, - expectedOrder: []string{"First", "Middle", "Later"}, + tolerance: 30 * time.Second, + expectedLen: 2, + expectedMergedTotal: 2, + expectedOrder: []string{"A Song", "B Song"}, }, { - name: "same timestamp sorted by track name", + name: "many duplicates in same bucket", lastfm: []PlayRecord{ - {TrackName: "B Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(5 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, + }, + spotify: []PlayRecord{ + {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(15 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(20 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - spotify: []PlayRecord{}, - tolerance: DefaultCrossSourceTolerance, - expectedOrder: []string{"A Song", "B Song"}, + tolerance: 30 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, }, { - name: "many records out of order", + name: "adjacent bucket detection works", lastfm: []PlayRecord{ - {TrackName: "Song 5", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(40 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Song 1", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "Song 3", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(20 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, }, spotify: []PlayRecord{ - {TrackName: "Song 2", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Minute)}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "Song 4", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Minute)}, MusicServiceBaseDomain: MusicServiceSpotify}, + {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, }, - tolerance: DefaultCrossSourceTolerance, - expectedOrder: []string{"Song 1", "Song 2", "Song 3", "Song 4", "Song 5"}, + tolerance: 5 * time.Second, + expectedLen: 1, + expectedMergedTotal: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - merged, _ := MergeRecords(tt.lastfm, tt.spotify, tt.tolerance) + result := kway.Merge([][]PlayRecord{tt.lastfm, tt.spotify}, tt.tolerance) - if len(merged) != len(tt.expectedOrder) { - t.Fatalf("len(merged) = %d, want %d", len(merged), len(tt.expectedOrder)) + if len(result) != tt.expectedLen { + t.Errorf("MergeRecords() length = %d, want %d", len(result), tt.expectedLen) } - for i, expected := range tt.expectedOrder { - if merged[i].TrackName != expected { - t.Errorf("merged[%d].TrackName = %q, want %q", i, merged[i].TrackName, expected) + if tt.expectedFirstTrack != "" && len(result) > 0 { + if result[0].TrackName != tt.expectedFirstTrack { + t.Errorf("MergeRecords() first track = %q, want %q", result[0].TrackName, tt.expectedFirstTrack) } } - }) - } -} - -func TestMergeRecordsLastFMPriority(t *testing.T) { - baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) - tests := []struct { - name string - lastfm PlayRecord - spotify PlayRecord - tolerance time.Duration - expectedService string - }{ - { - name: "lastfm wins same timestamp", - lastfm: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - spotify: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - tolerance: DefaultCrossSourceTolerance, - expectedService: MusicServiceLastFM, - }, - { - name: "lastfm wins within tolerance", - lastfm: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - spotify: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - tolerance: DefaultCrossSourceTolerance, - expectedService: MusicServiceLastFM, - }, - { - name: "lastfm wins even when later", - lastfm: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, - MusicServiceBaseDomain: MusicServiceLastFM, - }, - spotify: PlayRecord{ - TrackName: "Song", - Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, - PlayedTime: Timestamp{Time: baseTime}, - MusicServiceBaseDomain: MusicServiceSpotify, - }, - tolerance: DefaultCrossSourceTolerance, - expectedService: MusicServiceLastFM, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - merged, _ := MergeRecords([]PlayRecord{tt.lastfm}, []PlayRecord{tt.spotify}, tt.tolerance) - - if len(merged) != 1 { - t.Fatalf("len(merged) = %d, want 1", len(merged)) - } - if merged[0].MusicServiceBaseDomain != tt.expectedService { - t.Errorf("merged[0].MusicServiceBaseDomain = %q, want %q", merged[0].MusicServiceBaseDomain, tt.expectedService) + if len(tt.expectedOrder) > 0 { + if len(result) != len(tt.expectedOrder) { + t.Errorf("MergeRecords() order length mismatch, got %d, want %d", len(result), len(tt.expectedOrder)) + } else { + for i, expectedTrack := range tt.expectedOrder { + if i < len(result) && result[i].TrackName != expectedTrack { + t.Errorf("MergeRecords() order[%d] = %q, want %q", i, result[i].TrackName, expectedTrack) + } + } + } } - }) - } -} - -func TestMergeRecordsStats(t *testing.T) { - baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) - - tests := []struct { - name string - lastfm []PlayRecord - spotify []PlayRecord - tolerance time.Duration - expectedLastFMTotal int - expectedSpotifyTotal int - expectedMergedTotal int - }{ - { - name: "all unique", - lastfm: []PlayRecord{{TrackName: "A", Artists: []PlayRecordArtist{{ArtistName: "X"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}}, - spotify: []PlayRecord{{TrackName: "B", Artists: []PlayRecordArtist{{ArtistName: "Y"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}}, - tolerance: DefaultCrossSourceTolerance, - expectedLastFMTotal: 1, - expectedSpotifyTotal: 1, - expectedMergedTotal: 2, - }, - { - name: "all duplicates", - lastfm: []PlayRecord{{TrackName: "A", Artists: []PlayRecordArtist{{ArtistName: "X"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}}, - spotify: []PlayRecord{{TrackName: "A", Artists: []PlayRecordArtist{{ArtistName: "X"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}}, - tolerance: DefaultCrossSourceTolerance, - expectedLastFMTotal: 1, - expectedSpotifyTotal: 1, - expectedMergedTotal: 1, - }, - { - name: "mixed", - lastfm: []PlayRecord{ - {TrackName: "A", Artists: []PlayRecordArtist{{ArtistName: "X"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, - {TrackName: "B", Artists: []PlayRecordArtist{{ArtistName: "Y"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, - }, - spotify: []PlayRecord{ - {TrackName: "A", Artists: []PlayRecordArtist{{ArtistName: "X"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, - {TrackName: "C", Artists: []PlayRecordArtist{{ArtistName: "Z"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, - }, - tolerance: DefaultCrossSourceTolerance, - expectedLastFMTotal: 2, - expectedSpotifyTotal: 2, - expectedMergedTotal: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, stats := MergeRecords(tt.lastfm, tt.spotify, tt.tolerance) - if stats.LastFMTotal != tt.expectedLastFMTotal { - t.Errorf("stats.LastFMTotal = %d, want %d", stats.LastFMTotal, tt.expectedLastFMTotal) - } - if stats.SpotifyTotal != tt.expectedSpotifyTotal { - t.Errorf("stats.SpotifyTotal = %d, want %d", stats.SpotifyTotal, tt.expectedSpotifyTotal) - } - if stats.MergedTotal != tt.expectedMergedTotal { - t.Errorf("stats.MergedTotal = %d, want %d", stats.MergedTotal, tt.expectedMergedTotal) + // Verify sorting is correct + for i := 1; i < len(result); i++ { + prev, curr := result[i-1], result[i] + if prev.PlayedTime.After(curr.PlayedTime.Time) { + t.Errorf("MergeRecords() sorting failed: %q at %v should be after %q at %v", + prev.TrackName, prev.PlayedTime.Time, curr.TrackName, curr.PlayedTime.Time) + } + if prev.PlayedTime.Equal(curr.PlayedTime.Time) && prev.TrackName > curr.TrackName { + t.Errorf("MergeRecords() same-time sorting failed: %q should be before %q", + prev.TrackName, curr.TrackName) + } } }) }