diff --git a/cmd/handlers.go b/cmd/handlers.go index 5464fd7..150fd23 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -512,6 +512,15 @@ func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.AuthSe continue } + // Skip listens we already stored so client retries stay idempotent + exists, err := database.HasTrackListen(userID, track.Name, track.Timestamp) + if err != nil { + log.Printf("apiSubmitListensHandler: Error checking for existing listen for user %d: %v", userID, err) + } else if exists { + processedTracks = append(processedTracks, track) + continue + } + // Store the track if _, err := database.SaveTrack(userID, &track); err != nil { log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err) diff --git a/cmd/listenbrainz_test.go b/cmd/listenbrainz_test.go index 384c75b..8bdfaf4 100644 --- a/cmd/listenbrainz_test.go +++ b/cmd/listenbrainz_test.go @@ -437,6 +437,57 @@ func TestListenBrainzSubmission_ValidationErrors(t *testing.T) { } } +func TestListenBrainzSubmission_DuplicateResubmission(t *testing.T) { + database := setupTestDB(t) + defer database.Close() + + userID, apiKey := createTestUser(t, database) + + submission := models.ListenBrainzSubmission{ + ListenType: "single", + Payload: []models.ListenBrainzPayload{ + { + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), + TrackMetadata: models.ListenBrainzTrackMetadata{ + ArtistName: "Daft Punk", + TrackName: "One More Time", + }, + }, + }, + } + + jsonData, err := json.Marshal(submission) + if err != nil { + t.Fatalf("Failed to marshal submission: %v", err) + } + + handler := apiSubmitListensHandler(database, nil, nil, nil) + + // Submit the identical payload twice, as a client retrying would + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Token "+apiKey) + req = req.WithContext(withUserContext(req.Context(), userID)) + + rr := httptest.NewRecorder() + handler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Submission %d: expected status %d, got %d. Body: %s", i+1, http.StatusOK, rr.Code, rr.Body.String()) + } + } + + tracks, err := database.GetRecentTracks(userID, 10) + if err != nil { + t.Fatalf("Failed to get tracks from database: %v", err) + } + + if len(tracks) != 1 { + t.Fatalf("Expected 1 track in database after duplicate resubmission, got %d", len(tracks)) + } +} + func TestListenBrainzSubmission_Unauthorized(t *testing.T) { database := setupTestDB(t) defer database.Close() diff --git a/db/db.go b/db/db.go index 63d42f3..890c964 100644 --- a/db/db.go +++ b/db/db.go @@ -379,6 +379,17 @@ func (db *DB) SaveTrack(userID int64, track *models.Track) (int64, error) { return trackID, err } +// HasTrackListen reports whether a listen with the same name and timestamp +// is already stored for the user, so resubmitted payloads stay idempotent. +func (db *DB) HasTrackListen(userID int64, name string, timestamp time.Time) (bool, error) { + var exists bool + err := db.QueryRow(` + SELECT EXISTS( + SELECT 1 FROM tracks WHERE user_id = ? AND name = ? AND timestamp = ? + )`, userID, name, timestamp).Scan(&exists) + return exists, err +} + func (db *DB) UpdateTrack(trackID int64, track *models.Track) error { // marshal artist json artistString := ""