[WIP] music platform user data scraper
teal-fm atproto
31
fork

Configure Feed

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

Fix instant submission and refactor spotify

+1601 -170
+249 -170
service/spotify/spotify.go
··· 28 28 "github.com/teal-fm/piper/session" 29 29 ) 30 30 31 + // Maximum delta time to add per poll cycle (prevents spurious accumulation if polling is delayed) 32 + const maxDeltaMs int64 = 30000 33 + 34 + // Maximum delta time that can be skipped upon first appearance of a track. 35 + const maxSkipDeltaMs int64 = 30000 36 + 37 + // userPlayState tracks the listening state for a user, including accumulated 38 + // listening time per track 39 + type userPlayState struct { 40 + track *models.Track // Full track info for now-playing and stamping 41 + accumulatedMs int64 // Accumulated listening time in ms 42 + lastPollTime time.Time // When we last polled (for delta calculation) 43 + hasStamped bool // Whether we've stamped this "play cycle" 44 + isPaused bool // Whether currently paused 45 + } 46 + 47 + // SpotifyTrackResponse contains track info and playback state from Spotify 48 + type SpotifyTrackResponse struct { 49 + Track *models.Track 50 + IsPlaying bool 51 + } 52 + 53 + // stateAction describes what external actions to take after state computation 54 + type stateAction struct { 55 + clearNowPlaying bool 56 + publishNowPlaying bool 57 + stampTrack bool 58 + track *models.Track 59 + accumulatedMs int64 60 + } 61 + 31 62 type Service struct { 32 - DB *db.DB 33 - atprotoService *atprotoauth.AuthService // Added field 34 - mb *musicbrainz.Service // Added field 35 - playingNowService interface { 63 + DB *db.DB 64 + atprotoAuthService *atprotoauth.AuthService // Added field 65 + mb *musicbrainz.Service // Added field 66 + playingNowService interface { 36 67 PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 37 68 ClearPlayingNow(ctx context.Context, userID int64) error 38 69 } // Added field for playing now service 39 - userTracks map[int64]*models.Track 40 - userTokens map[int64]string 41 - mu sync.RWMutex 42 - logger *log.Logger 70 + userPlayStates map[int64]*userPlayState 71 + userTokens map[int64]string 72 + mu sync.RWMutex 73 + logger *log.Logger 43 74 } 44 75 45 - func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.AuthService, musicBrainzService *musicbrainz.Service, playingNowService interface { 76 + func NewSpotifyService(database *db.DB, atprotoAuthService *atprotoauth.AuthService, musicBrainzService *musicbrainz.Service, playingNowService interface { 46 77 PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 47 78 ClearPlayingNow(ctx context.Context, userID int64) error 48 79 }) *Service { 49 80 logger := log.New(os.Stdout, "spotify: ", log.LstdFlags|log.Lmsgprefix) 50 81 51 82 return &Service{ 52 - DB: database, 53 - atprotoService: atprotoService, 54 - mb: musicBrainzService, 55 - playingNowService: playingNowService, 56 - userTracks: make(map[int64]*models.Track), 57 - userTokens: make(map[int64]string), 58 - logger: logger, 83 + DB: database, 84 + atprotoAuthService: atprotoAuthService, 85 + mb: musicBrainzService, 86 + playingNowService: playingNowService, 87 + userPlayStates: make(map[int64]*userPlayState), 88 + userTokens: make(map[int64]string), 89 + logger: logger, 59 90 } 60 91 } 61 92 ··· 67 98 } 68 99 69 100 // Use shared atproto service for submission 70 - return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoService) 101 + return atprotoservice.SubmitPlayToPDS(ctx, did, mostRecentAtProtoSessionID, track, s.atprotoAuthService) 71 102 } 72 103 73 104 func (s *Service) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) { ··· 360 391 } 361 392 362 393 s.mu.RLock() 363 - track, exists := s.userTracks[userID] 394 + state, exists := s.userPlayStates[userID] 364 395 s.mu.RUnlock() 365 396 366 - if !exists || track == nil { 397 + if !exists || state == nil || state.track == nil { 367 398 _, err := fmt.Fprintf(w, "No track currently playing") 368 399 if err != nil { 369 400 s.logger.Printf("Error writing response: %v", err) ··· 373 404 } 374 405 375 406 w.Header().Set("Content-Type", "application/json") 376 - err := json.NewEncoder(w).Encode(track) 407 + err := json.NewEncoder(w).Encode(state.track) 377 408 if err != nil { 378 409 s.logger.Printf("Error encoding response: %v", err) 379 410 return ··· 402 433 } 403 434 } 404 435 405 - func (s *Service) FetchCurrentTrack(userID int64) (*models.Track, error) { 436 + func (s *Service) FetchCurrentTrack(userID int64) (*SpotifyTrackResponse, error) { 406 437 s.mu.RLock() 407 438 token, exists := s.userTokens[userID] 408 439 s.mu.RUnlock() ··· 510 541 if err != nil { 511 542 return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) 512 543 } 513 - if response.IsPlaying == false { 514 - return nil, nil 515 - } 544 + 516 545 var artists []models.Artist 517 546 for _, artist := range response.Item.Artists { 518 547 artists = append(artists, models.Artist{ ··· 523 552 524 553 // ignore tracks with no artists (podcasts, audiobooks, etc) 525 554 if len(artists) == 0 { 526 - return nil, nil 555 + return &SpotifyTrackResponse{Track: nil, IsPlaying: response.IsPlaying}, nil 527 556 } 528 557 529 558 // assemble Track ··· 540 569 Timestamp: time.Now().UTC(), 541 570 } 542 571 543 - return track, nil 572 + return &SpotifyTrackResponse{Track: track, IsPlaying: response.IsPlaying}, nil 544 573 } 545 574 546 - func (s *Service) fetchAllUserTracks(ctx context.Context) { 547 - // copy userIDs to avoid holding the lock too long 548 - s.mu.RLock() 549 - userIDs := make([]int64, 0, len(s.userTokens)) 550 - for userID := range s.userTokens { 551 - userIDs = append(userIDs, userID) 575 + func getFirstArtist(track *models.Track) string { 576 + if track != nil && len(track.Artist) > 0 { 577 + return track.Artist[0].Name 552 578 } 553 - s.mu.RUnlock() 579 + return "Unknown Artist" 580 + } 554 581 555 - for _, userID := range userIDs { 556 - if ctx.Err() != nil { 557 - s.logger.Printf("Context cancelled before starting fetch for user id %d.", userID) 558 - break // Exit loop if context is cancelled 559 - } 582 + // computeStateUpdate holds the lock, updates user play state based on the 583 + // Spotify response, and returns the actions that should be taken after 584 + // releasing the lock. This separates state computation from external I/O. 585 + // More importantly, this allows for holding a lock while doing early returns 586 + // through the use of defer. 587 + func (s *Service) computeStateUpdate(userID int64, resp *SpotifyTrackResponse) stateAction { 588 + now := time.Now() 589 + var action stateAction 590 + 591 + s.mu.Lock() 592 + defer s.mu.Unlock() 560 593 561 - track, err := s.FetchCurrentTrack(userID) 562 - if err != nil { 563 - s.logger.Printf("Error fetching track for user %d: %v", userID, err) 564 - continue 594 + state := s.userPlayStates[userID] 595 + 596 + // No track from Spotify (nothing playing, not even paused) 597 + if resp == nil || resp.Track == nil { 598 + // If the user already has some state, pause it 599 + if state != nil { 600 + state.isPaused = true 601 + action.clearNowPlaying = true 565 602 } 603 + return action 604 + } 605 + 606 + track := resp.Track 607 + action.track = track 566 608 567 - if track == nil { 568 - // No track currently playing - clear playing now status 569 - if s.playingNowService != nil { 570 - if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil { 571 - s.logger.Printf("Error clearing playing now for user %d: %v", userID, err) 572 - } 609 + // Track is paused 610 + if !resp.IsPlaying { 611 + if state != nil && state.track != nil && state.track.URL == track.URL { 612 + // Same song paused - preserve state, mark paused 613 + state.isPaused = true 614 + } else { 615 + // Different song paused or no prior state - create new state but paused 616 + // 617 + // We use the track's progress rather than 0 to account 618 + // for time missed due to polling latency. A cap is 619 + // used to prevent instant skips past the stamping point 620 + s.userPlayStates[userID] = &userPlayState{ 621 + track: track, 622 + accumulatedMs: min(track.ProgressMs, maxSkipDeltaMs), 623 + lastPollTime: now, 624 + hasStamped: false, 625 + isPaused: true, 573 626 } 574 - continue 575 627 } 628 + action.clearNowPlaying = true 629 + return action 630 + } 576 631 577 - s.mu.RLock() 578 - currentTrack := s.userTracks[userID] 579 - s.mu.RUnlock() 632 + // Track is playing 580 633 581 - if currentTrack == nil { 582 - currentTracks, _ := s.DB.GetRecentTracks(userID, 1) 583 - if len(currentTracks) > 0 { 584 - currentTrack = currentTracks[0] 585 - } 634 + isNewTrack := state == nil || state.track == nil || state.track.URL != track.URL 635 + if isNewTrack { 636 + // New song - reset state 637 + // 638 + // We use the track's progress rather than 0 to account 639 + // for time missed due to polling latency. A cap is 640 + // used to prevent instant skips past the stamping point 641 + s.userPlayStates[userID] = &userPlayState{ 642 + track: track, 643 + accumulatedMs: min(track.ProgressMs, maxSkipDeltaMs), 644 + lastPollTime: now, 645 + hasStamped: false, 646 + isPaused: false, 586 647 } 648 + state = s.userPlayStates[userID] 649 + action.publishNowPlaying = true 650 + s.logger.Printf("Track changed for user %d: %s by %s", userID, track.Name, getFirstArtist(track)) 651 + } else { 652 + // Same song continuing 653 + state.track = track 654 + if state.isPaused { 655 + // Resuming from pause - just mark as playing, don't add delta yet 656 + // 657 + // This technically causes a loss of acc time due to 658 + // polling latency, but there isn't really a safe way 659 + // to fix this. If the user stops and starts the same 660 + // song many times, this would cause issues. That's 661 + // likely rare though. 662 + state.isPaused = false 663 + action.publishNowPlaying = true 664 + } else { 665 + // Was already playing - add delta time 666 + // (capped to prevent spurious large accumulation from server issues) 667 + deltaMs := min(now.Sub(state.lastPollTime).Milliseconds(), maxDeltaMs) 668 + state.accumulatedMs += deltaMs 669 + } 670 + state.lastPollTime = now 671 + } 587 672 588 - // if flagged true, we have a new track 589 - isNewTrack := currentTrack == nil || 590 - currentTrack.Name != track.Name || 591 - // just check the first one for now 592 - currentTrack.Artist[0].Name != track.Artist[0].Name 673 + // Check for song repeat (accumulated >= duration) 674 + if state.accumulatedMs >= track.DurationMs { 675 + // Subtract duration rather than setting to 0 to account for 676 + // polling latency (leaves acc > 0 after in many cases). 677 + state.accumulatedMs -= track.DurationMs 678 + state.hasStamped = false 679 + s.logger.Printf( 680 + "Song repeat detected for user %d: %s (acc: %dms, dur: %dms)", 681 + userID, track.Name, state.accumulatedMs, state.track.DurationMs, 682 + ) 683 + } 684 + 685 + // Check for stamp threshold 686 + // We stamp a track iff we've played more than half or 30 seconds, whichever is greater 687 + stampThreshold := max(track.DurationMs/2, 30000) 688 + if state.accumulatedMs > stampThreshold && !state.hasStamped { 689 + state.hasStamped = true 690 + action.stampTrack = true 691 + action.accumulatedMs = state.accumulatedMs 692 + } 693 + 694 + return action 695 + } 593 696 594 - // we stamp a track iff we've played more than half (or 30 seconds whichever is greater) 595 - isStamped := track.ProgressMs > track.DurationMs/2 && track.ProgressMs > 30000 697 + // fetchTrackForUser fetches the current track from Spotify, computes the 698 + // state update, and executes any required external actions. 699 + func (s *Service) fetchTrackForUser(ctx context.Context, userID int64) { 700 + // Fetch from Spotify 701 + resp, err := s.FetchCurrentTrack(userID) 702 + if err != nil { 703 + s.logger.Printf("Error fetching track for user %d: %v", userID, err) 704 + return 705 + } 596 706 597 - // if currentTrack.Timestamp minus track.Timestamp is greater than 30 seconds 598 - isLastTrackStamped := currentTrack != nil && time.Since(currentTrack.Timestamp) > 30*time.Second && 599 - currentTrack.DurationMs > 30000 707 + // Compute state changes (holds lock internally) 708 + action := s.computeStateUpdate(userID, resp) 600 709 601 - // just log when we stamp tracks 602 - if isNewTrack && isLastTrackStamped && currentTrack != nil && !currentTrack.HasStamped { 603 - artistName := "Unknown Artist" 604 - if len(currentTrack.Artist) > 0 { 605 - artistName = currentTrack.Artist[0].Name 606 - } 607 - s.logger.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, artistName) 608 - currentTrack.HasStamped = true 609 - if currentTrack.PlayID != 0 { 610 - err := s.DB.UpdateTrack(currentTrack.PlayID, currentTrack) 611 - if err != nil { 612 - s.logger.Printf("Error updating track %d in DB: %v", currentTrack.PlayID, err) 613 - return 614 - } 615 - s.logger.Printf("Updated!") 616 - } 710 + // Execute external calls based on computed actions (no lock held) 711 + if action.clearNowPlaying && s.playingNowService != nil { 712 + if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil { 713 + s.logger.Printf("Error clearing playing now for user %d: %v", userID, err) 617 714 } 715 + } 618 716 619 - if isStamped && currentTrack != nil && !currentTrack.HasStamped { 620 - artistName := "Unknown Artist" 621 - if len(track.Artist) > 0 { 622 - artistName = track.Artist[0].Name 623 - } 624 - s.logger.Printf("User %d stamped track: %s by %s", userID, track.Name, artistName) 625 - track.HasStamped = true 626 - // if currenttrack has a playid and the last track is the same as the current track 627 - if !isNewTrack && currentTrack.PlayID != 0 { 628 - err := s.DB.UpdateTrack(currentTrack.PlayID, track) 629 - if err != nil { 630 - s.logger.Printf("Error updating track %d in DB: %v", currentTrack.PlayID, err) 631 - return 632 - } 717 + if action.publishNowPlaying && s.playingNowService != nil { 718 + if err := s.playingNowService.PublishPlayingNow(ctx, userID, action.track); err != nil { 719 + s.logger.Printf("Error publishing playing now for user %d: %v", userID, err) 720 + } 721 + } 633 722 634 - // Update in memory 635 - s.mu.Lock() 636 - s.userTracks[userID] = track 637 - s.mu.Unlock() 723 + if action.stampTrack { 724 + s.logger.Printf( 725 + "User %d stamped track: %s by %s (acc: %dms, dur: %dms)", 726 + userID, action.track.Name, getFirstArtist(action.track), 727 + action.accumulatedMs, action.track.DurationMs, 728 + ) 729 + s.stampTrack(ctx, userID, action.track) 730 + } 731 + } 638 732 639 - // Update playing now status since track progress changed 640 - if s.playingNowService != nil { 641 - if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { 642 - s.logger.Printf("Error updating playing now for user %d: %v", userID, err) 643 - } 644 - } 733 + func (s *Service) fetchAllUserTracks(ctx context.Context) { 734 + // copy userIDs to avoid holding the lock too long 735 + s.mu.RLock() 736 + userIDs := make([]int64, 0, len(s.userTokens)) 737 + for userID := range s.userTokens { 738 + userIDs = append(userIDs, userID) 739 + } 740 + s.mu.RUnlock() 645 741 646 - s.logger.Printf("Updated!") 647 - } 742 + for _, userID := range userIDs { 743 + if ctx.Err() != nil { 744 + s.logger.Printf("Context cancelled before starting fetch for user id %d.", userID) 745 + break // Exit loop if context is cancelled 648 746 } 649 747 650 - if isNewTrack { 651 - id, err := s.DB.SaveTrack(userID, track) 652 - if err != nil { 653 - s.logger.Printf("Error saving track for user %d: %v", userID, err) 654 - continue 655 - } 748 + s.fetchTrackForUser(ctx, userID) 749 + } 750 + } 656 751 657 - track.PlayID = id 658 - 659 - s.mu.Lock() 660 - s.userTracks[userID] = track 661 - s.mu.Unlock() 752 + // stampTrack handles MusicBrainz hydration, DB save, and PDS submission for a stamped track. 753 + func (s *Service) stampTrack(ctx context.Context, userID int64, track *models.Track) { 754 + track.HasStamped = true 662 755 663 - // Publish playing now status 664 - if s.playingNowService != nil { 665 - if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { 666 - s.logger.Printf("Error publishing playing now for user %d: %v", userID, err) 667 - } 668 - } 756 + trackToSubmit := track 757 + if s.mb != nil { 758 + hydratedTrack, err := musicbrainz.HydrateTrack(s.mb, *track) 759 + if err != nil { 760 + s.logger.Printf("User %d: Error hydrating track '%s' with MusicBrainz: %v", userID, track.Name, err) 761 + } else { 762 + s.logger.Printf("User %d: Successfully hydrated track '%s'", userID, track.Name) 763 + trackToSubmit = hydratedTrack 764 + } 765 + } 669 766 670 - // Submit to ATProto PDS 671 - // The 'track' variable is *models.Track and has been saved to DB, PlayID is populated. 672 - dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID 673 - if errUser != nil { 674 - s.logger.Printf("User %d: Error fetching user details for PDS submission: %v", userID, errUser) 675 - } else if dbUser == nil { 676 - s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID) 677 - } else if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" { 678 - s.logger.Printf("User %d (%d): ATProto DID not set. Skipping PDS submission for track '%s'.", userID, dbUser.ATProtoDID, track.Name) 679 - } else { 680 - // User has a DID, proceed with hydration and submission 681 - var trackToSubmitToPDS = track // Default to the original track (already *models.Track) 682 - if s.mb != nil { // Check if MusicBrainz service is available 683 - // musicbrainz.HydrateTrack expects models.Track as second argument, so we pass *track 684 - // and it returns *models.Track 685 - hydratedTrack, errHydrate := musicbrainz.HydrateTrack(s.mb, *track) 686 - if errHydrate != nil { 687 - s.logger.Printf("User %d (%d): Error hydrating track '%s' with MusicBrainz: %v. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID, track.Name, errHydrate) 688 - } else { 689 - s.logger.Printf("User %d (%d): Successfully hydrated track '%s' with MusicBrainz.", userID, dbUser.ATProtoDID, track.Name) 690 - trackToSubmitToPDS = hydratedTrack // hydratedTrack is *models.Track 691 - } 692 - } else { 693 - s.logger.Printf("User %d (%d): MusicBrainz service not configured. Proceeding with original track data for PDS.", userID, dbUser.ATProtoDID) 694 - } 767 + // Save the track now that it is stamped and hydrated 768 + if _, err := s.DB.SaveTrack(userID, trackToSubmit); err != nil { 769 + s.logger.Printf("Error saving track for user %d: %v", userID, err) 770 + return 771 + } 695 772 696 - artistName := "Unknown Artist" 697 - if len(trackToSubmitToPDS.Artist) > 0 { 698 - artistName = trackToSubmitToPDS.Artist[0].Name 699 - } 773 + // Submit play record to ATProto PDS 700 774 701 - s.logger.Printf("User %d (%d): Attempting to submit track '%s' by %s to PDS (DID: %s)", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, artistName, *dbUser.ATProtoDID) 702 - // Use context.Background() for now, or pass down a context if available 703 - if errPDS := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmitToPDS, context.Background()); errPDS != nil { 704 - s.logger.Printf("User %d (%d): Error submitting track '%s' to PDS: %v", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name, errPDS) 705 - } else { 706 - s.logger.Printf("User %d (%d): Successfully submitted track '%s' to PDS.", userID, dbUser.ATProtoDID, trackToSubmitToPDS.Name) 707 - } 708 - } 709 - // End of PDS submission block 775 + // Fetch the user 776 + dbUser, err := s.DB.GetUserByID(userID) 777 + if err != nil { 778 + s.logger.Printf("User %d: Error fetching user for PDS: %v", userID, err) 779 + return 780 + } 781 + if dbUser == nil { 782 + s.logger.Printf("User %d: User not found in DB. Skipping PDS submission.", userID) 783 + return 784 + } 785 + if dbUser.ATProtoDID == nil || *dbUser.ATProtoDID == "" { 786 + // No DID configured, skip PDS submission silently 787 + return 788 + } 710 789 711 - artistName := "Unknown Artist" 712 - if len(track.Artist) > 0 { 713 - artistName = track.Artist[0].Name 714 - } 715 - s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, artistName) 716 - } 790 + // Perform submission to PDS 791 + s.logger.Printf("User %d: Submitting track '%s' to PDS (DID: %s)", userID, trackToSubmit.Name, *dbUser.ATProtoDID) 792 + if err := s.SubmitTrackToPDS(*dbUser.ATProtoDID, *dbUser.MostRecentAtProtoSessionID, trackToSubmit, ctx); err != nil { 793 + s.logger.Printf("User %d: Error submitting to PDS: %v", userID, err) 794 + } else { 795 + s.logger.Printf("User %d: Successfully submitted track '%s' to PDS", userID, trackToSubmit.Name) 717 796 } 718 797 } 719 798
+1352
service/spotify/spotify_test.go
··· 1 + package spotify 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "io" 7 + "log" 8 + "net/http" 9 + "net/http/httptest" 10 + "testing" 11 + "time" 12 + 13 + "github.com/teal-fm/piper/db" 14 + "github.com/teal-fm/piper/models" 15 + "github.com/teal-fm/piper/session" 16 + ) 17 + 18 + // ===== Mock Implementations ===== 19 + 20 + // publishCall records a call to PublishPlayingNow 21 + type publishCall struct { 22 + userID int64 23 + track *models.Track 24 + } 25 + 26 + // mockPlayingNowService implements the playingNowService interface for testing 27 + type mockPlayingNowService struct { 28 + publishCalls []publishCall 29 + clearCalls []int64 30 + publishErr error 31 + clearErr error 32 + } 33 + 34 + func (m *mockPlayingNowService) PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error { 35 + m.publishCalls = append(m.publishCalls, publishCall{userID: userID, track: track}) 36 + return m.publishErr 37 + } 38 + 39 + func (m *mockPlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error { 40 + m.clearCalls = append(m.clearCalls, userID) 41 + return m.clearErr 42 + } 43 + 44 + // ===== Test Helpers ===== 45 + 46 + func setupTestDB(t *testing.T) *db.DB { 47 + database, err := db.New(":memory:") 48 + if err != nil { 49 + t.Fatalf("Failed to create test database: %v", err) 50 + } 51 + 52 + if err := database.Initialize(); err != nil { 53 + t.Fatalf("Failed to initialize test database: %v", err) 54 + } 55 + 56 + return database 57 + } 58 + 59 + func createTestUser(t *testing.T, database *db.DB) int64 { 60 + user := &models.User{ 61 + Email: func() *string { s := "test@example.com"; return &s }(), 62 + } 63 + userID, err := database.CreateUser(user) 64 + if err != nil { 65 + t.Fatalf("Failed to create test user: %v", err) 66 + } 67 + return userID 68 + } 69 + 70 + func createTestTrack(name, artistName, url string, durationMs, progressMs int64) *models.Track { 71 + return &models.Track{ 72 + Name: name, 73 + Artist: []models.Artist{{Name: artistName, ID: "artist123"}}, 74 + Album: "Test Album", 75 + URL: url, 76 + DurationMs: durationMs, 77 + ProgressMs: progressMs, 78 + ServiceBaseUrl: "open.spotify.com", 79 + ISRC: "TEST1234567", 80 + Timestamp: time.Now().UTC(), 81 + } 82 + } 83 + 84 + func newTestService(database *db.DB, playingNow *mockPlayingNowService) *Service { 85 + return &Service{ 86 + DB: database, 87 + atprotoAuthService: nil, 88 + mb: nil, 89 + playingNowService: playingNow, 90 + userPlayStates: make(map[int64]*userPlayState), 91 + userTokens: make(map[int64]string), 92 + logger: log.New(io.Discard, "", 0), 93 + } 94 + } 95 + 96 + func withUserContext(ctx context.Context, userID int64) context.Context { 97 + return session.WithUserID(ctx, userID) 98 + } 99 + 100 + // ===== getFirstArtist Tests ===== 101 + 102 + func TestGetFirstArtist(t *testing.T) { 103 + testCases := []struct { 104 + name string 105 + track *models.Track 106 + expected string 107 + }{ 108 + { 109 + name: "nil track", 110 + track: nil, 111 + expected: "Unknown Artist", 112 + }, 113 + { 114 + name: "empty artists", 115 + track: &models.Track{ 116 + Name: "Test Track", 117 + Artist: []models.Artist{}, 118 + }, 119 + expected: "Unknown Artist", 120 + }, 121 + { 122 + name: "one artist", 123 + track: &models.Track{ 124 + Name: "Test Track", 125 + Artist: []models.Artist{{Name: "Daft Punk", ID: "123"}}, 126 + }, 127 + expected: "Daft Punk", 128 + }, 129 + { 130 + name: "multiple artists", 131 + track: &models.Track{ 132 + Name: "Test Track", 133 + Artist: []models.Artist{ 134 + {Name: "Artist A", ID: "1"}, 135 + {Name: "Artist B", ID: "2"}, 136 + }, 137 + }, 138 + expected: "Artist A", 139 + }, 140 + } 141 + 142 + for _, tc := range testCases { 143 + t.Run(tc.name, func(t *testing.T) { 144 + result := getFirstArtist(tc.track) 145 + if result != tc.expected { 146 + t.Errorf("Expected '%s', got '%s'", tc.expected, result) 147 + } 148 + }) 149 + } 150 + } 151 + 152 + // ===== computeStateUpdate Tests ===== 153 + 154 + func TestComputeStateUpdate_NoPriorState(t *testing.T) { 155 + t.Run("track playing, no prior state", func(t *testing.T) { 156 + database := setupTestDB(t) 157 + defer database.Close() 158 + 159 + svc := newTestService(database, nil) 160 + userID := int64(1) 161 + 162 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 163 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 164 + 165 + action := svc.computeStateUpdate(userID, resp) 166 + 167 + // Should publish now playing 168 + if !action.publishNowPlaying { 169 + t.Error("Expected publishNowPlaying to be true") 170 + } 171 + if action.clearNowPlaying { 172 + t.Error("Expected clearNowPlaying to be false") 173 + } 174 + 175 + // State should be created 176 + state := svc.userPlayStates[userID] 177 + if state == nil { 178 + t.Fatal("Expected state to be created") 179 + } 180 + if state.isPaused { 181 + t.Error("Expected isPaused to be false") 182 + } 183 + // accumulatedMs should be min(progressMs, maxSkipDeltaMs) 184 + if state.accumulatedMs != 5000 { 185 + t.Errorf("Expected accumulatedMs to be 5000, got %d", state.accumulatedMs) 186 + } 187 + }) 188 + 189 + t.Run("track playing with high progress, capped at maxSkipDeltaMs", func(t *testing.T) { 190 + database := setupTestDB(t) 191 + defer database.Close() 192 + 193 + svc := newTestService(database, nil) 194 + userID := int64(1) 195 + 196 + // Progress is 60s, should be capped at 30s 197 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 60000) 198 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 199 + 200 + action := svc.computeStateUpdate(userID, resp) 201 + 202 + if !action.publishNowPlaying { 203 + t.Error("Expected publishNowPlaying to be true") 204 + } 205 + 206 + state := svc.userPlayStates[userID] 207 + if state.accumulatedMs != maxSkipDeltaMs { 208 + t.Errorf("Expected accumulatedMs to be capped at %d, got %d", maxSkipDeltaMs, state.accumulatedMs) 209 + } 210 + }) 211 + 212 + t.Run("track paused, no prior state", func(t *testing.T) { 213 + database := setupTestDB(t) 214 + defer database.Close() 215 + 216 + svc := newTestService(database, nil) 217 + userID := int64(1) 218 + 219 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 220 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: false} 221 + 222 + action := svc.computeStateUpdate(userID, resp) 223 + 224 + if !action.clearNowPlaying { 225 + t.Error("Expected clearNowPlaying to be true") 226 + } 227 + if action.publishNowPlaying { 228 + t.Error("Expected publishNowPlaying to be false") 229 + } 230 + 231 + state := svc.userPlayStates[userID] 232 + if state == nil { 233 + t.Fatal("Expected state to be created") 234 + } 235 + if !state.isPaused { 236 + t.Error("Expected isPaused to be true") 237 + } 238 + }) 239 + 240 + t.Run("nil response", func(t *testing.T) { 241 + database := setupTestDB(t) 242 + defer database.Close() 243 + 244 + svc := newTestService(database, nil) 245 + userID := int64(1) 246 + 247 + action := svc.computeStateUpdate(userID, nil) 248 + 249 + // Should be a no-op 250 + if action.clearNowPlaying { 251 + t.Error("Expected clearNowPlaying to be false for nil response with no prior state") 252 + } 253 + if action.publishNowPlaying { 254 + t.Error("Expected publishNowPlaying to be false") 255 + } 256 + if action.stampTrack { 257 + t.Error("Expected stampTrack to be false") 258 + } 259 + }) 260 + 261 + t.Run("nil track in response", func(t *testing.T) { 262 + database := setupTestDB(t) 263 + defer database.Close() 264 + 265 + svc := newTestService(database, nil) 266 + userID := int64(1) 267 + 268 + resp := &SpotifyTrackResponse{Track: nil, IsPlaying: true} 269 + action := svc.computeStateUpdate(userID, resp) 270 + 271 + // Should be a no-op 272 + if action.clearNowPlaying { 273 + t.Error("Expected clearNowPlaying to be false for nil track with no prior state") 274 + } 275 + if action.publishNowPlaying { 276 + t.Error("Expected publishNowPlaying to be false") 277 + } 278 + if action.stampTrack { 279 + t.Error("Expected stampTrack to be false") 280 + } 281 + }) 282 + } 283 + 284 + func TestComputeStateUpdate_SameTrackContinues(t *testing.T) { 285 + t.Run("same track still playing, accumulates time", func(t *testing.T) { 286 + database := setupTestDB(t) 287 + defer database.Close() 288 + 289 + svc := newTestService(database, nil) 290 + userID := int64(1) 291 + 292 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 293 + 294 + // Set up existing state 295 + pastTime := time.Now().Add(-10 * time.Second) // 10 seconds ago 296 + svc.userPlayStates[userID] = &userPlayState{ 297 + track: track, 298 + accumulatedMs: 5000, 299 + lastPollTime: pastTime, 300 + hasStamped: false, 301 + isPaused: false, 302 + } 303 + 304 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 305 + action := svc.computeStateUpdate(userID, resp) 306 + 307 + // Should not publish (same track continuing) 308 + if action.publishNowPlaying { 309 + t.Error("Expected publishNowPlaying to be false for same track continuing") 310 + } 311 + 312 + state := svc.userPlayStates[userID] 313 + // Should have added ~10s to accumulated (within tolerance) 314 + if state.accumulatedMs != 15000 { 315 + t.Errorf("Expected accumulatedMs to be %d, got %d", 15000, state.accumulatedMs) 316 + } 317 + }) 318 + 319 + t.Run("same track now paused", func(t *testing.T) { 320 + database := setupTestDB(t) 321 + defer database.Close() 322 + 323 + svc := newTestService(database, nil) 324 + userID := int64(1) 325 + 326 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 327 + 328 + svc.userPlayStates[userID] = &userPlayState{ 329 + track: track, 330 + accumulatedMs: 60000, 331 + lastPollTime: time.Now(), 332 + hasStamped: false, 333 + isPaused: false, 334 + } 335 + 336 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: false} 337 + action := svc.computeStateUpdate(userID, resp) 338 + 339 + if !action.clearNowPlaying { 340 + t.Error("Expected clearNowPlaying to be true") 341 + } 342 + 343 + state := svc.userPlayStates[userID] 344 + if !state.isPaused { 345 + t.Error("Expected isPaused to be true") 346 + } 347 + }) 348 + 349 + t.Run("same track resumed from pause", func(t *testing.T) { 350 + database := setupTestDB(t) 351 + defer database.Close() 352 + 353 + svc := newTestService(database, nil) 354 + userID := int64(1) 355 + 356 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 357 + 358 + svc.userPlayStates[userID] = &userPlayState{ 359 + track: track, 360 + accumulatedMs: 60000, 361 + lastPollTime: time.Now(), 362 + hasStamped: false, 363 + isPaused: true, // Was paused 364 + } 365 + 366 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 367 + action := svc.computeStateUpdate(userID, resp) 368 + 369 + if !action.publishNowPlaying { 370 + t.Error("Expected publishNowPlaying to be true when resuming") 371 + } 372 + 373 + state := svc.userPlayStates[userID] 374 + if state.isPaused { 375 + t.Error("Expected isPaused to be false after resume") 376 + } 377 + }) 378 + 379 + t.Run("delta time capped at maxDeltaMs", func(t *testing.T) { 380 + database := setupTestDB(t) 381 + defer database.Close() 382 + 383 + svc := newTestService(database, nil) 384 + userID := int64(1) 385 + 386 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 387 + 388 + // Set up state with lastPollTime 60 seconds ago 389 + pastTime := time.Now().Add(-60 * time.Second) 390 + svc.userPlayStates[userID] = &userPlayState{ 391 + track: track, 392 + accumulatedMs: 10000, 393 + lastPollTime: pastTime, 394 + hasStamped: false, 395 + isPaused: false, 396 + } 397 + 398 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 399 + svc.computeStateUpdate(userID, resp) 400 + 401 + state := svc.userPlayStates[userID] 402 + // Should be capped: 10000 + 30000 = 40000 (not 10000 + 60000) 403 + if state.accumulatedMs != 10000+maxDeltaMs { // small tolerance 404 + t.Errorf("Expected delta to be capped at maxDeltaMs, got accumulatedMs=%d", state.accumulatedMs) 405 + } 406 + }) 407 + } 408 + 409 + func TestComputeStateUpdate_NewTrackDetected(t *testing.T) { 410 + t.Run("different track URL", func(t *testing.T) { 411 + database := setupTestDB(t) 412 + defer database.Close() 413 + 414 + svc := newTestService(database, nil) 415 + userID := int64(1) 416 + 417 + oldTrack := createTestTrack("Old Song", "Old Artist", "http://spotify/track1", 240000, 120000) 418 + newTrack := createTestTrack("New Song", "New Artist", "http://spotify/track2", 180000, 5000) 419 + 420 + svc.userPlayStates[userID] = &userPlayState{ 421 + track: oldTrack, 422 + accumulatedMs: 120000, 423 + lastPollTime: time.Now(), 424 + hasStamped: true, 425 + isPaused: false, 426 + } 427 + 428 + resp := &SpotifyTrackResponse{Track: newTrack, IsPlaying: true} 429 + action := svc.computeStateUpdate(userID, resp) 430 + 431 + if !action.publishNowPlaying { 432 + t.Error("Expected publishNowPlaying to be true for new track") 433 + } 434 + 435 + state := svc.userPlayStates[userID] 436 + if state.track.URL != newTrack.URL { 437 + t.Error("Expected state to have new track") 438 + } 439 + if state.hasStamped { 440 + t.Error("Expected hasStamped to be reset to false") 441 + } 442 + if state.accumulatedMs != 5000 { 443 + t.Errorf("Expected accumulatedMs to be reset to progressMs (5000), got %d", state.accumulatedMs) 444 + } 445 + }) 446 + } 447 + 448 + func TestComputeStateUpdate_SongRepeat(t *testing.T) { 449 + t.Run("loop detected when accumulated >= duration", func(t *testing.T) { 450 + database := setupTestDB(t) 451 + defer database.Close() 452 + 453 + svc := newTestService(database, nil) 454 + userID := int64(1) 455 + 456 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 180000, 5000) 457 + 458 + // Set accumulated to just under duration 459 + svc.userPlayStates[userID] = &userPlayState{ 460 + track: track, 461 + accumulatedMs: 175000, 462 + lastPollTime: time.Now().Add(-10 * time.Second), 463 + hasStamped: true, 464 + isPaused: false, 465 + } 466 + 467 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 468 + svc.computeStateUpdate(userID, resp) 469 + 470 + state := svc.userPlayStates[userID] 471 + // After adding ~10s, accumulated should be ~185000, exceeding duration of 180000 472 + // So it should subtract duration: 185000 - 180000 = 5000 473 + if state.accumulatedMs != 5000 { 474 + t.Errorf("Expected accumulatedMs to be reset below duration, got %d", state.accumulatedMs) 475 + } 476 + if state.hasStamped { 477 + t.Error("Expected hasStamped to be reset to false after loop") 478 + } 479 + }) 480 + 481 + t.Run("overflow preserved after loop", func(t *testing.T) { 482 + database := setupTestDB(t) 483 + defer database.Close() 484 + 485 + svc := newTestService(database, nil) 486 + userID := int64(1) 487 + 488 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 100000, 5000) 489 + 490 + // Set accumulated to duration + 5000 491 + svc.userPlayStates[userID] = &userPlayState{ 492 + track: track, 493 + accumulatedMs: 105000, 494 + lastPollTime: time.Now(), // recent, so delta is small 495 + hasStamped: true, 496 + isPaused: false, 497 + } 498 + 499 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 500 + svc.computeStateUpdate(userID, resp) 501 + 502 + state := svc.userPlayStates[userID] 503 + // Should have subtracted duration: 105000 - 100000 = 5000 504 + if state.accumulatedMs != 5000 { 505 + t.Errorf("Expected accumulatedMs to be 5000 after loop, got %d", state.accumulatedMs) 506 + } 507 + }) 508 + } 509 + 510 + func TestComputeStateUpdate_StampThreshold(t *testing.T) { 511 + testCases := []struct { 512 + name string 513 + durationMs int64 514 + accumulatedMs int64 515 + hasStamped bool 516 + expectStamp bool 517 + }{ 518 + { 519 + name: "half duration on long track", 520 + durationMs: 240000, // 4 min 521 + accumulatedMs: 121000, // just over 2 min 522 + hasStamped: false, 523 + expectStamp: true, 524 + }, 525 + { 526 + name: "30s threshold on medium track", 527 + durationMs: 50000, // 50 sec track, threshold = max(25s, 30s) = 30s 528 + accumulatedMs: 31000, // over 30s 529 + hasStamped: false, 530 + expectStamp: true, 531 + }, 532 + { 533 + name: "below threshold", 534 + durationMs: 240000, 535 + accumulatedMs: 50000, // threshold is 120000 536 + hasStamped: false, 537 + expectStamp: false, 538 + }, 539 + { 540 + name: "already stamped", 541 + durationMs: 240000, 542 + accumulatedMs: 150000, 543 + hasStamped: true, 544 + expectStamp: false, 545 + }, 546 + { 547 + name: "exactly at threshold should not stamp", 548 + durationMs: 240000, 549 + accumulatedMs: 120000, // exactly at threshold, needs to be > threshold 550 + hasStamped: false, 551 + expectStamp: false, 552 + }, 553 + } 554 + 555 + for _, tc := range testCases { 556 + t.Run(tc.name, func(t *testing.T) { 557 + database := setupTestDB(t) 558 + defer database.Close() 559 + 560 + svc := newTestService(database, nil) 561 + userID := int64(1) 562 + 563 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", tc.durationMs, 5000) 564 + 565 + svc.userPlayStates[userID] = &userPlayState{ 566 + track: track, 567 + accumulatedMs: tc.accumulatedMs, 568 + lastPollTime: time.Now(), // recent, so minimal delta added 569 + hasStamped: tc.hasStamped, 570 + isPaused: false, 571 + } 572 + 573 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 574 + action := svc.computeStateUpdate(userID, resp) 575 + 576 + if action.stampTrack != tc.expectStamp { 577 + t.Errorf("Expected stampTrack=%v, got %v", tc.expectStamp, action.stampTrack) 578 + } 579 + 580 + if tc.expectStamp { 581 + state := svc.userPlayStates[userID] 582 + if !state.hasStamped { 583 + t.Error("Expected hasStamped to be true after stamping") 584 + } 585 + } 586 + }) 587 + } 588 + } 589 + 590 + func TestComputeStateUpdate_EdgeCases(t *testing.T) { 591 + t.Run("zero duration track", func(t *testing.T) { 592 + database := setupTestDB(t) 593 + defer database.Close() 594 + 595 + svc := newTestService(database, nil) 596 + userID := int64(1) 597 + 598 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 0, 0) 599 + 600 + resp := &SpotifyTrackResponse{Track: track, IsPlaying: true} 601 + action := svc.computeStateUpdate(userID, resp) 602 + 603 + // Should not panic, threshold should be max(0, 30000) = 30000 604 + if action.stampTrack { 605 + t.Error("Should not stamp with 0 accumulated time") 606 + } 607 + }) 608 + 609 + t.Run("nil response with existing state clears now playing", func(t *testing.T) { 610 + database := setupTestDB(t) 611 + defer database.Close() 612 + 613 + svc := newTestService(database, nil) 614 + userID := int64(1) 615 + 616 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 5000) 617 + svc.userPlayStates[userID] = &userPlayState{ 618 + track: track, 619 + accumulatedMs: 60000, 620 + lastPollTime: time.Now(), 621 + hasStamped: false, 622 + isPaused: false, 623 + } 624 + 625 + action := svc.computeStateUpdate(userID, nil) 626 + 627 + if !action.clearNowPlaying { 628 + t.Error("Expected clearNowPlaying to be true when response is nil with existing state") 629 + } 630 + 631 + state := svc.userPlayStates[userID] 632 + if !state.isPaused { 633 + t.Error("Expected isPaused to be true") 634 + } 635 + }) 636 + } 637 + 638 + // ===== HTTP Handler Tests ===== 639 + 640 + func TestHandleCurrentTrack(t *testing.T) { 641 + t.Run("no auth returns unauthorized", func(t *testing.T) { 642 + database := setupTestDB(t) 643 + defer database.Close() 644 + 645 + svc := newTestService(database, nil) 646 + 647 + req := httptest.NewRequest(http.MethodGet, "/current", nil) 648 + rr := httptest.NewRecorder() 649 + 650 + svc.HandleCurrentTrack(rr, req) 651 + 652 + if rr.Code != http.StatusUnauthorized { 653 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 654 + } 655 + }) 656 + 657 + t.Run("no state returns no track playing", func(t *testing.T) { 658 + database := setupTestDB(t) 659 + defer database.Close() 660 + 661 + svc := newTestService(database, nil) 662 + userID := createTestUser(t, database) 663 + 664 + req := httptest.NewRequest(http.MethodGet, "/current", nil) 665 + ctx := withUserContext(req.Context(), userID) 666 + req = req.WithContext(ctx) 667 + rr := httptest.NewRecorder() 668 + 669 + svc.HandleCurrentTrack(rr, req) 670 + 671 + if rr.Code != http.StatusOK { 672 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 673 + } 674 + if rr.Body.String() != "No track currently playing" { 675 + t.Errorf("Expected 'No track currently playing', got '%s'", rr.Body.String()) 676 + } 677 + }) 678 + 679 + t.Run("nil track in state returns no track playing", func(t *testing.T) { 680 + database := setupTestDB(t) 681 + defer database.Close() 682 + 683 + svc := newTestService(database, nil) 684 + userID := createTestUser(t, database) 685 + 686 + svc.userPlayStates[userID] = &userPlayState{ 687 + track: nil, 688 + } 689 + 690 + req := httptest.NewRequest(http.MethodGet, "/current", nil) 691 + ctx := withUserContext(req.Context(), userID) 692 + req = req.WithContext(ctx) 693 + rr := httptest.NewRecorder() 694 + 695 + svc.HandleCurrentTrack(rr, req) 696 + 697 + if rr.Body.String() != "No track currently playing" { 698 + t.Errorf("Expected 'No track currently playing', got '%s'", rr.Body.String()) 699 + } 700 + }) 701 + 702 + t.Run("success returns track JSON", func(t *testing.T) { 703 + database := setupTestDB(t) 704 + defer database.Close() 705 + 706 + svc := newTestService(database, nil) 707 + userID := createTestUser(t, database) 708 + 709 + track := createTestTrack("Test Song", "Test Artist", "http://spotify/track1", 240000, 60000) 710 + svc.userPlayStates[userID] = &userPlayState{ 711 + track: track, 712 + } 713 + 714 + req := httptest.NewRequest(http.MethodGet, "/current", nil) 715 + ctx := withUserContext(req.Context(), userID) 716 + req = req.WithContext(ctx) 717 + rr := httptest.NewRecorder() 718 + 719 + svc.HandleCurrentTrack(rr, req) 720 + 721 + if rr.Code != http.StatusOK { 722 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 723 + } 724 + 725 + contentType := rr.Header().Get("Content-Type") 726 + if contentType != "application/json" { 727 + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) 728 + } 729 + 730 + var returnedTrack models.Track 731 + if err := json.Unmarshal(rr.Body.Bytes(), &returnedTrack); err != nil { 732 + t.Fatalf("Failed to parse response JSON: %v", err) 733 + } 734 + 735 + if returnedTrack.Name != "Test Song" { 736 + t.Errorf("Expected track name 'Test Song', got '%s'", returnedTrack.Name) 737 + } 738 + }) 739 + } 740 + 741 + func TestHandleTrackHistory(t *testing.T) { 742 + t.Run("no auth returns unauthorized", func(t *testing.T) { 743 + database := setupTestDB(t) 744 + defer database.Close() 745 + 746 + svc := newTestService(database, nil) 747 + 748 + req := httptest.NewRequest(http.MethodGet, "/history", nil) 749 + rr := httptest.NewRecorder() 750 + 751 + svc.HandleTrackHistory(rr, req) 752 + 753 + if rr.Code != http.StatusUnauthorized { 754 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 755 + } 756 + }) 757 + 758 + t.Run("empty history returns empty array", func(t *testing.T) { 759 + database := setupTestDB(t) 760 + defer database.Close() 761 + 762 + svc := newTestService(database, nil) 763 + userID := createTestUser(t, database) 764 + 765 + req := httptest.NewRequest(http.MethodGet, "/history", nil) 766 + ctx := withUserContext(req.Context(), userID) 767 + req = req.WithContext(ctx) 768 + rr := httptest.NewRecorder() 769 + 770 + svc.HandleTrackHistory(rr, req) 771 + 772 + if rr.Code != http.StatusOK { 773 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 774 + } 775 + 776 + var tracks []*models.Track 777 + if err := json.Unmarshal(rr.Body.Bytes(), &tracks); err != nil { 778 + t.Fatalf("Failed to parse response JSON: %v", err) 779 + } 780 + 781 + if len(tracks) != 0 { 782 + t.Errorf("Expected empty array, got %d tracks", len(tracks)) 783 + } 784 + }) 785 + 786 + t.Run("success returns tracks", func(t *testing.T) { 787 + database := setupTestDB(t) 788 + defer database.Close() 789 + 790 + svc := newTestService(database, nil) 791 + userID := createTestUser(t, database) 792 + 793 + // Save some tracks to the database 794 + track1 := createTestTrack("Track 1", "Artist 1", "http://spotify/track1", 180000, 0) 795 + track2 := createTestTrack("Track 2", "Artist 2", "http://spotify/track2", 200000, 0) 796 + 797 + if _, err := database.SaveTrack(userID, track1); err != nil { 798 + t.Fatalf("Failed to save track1: %v", err) 799 + } 800 + if _, err := database.SaveTrack(userID, track2); err != nil { 801 + t.Fatalf("Failed to save track2: %v", err) 802 + } 803 + 804 + req := httptest.NewRequest(http.MethodGet, "/history", nil) 805 + ctx := withUserContext(req.Context(), userID) 806 + req = req.WithContext(ctx) 807 + rr := httptest.NewRecorder() 808 + 809 + svc.HandleTrackHistory(rr, req) 810 + 811 + if rr.Code != http.StatusOK { 812 + t.Errorf("Expected status %d, got %d", http.StatusOK, rr.Code) 813 + } 814 + 815 + contentType := rr.Header().Get("Content-Type") 816 + if contentType != "application/json" { 817 + t.Errorf("Expected Content-Type 'application/json', got '%s'", contentType) 818 + } 819 + 820 + var tracks []*models.Track 821 + if err := json.Unmarshal(rr.Body.Bytes(), &tracks); err != nil { 822 + t.Fatalf("Failed to parse response JSON: %v", err) 823 + } 824 + 825 + if len(tracks) != 2 { 826 + t.Errorf("Expected 2 tracks, got %d", len(tracks)) 827 + } 828 + }) 829 + } 830 + 831 + // ===== stampTrack Tests ===== 832 + 833 + func TestStampTrack(t *testing.T) { 834 + t.Run("saves track to database with HasStamped true", func(t *testing.T) { 835 + database := setupTestDB(t) 836 + defer database.Close() 837 + 838 + svc := newTestService(database, nil) 839 + // createTestUser does not assign a DID to the user. 840 + // This prevents a PDS submission from occurring. 841 + userID := createTestUser(t, database) 842 + 843 + track := createTestTrack("Stamp Test", "Test Artist", "http://spotify/track1", 240000, 0) 844 + 845 + svc.stampTrack(context.Background(), userID, track) 846 + 847 + // Verify track was saved 848 + tracks, err := database.GetRecentTracks(userID, 10) 849 + if err != nil { 850 + t.Fatalf("Failed to get recent tracks: %v", err) 851 + } 852 + 853 + if len(tracks) != 1 { 854 + t.Fatalf("Expected 1 track, got %d", len(tracks)) 855 + } 856 + 857 + if tracks[0].Name != "Stamp Test" { 858 + t.Errorf("Expected track name 'Stamp Test', got '%s'", tracks[0].Name) 859 + } 860 + 861 + if !tracks[0].HasStamped { 862 + t.Error("Expected HasStamped to be true") 863 + } 864 + }) 865 + 866 + t.Run("without MusicBrainz service saves original track", func(t *testing.T) { 867 + database := setupTestDB(t) 868 + defer database.Close() 869 + 870 + svc := newTestService(database, nil) 871 + svc.mb = nil // Explicitly nil, already should be but just in case 872 + userID := createTestUser(t, database) 873 + 874 + track := createTestTrack("No MB Test", "Test Artist", "http://spotify/track1", 240000, 0) 875 + 876 + svc.stampTrack(context.Background(), userID, track) 877 + 878 + tracks, err := database.GetRecentTracks(userID, 10) 879 + if err != nil { 880 + t.Fatalf("Failed to get recent tracks: %v", err) 881 + } 882 + 883 + if len(tracks) != 1 { 884 + t.Fatalf("Expected 1 track, got %d", len(tracks)) 885 + } 886 + 887 + // Track should be saved even without MB service 888 + if tracks[0].Name != "No MB Test" { 889 + t.Errorf("Expected track name 'No MB Test', got '%s'", tracks[0].Name) 890 + } 891 + }) 892 + } 893 + 894 + // ===== Multi-User Tests ===== 895 + 896 + func TestComputeStateUpdate_MultipleUsersIsolation(t *testing.T) { 897 + t.Run("two users with different tracks playing simultaneously", func(t *testing.T) { 898 + database := setupTestDB(t) 899 + defer database.Close() 900 + 901 + svc := newTestService(database, nil) 902 + userA := int64(1) 903 + userB := int64(2) 904 + 905 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 5000) 906 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 10000) 907 + 908 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 909 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 910 + 911 + // Both users start playing 912 + actionA := svc.computeStateUpdate(userA, respA) 913 + actionB := svc.computeStateUpdate(userB, respB) 914 + 915 + // Both should publish now playing 916 + if !actionA.publishNowPlaying { 917 + t.Error("Expected User A to publishNowPlaying") 918 + } 919 + if !actionB.publishNowPlaying { 920 + t.Error("Expected User B to publishNowPlaying") 921 + } 922 + 923 + // Verify states are independent 924 + stateA := svc.userPlayStates[userA] 925 + stateB := svc.userPlayStates[userB] 926 + 927 + if stateA.track.URL != trackA.URL { 928 + t.Errorf("User A has wrong track: expected %s, got %s", trackA.URL, stateA.track.URL) 929 + } 930 + if stateB.track.URL != trackB.URL { 931 + t.Errorf("User B has wrong track: expected %s, got %s", trackB.URL, stateB.track.URL) 932 + } 933 + if stateA.accumulatedMs != 5000 { 934 + t.Errorf("User A accumulatedMs: expected 5000, got %d", stateA.accumulatedMs) 935 + } 936 + if stateB.accumulatedMs != 10000 { 937 + t.Errorf("User B accumulatedMs: expected 10000, got %d", stateB.accumulatedMs) 938 + } 939 + }) 940 + 941 + t.Run("one user's track change doesn't reset another user's accumulated time", func(t *testing.T) { 942 + database := setupTestDB(t) 943 + defer database.Close() 944 + 945 + svc := newTestService(database, nil) 946 + userA := int64(1) 947 + userB := int64(2) 948 + 949 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) 950 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) 951 + 952 + // Set up existing states 953 + svc.userPlayStates[userA] = &userPlayState{ 954 + track: trackA, 955 + accumulatedMs: 100000, // 100 seconds accumulated 956 + lastPollTime: time.Now(), 957 + hasStamped: false, 958 + isPaused: false, 959 + } 960 + svc.userPlayStates[userB] = &userPlayState{ 961 + track: trackB, 962 + accumulatedMs: 50000, // 50 seconds accumulated 963 + lastPollTime: time.Now(), 964 + hasStamped: false, 965 + isPaused: false, 966 + } 967 + 968 + // User A changes track 969 + newTrackA := createTestTrack("New Song A", "Artist A", "http://spotify/trackA2", 200000, 5000) 970 + respA := &SpotifyTrackResponse{Track: newTrackA, IsPlaying: true} 971 + svc.computeStateUpdate(userA, respA) 972 + 973 + // User A should have reset state 974 + stateA := svc.userPlayStates[userA] 975 + if stateA.accumulatedMs != 5000 { 976 + t.Errorf("User A should have reset accumulatedMs to 5000, got %d", stateA.accumulatedMs) 977 + } 978 + 979 + // User B should be unchanged 980 + stateB := svc.userPlayStates[userB] 981 + if stateB.accumulatedMs != 50000 { 982 + t.Errorf("User B accumulatedMs should remain 50000, got %d", stateB.accumulatedMs) 983 + } 984 + if stateB.track.URL != trackB.URL { 985 + t.Errorf("User B track should be unchanged") 986 + } 987 + }) 988 + 989 + t.Run("one user's stamp doesn't affect another user's stamp status", func(t *testing.T) { 990 + database := setupTestDB(t) 991 + defer database.Close() 992 + 993 + svc := newTestService(database, nil) 994 + userA := int64(1) 995 + userB := int64(2) 996 + 997 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) 998 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) 999 + 1000 + // User A is above stamp threshold, User B is below 1001 + svc.userPlayStates[userA] = &userPlayState{ 1002 + track: trackA, 1003 + accumulatedMs: 125000, // Above threshold (120000 for 4 min track) 1004 + lastPollTime: time.Now(), 1005 + hasStamped: false, 1006 + isPaused: false, 1007 + } 1008 + svc.userPlayStates[userB] = &userPlayState{ 1009 + track: trackB, 1010 + accumulatedMs: 50000, // Below threshold (90000 for 3 min track) 1011 + lastPollTime: time.Now(), 1012 + hasStamped: false, 1013 + isPaused: false, 1014 + } 1015 + 1016 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1017 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1018 + 1019 + actionA := svc.computeStateUpdate(userA, respA) 1020 + actionB := svc.computeStateUpdate(userB, respB) 1021 + 1022 + // User A should stamp 1023 + if !actionA.stampTrack { 1024 + t.Error("Expected User A to stamp") 1025 + } 1026 + if !svc.userPlayStates[userA].hasStamped { 1027 + t.Error("User A hasStamped should be true") 1028 + } 1029 + 1030 + // User B should NOT stamp 1031 + if actionB.stampTrack { 1032 + t.Error("User B should NOT stamp") 1033 + } 1034 + if svc.userPlayStates[userB].hasStamped { 1035 + t.Error("User B hasStamped should remain false") 1036 + } 1037 + }) 1038 + } 1039 + 1040 + func TestComputeStateUpdate_MultipleUsersDifferentStates(t *testing.T) { 1041 + t.Run("user A playing, user B paused", func(t *testing.T) { 1042 + database := setupTestDB(t) 1043 + defer database.Close() 1044 + 1045 + svc := newTestService(database, nil) 1046 + userA := int64(1) 1047 + userB := int64(2) 1048 + 1049 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 5000) 1050 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 30000) 1051 + 1052 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1053 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: false} // paused 1054 + 1055 + actionA := svc.computeStateUpdate(userA, respA) 1056 + actionB := svc.computeStateUpdate(userB, respB) 1057 + 1058 + // User A should publish now playing 1059 + if !actionA.publishNowPlaying { 1060 + t.Error("User A should publishNowPlaying") 1061 + } 1062 + if actionA.clearNowPlaying { 1063 + t.Error("User A should NOT clearNowPlaying") 1064 + } 1065 + 1066 + // User B should clear now playing (paused) 1067 + if !actionB.clearNowPlaying { 1068 + t.Error("User B should clearNowPlaying") 1069 + } 1070 + if actionB.publishNowPlaying { 1071 + t.Error("User B should NOT publishNowPlaying") 1072 + } 1073 + 1074 + // Verify states 1075 + stateA := svc.userPlayStates[userA] 1076 + stateB := svc.userPlayStates[userB] 1077 + 1078 + if stateA.isPaused { 1079 + t.Error("User A should NOT be paused") 1080 + } 1081 + if !stateB.isPaused { 1082 + t.Error("User B should be paused") 1083 + } 1084 + }) 1085 + 1086 + t.Run("user A pauses while user B continues playing", func(t *testing.T) { 1087 + database := setupTestDB(t) 1088 + defer database.Close() 1089 + 1090 + svc := newTestService(database, nil) 1091 + userA := int64(1) 1092 + userB := int64(2) 1093 + 1094 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) 1095 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) 1096 + 1097 + // Both users are playing 1098 + pastTime := time.Now().Add(-10 * time.Second) 1099 + svc.userPlayStates[userA] = &userPlayState{ 1100 + track: trackA, 1101 + accumulatedMs: 60000, 1102 + lastPollTime: pastTime, 1103 + hasStamped: false, 1104 + isPaused: false, 1105 + } 1106 + svc.userPlayStates[userB] = &userPlayState{ 1107 + track: trackB, 1108 + accumulatedMs: 40000, 1109 + lastPollTime: pastTime, 1110 + hasStamped: false, 1111 + isPaused: false, 1112 + } 1113 + 1114 + // User A pauses, User B continues 1115 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: false} 1116 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1117 + 1118 + actionA := svc.computeStateUpdate(userA, respA) 1119 + actionB := svc.computeStateUpdate(userB, respB) 1120 + 1121 + // User A should clear 1122 + if !actionA.clearNowPlaying { 1123 + t.Error("User A should clearNowPlaying") 1124 + } 1125 + 1126 + // User B should NOT clear and should NOT publish (same track continuing) 1127 + if actionB.clearNowPlaying { 1128 + t.Error("User B should NOT clearNowPlaying") 1129 + } 1130 + if actionB.publishNowPlaying { 1131 + t.Error("User B should NOT publishNowPlaying (same track continuing)") 1132 + } 1133 + 1134 + // User A should be paused, User B should not 1135 + if !svc.userPlayStates[userA].isPaused { 1136 + t.Error("User A should be paused") 1137 + } 1138 + if svc.userPlayStates[userB].isPaused { 1139 + t.Error("User B should NOT be paused") 1140 + } 1141 + 1142 + // User B should have accumulated more time (~10s) 1143 + stateB := svc.userPlayStates[userB] 1144 + if stateB.accumulatedMs != 50000 { 1145 + t.Errorf("User B accumulatedMs should be ~50000, got %d", stateB.accumulatedMs) 1146 + } 1147 + }) 1148 + 1149 + t.Run("user A resumes while user B is already playing", func(t *testing.T) { 1150 + database := setupTestDB(t) 1151 + defer database.Close() 1152 + 1153 + svc := newTestService(database, nil) 1154 + userA := int64(1) 1155 + userB := int64(2) 1156 + 1157 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) 1158 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 180000, 0) 1159 + 1160 + // User A is paused, User B is playing 1161 + svc.userPlayStates[userA] = &userPlayState{ 1162 + track: trackA, 1163 + accumulatedMs: 60000, 1164 + lastPollTime: time.Now(), 1165 + hasStamped: false, 1166 + isPaused: true, 1167 + } 1168 + svc.userPlayStates[userB] = &userPlayState{ 1169 + track: trackB, 1170 + accumulatedMs: 40000, 1171 + lastPollTime: time.Now(), 1172 + hasStamped: false, 1173 + isPaused: false, 1174 + } 1175 + 1176 + // User A resumes, User B continues 1177 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1178 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1179 + 1180 + actionA := svc.computeStateUpdate(userA, respA) 1181 + actionB := svc.computeStateUpdate(userB, respB) 1182 + 1183 + // User A should publish (resuming from pause) 1184 + if !actionA.publishNowPlaying { 1185 + t.Error("User A should publishNowPlaying on resume") 1186 + } 1187 + 1188 + // User B should NOT publish (same track continuing) 1189 + if actionB.publishNowPlaying { 1190 + t.Error("User B should NOT publishNowPlaying (same track continuing)") 1191 + } 1192 + 1193 + // Both should not be paused 1194 + if svc.userPlayStates[userA].isPaused { 1195 + t.Error("User A should NOT be paused after resume") 1196 + } 1197 + if svc.userPlayStates[userB].isPaused { 1198 + t.Error("User B should NOT be paused") 1199 + } 1200 + }) 1201 + } 1202 + 1203 + func TestComputeStateUpdate_MultipleUsersStampThreshold(t *testing.T) { 1204 + t.Run("user A reaches stamp threshold, user B doesn't", func(t *testing.T) { 1205 + database := setupTestDB(t) 1206 + defer database.Close() 1207 + 1208 + svc := newTestService(database, nil) 1209 + userA := int64(1) 1210 + userB := int64(2) 1211 + 1212 + // Both have same duration track (threshold = 120000) 1213 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) 1214 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 240000, 0) 1215 + 1216 + // User A is past threshold, User B is not 1217 + svc.userPlayStates[userA] = &userPlayState{ 1218 + track: trackA, 1219 + accumulatedMs: 125000, 1220 + lastPollTime: time.Now(), 1221 + hasStamped: false, 1222 + isPaused: false, 1223 + } 1224 + svc.userPlayStates[userB] = &userPlayState{ 1225 + track: trackB, 1226 + accumulatedMs: 60000, 1227 + lastPollTime: time.Now(), 1228 + hasStamped: false, 1229 + isPaused: false, 1230 + } 1231 + 1232 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1233 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1234 + 1235 + actionA := svc.computeStateUpdate(userA, respA) 1236 + actionB := svc.computeStateUpdate(userB, respB) 1237 + 1238 + if !actionA.stampTrack { 1239 + t.Error("User A should stamp") 1240 + } 1241 + if actionB.stampTrack { 1242 + t.Error("User B should NOT stamp") 1243 + } 1244 + }) 1245 + 1246 + t.Run("both users reach threshold at different accumulated times", func(t *testing.T) { 1247 + database := setupTestDB(t) 1248 + defer database.Close() 1249 + 1250 + svc := newTestService(database, nil) 1251 + userA := int64(1) 1252 + userB := int64(2) 1253 + 1254 + // Different duration tracks, different thresholds 1255 + trackA := createTestTrack("Song A", "Artist A", "http://spotify/trackA", 240000, 0) // threshold = 120000 1256 + trackB := createTestTrack("Song B", "Artist B", "http://spotify/trackB", 50000, 0) // threshold = 30000 (max(25000, 30000)) 1257 + 1258 + // Both are past their respective thresholds 1259 + svc.userPlayStates[userA] = &userPlayState{ 1260 + track: trackA, 1261 + accumulatedMs: 125000, 1262 + lastPollTime: time.Now(), 1263 + hasStamped: false, 1264 + isPaused: false, 1265 + } 1266 + svc.userPlayStates[userB] = &userPlayState{ 1267 + track: trackB, 1268 + accumulatedMs: 35000, 1269 + lastPollTime: time.Now(), 1270 + hasStamped: false, 1271 + isPaused: false, 1272 + } 1273 + 1274 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1275 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1276 + 1277 + actionA := svc.computeStateUpdate(userA, respA) 1278 + actionB := svc.computeStateUpdate(userB, respB) 1279 + 1280 + // Both should stamp 1281 + if !actionA.stampTrack { 1282 + t.Error("User A should stamp") 1283 + } 1284 + if !actionB.stampTrack { 1285 + t.Error("User B should stamp") 1286 + } 1287 + 1288 + // Both should have hasStamped = true 1289 + if !svc.userPlayStates[userA].hasStamped { 1290 + t.Error("User A hasStamped should be true") 1291 + } 1292 + if !svc.userPlayStates[userB].hasStamped { 1293 + t.Error("User B hasStamped should be true") 1294 + } 1295 + }) 1296 + 1297 + t.Run("one user loops track while another continues - independent loop detection", func(t *testing.T) { 1298 + database := setupTestDB(t) 1299 + defer database.Close() 1300 + 1301 + svc := newTestService(database, nil) 1302 + userA := int64(1) 1303 + userB := int64(2) 1304 + 1305 + // User A has a short track that will loop 1306 + trackA := createTestTrack("Short Song", "Artist A", "http://spotify/trackA", 100000, 0) 1307 + trackB := createTestTrack("Long Song", "Artist B", "http://spotify/trackB", 300000, 0) 1308 + 1309 + // User A is past their track duration (will trigger loop) 1310 + // User B is still in the middle of their track 1311 + svc.userPlayStates[userA] = &userPlayState{ 1312 + track: trackA, 1313 + accumulatedMs: 105000, // Past 100000 duration 1314 + lastPollTime: time.Now(), 1315 + hasStamped: true, 1316 + isPaused: false, 1317 + } 1318 + svc.userPlayStates[userB] = &userPlayState{ 1319 + track: trackB, 1320 + accumulatedMs: 100000, // Still less than 300000 duration 1321 + lastPollTime: time.Now(), 1322 + hasStamped: false, 1323 + isPaused: false, 1324 + } 1325 + 1326 + respA := &SpotifyTrackResponse{Track: trackA, IsPlaying: true} 1327 + respB := &SpotifyTrackResponse{Track: trackB, IsPlaying: true} 1328 + 1329 + svc.computeStateUpdate(userA, respA) 1330 + svc.computeStateUpdate(userB, respB) 1331 + 1332 + stateA := svc.userPlayStates[userA] 1333 + stateB := svc.userPlayStates[userB] 1334 + 1335 + // User A should have looped: accumulatedMs reduced, hasStamped reset 1336 + if stateA.accumulatedMs >= trackA.DurationMs { 1337 + t.Errorf("User A should have looped, accumulatedMs=%d should be < %d", stateA.accumulatedMs, trackA.DurationMs) 1338 + } 1339 + if stateA.hasStamped { 1340 + t.Error("User A hasStamped should be reset to false after loop") 1341 + } 1342 + 1343 + // User B should NOT have looped 1344 + if stateB.accumulatedMs < 100000 { 1345 + t.Errorf("User B should NOT have looped, accumulatedMs=%d", stateB.accumulatedMs) 1346 + } 1347 + // User B should still not be stamped (threshold is 150000 for 300000ms track) 1348 + if stateB.hasStamped { 1349 + t.Error("User B hasStamped should still be false (not reached threshold yet)") 1350 + } 1351 + }) 1352 + }