A fork of https://github.com/teal-fm/piper

Merge pull request #13 from fatfingers23/natb/now-playing-and-mb

add status support + mb endpoint

authored by

natalie and committed by
GitHub
9420ff6d f1501f64

+1337 -37
+99
cmd/handlers.go
··· 8 8 "strconv" 9 9 10 10 "github.com/teal-fm/piper/db" 11 + "github.com/teal-fm/piper/models" 11 12 "github.com/teal-fm/piper/service/musicbrainz" 12 13 "github.com/teal-fm/piper/service/spotify" 13 14 "github.com/teal-fm/piper/session" ··· 421 422 jsonResponse(w, http.StatusOK, map[string]string{"message": "Last.fm username unlinked successfully"}) 422 423 } 423 424 } 425 + 426 + // apiSubmitListensHandler handles ListenBrainz-compatible submissions 427 + func apiSubmitListensHandler(database *db.DB) http.HandlerFunc { 428 + return func(w http.ResponseWriter, r *http.Request) { 429 + userID, authenticated := session.GetUserID(r.Context()) 430 + if !authenticated { 431 + jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "Unauthorized"}) 432 + return 433 + } 434 + 435 + if r.Method != http.MethodPost { 436 + jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "Method not allowed"}) 437 + return 438 + } 439 + 440 + // Parse the ListenBrainz submission 441 + var submission models.ListenBrainzSubmission 442 + if err := json.NewDecoder(r.Body).Decode(&submission); err != nil { 443 + log.Printf("apiSubmitListensHandler: Error decoding submission for user %d: %v", userID, err) 444 + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Invalid JSON format"}) 445 + return 446 + } 447 + 448 + // Validate listen_type 449 + validListenTypes := map[string]bool{ 450 + "single": true, 451 + "import": true, 452 + "playing_now": true, 453 + } 454 + if !validListenTypes[submission.ListenType] { 455 + jsonResponse(w, http.StatusBadRequest, map[string]string{ 456 + "error": "Invalid listen_type. Must be 'single', 'import', or 'playing_now'", 457 + }) 458 + return 459 + } 460 + 461 + // Validate payload 462 + if len(submission.Payload) == 0 { 463 + jsonResponse(w, http.StatusBadRequest, map[string]string{"error": "Payload cannot be empty"}) 464 + return 465 + } 466 + 467 + // Process each listen in the payload 468 + var processedTracks []models.Track 469 + var errors []string 470 + 471 + for i, listen := range submission.Payload { 472 + // Validate required fields 473 + if listen.TrackMetadata.ArtistName == "" { 474 + errors = append(errors, fmt.Sprintf("payload[%d]: artist_name is required", i)) 475 + continue 476 + } 477 + if listen.TrackMetadata.TrackName == "" { 478 + errors = append(errors, fmt.Sprintf("payload[%d]: track_name is required", i)) 479 + continue 480 + } 481 + 482 + // Convert to internal Track format 483 + track := listen.ConvertToTrack(userID) 484 + 485 + // For 'playing_now' type, we might want to handle differently 486 + // For now, treat all the same but could add temporary storage later 487 + if submission.ListenType == "playing_now" { 488 + log.Printf("Received playing_now listen for user %d: %s - %s", userID, track.Artist[0].Name, track.Name) 489 + // Could store in a separate playing_now table or just log 490 + continue 491 + } 492 + 493 + // Store the track 494 + if _, err := database.SaveTrack(userID, &track); err != nil { 495 + log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err) 496 + errors = append(errors, fmt.Sprintf("payload[%d]: failed to save track", i)) 497 + continue 498 + } 499 + 500 + processedTracks = append(processedTracks, track) 501 + } 502 + 503 + // Prepare response 504 + response := map[string]interface{}{ 505 + "status": "ok", 506 + "processed": len(processedTracks), 507 + } 508 + 509 + if len(errors) > 0 { 510 + response["errors"] = errors 511 + if len(processedTracks) == 0 { 512 + jsonResponse(w, http.StatusBadRequest, response) 513 + return 514 + } 515 + } 516 + 517 + log.Printf("Successfully processed %d ListenBrainz submissions for user %d (type: %s)", 518 + len(processedTracks), userID, submission.ListenType) 519 + 520 + jsonResponse(w, http.StatusOK, response) 521 + } 522 + }
+539
cmd/listenbrainz_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + "time" 11 + 12 + "github.com/teal-fm/piper/db" 13 + "github.com/teal-fm/piper/models" 14 + "github.com/teal-fm/piper/session" 15 + ) 16 + 17 + func setupTestDB(t *testing.T) *db.DB { 18 + // Use in-memory SQLite database for testing 19 + database, err := db.New(":memory:") 20 + if err != nil { 21 + t.Fatalf("Failed to create test database: %v", err) 22 + } 23 + 24 + if err := database.Initialize(); err != nil { 25 + t.Fatalf("Failed to initialize test database: %v", err) 26 + } 27 + 28 + return database 29 + } 30 + 31 + func createTestUser(t *testing.T, database *db.DB) (int64, string) { 32 + // Create a test user 33 + user := &models.User{ 34 + Email: func() *string { s := "test@example.com"; return &s }(), 35 + ATProtoDID: func() *string { s := "did:test:user"; return &s }(), 36 + } 37 + 38 + userID, err := database.CreateUser(user) 39 + if err != nil { 40 + t.Fatalf("Failed to create test user: %v", err) 41 + } 42 + 43 + // Create API key for the user 44 + sessionManager := session.NewSessionManager(database) 45 + apiKeyObj, err := sessionManager.CreateAPIKey(userID, "test-key", 30) // 30 days validity 46 + if err != nil { 47 + t.Fatalf("Failed to create API key: %v", err) 48 + } 49 + 50 + return userID, apiKeyObj.ID 51 + } 52 + 53 + // Helper to create context with user ID (simulating auth middleware) 54 + func withUserContext(ctx context.Context, userID int64) context.Context { 55 + return session.WithUserID(ctx, userID) 56 + } 57 + 58 + func TestListenBrainzSubmission_Success(t *testing.T) { 59 + database := setupTestDB(t) 60 + defer database.Close() 61 + 62 + userID, apiKey := createTestUser(t, database) 63 + 64 + // Create test submission 65 + submission := models.ListenBrainzSubmission{ 66 + ListenType: "single", 67 + Payload: []models.ListenBrainzPayload{ 68 + { 69 + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), 70 + TrackMetadata: models.ListenBrainzTrackMetadata{ 71 + ArtistName: "Daft Punk", 72 + TrackName: "One More Time", 73 + ReleaseName: func() *string { s := "Discovery"; return &s }(), 74 + AdditionalInfo: &models.ListenBrainzAdditionalInfo{ 75 + RecordingMBID: func() *string { s := "98255a8c-017a-4bc7-8dd6-1fa36124572b"; return &s }(), 76 + ArtistMBIDs: []string{"db92a151-1ac2-438b-bc43-b82e149ddd50"}, 77 + ReleaseMBID: func() *string { s := "bf9e91ea-8029-4a04-a26a-224e00a83266"; return &s }(), 78 + DurationMs: func() *int64 { i := int64(320000); return &i }(), 79 + SpotifyID: func() *string { s := "4PTG3Z6ehGkBFwjybzWkR8"; return &s }(), 80 + ISRC: func() *string { s := "GBARL0600925"; return &s }(), 81 + }, 82 + }, 83 + }, 84 + }, 85 + } 86 + 87 + jsonData, err := json.Marshal(submission) 88 + if err != nil { 89 + t.Fatalf("Failed to marshal submission: %v", err) 90 + } 91 + 92 + // Create request 93 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 94 + req.Header.Set("Content-Type", "application/json") 95 + req.Header.Set("Authorization", "Token "+apiKey) 96 + 97 + // Add user context (simulating authentication middleware) 98 + ctx := withUserContext(req.Context(), userID) 99 + req = req.WithContext(ctx) 100 + 101 + // Create response recorder 102 + rr := httptest.NewRecorder() 103 + 104 + // Call handler 105 + handler := apiSubmitListensHandler(database) 106 + handler(rr, req) 107 + 108 + // Check response 109 + if rr.Code != http.StatusOK { 110 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 111 + } 112 + 113 + // Parse response 114 + var response map[string]interface{} 115 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { 116 + t.Fatalf("Failed to parse response: %v", err) 117 + } 118 + 119 + // Verify response 120 + if response["status"] != "ok" { 121 + t.Errorf("Expected status 'ok', got %v", response["status"]) 122 + } 123 + 124 + processed, ok := response["processed"].(float64) 125 + if !ok || processed != 1 { 126 + t.Errorf("Expected processed count 1, got %v", response["processed"]) 127 + } 128 + 129 + // Verify data was saved to database 130 + tracks, err := database.GetRecentTracks(userID, 10) 131 + if err != nil { 132 + t.Fatalf("Failed to get tracks from database: %v", err) 133 + } 134 + 135 + if len(tracks) != 1 { 136 + t.Fatalf("Expected 1 track in database, got %d", len(tracks)) 137 + } 138 + 139 + track := tracks[0] 140 + if track.Name != "One More Time" { 141 + t.Errorf("Expected track name 'One More Time', got %s", track.Name) 142 + } 143 + if len(track.Artist) == 0 || track.Artist[0].Name != "Daft Punk" { 144 + t.Errorf("Expected artist 'Daft Punk', got %v", track.Artist) 145 + } 146 + if track.Album != "Discovery" { 147 + t.Errorf("Expected album 'Discovery', got %s", track.Album) 148 + } 149 + if track.RecordingMBID == nil || *track.RecordingMBID != "98255a8c-017a-4bc7-8dd6-1fa36124572b" { 150 + t.Errorf("Expected recording MBID to be set correctly") 151 + } 152 + if track.DurationMs != 320000 { 153 + t.Errorf("Expected duration 320000ms, got %d", track.DurationMs) 154 + } 155 + } 156 + 157 + func TestListenBrainzSubmission_MinimalPayload(t *testing.T) { 158 + database := setupTestDB(t) 159 + defer database.Close() 160 + 161 + userID, apiKey := createTestUser(t, database) 162 + 163 + // Create minimal submission (only required fields) 164 + submission := models.ListenBrainzSubmission{ 165 + ListenType: "single", 166 + Payload: []models.ListenBrainzPayload{ 167 + { 168 + TrackMetadata: models.ListenBrainzTrackMetadata{ 169 + ArtistName: "The Beatles", 170 + TrackName: "Hey Jude", 171 + }, 172 + }, 173 + }, 174 + } 175 + 176 + jsonData, err := json.Marshal(submission) 177 + if err != nil { 178 + t.Fatalf("Failed to marshal submission: %v", err) 179 + } 180 + 181 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 182 + req.Header.Set("Content-Type", "application/json") 183 + req.Header.Set("Authorization", "Token "+apiKey) 184 + 185 + ctx := withUserContext(req.Context(), userID) 186 + req = req.WithContext(ctx) 187 + 188 + rr := httptest.NewRecorder() 189 + handler := apiSubmitListensHandler(database) 190 + handler(rr, req) 191 + 192 + if rr.Code != http.StatusOK { 193 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 194 + } 195 + 196 + // Verify track was saved 197 + tracks, err := database.GetRecentTracks(userID, 10) 198 + if err != nil { 199 + t.Fatalf("Failed to get tracks from database: %v", err) 200 + } 201 + 202 + if len(tracks) != 1 { 203 + t.Fatalf("Expected 1 track in database, got %d", len(tracks)) 204 + } 205 + 206 + track := tracks[0] 207 + if track.Name != "Hey Jude" { 208 + t.Errorf("Expected track name 'Hey Jude', got %s", track.Name) 209 + } 210 + if len(track.Artist) == 0 || track.Artist[0].Name != "The Beatles" { 211 + t.Errorf("Expected artist 'The Beatles', got %v", track.Artist) 212 + } 213 + // Timestamp should be set to current time if not provided 214 + if track.Timestamp.IsZero() { 215 + t.Error("Expected timestamp to be set") 216 + } 217 + } 218 + 219 + func TestListenBrainzSubmission_BulkImport(t *testing.T) { 220 + database := setupTestDB(t) 221 + defer database.Close() 222 + 223 + userID, apiKey := createTestUser(t, database) 224 + 225 + // Create bulk submission 226 + submission := models.ListenBrainzSubmission{ 227 + ListenType: "import", 228 + Payload: []models.ListenBrainzPayload{ 229 + { 230 + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), 231 + TrackMetadata: models.ListenBrainzTrackMetadata{ 232 + ArtistName: "Track One Artist", 233 + TrackName: "Track One", 234 + }, 235 + }, 236 + { 237 + ListenedAt: func() *int64 { i := int64(1704067300); return &i }(), 238 + TrackMetadata: models.ListenBrainzTrackMetadata{ 239 + ArtistName: "Track Two Artist", 240 + TrackName: "Track Two", 241 + }, 242 + }, 243 + { 244 + ListenedAt: func() *int64 { i := int64(1704067400); return &i }(), 245 + TrackMetadata: models.ListenBrainzTrackMetadata{ 246 + ArtistName: "Track Three Artist", 247 + TrackName: "Track Three", 248 + }, 249 + }, 250 + }, 251 + } 252 + 253 + jsonData, err := json.Marshal(submission) 254 + if err != nil { 255 + t.Fatalf("Failed to marshal submission: %v", err) 256 + } 257 + 258 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 259 + req.Header.Set("Content-Type", "application/json") 260 + req.Header.Set("Authorization", "Token "+apiKey) 261 + 262 + ctx := withUserContext(req.Context(), userID) 263 + req = req.WithContext(ctx) 264 + 265 + rr := httptest.NewRecorder() 266 + handler := apiSubmitListensHandler(database) 267 + handler(rr, req) 268 + 269 + if rr.Code != http.StatusOK { 270 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 271 + } 272 + 273 + // Parse response 274 + var response map[string]interface{} 275 + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { 276 + t.Fatalf("Failed to parse response: %v", err) 277 + } 278 + 279 + processed, ok := response["processed"].(float64) 280 + if !ok || processed != 3 { 281 + t.Errorf("Expected processed count 3, got %v", response["processed"]) 282 + } 283 + 284 + // Verify all tracks were saved 285 + tracks, err := database.GetRecentTracks(userID, 10) 286 + if err != nil { 287 + t.Fatalf("Failed to get tracks from database: %v", err) 288 + } 289 + 290 + if len(tracks) != 3 { 291 + t.Fatalf("Expected 3 tracks in database, got %d", len(tracks)) 292 + } 293 + } 294 + 295 + func TestListenBrainzSubmission_PlayingNow(t *testing.T) { 296 + database := setupTestDB(t) 297 + defer database.Close() 298 + 299 + userID, apiKey := createTestUser(t, database) 300 + 301 + // Create playing_now submission 302 + submission := models.ListenBrainzSubmission{ 303 + ListenType: "playing_now", 304 + Payload: []models.ListenBrainzPayload{ 305 + { 306 + TrackMetadata: models.ListenBrainzTrackMetadata{ 307 + ArtistName: "Current Artist", 308 + TrackName: "Currently Playing", 309 + }, 310 + }, 311 + }, 312 + } 313 + 314 + jsonData, err := json.Marshal(submission) 315 + if err != nil { 316 + t.Fatalf("Failed to marshal submission: %v", err) 317 + } 318 + 319 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 320 + req.Header.Set("Content-Type", "application/json") 321 + req.Header.Set("Authorization", "Token "+apiKey) 322 + 323 + ctx := withUserContext(req.Context(), userID) 324 + req = req.WithContext(ctx) 325 + 326 + rr := httptest.NewRecorder() 327 + handler := apiSubmitListensHandler(database) 328 + handler(rr, req) 329 + 330 + if rr.Code != http.StatusOK { 331 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 332 + } 333 + 334 + // playing_now tracks should not be permanently stored 335 + tracks, err := database.GetRecentTracks(userID, 10) 336 + if err != nil { 337 + t.Fatalf("Failed to get tracks from database: %v", err) 338 + } 339 + 340 + if len(tracks) != 0 { 341 + t.Errorf("Expected 0 tracks in database for playing_now, got %d", len(tracks)) 342 + } 343 + } 344 + 345 + func TestListenBrainzSubmission_ValidationErrors(t *testing.T) { 346 + database := setupTestDB(t) 347 + defer database.Close() 348 + 349 + userID, apiKey := createTestUser(t, database) 350 + 351 + testCases := []struct { 352 + name string 353 + submission models.ListenBrainzSubmission 354 + expectedStatus int 355 + expectedError string 356 + }{ 357 + { 358 + name: "invalid_listen_type", 359 + submission: models.ListenBrainzSubmission{ 360 + ListenType: "invalid", 361 + Payload: []models.ListenBrainzPayload{}, 362 + }, 363 + expectedStatus: http.StatusBadRequest, 364 + expectedError: "Invalid listen_type", 365 + }, 366 + { 367 + name: "empty_payload", 368 + submission: models.ListenBrainzSubmission{ 369 + ListenType: "single", 370 + Payload: []models.ListenBrainzPayload{}, 371 + }, 372 + expectedStatus: http.StatusBadRequest, 373 + expectedError: "Payload cannot be empty", 374 + }, 375 + { 376 + name: "missing_artist_name", 377 + submission: models.ListenBrainzSubmission{ 378 + ListenType: "single", 379 + Payload: []models.ListenBrainzPayload{ 380 + { 381 + TrackMetadata: models.ListenBrainzTrackMetadata{ 382 + TrackName: "Track Without Artist", 383 + }, 384 + }, 385 + }, 386 + }, 387 + expectedStatus: http.StatusBadRequest, 388 + expectedError: "artist_name is required", 389 + }, 390 + { 391 + name: "missing_track_name", 392 + submission: models.ListenBrainzSubmission{ 393 + ListenType: "single", 394 + Payload: []models.ListenBrainzPayload{ 395 + { 396 + TrackMetadata: models.ListenBrainzTrackMetadata{ 397 + ArtistName: "Artist Without Track", 398 + }, 399 + }, 400 + }, 401 + }, 402 + expectedStatus: http.StatusBadRequest, 403 + expectedError: "track_name is required", 404 + }, 405 + } 406 + 407 + for _, tc := range testCases { 408 + t.Run(tc.name, func(t *testing.T) { 409 + jsonData, err := json.Marshal(tc.submission) 410 + if err != nil { 411 + t.Fatalf("Failed to marshal submission: %v", err) 412 + } 413 + 414 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 415 + req.Header.Set("Content-Type", "application/json") 416 + req.Header.Set("Authorization", "Token "+apiKey) 417 + 418 + ctx := withUserContext(req.Context(), userID) 419 + req = req.WithContext(ctx) 420 + 421 + rr := httptest.NewRecorder() 422 + handler := apiSubmitListensHandler(database) 423 + handler(rr, req) 424 + 425 + if rr.Code != tc.expectedStatus { 426 + t.Errorf("Expected status %d, got %d. Body: %s", tc.expectedStatus, rr.Code, rr.Body.String()) 427 + } 428 + 429 + if tc.expectedError != "" { 430 + body := rr.Body.String() 431 + if !bytes.Contains([]byte(body), []byte(tc.expectedError)) { 432 + t.Errorf("Expected error containing '%s', got: %s", tc.expectedError, body) 433 + } 434 + } 435 + }) 436 + } 437 + } 438 + 439 + func TestListenBrainzSubmission_Unauthorized(t *testing.T) { 440 + database := setupTestDB(t) 441 + defer database.Close() 442 + 443 + submission := models.ListenBrainzSubmission{ 444 + ListenType: "single", 445 + Payload: []models.ListenBrainzPayload{ 446 + { 447 + TrackMetadata: models.ListenBrainzTrackMetadata{ 448 + ArtistName: "Test Artist", 449 + TrackName: "Test Track", 450 + }, 451 + }, 452 + }, 453 + } 454 + 455 + jsonData, err := json.Marshal(submission) 456 + if err != nil { 457 + t.Fatalf("Failed to marshal submission: %v", err) 458 + } 459 + 460 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 461 + req.Header.Set("Content-Type", "application/json") 462 + // No Authorization header 463 + 464 + rr := httptest.NewRecorder() 465 + handler := apiSubmitListensHandler(database) 466 + handler(rr, req) 467 + 468 + if rr.Code != http.StatusUnauthorized { 469 + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, rr.Code) 470 + } 471 + } 472 + 473 + func TestListenBrainzDataConversion(t *testing.T) { 474 + // Test the conversion logic directly 475 + payload := models.ListenBrainzPayload{ 476 + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), 477 + TrackMetadata: models.ListenBrainzTrackMetadata{ 478 + ArtistName: "Test Artist", 479 + TrackName: "Test Track", 480 + ReleaseName: func() *string { s := "Test Album"; return &s }(), 481 + AdditionalInfo: &models.ListenBrainzAdditionalInfo{ 482 + RecordingMBID: func() *string { s := "test-recording-mbid"; return &s }(), 483 + ArtistMBIDs: []string{"test-artist-mbid-1", "test-artist-mbid-2"}, 484 + ReleaseMBID: func() *string { s := "test-release-mbid"; return &s }(), 485 + DurationMs: func() *int64 { i := int64(240000); return &i }(), 486 + SpotifyID: func() *string { s := "test-spotify-id"; return &s }(), 487 + ISRC: func() *string { s := "TEST1234567"; return &s }(), 488 + }, 489 + }, 490 + } 491 + 492 + track := payload.ConvertToTrack(123) 493 + 494 + // Verify conversion 495 + if track.Name != "Test Track" { 496 + t.Errorf("Expected track name 'Test Track', got %s", track.Name) 497 + } 498 + if track.Album != "Test Album" { 499 + t.Errorf("Expected album 'Test Album', got %s", track.Album) 500 + } 501 + if track.RecordingMBID == nil || *track.RecordingMBID != "test-recording-mbid" { 502 + t.Errorf("Recording MBID not set correctly") 503 + } 504 + if track.ReleaseMBID == nil || *track.ReleaseMBID != "test-release-mbid" { 505 + t.Errorf("Release MBID not set correctly") 506 + } 507 + if track.DurationMs != 240000 { 508 + t.Errorf("Expected duration 240000ms, got %d", track.DurationMs) 509 + } 510 + if track.ISRC != "TEST1234567" { 511 + t.Errorf("Expected ISRC 'TEST1234567', got %s", track.ISRC) 512 + } 513 + if track.URL != "https://open.spotify.com/track/test-spotify-id" { 514 + t.Errorf("Expected Spotify URL to be constructed correctly, got %s", track.URL) 515 + } 516 + if track.ServiceBaseUrl != "spotify" { 517 + t.Errorf("Expected service 'spotify', got %s", track.ServiceBaseUrl) 518 + } 519 + 520 + expectedTime := time.Unix(1704067200, 0) 521 + if !track.Timestamp.Equal(expectedTime) { 522 + t.Errorf("Expected timestamp %v, got %v", expectedTime, track.Timestamp) 523 + } 524 + 525 + if !track.HasStamped { 526 + t.Error("Expected HasStamped to be true for external submissions") 527 + } 528 + 529 + // Check artists 530 + if len(track.Artist) != 2 { 531 + t.Errorf("Expected 2 artists, got %d", len(track.Artist)) 532 + } 533 + if track.Artist[0].MBID == nil || *track.Artist[0].MBID != "test-artist-mbid-1" { 534 + t.Errorf("First artist MBID not set correctly") 535 + } 536 + if track.Artist[1].MBID == nil || *track.Artist[1].MBID != "test-artist-mbid-2" { 537 + t.Errorf("Second artist MBID not set correctly") 538 + } 539 + }
+22 -17
cmd/main.go
··· 3 3 import ( 4 4 "encoding/json" 5 5 "fmt" 6 - "github.com/teal-fm/piper/service/lastfm" 7 6 "log" 8 7 "net/http" 9 8 "os" 10 9 "time" 10 + 11 + "github.com/teal-fm/piper/service/lastfm" 12 + "github.com/teal-fm/piper/service/playingnow" 11 13 12 14 "github.com/spf13/viper" 13 15 "github.com/teal-fm/piper/config" ··· 21 23 ) 22 24 23 25 type application struct { 24 - database *db.DB 25 - sessionManager *session.SessionManager 26 - oauthManager *oauth.OAuthServiceManager 27 - spotifyService *spotify.SpotifyService 28 - apiKeyService *apikeyService.Service 29 - mbService *musicbrainz.MusicBrainzService 30 - atprotoService *atproto.ATprotoAuthService 26 + database *db.DB 27 + sessionManager *session.SessionManager 28 + oauthManager *oauth.OAuthServiceManager 29 + spotifyService *spotify.SpotifyService 30 + apiKeyService *apikeyService.Service 31 + mbService *musicbrainz.MusicBrainzService 32 + atprotoService *atproto.ATprotoAuthService 33 + playingNowService *playingnow.PlayingNowService 31 34 } 32 35 33 36 // JSON API handlers ··· 73 76 } 74 77 75 78 mbService := musicbrainz.NewMusicBrainzService(database) 76 - spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService) 77 - lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService) 79 + playingNowService := playingnow.NewPlayingNowService(database, atprotoService) 80 + spotifyService := spotify.NewSpotifyService(database, atprotoService, mbService, playingNowService) 81 + lastfmService := lastfm.NewLastFMService(database, viper.GetString("lastfm.api_key"), mbService, atprotoService, playingNowService) 78 82 79 83 sessionManager := session.NewSessionManager(database) 80 84 oauthManager := oauth.NewOAuthServiceManager(sessionManager) ··· 93 97 apiKeyService := apikeyService.NewAPIKeyService(database, sessionManager) 94 98 95 99 app := &application{ 96 - database: database, 97 - sessionManager: sessionManager, 98 - oauthManager: oauthManager, 99 - apiKeyService: apiKeyService, 100 - mbService: mbService, 101 - spotifyService: spotifyService, 102 - atprotoService: atprotoService, 100 + database: database, 101 + sessionManager: sessionManager, 102 + oauthManager: oauthManager, 103 + apiKeyService: apiKeyService, 104 + mbService: mbService, 105 + spotifyService: spotifyService, 106 + atprotoService: atprotoService, 107 + playingNowService: playingNowService, 103 108 } 104 109 105 110 trackerInterval := time.Duration(viper.GetInt("tracker.interval")) * time.Second
+3
cmd/routes.go
··· 36 36 mux.HandleFunc("/api/v1/history", session.WithAPIAuth(apiTrackHistory(app.spotifyService), app.sessionManager)) // Spotify History 37 37 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 38 38 39 + // ListenBrainz-compatible endpoint 40 + mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database), app.sessionManager)) 41 + 39 42 serverUrlRoot := viper.GetString("server.root_url") 40 43 atpClientId := viper.GetString("atproto.client_id") 41 44 atpCallbackUrl := viper.GetString("atproto.callback_url")
+122
models/listenbrainz.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // ListenBrainzSubmission represents the top-level submission format 6 + type ListenBrainzSubmission struct { 7 + ListenType string `json:"listen_type"` 8 + Payload []ListenBrainzPayload `json:"payload"` 9 + } 10 + 11 + // ListenBrainzPayload represents individual listen data 12 + type ListenBrainzPayload struct { 13 + ListenedAt *int64 `json:"listened_at,omitempty"` 14 + TrackMetadata ListenBrainzTrackMetadata `json:"track_metadata"` 15 + } 16 + 17 + // ListenBrainzTrackMetadata contains the track information 18 + type ListenBrainzTrackMetadata struct { 19 + ArtistName string `json:"artist_name"` 20 + TrackName string `json:"track_name"` 21 + ReleaseName *string `json:"release_name,omitempty"` 22 + AdditionalInfo *ListenBrainzAdditionalInfo `json:"additional_info,omitempty"` 23 + } 24 + 25 + // ListenBrainzAdditionalInfo contains optional metadata 26 + type ListenBrainzAdditionalInfo struct { 27 + MediaPlayer *string `json:"media_player,omitempty"` 28 + SubmissionClient *string `json:"submission_client,omitempty"` 29 + SubmissionClientVersion *string `json:"submission_client_version,omitempty"` 30 + RecordingMBID *string `json:"recording_mbid,omitempty"` 31 + ArtistMBIDs []string `json:"artist_mbids,omitempty"` 32 + ReleaseMBID *string `json:"release_mbid,omitempty"` 33 + ReleaseGroupMBID *string `json:"release_group_mbid,omitempty"` 34 + TrackMBID *string `json:"track_mbid,omitempty"` 35 + WorkMBIDs []string `json:"work_mbids,omitempty"` 36 + Tags []string `json:"tags,omitempty"` 37 + DurationMs *int64 `json:"duration_ms,omitempty"` 38 + SpotifyID *string `json:"spotify_id,omitempty"` 39 + ISRC *string `json:"isrc,omitempty"` 40 + TrackNumber *int `json:"tracknumber,omitempty"` 41 + DiscNumber *int `json:"discnumber,omitempty"` 42 + MusicService *string `json:"music_service,omitempty"` 43 + MusicServiceName *string `json:"music_service_name,omitempty"` 44 + OriginURL *string `json:"origin_url,omitempty"` 45 + LastFMTrackURL *string `json:"lastfm_track_url,omitempty"` 46 + YoutubeID *string `json:"youtube_id,omitempty"` 47 + } 48 + 49 + // ConvertToTrack converts ListenBrainz format to internal Track format 50 + func (lbp *ListenBrainzPayload) ConvertToTrack(userID int64) Track { 51 + track := Track{ 52 + Name: lbp.TrackMetadata.TrackName, 53 + Artist: []Artist{{Name: lbp.TrackMetadata.ArtistName}}, 54 + } 55 + 56 + // Set timestamp 57 + if lbp.ListenedAt != nil { 58 + track.Timestamp = time.Unix(*lbp.ListenedAt, 0) 59 + } else { 60 + track.Timestamp = time.Now() 61 + } 62 + 63 + // Set album/release name 64 + if lbp.TrackMetadata.ReleaseName != nil { 65 + track.Album = *lbp.TrackMetadata.ReleaseName 66 + } 67 + 68 + // Handle additional info if present 69 + if info := lbp.TrackMetadata.AdditionalInfo; info != nil { 70 + // Set MBIDs 71 + if info.RecordingMBID != nil { 72 + track.RecordingMBID = info.RecordingMBID 73 + } 74 + if info.ReleaseMBID != nil { 75 + track.ReleaseMBID = info.ReleaseMBID 76 + } 77 + 78 + // Set duration 79 + if info.DurationMs != nil { 80 + track.DurationMs = *info.DurationMs 81 + } 82 + 83 + // Set ISRC 84 + if info.ISRC != nil { 85 + track.ISRC = *info.ISRC 86 + } 87 + 88 + // Handle multiple artists from MBIDs 89 + if len(info.ArtistMBIDs) > 0 { 90 + artists := make([]Artist, len(info.ArtistMBIDs)) 91 + for i, mbid := range info.ArtistMBIDs { 92 + artists[i] = Artist{ 93 + Name: lbp.TrackMetadata.ArtistName, // Use main artist name 94 + MBID: &mbid, 95 + } 96 + } 97 + track.Artist = artists 98 + } 99 + 100 + // Set service information 101 + if info.MusicService != nil { 102 + track.ServiceBaseUrl = *info.MusicService 103 + } 104 + if info.OriginURL != nil { 105 + track.URL = *info.OriginURL 106 + } 107 + if info.SpotifyID != nil { 108 + track.URL = "https://open.spotify.com/track/" + *info.SpotifyID 109 + track.ServiceBaseUrl = "spotify" 110 + } 111 + } 112 + 113 + // Default service if not set 114 + if track.ServiceBaseUrl == "" { 115 + track.ServiceBaseUrl = "listenbrainz" 116 + } 117 + 118 + // Mark as stamped since it came from external submission 119 + track.HasStamped = true 120 + 121 + return track 122 + }
+60 -1
service/lastfm/lastfm.go
··· 39 39 Usernames []string 40 40 musicBrainzService *musicbrainz.MusicBrainzService 41 41 atprotoService *atprotoauth.ATprotoAuthService 42 + playingNowService interface { 43 + PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 44 + ClearPlayingNow(ctx context.Context, userID int64) error 45 + } 42 46 lastSeenNowPlaying map[string]Track 43 47 mu sync.Mutex 44 48 logger *log.Logger 45 49 } 46 50 47 - func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService, atprotoService *atprotoauth.ATprotoAuthService) *LastFMService { 51 + func NewLastFMService(db *db.DB, apiKey string, musicBrainzService *musicbrainz.MusicBrainzService, atprotoService *atprotoauth.ATprotoAuthService, playingNowService interface { 52 + PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 53 + ClearPlayingNow(ctx context.Context, userID int64) error 54 + }) *LastFMService { 48 55 logger := log.New(os.Stdout, "lastfm: ", log.LstdFlags|log.Lmsgprefix) 49 56 50 57 return &LastFMService{ ··· 58 65 Usernames: make([]string, 0), 59 66 atprotoService: atprotoService, 60 67 musicBrainzService: musicBrainzService, 68 + playingNowService: playingNowService, 61 69 lastSeenNowPlaying: make(map[string]Track), 62 70 mu: sync.Mutex{}, 63 71 logger: logger, ··· 303 311 l.logger.Printf("current track does not match last seen track for %s", username) 304 312 // aha! we record this! 305 313 l.lastSeenNowPlaying[username] = nowPlayingTrack 314 + 315 + // Publish playing now status 316 + if l.playingNowService != nil { 317 + // Convert Last.fm track to models.Track format 318 + piperTrack := l.convertLastFMTrackToModelsTrack(nowPlayingTrack) 319 + if err := l.playingNowService.PublishPlayingNow(ctx, user.ID, piperTrack); err != nil { 320 + l.logger.Printf("Error publishing playing now for user %s: %v", username, err) 321 + } 322 + } 306 323 } 307 324 l.mu.Unlock() 325 + } else { 326 + // No now playing track - clear playing now status 327 + if l.playingNowService != nil { 328 + if err := l.playingNowService.ClearPlayingNow(ctx, user.ID); err != nil { 329 + l.logger.Printf("Error clearing playing now for user %s: %v", username, err) 330 + } 331 + } 308 332 } 309 333 310 334 // find last non-now-playing track ··· 467 491 468 492 return nil 469 493 } 494 + 495 + // convertLastFMTrackToModelsTrack converts a Last.fm Track to models.Track format 496 + func (l *LastFMService) convertLastFMTrackToModelsTrack(track Track) *models.Track { 497 + // Create artist array 498 + artists := []models.Artist{ 499 + { 500 + Name: track.Artist.Text, 501 + // Note: Last.fm doesn't provide MBID in now playing, would need separate lookup 502 + }, 503 + } 504 + 505 + // Set timestamp to current time for now playing 506 + timestamp := time.Now() 507 + 508 + piperTrack := &models.Track{ 509 + Name: track.Name, 510 + Artist: artists, 511 + Album: track.Album.Text, // Album is a struct with Text field 512 + Timestamp: timestamp, 513 + ServiceBaseUrl: "lastfm", 514 + HasStamped: false, // Playing now tracks aren't stamped yet 515 + } 516 + 517 + // Add URL if available 518 + if track.URL != "" { 519 + piperTrack.URL = track.URL 520 + } 521 + 522 + // Try to extract MBID if available (Last.fm sometimes provides this) 523 + if track.MBID != "" { // MBID is capitalized 524 + piperTrack.RecordingMBID = &track.MBID 525 + } 526 + 527 + return piperTrack 528 + }
+286
service/playingnow/playingnow.go
··· 1 + package playingnow 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "os" 8 + "strconv" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + lexutil "github.com/bluesky-social/indigo/lex/util" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + oauth "github.com/haileyok/atproto-oauth-golang" 15 + "github.com/spf13/viper" 16 + "github.com/teal-fm/piper/api/teal" 17 + "github.com/teal-fm/piper/db" 18 + "github.com/teal-fm/piper/models" 19 + atprotoauth "github.com/teal-fm/piper/oauth/atproto" 20 + ) 21 + 22 + // PlayingNowService handles publishing current playing status to ATProto 23 + type PlayingNowService struct { 24 + db *db.DB 25 + atprotoService *atprotoauth.ATprotoAuthService 26 + logger *log.Logger 27 + } 28 + 29 + // NewPlayingNowService creates a new playing now service 30 + func NewPlayingNowService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService) *PlayingNowService { 31 + logger := log.New(os.Stdout, "playingnow: ", log.LstdFlags|log.Lmsgprefix) 32 + 33 + return &PlayingNowService{ 34 + db: database, 35 + atprotoService: atprotoService, 36 + logger: logger, 37 + } 38 + } 39 + 40 + // PublishPlayingNow publishes a currently playing track as actor status 41 + func (p *PlayingNowService) PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error { 42 + // Get user information to find their DID 43 + user, err := p.db.GetUserByID(userID) 44 + if err != nil { 45 + return fmt.Errorf("failed to get user: %w", err) 46 + } 47 + 48 + if user.ATProtoDID == nil { 49 + p.logger.Printf("User %d has no ATProto DID, skipping playing now", userID) 50 + return nil 51 + } 52 + 53 + did := *user.ATProtoDID 54 + 55 + // Get ATProto client 56 + client, err := p.atprotoService.GetATProtoClient() 57 + if err != nil || client == nil { 58 + return fmt.Errorf("failed to get ATProto client: %w", err) 59 + } 60 + 61 + xrpcClient := p.atprotoService.GetXrpcClient() 62 + if xrpcClient == nil { 63 + return fmt.Errorf("xrpc client is not available") 64 + } 65 + 66 + // Get user session 67 + sess, err := p.db.GetAtprotoSession(did, ctx, *client) 68 + if err != nil { 69 + return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 70 + } 71 + 72 + // Convert track to PlayView format 73 + playView, err := p.trackToPlayView(track) 74 + if err != nil { 75 + return fmt.Errorf("failed to convert track to PlayView: %w", err) 76 + } 77 + 78 + // Create actor status record 79 + now := time.Now() 80 + expiry := now.Add(10 * time.Minute) // Default 10 minutes as mentioned in schema 81 + 82 + status := &teal.AlphaActorStatus{ 83 + LexiconTypeID: "fm.teal.alpha.actor.status", 84 + Time: strconv.FormatInt(now.Unix(), 10), 85 + Expiry: func() *string { s := strconv.FormatInt(expiry.Unix(), 10); return &s }(), 86 + Item: playView, 87 + } 88 + 89 + var swapRecord *string 90 + authArgs := db.AtpSessionToAuthArgs(sess) 91 + 92 + swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 93 + if err != nil { 94 + return err 95 + } 96 + 97 + // Create the record input 98 + input := atproto.RepoPutRecord_Input{ 99 + Collection: "fm.teal.alpha.actor.status", 100 + Repo: sess.DID, 101 + Rkey: "self", // Use "self" as the record key for current status 102 + Record: &lexutil.LexiconTypeDecoder{Val: status}, 103 + SwapRecord: swapRecord, 104 + } 105 + 106 + // Submit to PDS 107 + var out atproto.RepoPutRecord_Output 108 + if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 109 + p.logger.Printf("Error creating playing now status for DID %s: %v", did, err) 110 + return fmt.Errorf("failed to create playing now status for DID %s: %w", did, err) 111 + } 112 + 113 + p.logger.Printf("Successfully published playing now status for user %d (DID: %s): %s - %s", 114 + userID, did, track.Artist[0].Name, track.Name) 115 + 116 + return nil 117 + } 118 + 119 + // ClearPlayingNow removes the current playing status by setting an expired status 120 + func (p *PlayingNowService) ClearPlayingNow(ctx context.Context, userID int64) error { 121 + // Get user information 122 + user, err := p.db.GetUserByID(userID) 123 + if err != nil { 124 + return fmt.Errorf("failed to get user: %w", err) 125 + } 126 + 127 + if user.ATProtoDID == nil { 128 + p.logger.Printf("User %d has no ATProto DID, skipping clear playing now", userID) 129 + return nil 130 + } 131 + 132 + did := *user.ATProtoDID 133 + 134 + // Get ATProto clients 135 + client, err := p.atprotoService.GetATProtoClient() 136 + if err != nil || client == nil { 137 + return fmt.Errorf("failed to get ATProto client: %w", err) 138 + } 139 + 140 + xrpcClient := p.atprotoService.GetXrpcClient() 141 + if xrpcClient == nil { 142 + return fmt.Errorf("xrpc client is not available") 143 + } 144 + 145 + // Get user session 146 + sess, err := p.db.GetAtprotoSession(did, ctx, *client) 147 + if err != nil { 148 + return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 149 + } 150 + 151 + // Create an expired status (essentially clearing it) 152 + now := time.Now() 153 + expiredTime := now.Add(-1 * time.Minute) // Set expiry to 1 minute ago 154 + 155 + // Create empty play view 156 + emptyPlayView := &teal.AlphaFeedDefs_PlayView{ 157 + TrackName: "", // Empty track indicates no current playing 158 + Artists: []*teal.AlphaFeedDefs_Artist{}, 159 + } 160 + 161 + status := &teal.AlphaActorStatus{ 162 + LexiconTypeID: "fm.teal.alpha.actor.status", 163 + Time: strconv.FormatInt(now.Unix(), 10), 164 + Expiry: func() *string { s := strconv.FormatInt(expiredTime.Unix(), 10); return &s }(), 165 + Item: emptyPlayView, 166 + } 167 + 168 + authArgs := db.AtpSessionToAuthArgs(sess) 169 + 170 + var swapRecord *string 171 + swapRecord, err = p.getStatusSwapRecord(ctx, xrpcClient, sess, authArgs) 172 + if err != nil { 173 + return err 174 + } 175 + 176 + // Update the record 177 + input := atproto.RepoPutRecord_Input{ 178 + Collection: "fm.teal.alpha.actor.status", 179 + Repo: sess.DID, 180 + Rkey: "self", 181 + Record: &lexutil.LexiconTypeDecoder{Val: status}, 182 + SwapRecord: swapRecord, 183 + } 184 + 185 + var out atproto.RepoPutRecord_Output 186 + if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 187 + p.logger.Printf("Error clearing playing now status for DID %s: %v", did, err) 188 + return fmt.Errorf("failed to clear playing now status for DID %s: %w", did, err) 189 + } 190 + 191 + p.logger.Printf("Successfully cleared playing now status for user %d (DID: %s)", userID, did) 192 + return nil 193 + } 194 + 195 + // trackToPlayView converts a models.Track to teal.AlphaFeedDefs_PlayView 196 + func (p *PlayingNowService) trackToPlayView(track *models.Track) (*teal.AlphaFeedDefs_PlayView, error) { 197 + if track.Name == "" { 198 + return nil, fmt.Errorf("track name cannot be empty") 199 + } 200 + 201 + // Convert artists 202 + artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 203 + for _, a := range track.Artist { 204 + artist := &teal.AlphaFeedDefs_Artist{ 205 + ArtistName: a.Name, 206 + ArtistMbId: a.MBID, 207 + } 208 + artists = append(artists, artist) 209 + } 210 + 211 + // Prepare optional fields 212 + var durationPtr *int64 213 + if track.DurationMs > 0 { 214 + durationSeconds := track.DurationMs / 1000 215 + durationPtr = &durationSeconds 216 + } 217 + 218 + var playedTimeStr *string 219 + if !track.Timestamp.IsZero() { 220 + timeStr := track.Timestamp.Format(time.RFC3339) 221 + playedTimeStr = &timeStr 222 + } 223 + 224 + var isrcPtr *string 225 + if track.ISRC != "" { 226 + isrcPtr = &track.ISRC 227 + } 228 + 229 + var originUrlPtr *string 230 + if track.URL != "" { 231 + originUrlPtr = &track.URL 232 + } 233 + 234 + var servicePtr *string 235 + if track.ServiceBaseUrl != "" { 236 + servicePtr = &track.ServiceBaseUrl 237 + } 238 + 239 + var releaseNamePtr *string 240 + if track.Album != "" { 241 + releaseNamePtr = &track.Album 242 + } 243 + 244 + // Get submission client agent 245 + submissionAgent := viper.GetString("app.submission_agent") 246 + if submissionAgent == "" { 247 + submissionAgent = "piper/v0.0.1" 248 + } 249 + 250 + playView := &teal.AlphaFeedDefs_PlayView{ 251 + TrackName: track.Name, 252 + Artists: artists, 253 + Duration: durationPtr, 254 + PlayedTime: playedTimeStr, 255 + RecordingMbId: track.RecordingMBID, 256 + ReleaseMbId: track.ReleaseMBID, 257 + ReleaseName: releaseNamePtr, 258 + Isrc: isrcPtr, 259 + OriginUrl: originUrlPtr, 260 + MusicServiceBaseDomain: servicePtr, 261 + SubmissionClientAgent: &submissionAgent, 262 + } 263 + 264 + return playView, nil 265 + } 266 + 267 + // getStatusSwapRecord retrieves the current swap record (CID) for the actor status record. 268 + // Returns (nil, nil) if the record does not exist yet. 269 + func (p *PlayingNowService) getStatusSwapRecord(ctx context.Context, xrpcClient *oauth.XrpcClient, sess *models.ATprotoAuthSession, authArgs *oauth.XrpcAuthedRequestArgs) (*string, error) { 270 + getOutput := atproto.RepoGetRecord_Output{} 271 + if err := xrpcClient.Do(ctx, authArgs, xrpc.Query, "application/json", "com.atproto.repo.getRecord", map[string]any{ 272 + "repo": sess.DID, 273 + "collection": "fm.teal.alpha.actor.status", 274 + "rkey": "self", 275 + }, nil, &getOutput); err != nil { 276 + xErr, ok := err.(*xrpc.Error) 277 + if !ok { 278 + return nil, fmt.Errorf("could not get record: %w", err) 279 + } 280 + if xErr.StatusCode != 400 { // 400 means not found in this API 281 + return nil, fmt.Errorf("could not get record: %w", err) 282 + } 283 + return nil, nil 284 + } 285 + return getOutput.Cid, nil 286 + }
+144
service/playingnow/playingnow_test.go
··· 1 + package playingnow 2 + 3 + import ( 4 + "testing" 5 + "time" 6 + 7 + "github.com/teal-fm/piper/db" 8 + "github.com/teal-fm/piper/models" 9 + ) 10 + 11 + func TestTrackToPlayView(t *testing.T) { 12 + // Create a mock playing now service (we'll test the conversion logic) 13 + database, err := db.New(":memory:") 14 + if err != nil { 15 + t.Fatalf("Failed to create test database: %v", err) 16 + } 17 + defer database.Close() 18 + 19 + if err := database.Initialize(); err != nil { 20 + t.Fatalf("Failed to initialize test database: %v", err) 21 + } 22 + 23 + // Mock ATProto service (we'll just test the conversion, not the actual submission) 24 + service := &PlayingNowService{ 25 + db: database, 26 + logger: nil, // We'll skip logging in tests 27 + } 28 + 29 + // Create a test track 30 + track := &models.Track{ 31 + Name: "Test Track", 32 + Artist: []models.Artist{ 33 + { 34 + Name: "Test Artist", 35 + MBID: func() *string { s := "test-artist-mbid"; return &s }(), 36 + }, 37 + }, 38 + Album: "Test Album", 39 + DurationMs: 240000, // 4 minutes 40 + Timestamp: time.Now(), 41 + ServiceBaseUrl: "spotify", 42 + URL: "https://open.spotify.com/track/test", 43 + RecordingMBID: func() *string { s := "test-recording-mbid"; return &s }(), 44 + ReleaseMBID: func() *string { s := "test-release-mbid"; return &s }(), 45 + ISRC: "TEST1234567", 46 + } 47 + 48 + // Test the conversion 49 + playView, err := service.trackToPlayView(track) 50 + if err != nil { 51 + t.Fatalf("Failed to convert track to PlayView: %v", err) 52 + } 53 + 54 + // Verify the conversion 55 + if playView.TrackName != "Test Track" { 56 + t.Errorf("Expected track name 'Test Track', got %s", playView.TrackName) 57 + } 58 + 59 + if len(playView.Artists) != 1 { 60 + t.Errorf("Expected 1 artist, got %d", len(playView.Artists)) 61 + } else { 62 + if playView.Artists[0].ArtistName != "Test Artist" { 63 + t.Errorf("Expected artist name 'Test Artist', got %s", playView.Artists[0].ArtistName) 64 + } 65 + if playView.Artists[0].ArtistMbId == nil || *playView.Artists[0].ArtistMbId != "test-artist-mbid" { 66 + t.Errorf("Artist MBID not set correctly") 67 + } 68 + } 69 + 70 + if playView.ReleaseName == nil || *playView.ReleaseName != "Test Album" { 71 + t.Errorf("Release name not set correctly") 72 + } 73 + 74 + if playView.Duration == nil || *playView.Duration != 240 { 75 + t.Errorf("Expected duration 240 seconds, got %v", playView.Duration) 76 + } 77 + 78 + if playView.RecordingMbId == nil || *playView.RecordingMbId != "test-recording-mbid" { 79 + t.Errorf("Recording MBID not set correctly") 80 + } 81 + 82 + if playView.ReleaseMbId == nil || *playView.ReleaseMbId != "test-release-mbid" { 83 + t.Errorf("Release MBID not set correctly") 84 + } 85 + 86 + if playView.Isrc == nil || *playView.Isrc != "TEST1234567" { 87 + t.Errorf("ISRC not set correctly") 88 + } 89 + 90 + if playView.OriginUrl == nil || *playView.OriginUrl != "https://open.spotify.com/track/test" { 91 + t.Errorf("Origin URL not set correctly") 92 + } 93 + 94 + if playView.MusicServiceBaseDomain == nil || *playView.MusicServiceBaseDomain != "spotify" { 95 + t.Errorf("Music service not set correctly") 96 + } 97 + } 98 + 99 + func TestTrackToPlayViewEmptyTrack(t *testing.T) { 100 + service := &PlayingNowService{} 101 + 102 + // Test with empty track name (should fail) 103 + track := &models.Track{ 104 + Name: "", // Empty name should cause error 105 + Artist: []models.Artist{{Name: "Test Artist"}}, 106 + } 107 + 108 + _, err := service.trackToPlayView(track) 109 + if err == nil { 110 + t.Error("Expected error for empty track name, got nil") 111 + } 112 + } 113 + 114 + func TestTrackToPlayViewMinimal(t *testing.T) { 115 + service := &PlayingNowService{} 116 + 117 + // Test with minimal track data 118 + track := &models.Track{ 119 + Name: "Minimal Track", 120 + Artist: []models.Artist{{Name: "Minimal Artist"}}, 121 + } 122 + 123 + playView, err := service.trackToPlayView(track) 124 + if err != nil { 125 + t.Fatalf("Failed to convert minimal track: %v", err) 126 + } 127 + 128 + if playView.TrackName != "Minimal Track" { 129 + t.Errorf("Expected track name 'Minimal Track', got %s", playView.TrackName) 130 + } 131 + 132 + if len(playView.Artists) != 1 || playView.Artists[0].ArtistName != "Minimal Artist" { 133 + t.Errorf("Artist not set correctly") 134 + } 135 + 136 + // Optional fields should be nil for minimal track 137 + if playView.Duration != nil { 138 + t.Errorf("Expected duration to be nil for minimal track") 139 + } 140 + 141 + if playView.ReleaseName != nil { 142 + t.Errorf("Expected release name to be nil for minimal track") 143 + } 144 + }
+62 -19
service/spotify/spotify.go
··· 29 29 ) 30 30 31 31 type SpotifyService struct { 32 - DB *db.DB 33 - atprotoService *atprotoauth.ATprotoAuthService // Added field 34 - mb *musicbrainz.MusicBrainzService // Added field 35 - userTracks map[int64]*models.Track 36 - userTokens map[int64]string 37 - mu sync.RWMutex 38 - logger *log.Logger 32 + DB *db.DB 33 + atprotoService *atprotoauth.ATprotoAuthService // Added field 34 + mb *musicbrainz.MusicBrainzService // Added field 35 + playingNowService interface { 36 + PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 37 + ClearPlayingNow(ctx context.Context, userID int64) error 38 + } // 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 39 43 } 40 44 41 - func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService) *SpotifyService { 45 + func NewSpotifyService(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, musicBrainzService *musicbrainz.MusicBrainzService, playingNowService interface { 46 + PublishPlayingNow(ctx context.Context, userID int64, track *models.Track) error 47 + ClearPlayingNow(ctx context.Context, userID int64) error 48 + }) *SpotifyService { 42 49 logger := log.New(os.Stdout, "spotify: ", log.LstdFlags|log.Lmsgprefix) 43 50 44 51 return &SpotifyService{ 45 - DB: database, 46 - atprotoService: atprotoService, 47 - mb: musicBrainzService, 48 - userTracks: make(map[int64]*models.Track), 49 - userTokens: make(map[int64]string), 50 - logger: logger, 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, 51 59 } 52 60 } 53 61 ··· 530 538 } `json:"external_urls"` 531 539 DurationMs int `json:"duration_ms"` 532 540 } `json:"item"` 533 - ProgressMS int `json:"progress_ms"` 541 + ProgressMS int `json:"progress_ms"` 542 + IsPlaying bool `json:"is_playing"` 534 543 } 535 544 536 545 err = json.Unmarshal(bodyBytes, &response) // Use bodyBytes here 537 546 if err != nil { 538 547 return nil, fmt.Errorf("failed to unmarshal spotify response: %w", err) 539 548 } 540 - 549 + if response.IsPlaying == false { 550 + return nil, nil 551 + } 541 552 var artists []models.Artist 542 553 for _, artist := range response.Item.Artists { 543 554 artists = append(artists, models.Artist{ ··· 585 596 } 586 597 587 598 if track == nil { 599 + // No track currently playing - clear playing now status 600 + if s.playingNowService != nil { 601 + if err := s.playingNowService.ClearPlayingNow(ctx, userID); err != nil { 602 + s.logger.Printf("Error clearing playing now for user %d: %v", userID, err) 603 + } 604 + } 588 605 continue 589 606 } 590 607 ··· 614 631 615 632 // just log when we stamp tracks 616 633 if isNewTrack && isLastTrackStamped && !currentTrack.HasStamped { 617 - s.logger.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, currentTrack.Artist) 634 + artistName := "Unknown Artist" 635 + if len(currentTrack.Artist) > 0 { 636 + artistName = currentTrack.Artist[0].Name 637 + } 638 + s.logger.Printf("User %d stamped (previous) track: %s by %s", userID, currentTrack.Name, artistName) 618 639 currentTrack.HasStamped = true 619 640 if currentTrack.PlayID != 0 { 620 641 s.DB.UpdateTrack(currentTrack.PlayID, currentTrack) ··· 624 645 } 625 646 626 647 if isStamped && currentTrack != nil && !currentTrack.HasStamped { 627 - s.logger.Printf("User %d stamped track: %s by %s", userID, track.Name, track.Artist) 648 + artistName := "Unknown Artist" 649 + if len(track.Artist) > 0 { 650 + artistName = track.Artist[0].Name 651 + } 652 + s.logger.Printf("User %d stamped track: %s by %s", userID, track.Name, artistName) 628 653 track.HasStamped = true 629 654 // if currenttrack has a playid and the last track is the same as the current track 630 655 if !isNewTrack && currentTrack.PlayID != 0 { ··· 635 660 s.userTracks[userID] = track 636 661 s.mu.Unlock() 637 662 663 + // Update playing now status since track progress changed 664 + if s.playingNowService != nil { 665 + if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { 666 + s.logger.Printf("Error updating playing now for user %d: %v", userID, err) 667 + } 668 + } 669 + 638 670 s.logger.Printf("Updated!") 639 671 } 640 672 } ··· 652 684 s.userTracks[userID] = track 653 685 s.mu.Unlock() 654 686 687 + // Publish playing now status 688 + if s.playingNowService != nil { 689 + if err := s.playingNowService.PublishPlayingNow(ctx, userID, track); err != nil { 690 + s.logger.Printf("Error publishing playing now for user %d: %v", userID, err) 691 + } 692 + } 693 + 655 694 // Submit to ATProto PDS 656 695 // The 'track' variable is *models.Track and has been saved to DB, PlayID is populated. 657 696 dbUser, errUser := s.DB.GetUserByID(userID) // Fetch user by their internal ID ··· 693 732 } 694 733 // End of PDS submission block 695 734 696 - s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, track.Artist) 735 + artistName := "Unknown Artist" 736 + if len(track.Artist) > 0 { 737 + artistName = track.Artist[0].Name 738 + } 739 + s.logger.Printf("User %d is listening to: %s by %s", userID, track.Name, artistName) 697 740 } 698 741 } 699 742 }