like malachite (atproto-lastfm-importer) but in go and bluer
go spotify tealfm lastfm atproto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

optimize allocations

karitham 1376811e d9d69980

+178 -146
+1 -1
flake.nix
··· 17 17 let 18 18 lazuli = pkgs.buildGoModule rec { 19 19 name = "lazuli"; 20 - version = "0.1.7"; 20 + version = "v0.2.0"; 21 21 src = pkgs.nix-gitignore.gitignoreSource [ "*.csv" "*.zip" "*.json" ] ./.; 22 22 vendorHash = "sha256-KnWoZ5UK8eigYw5uMSsLu4DIhzkSXmVHaE51Mr6hFmA="; 23 23 ldflags = [
+7 -2
kway/merge.go
··· 38 38 // It combines the sources while removing duplicates within the specified tolerance. 39 39 // The result is sorted according to the Compare method of the items. 40 40 func Merge[T Mergeable[T]](sources [][]T, tolerance time.Duration) []T { 41 + totalCap := 0 42 + for _, s := range sources { 43 + totalCap += len(s) 44 + } 45 + 41 46 h := &mergeHeap[T]{} 42 47 heap.Init(h) 43 48 ··· 48 53 } 49 54 } 50 55 51 - result := make([]T, 0) 52 - window := make([]T, 0) 56 + result := make([]T, 0, totalCap) 57 + window := make([]T, 0, 16) 53 58 54 59 // Process items from the heap 55 60 for h.Len() > 0 {
+36 -8
main.go
··· 2 2 3 3 import ( 4 4 "archive/zip" 5 + "cmp" 5 6 "context" 6 7 "encoding/json" 7 8 "fmt" ··· 9 10 "io/fs" 10 11 "log/slog" 11 12 "os" 13 + "os/signal" 14 + "runtime/pprof" 12 15 "slices" 13 16 "strings" 14 17 "time" ··· 67 70 if err != nil { 68 71 return fmt.Errorf("open cache: %w", err) 69 72 } 73 + 74 + cpuFile, _ := os.Create("cpu.prof") 75 + _ = pprof.StartCPUProfile(cpuFile) 76 + defer pprof.StopCPUProfile() // Ensures profile is written when main exits 77 + 78 + c := make(chan os.Signal, 1) 79 + signal.Notify(c, os.Interrupt) 80 + 81 + go func() { 82 + <-c 83 + fmt.Println("\nInterrupt received, saving profile and exiting...") 84 + pprof.StopCPUProfile() 85 + _ = cpuFile.Close() 86 + os.Exit(0) 87 + }() 70 88 71 89 app := &App{storage: storage} 72 90 ··· 318 336 continue 319 337 } 320 338 321 - res := sync.PublishBatch(ctx, repoClient, did, []sync.PlayRecord{fr.rec}, a.storage, sync.DefaultClientAgent) 339 + res := sync.PublishBatch(ctx, repoClient, did, []*sync.PlayRecord{&fr.rec}, a.storage, sync.DefaultClientAgent) 322 340 323 341 if res == nil { 324 342 fmt.Printf("Successfully retried: %s - %s\n", fr.rec.ArtistName(), fr.rec.TrackName) ··· 776 794 return nil 777 795 } 778 796 779 - func (a *App) outputRecords(records []sync.PlayRecord, outputPath string) error { 797 + func (a *App) outputRecords(records []*sync.PlayRecord, outputPath string) error { 780 798 var output io.Writer = os.Stdout 781 799 if outputPath != "" { 782 800 file, err := os.Create(outputPath) ··· 928 946 } 929 947 930 948 type Parser interface { 931 - ParseFile(ctx context.Context, r io.Reader) ([]sync.PlayRecord, error) 932 - ParseFS(ctx context.Context, fsys fs.FS) ([]sync.PlayRecord, error) 949 + ParseFile(ctx context.Context, r io.Reader) ([]*sync.PlayRecord, error) 950 + ParseFS(ctx context.Context, fsys fs.FS) ([]*sync.PlayRecord, error) 933 951 } 934 952 935 - func parseInput(ctx context.Context, path string, parser Parser) ([]sync.PlayRecord, error) { 953 + func parseInput(ctx context.Context, path string, parser Parser) ([]*sync.PlayRecord, error) { 936 954 info, err := os.Stat(path) 937 955 if err != nil { 938 956 return nil, fmt.Errorf("stat path: %w", err) ··· 963 981 return parser.ParseFile(ctx, file) 964 982 } 965 983 966 - func loadRecordsMerge(ctx context.Context, lastFMPath, spotifyPath string, tolerance time.Duration) ([]sync.PlayRecord, int, error) { 967 - var lastfmRecords, spotifyRecords []sync.PlayRecord 984 + func loadRecordsMerge(ctx context.Context, lastFMPath, spotifyPath string, tolerance time.Duration) ([]*sync.PlayRecord, int, error) { 985 + var lastfmRecords, spotifyRecords []*sync.PlayRecord 968 986 var err error 969 987 970 988 if lastFMPath != "" { ··· 981 999 } 982 1000 } 983 1001 1002 + f := func(a, b *sync.PlayRecord) int { 1003 + if v := a.Time().Compare(b.Time()); v != 0 { 1004 + return v 1005 + } 1006 + 1007 + return cmp.Compare(a.ArtistName(), b.ArtistName()) 1008 + } 1009 + slices.SortFunc(spotifyRecords, f) 1010 + slices.SortFunc(lastfmRecords, f) 1011 + 984 1012 totalInput := len(lastfmRecords) + len(spotifyRecords) 985 1013 986 - mergedRecords := kway.Merge([][]sync.PlayRecord{lastfmRecords, spotifyRecords}, tolerance) 1014 + mergedRecords := kway.Merge([][]*sync.PlayRecord{lastfmRecords, spotifyRecords}, tolerance) 987 1015 988 1016 return mergedRecords, totalInput, nil 989 1017 }
+6 -6
sources/lastfm/lastfm.go
··· 16 16 17 17 type Parser struct{} 18 18 19 - func (Parser) ParseFile(ctx context.Context, r io.Reader) ([]sync.PlayRecord, error) { 19 + func (Parser) ParseFile(ctx context.Context, r io.Reader) ([]*sync.PlayRecord, error) { 20 20 reader := csv.NewReader(r) 21 21 reader.TrimLeadingSpace = true 22 22 reader.FieldsPerRecord = -1 ··· 65 65 return toSync(records), nil 66 66 } 67 67 68 - func (Parser) ParseFS(ctx context.Context, fsys fs.FS) ([]sync.PlayRecord, error) { 69 - allRecords := make([]sync.PlayRecord, 0, 256) 68 + func (Parser) ParseFS(ctx context.Context, fsys fs.FS) ([]*sync.PlayRecord, error) { 69 + allRecords := make([]*sync.PlayRecord, 0, 256) 70 70 71 71 var walkErr error 72 72 fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { ··· 115 115 TrackMbid string 116 116 } 117 117 118 - func toSync(records []record) []sync.PlayRecord { 119 - result := make([]sync.PlayRecord, 0, len(records)) 118 + func toSync(records []record) []*sync.PlayRecord { 119 + result := make([]*sync.PlayRecord, 0, len(records)) 120 120 121 121 for _, r := range records { 122 122 utsSec, err := strconv.ParseInt(r.Uts, 10, 64) ··· 124 124 continue 125 125 } 126 126 127 - result = append(result, sync.PlayRecord{ 127 + result = append(result, &sync.PlayRecord{ 128 128 Type: sync.RecordType, 129 129 TrackName: r.Track, 130 130 Artists: []sync.PlayRecordArtist{{ArtistName: r.Artist, ArtistMbId: r.ArtistMbid}},
+6 -6
sources/lastfm/lastfm_test.go
··· 12 12 name string 13 13 content string 14 14 wantLen int 15 - checkFn func([]sync.PlayRecord) bool 15 + checkFn func([]*sync.PlayRecord) bool 16 16 }{ 17 17 { 18 18 name: "parses multiple records", ··· 20 20 "1705315800",2024-01-15 10:30:00,The Smiths,,The Queen Is Dead,,There Is a Light That Never Goes Out, 21 21 1705319400,2024-01-15 11:30:00,Queen,abc123,A Night at the Opera,def456,Bohemian Rhapsody,ghi789`, 22 22 wantLen: 2, 23 - checkFn: func(records []sync.PlayRecord) bool { 23 + checkFn: func(records []*sync.PlayRecord) bool { 24 24 return records[0].Artists[0].ArtistName == "The Smiths" && 25 25 records[1].TrackName == "Bohemian Rhapsody" && 26 26 records[1].Artists[0].ArtistMbId == "abc123" ··· 63 63 tests := []struct { 64 64 name string 65 65 records []record 66 - checkFn func(sync.PlayRecord) bool 66 + checkFn func(*sync.PlayRecord) bool 67 67 }{ 68 68 { 69 69 name: "converts record with all fields", ··· 77 77 Track: "There Is a Light That Never Goes Out", 78 78 TrackMbid: "mbid-789", 79 79 }}, 80 - checkFn: func(rec sync.PlayRecord) bool { 80 + checkFn: func(rec *sync.PlayRecord) bool { 81 81 return rec.Type == sync.RecordType && 82 82 rec.TrackName == "There Is a Light That Never Goes Out" && 83 83 rec.MusicServiceBaseDomain == sync.MusicServiceLastFM && ··· 96 96 Artist: "Unknown Artist", 97 97 Track: "Unknown Track", 98 98 }}, 99 - checkFn: func(rec sync.PlayRecord) bool { 99 + checkFn: func(rec *sync.PlayRecord) bool { 100 100 return rec.Artists[0].ArtistMbId == "" && 101 101 rec.ReleaseMbId == "" && 102 102 rec.RecordingMbId == "" && ··· 110 110 {Uts: "1705319400", Artist: "Artist2", Track: "Track2"}, 111 111 {Uts: "1705323000", Artist: "Artist3", Track: "Track3"}, 112 112 }, 113 - checkFn: func(rec sync.PlayRecord) bool { 113 + checkFn: func(rec *sync.PlayRecord) bool { 114 114 return rec.Artists[0].ArtistName == "Artist1" || 115 115 rec.Artists[0].ArtistName == "Artist2" || 116 116 rec.Artists[0].ArtistName == "Artist3"
+5 -5
sources/spotify/spotify.go
··· 13 13 14 14 type Parser struct{} 15 15 16 - func (Parser) ParseFile(ctx context.Context, r io.Reader) ([]sync.PlayRecord, error) { 16 + func (Parser) ParseFile(ctx context.Context, r io.Reader) ([]*sync.PlayRecord, error) { 17 17 var records []record 18 18 if err := json.NewDecoder(r).Decode(&records); err != nil { 19 19 return nil, err 20 20 } 21 21 22 - result := make([]sync.PlayRecord, 0, len(records)) 22 + result := make([]*sync.PlayRecord, 0, len(records)) 23 23 for _, r := range records { 24 24 select { 25 25 case <-ctx.Done(): ··· 59 59 releaseName = *r.MasterMetadataAlbumAlbumName 60 60 } 61 61 62 - result = append(result, sync.PlayRecord{ 62 + result = append(result, &sync.PlayRecord{ 63 63 Type: sync.RecordType, 64 64 TrackName: trackName, 65 65 Artists: []sync.PlayRecordArtist{{ArtistName: artistName}}, ··· 75 75 return result, nil 76 76 } 77 77 78 - func (Parser) ParseFS(ctx context.Context, fsys fs.FS) ([]sync.PlayRecord, error) { 79 - allRecords := make([]sync.PlayRecord, 0, 256) 78 + func (Parser) ParseFS(ctx context.Context, fsys fs.FS) ([]*sync.PlayRecord, error) { 79 + allRecords := make([]*sync.PlayRecord, 0, 256) 80 80 81 81 err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { 82 82 select {
+2 -2
sources/spotify/spotify_test.go
··· 12 12 name string 13 13 content string 14 14 wantLen int 15 - checkFn func([]sync.PlayRecord) bool 15 + checkFn func([]*sync.PlayRecord) bool 16 16 }{ 17 17 { 18 18 name: "parses single record", ··· 36 36 "incognito_mode": false 37 37 }]`, 38 38 wantLen: 1, 39 - checkFn: func(records []sync.PlayRecord) bool { 39 + checkFn: func(records []*sync.PlayRecord) bool { 40 40 return records[0].Artists[0].ArtistName == "The Smiths" && 41 41 records[0].TrackName == "There Is a Light That Never Goes Out" 42 42 },
+17 -17
sync/publish.go
··· 49 49 Build() 50 50 51 51 type ( 52 - ATProtoClient = atproto.RepoClient[PlayRecord] 52 + ATProtoClient = atproto.RepoClient[*PlayRecord] 53 53 AuthClient = atproto.AuthClient 54 54 RateLimiter = atproto.RateLimiter 55 55 Client = atproto.Client ··· 79 79 } 80 80 81 81 recordBatch struct { 82 - Records []PlayRecord 82 + Records []*PlayRecord 83 83 Keys []string 84 84 } 85 85 ··· 103 103 return atproto.NewRateLimiter(kv, maxPercent) 104 104 } 105 105 106 - func NewRateClient(client *atclient.APIClient, did string, limiter RateLimiter) *atproto.RateClient[PlayRecord] { 107 - return atproto.NewRateClient[PlayRecord](client, did, limiter) 106 + func NewRateClient(client *atclient.APIClient, did string, limiter RateLimiter) *atproto.RateClient[*PlayRecord] { 107 + return atproto.NewRateClient[*PlayRecord](client, did, limiter) 108 108 } 109 109 110 110 func IsTransientError(err error) bool { ··· 136 136 return nil // Skip malformed records 137 137 } 138 138 139 - currentBatch.Records = append(currentBatch.Records, record) 139 + currentBatch.Records = append(currentBatch.Records, &record) 140 140 currentBatch.Keys = append(currentBatch.Keys, key) 141 141 142 142 if len(currentBatch.Records) >= batchSize { 143 143 batches = append(batches, recordBatch{ 144 - Records: append([]PlayRecord{}, currentBatch.Records...), 144 + Records: append([]*PlayRecord{}, currentBatch.Records...), 145 145 Keys: append([]string{}, currentBatch.Keys...), 146 146 }) 147 147 currentBatch = recordBatch{} ··· 171 171 if processor.DryRun { 172 172 for _, r := range batch.Records { 173 173 tid := syntax.NewTIDFromTime(r.PlayedTime.Time, 0) 174 - slog.Info("would publish record (dry run)", trackAttr(r), slog.String("rkey", string(tid))) 174 + slog.Info("would publish record (dry run)", trackAttr(*r), slog.String("rkey", string(tid))) 175 175 } 176 176 return batchResult{ 177 177 SuccessCount: len(batch.Records), ··· 339 339 slog.String("rate", formatRate(ratePerMinute(success, time.Since(startTime))))) 340 340 } 341 341 342 - func PublishBatch(ctx context.Context, client ATProtoClient, did string, batch []PlayRecord, storage cache.Storage, clientAgent string) error { 342 + func PublishBatch(ctx context.Context, client ATProtoClient, did string, batch []*PlayRecord, storage cache.Storage, clientAgent string) error { 343 343 if len(batch) == 0 { 344 344 return nil 345 345 } ··· 372 372 return nil 373 373 } 374 374 375 - func prepareRecords(batch []PlayRecord, clientAgent string) []PlayRecord { 376 - atprotoRecords := make([]PlayRecord, 0, len(batch)) 375 + func prepareRecords(batch []*PlayRecord, clientAgent string) []*PlayRecord { 376 + atprotoRecords := make([]*PlayRecord, 0, len(batch)) 377 377 for _, record := range batch { 378 378 record.Type = RecordType 379 379 record.SubmissionClientAgent = clientAgent ··· 389 389 return float64(count) / duration.Minutes() 390 390 } 391 391 392 - func FetchExisting(ctx context.Context, client RepoClient[PlayRecord], did string, storage cache.Storage, forceRefresh bool) ([]ExistingRecord, error) { 392 + func FetchExisting(ctx context.Context, client RepoClient[*PlayRecord], did string, storage cache.Storage, forceRefresh bool) ([]ExistingRecord, error) { 393 393 if !forceRefresh && storage != nil { 394 394 published, err := storage.GetPublished(did) 395 395 if err == nil && len(published) > 0 && storage.IsValid(did) { ··· 400 400 return nil 401 401 } 402 402 records = append(records, ExistingRecord{ 403 - URI: generateRecordURI(did, value), 404 - Value: value, 403 + URI: generateRecordURI(did, &value), 404 + Value: &value, 405 405 }) 406 406 return nil 407 407 }) ··· 422 422 return fetchExistingLoop(ctx, client, did, storage, allRecords) 423 423 } 424 424 425 - func fetchExistingLoop(ctx context.Context, client RepoClient[PlayRecord], did string, storage cache.Storage, allRecords []ExistingRecord) ([]ExistingRecord, error) { 425 + func fetchExistingLoop(ctx context.Context, client RepoClient[*PlayRecord], did string, storage cache.Storage, allRecords []ExistingRecord) ([]ExistingRecord, error) { 426 426 const batchSize = 100 427 427 var cursor string 428 428 429 429 type fetchResult struct { 430 - records []atproto.RecordRef[PlayRecord] 430 + records []atproto.RecordRef[*PlayRecord] 431 431 cursor string 432 432 } 433 433 ··· 504 504 return allRecords, nil 505 505 } 506 506 507 - func generateRecordURI(did string, record PlayRecord) string { 507 + func generateRecordURI(did string, record *PlayRecord) string { 508 508 return fmt.Sprintf("at://%s/%s/%s", did, RecordType, CreateRecordKey(record)) 509 509 } 510 510 511 - func prepareWrites(records []PlayRecord, collection string) ([]map[string]any, error) { 511 + func prepareWrites(records []*PlayRecord, collection string) ([]map[string]any, error) { 512 512 if len(records) == 0 { 513 513 return nil, nil 514 514 }
+11 -11
sync/publish_test.go
··· 98 98 99 99 // Mock ATProtoClient 100 100 type mockATProtoClient struct { 101 - applyWritesFunc func(ctx context.Context, collection string, records []PlayRecord) error 102 - listRecordsFunc func(ctx context.Context, collection string, limit int, cursor string) ([]atproto.RecordRef[PlayRecord], string, error) 101 + applyWritesFunc func(ctx context.Context, collection string, records []*PlayRecord) error 102 + listRecordsFunc func(ctx context.Context, collection string, limit int, cursor string) ([]atproto.RecordRef[*PlayRecord], string, error) 103 103 deleteRecordFunc func(ctx context.Context, collection, rkey string) error 104 104 } 105 105 106 - func (m *mockATProtoClient) ApplyWrites(ctx context.Context, collection string, records []PlayRecord) error { 106 + func (m *mockATProtoClient) ApplyWrites(ctx context.Context, collection string, records []*PlayRecord) error { 107 107 if m.applyWritesFunc != nil { 108 108 return m.applyWritesFunc(ctx, collection, records) 109 109 } 110 110 return nil 111 111 } 112 112 113 - func (m *mockATProtoClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) ([]atproto.RecordRef[PlayRecord], string, error) { 113 + func (m *mockATProtoClient) ListRecords(ctx context.Context, collection string, limit int, cursor string) ([]atproto.RecordRef[*PlayRecord], string, error) { 114 114 if m.listRecordsFunc != nil { 115 115 return m.listRecordsFunc(ctx, collection, limit, cursor) 116 116 } ··· 291 291 { 292 292 name: "empty batch", 293 293 batch: recordBatch{ 294 - Records: []PlayRecord{}, 294 + Records: []*PlayRecord{}, 295 295 Keys: []string{}, 296 296 }, 297 297 processor: batchProcessor{ ··· 305 305 { 306 306 name: "successful batch", 307 307 batch: recordBatch{ 308 - Records: []PlayRecord{{TrackName: "Song 1"}, {TrackName: "Song 2"}}, 308 + Records: []*PlayRecord{{TrackName: "Song 1"}, {TrackName: "Song 2"}}, 309 309 Keys: []string{"key1", "key2"}, 310 310 }, 311 311 processor: batchProcessor{ ··· 321 321 { 322 322 name: "dry run batch", 323 323 batch: recordBatch{ 324 - Records: []PlayRecord{{TrackName: "Song 1"}, {TrackName: "Song 2"}}, 324 + Records: []*PlayRecord{{TrackName: "Song 1"}, {TrackName: "Song 2"}}, 325 325 Keys: []string{"key1", "key2"}, 326 326 }, 327 327 processor: batchProcessor{ ··· 338 338 { 339 339 name: "batch with apply writes failure", 340 340 batch: recordBatch{ 341 - Records: []PlayRecord{{TrackName: "Song 1"}}, 341 + Records: []*PlayRecord{{TrackName: "Song 1"}}, 342 342 Keys: []string{"key1"}, 343 343 }, 344 344 processor: batchProcessor{ 345 345 Client: func() *mockATProtoClient { 346 346 return &mockATProtoClient{ 347 - applyWritesFunc: func(ctx context.Context, collection string, records []PlayRecord) error { 347 + applyWritesFunc: func(ctx context.Context, collection string, records []*PlayRecord) error { 348 348 return errors.New("apply writes failed") 349 349 }, 350 350 } ··· 360 360 { 361 361 name: "batch with storage failure", 362 362 batch: recordBatch{ 363 - Records: []PlayRecord{{TrackName: "Song 1"}}, 363 + Records: []*PlayRecord{{TrackName: "Song 1"}}, 364 364 Keys: []string{"key1"}, 365 365 }, 366 366 processor: batchProcessor{ ··· 539 539 }, 540 540 setupClient: func() *mockATProtoClient { 541 541 return &mockATProtoClient{ 542 - applyWritesFunc: func(ctx context.Context, collection string, records []PlayRecord) error { 542 + applyWritesFunc: func(ctx context.Context, collection string, records []*PlayRecord) error { 543 543 return &atclient.APIError{StatusCode: 400} // Non-transient error 544 544 }, 545 545 }
+30 -31
sync/record.go
··· 1 1 package sync 2 2 3 3 import ( 4 + "bytes" 4 5 "fmt" 5 - "strings" 6 6 "time" 7 7 "unicode" 8 + "unicode/utf8" 8 9 9 10 "github.com/bluesky-social/indigo/atproto/syntax" 10 11 ) ··· 12 13 type ExistingRecord struct { 13 14 URI string 14 15 CID string 15 - Value PlayRecord 16 + Value *PlayRecord 16 17 } 17 18 18 - func normalizeString(s string) string { 19 - s = strings.ToLower(s) 20 - s = strings.TrimSpace(s) 21 - 22 - var result strings.Builder 23 - result.Grow(len(s)) 19 + func normalizeToBytes(s string) []byte { 20 + b := make([]byte, 0, len(s)) 24 21 25 22 for _, r := range s { 23 + r = unicode.ToLower(r) 24 + 26 25 if r >= 128 || unicode.IsLetter(r) || unicode.IsNumber(r) { 27 - result.WriteRune(r) 26 + b = utf8.AppendRune(b, r) 28 27 } 29 28 } 30 29 31 - return result.String() 30 + return b 32 31 } 33 32 34 33 type PlayRecord struct { ··· 44 43 OriginUrl string `json:"originUrl"` 45 44 MsPlayed int `json:"msPlayed,omitempty"` 46 45 47 - normalizedTrack string `json:"-"` 48 - normalizedArtist string `json:"-"` 46 + normalizedTrack []byte `json:"-"` 47 + normalizedArtist []byte `json:"-"` 49 48 } 50 49 51 - func (r PlayRecord) ArtistName() string { 50 + func (r *PlayRecord) ArtistName() string { 52 51 if len(r.Artists) > 0 { 53 52 return r.Artists[0].ArtistName 54 53 } 55 54 return "Unknown Artist" 56 55 } 57 56 58 - func (r PlayRecord) normalizeArtist() string { 59 - if r.normalizedArtist != "" { 57 + func (r *PlayRecord) normalizeArtist() []byte { 58 + if len(r.normalizedArtist) > 0 { 60 59 return r.normalizedArtist 61 60 } 62 61 63 - r.normalizedArtist = normalizeString(r.ArtistName()) 62 + r.normalizedArtist = normalizeToBytes(r.ArtistName()) 64 63 65 64 return r.normalizedArtist 66 65 } 67 66 68 - func (r PlayRecord) normalizeTrack() string { 69 - if r.normalizedTrack != "" { 67 + func (r *PlayRecord) normalizeTrack() []byte { 68 + if len(r.normalizedTrack) != 0 { 70 69 return r.normalizedTrack 71 70 } 72 71 73 - r.normalizedTrack = normalizeString(r.TrackName) 72 + r.normalizedTrack = normalizeToBytes(r.TrackName) 74 73 75 74 return r.normalizedTrack 76 75 } 77 76 78 - func (r PlayRecord) hasMBID() bool { 77 + func (r *PlayRecord) hasMBID() bool { 79 78 for _, a := range r.Artists { 80 79 if a.ArtistMbId != "" { 81 80 return true ··· 85 84 return r.RecordingMbId != "" 86 85 } 87 86 88 - func (r PlayRecord) isLastFM() bool { 87 + func (r *PlayRecord) isLastFM() bool { 89 88 return r.MusicServiceBaseDomain == MusicServiceLastFM 90 89 } 91 90 92 - func (r PlayRecord) betterThan(other PlayRecord) bool { 91 + func (r *PlayRecord) betterThan(other *PlayRecord) bool { 93 92 return (r.hasMBID() && !other.hasMBID()) || (r.isLastFM() && !other.isLastFM()) 94 93 } 95 94 96 - func (r PlayRecord) IsDuplicate(other PlayRecord, tolerance time.Duration) (bool, bool) { 95 + func (r *PlayRecord) IsDuplicate(other *PlayRecord, tolerance time.Duration) (bool, bool) { 97 96 return r.sameAs(other, tolerance), r.betterThan(other) 98 97 } 99 98 100 - func (r PlayRecord) sameAs(other PlayRecord, tolerance time.Duration) bool { 101 - if r.normalizeTrack() != other.normalizeTrack() { 99 + func (r *PlayRecord) sameAs(other *PlayRecord, tolerance time.Duration) bool { 100 + if !bytes.Equal(r.normalizeTrack(), other.normalizeTrack()) { 102 101 return false 103 102 } 104 - if r.normalizeArtist() != other.normalizeArtist() { 103 + if !bytes.Equal(r.normalizeArtist(), other.normalizeArtist()) { 105 104 return false 106 105 } 107 106 ··· 109 108 return max(diff, -diff) <= tolerance 110 109 } 111 110 112 - func (r PlayRecord) Time() time.Time { 111 + func (r *PlayRecord) Time() time.Time { 113 112 return r.PlayedTime.Time 114 113 } 115 114 ··· 128 127 DefaultClientAgent = "lazuli/dev" 129 128 ) 130 129 131 - func CreateRecordKey(record PlayRecord) string { 130 + func CreateRecordKey(record *PlayRecord) string { 132 131 return string(syntax.NewTIDFromTime(record.PlayedTime.Time, 0)) 133 132 } 134 133 135 - func CreateRecordKeys(records []PlayRecord) []string { 134 + func CreateRecordKeys(records []*PlayRecord) []string { 136 135 keys := make([]string, len(records)) 137 136 usedTIDs := make(map[string]int) 138 137 ··· 149 148 return keys 150 149 } 151 150 152 - func FilterNew(records []PlayRecord, existing []ExistingRecord, processed map[string]bool) []PlayRecord { 151 + func FilterNew(records []*PlayRecord, existing []ExistingRecord, processed map[string]bool) []*PlayRecord { 153 152 existingKeys := make(map[string]bool) 154 153 for _, rec := range existing { 155 154 key := CreateRecordKey(rec.Value) ··· 159 158 existingKeys[key] = true 160 159 } 161 160 162 - var newRecords []PlayRecord 161 + var newRecords []*PlayRecord 163 162 for _, record := range records { 164 163 key := CreateRecordKey(record) 165 164 if !existingKeys[key] && !processed[key] {
+42 -42
sync/record_test.go
··· 11 11 func TestCreateRecordKey(t *testing.T) { 12 12 tests := []struct { 13 13 name string 14 - record PlayRecord 14 + record *PlayRecord 15 15 expected string 16 16 }{ 17 17 { 18 18 name: "basic TID", 19 - record: PlayRecord{ 19 + record: &PlayRecord{ 20 20 TrackName: "Test Track", 21 21 Artists: []PlayRecordArtist{{ArtistName: "Test Artist"}}, 22 22 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)}, ··· 37 37 38 38 func TestCreateRecordKeys(t *testing.T) { 39 39 baseTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) 40 - records := []PlayRecord{ 40 + records := []*PlayRecord{ 41 41 {TrackName: "A", PlayedTime: Timestamp{Time: baseTime}}, 42 42 {TrackName: "B", PlayedTime: Timestamp{Time: baseTime}}, 43 43 {TrackName: "C", PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}}, ··· 171 171 172 172 for _, tt := range tests { 173 173 t.Run(tt.name, func(t *testing.T) { 174 - result := tt.r1.betterThan(tt.r2) 174 + result := tt.r1.betterThan(&tt.r2) 175 175 var resultService string 176 176 if result { 177 177 resultService = tt.r1.MusicServiceBaseDomain ··· 191 191 192 192 tests := []struct { 193 193 name string 194 - lastfm []PlayRecord 195 - spotify []PlayRecord 194 + lastfm []*PlayRecord 195 + spotify []*PlayRecord 196 196 tolerance time.Duration 197 197 expectedLen int 198 198 expectedMergedTotal int ··· 201 201 }{ 202 202 { 203 203 name: "both slices empty", 204 - lastfm: []PlayRecord{}, 205 - spotify: []PlayRecord{}, 204 + lastfm: []*PlayRecord{}, 205 + spotify: []*PlayRecord{}, 206 206 tolerance: 0, 207 207 expectedLen: 0, 208 208 expectedMergedTotal: 0, 209 209 }, 210 210 { 211 211 name: "only lastfm records", 212 - lastfm: []PlayRecord{ 212 + lastfm: []*PlayRecord{ 213 213 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 214 214 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, 215 215 }, 216 - spotify: []PlayRecord{}, 216 + spotify: []*PlayRecord{}, 217 217 tolerance: 0, 218 218 expectedLen: 2, 219 219 expectedMergedTotal: 2, ··· 221 221 }, 222 222 { 223 223 name: "only spotify records", 224 - lastfm: []PlayRecord{}, 225 - spotify: []PlayRecord{ 224 + lastfm: []*PlayRecord{}, 225 + spotify: []*PlayRecord{ 226 226 {TrackName: "Song X", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, 227 227 {TrackName: "Song Y", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceSpotify}, 228 228 }, ··· 233 233 }, 234 234 { 235 235 name: "zero tolerance no duplicates", 236 - lastfm: []PlayRecord{ 236 + lastfm: []*PlayRecord{ 237 237 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 238 238 }, 239 - spotify: []PlayRecord{ 239 + spotify: []*PlayRecord{ 240 240 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 241 241 }, 242 242 tolerance: 0, ··· 245 245 }, 246 246 { 247 247 name: "zero tolerance exact duplicate", 248 - lastfm: []PlayRecord{ 248 + lastfm: []*PlayRecord{ 249 249 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 250 250 }, 251 - spotify: []PlayRecord{ 251 + spotify: []*PlayRecord{ 252 252 {TrackName: "Same Song", Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceSpotify}, 253 253 }, 254 254 tolerance: 0, ··· 258 258 }, 259 259 { 260 260 name: "within tolerance duplicate", 261 - lastfm: []PlayRecord{ 261 + lastfm: []*PlayRecord{ 262 262 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 263 263 }, 264 - spotify: []PlayRecord{ 264 + spotify: []*PlayRecord{ 265 265 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 266 266 }, 267 267 tolerance: 30 * time.Second, ··· 271 271 }, 272 272 { 273 273 name: "outside tolerance no duplicate", 274 - lastfm: []PlayRecord{ 274 + lastfm: []*PlayRecord{ 275 275 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 276 276 }, 277 - spotify: []PlayRecord{ 277 + spotify: []*PlayRecord{ 278 278 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(60 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 279 279 }, 280 280 tolerance: 30 * time.Second, ··· 283 283 }, 284 284 { 285 285 name: "time bucket boundary exact", 286 - lastfm: []PlayRecord{ 286 + lastfm: []*PlayRecord{ 287 287 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 288 288 }, 289 - spotify: []PlayRecord{ 289 + spotify: []*PlayRecord{ 290 290 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 291 291 }, 292 292 tolerance: 30 * time.Second, ··· 295 295 }, 296 296 { 297 297 name: "time bucket boundary crossed", 298 - lastfm: []PlayRecord{ 298 + lastfm: []*PlayRecord{ 299 299 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 300 300 }, 301 - spotify: []PlayRecord{ 301 + spotify: []*PlayRecord{ 302 302 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 303 303 }, 304 304 tolerance: 30 * time.Second, ··· 306 306 }, 307 307 { 308 308 name: "lastfm priority over spotify", 309 - lastfm: []PlayRecord{ 309 + lastfm: []*PlayRecord{ 310 310 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 311 311 }, 312 - spotify: []PlayRecord{ 312 + spotify: []*PlayRecord{ 313 313 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 314 314 }, 315 315 tolerance: 30 * time.Second, ··· 319 319 }, 320 320 { 321 321 name: "same source with mbid preferred", 322 - lastfm: []PlayRecord{ 322 + lastfm: []*PlayRecord{ 323 323 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM, RecordingMbId: "mbid-123"}, 324 324 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, 325 325 }, 326 - spotify: []PlayRecord{}, 326 + spotify: []*PlayRecord{}, 327 327 tolerance: 30 * time.Second, 328 328 expectedLen: 1, 329 329 expectedMergedTotal: 1, ··· 331 331 }, 332 332 { 333 333 name: "case insensitive duplicate detection", 334 - lastfm: []PlayRecord{ 334 + lastfm: []*PlayRecord{ 335 335 {TrackName: "song title", Artists: []PlayRecordArtist{{ArtistName: "artist name"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 336 336 }, 337 - spotify: []PlayRecord{ 337 + spotify: []*PlayRecord{ 338 338 {TrackName: "SONG TITLE", Artists: []PlayRecordArtist{{ArtistName: "ARTIST NAME"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 339 339 }, 340 340 tolerance: 30 * time.Second, ··· 343 343 }, 344 344 { 345 345 name: "multiple duplicates across time buckets", 346 - lastfm: []PlayRecord{ 346 + lastfm: []*PlayRecord{ 347 347 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 348 348 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2 * time.Minute)}, MusicServiceBaseDomain: MusicServiceLastFM}, 349 349 }, 350 - spotify: []PlayRecord{ 350 + spotify: []*PlayRecord{ 351 351 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 352 352 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(2*time.Minute + 10*time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 353 353 }, ··· 357 357 }, 358 358 { 359 359 name: "sorted by time then track name", 360 - lastfm: []PlayRecord{ 360 + lastfm: []*PlayRecord{ 361 361 {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 362 362 {TrackName: "B Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Hour)}, MusicServiceBaseDomain: MusicServiceLastFM}, 363 363 }, 364 - spotify: []PlayRecord{ 364 + spotify: []*PlayRecord{ 365 365 {TrackName: "A Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(30 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 366 366 }, 367 367 tolerance: 30 * time.Second, ··· 371 371 }, 372 372 { 373 373 name: "many duplicates in same bucket", 374 - lastfm: []PlayRecord{ 374 + lastfm: []*PlayRecord{ 375 375 {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime}, MusicServiceBaseDomain: MusicServiceLastFM}, 376 376 {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(5 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, 377 377 {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(10 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, 378 378 }, 379 - spotify: []PlayRecord{ 379 + spotify: []*PlayRecord{ 380 380 {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(15 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 381 381 {TrackName: "Popular Song", Artists: []PlayRecordArtist{{ArtistName: "Popular Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(20 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 382 382 }, ··· 386 386 }, 387 387 { 388 388 name: "adjacent bucket detection works", 389 - lastfm: []PlayRecord{ 389 + lastfm: []*PlayRecord{ 390 390 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(29 * time.Second)}, MusicServiceBaseDomain: MusicServiceLastFM}, 391 391 }, 392 - spotify: []PlayRecord{ 392 + spotify: []*PlayRecord{ 393 393 {TrackName: "Song", Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, PlayedTime: Timestamp{Time: baseTime.Add(31 * time.Second)}, MusicServiceBaseDomain: MusicServiceSpotify}, 394 394 }, 395 395 tolerance: 5 * time.Second, ··· 400 400 401 401 for _, tt := range tests { 402 402 t.Run(tt.name, func(t *testing.T) { 403 - result := kway.Merge([][]PlayRecord{tt.lastfm, tt.spotify}, tt.tolerance) 403 + result := kway.Merge([][]*PlayRecord{tt.lastfm, tt.spotify}, tt.tolerance) 404 404 405 405 if len(result) != tt.expectedLen { 406 406 t.Errorf("MergeRecords() length = %d, want %d", len(result), tt.expectedLen) ··· 447 447 tolerance := 10 * time.Minute 448 448 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 449 449 450 - sources := make([][]PlayRecord, numSources) 450 + sources := make([][]*PlayRecord, numSources) 451 451 for i := range numSources { 452 - sources[i] = make([]PlayRecord, itemsPerSource) 452 + sources[i] = make([]*PlayRecord, itemsPerSource) 453 453 for j := range itemsPerSource { 454 - sources[i][j] = PlayRecord{ 454 + sources[i][j] = &PlayRecord{ 455 455 Type: "app.bsky.feed.post", 456 456 TrackName: fmt.Sprintf("Song %d", (i+j)%100), 457 457 Artists: []PlayRecordArtist{{ArtistName: fmt.Sprintf("Artist %d", i%20)}},
+15 -15
sync/sync_test.go
··· 10 10 11 11 tests := []struct { 12 12 name string 13 - records []PlayRecord 13 + records []*PlayRecord 14 14 expectedWrites int 15 15 expectUnique bool 16 16 }{ 17 17 { 18 18 name: "empty records returns nil", 19 - records: []PlayRecord{}, 19 + records: []*PlayRecord{}, 20 20 expectedWrites: 0, 21 21 }, 22 22 { 23 23 name: "single record", 24 - records: []PlayRecord{ 24 + records: []*PlayRecord{ 25 25 { 26 26 TrackName: "Song A", 27 27 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, ··· 33 33 }, 34 34 { 35 35 name: "multiple records same timestamp", 36 - records: []PlayRecord{ 36 + records: []*PlayRecord{ 37 37 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 38 38 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime}}, 39 39 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime}}, ··· 43 43 }, 44 44 { 45 45 name: "mixed timestamps", 46 - records: []PlayRecord{ 46 + records: []*PlayRecord{ 47 47 {TrackName: "Song A", Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, PlayedTime: Timestamp{Time: baseTime}}, 48 48 {TrackName: "Song B", Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, PlayedTime: Timestamp{Time: baseTime.Add(time.Second)}}, 49 49 {TrackName: "Song C", Artists: []PlayRecordArtist{{ArtistName: "Artist C"}}, PlayedTime: Timestamp{Time: baseTime}}, ··· 94 94 baseTime := time.Date(2024, 1, 15, 10, 0, 0, 123456789, time.UTC) 95 95 96 96 numRecords := 50 97 - records := make([]PlayRecord, numRecords) 97 + records := make([]*PlayRecord, numRecords) 98 98 for i := range numRecords { 99 - records[i] = PlayRecord{ 99 + records[i] = &PlayRecord{ 100 100 TrackName: "Song", 101 101 Artists: []PlayRecordArtist{{ArtistName: "Artist"}}, 102 102 PlayedTime: Timestamp{Time: baseTime}, ··· 127 127 } 128 128 129 129 func TestFilterNewExcludesExisting(t *testing.T) { 130 - records := []PlayRecord{ 130 + records := []*PlayRecord{ 131 131 { 132 132 TrackName: "Song A", 133 133 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, ··· 149 149 { 150 150 URI: "at://did:example:user/fm.teal.alpha.feed.play/abc123", 151 151 CID: "bafyreabc123", 152 - Value: PlayRecord{ 152 + Value: &PlayRecord{ 153 153 TrackName: "Song B", 154 154 Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, 155 155 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 1, 0, 0, time.UTC)}, ··· 189 189 } 190 190 191 191 func TestFilterNewReturnsAllWhenNoneExist(t *testing.T) { 192 - records := []PlayRecord{ 192 + records := []*PlayRecord{ 193 193 { 194 194 TrackName: "Song A", 195 195 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, ··· 223 223 { 224 224 URI: "at://did:example:user/fm.teal.alpha.feed.play/abc123", 225 225 CID: "bafyreabc123", 226 - Value: PlayRecord{ 226 + Value: &PlayRecord{ 227 227 TrackName: "Same Song", 228 228 Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, 229 229 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, ··· 232 232 { 233 233 URI: "at://did:example:user/fm.teal.alpha.feed.play/def456", 234 234 CID: "bafyreedef456", 235 - Value: PlayRecord{ 235 + Value: &PlayRecord{ 236 236 TrackName: "Same Song", 237 237 Artists: []PlayRecordArtist{{ArtistName: "Same Artist"}}, 238 238 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, ··· 241 241 { 242 242 URI: "at://did:example:user/fm.teal.alpha.feed.play/ghi789", 243 243 CID: "bafyreghi789", 244 - Value: PlayRecord{ 244 + Value: &PlayRecord{ 245 245 TrackName: "Different Song", 246 246 Artists: []PlayRecordArtist{{ArtistName: "Different Artist"}}, 247 247 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)}, ··· 256 256 { 257 257 URI: "at://did:example:user/fm.teal.alpha.feed.play/abc123", 258 258 CID: "bafyreabc123", 259 - Value: PlayRecord{ 259 + Value: &PlayRecord{ 260 260 TrackName: "Song A", 261 261 Artists: []PlayRecordArtist{{ArtistName: "Artist A"}}, 262 262 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)}, ··· 265 265 { 266 266 URI: "at://did:example:user/fm.teal.alpha.feed.play/def456", 267 267 CID: "bafyreedef456", 268 - Value: PlayRecord{ 268 + Value: &PlayRecord{ 269 269 TrackName: "Song B", 270 270 Artists: []PlayRecordArtist{{ArtistName: "Artist B"}}, 271 271 PlayedTime: Timestamp{Time: time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)},