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

Refactor submissions into an atproto package

Changed files
+276 -162
cmd
service
atproto
lastfm
spotify
+44 -4
cmd/handlers.go
··· 9 9 10 10 "github.com/teal-fm/piper/db" 11 11 "github.com/teal-fm/piper/models" 12 + atprotoauth "github.com/teal-fm/piper/oauth/atproto" 12 13 pages "github.com/teal-fm/piper/pages" 14 + atprotoservice "github.com/teal-fm/piper/service/atproto" 13 15 "github.com/teal-fm/piper/service/musicbrainz" 16 + "github.com/teal-fm/piper/service/playingnow" 14 17 "github.com/teal-fm/piper/service/spotify" 15 18 "github.com/teal-fm/piper/session" 16 19 ) ··· 187 190 188 191 func apiMusicBrainzSearch(mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 189 192 return func(w http.ResponseWriter, r *http.Request) { 193 + if mbService == nil { 194 + jsonResponse(w, http.StatusServiceUnavailable, map[string]string{"error": "MusicBrainz service is not available"}) 195 + return 196 + } 190 197 191 198 params := musicbrainz.SearchParams{ 192 199 Track: r.URL.Query().Get("track"), ··· 318 325 } 319 326 320 327 // apiSubmitListensHandler handles ListenBrainz-compatible submissions 321 - func apiSubmitListensHandler(database *db.DB) http.HandlerFunc { 328 + func apiSubmitListensHandler(database *db.DB, atprotoService *atprotoauth.ATprotoAuthService, playingNowService *playingnow.PlayingNowService, mbService *musicbrainz.MusicBrainzService) http.HandlerFunc { 322 329 return func(w http.ResponseWriter, r *http.Request) { 323 330 userID, authenticated := session.GetUserID(r.Context()) 324 331 if !authenticated { ··· 358 365 return 359 366 } 360 367 368 + // Get user for PDS submission 369 + user, err := database.GetUserByID(userID) 370 + if err != nil { 371 + log.Printf("apiSubmitListensHandler: Error getting user %d: %v", userID, err) 372 + jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get user"}) 373 + return 374 + } 375 + 361 376 // Process each listen in the payload 362 377 var processedTracks []models.Track 363 378 var errors []string ··· 376 391 // Convert to internal Track format 377 392 track := listen.ConvertToTrack(userID) 378 393 379 - // For 'playing_now' type, we might want to handle differently 380 - // For now, treat all the same but could add temporary storage later 394 + // Attempt to hydrate with MusicBrainz data if service is available and track doesn't have MBIDs 395 + if mbService != nil && track.RecordingMBID == nil { 396 + hydratedTrack, err := musicbrainz.HydrateTrack(mbService, track) 397 + if err != nil { 398 + log.Printf("apiSubmitListensHandler: Could not hydrate track with MusicBrainz for user %d: %v (continuing with original data)", userID, err) 399 + // Continue with non-hydrated track 400 + } else if hydratedTrack != nil { 401 + track = *hydratedTrack 402 + log.Printf("apiSubmitListensHandler: Successfully hydrated track '%s' with MusicBrainz data", track.Name) 403 + } 404 + } 405 + 406 + // For 'playing_now' type, publish to PDS as actor status 381 407 if submission.ListenType == "playing_now" { 382 408 log.Printf("Received playing_now listen for user %d: %s - %s", userID, track.Artist[0].Name, track.Name) 383 - // Could store in a separate playing_now table or just log 409 + 410 + if user.ATProtoDID != nil && playingNowService != nil { 411 + if err := playingNowService.PublishPlayingNow(r.Context(), userID, &track); err != nil { 412 + log.Printf("apiSubmitListensHandler: Error publishing playing_now to PDS for user %d: %v", userID, err) 413 + // Don't fail the request, just log the error 414 + } 415 + } 384 416 continue 385 417 } 386 418 ··· 389 421 log.Printf("apiSubmitListensHandler: Error saving track for user %d: %v", userID, err) 390 422 errors = append(errors, fmt.Sprintf("payload[%d]: failed to save track", i)) 391 423 continue 424 + } 425 + 426 + // Submit to PDS as feed.play record 427 + if user.ATProtoDID != nil && atprotoService != nil { 428 + if err := atprotoservice.SubmitPlayToPDS(r.Context(), *user.ATProtoDID, &track, atprotoService); err != nil { 429 + log.Printf("apiSubmitListensHandler: Error submitting play to PDS for user %d: %v", userID, err) 430 + // Don't fail the request, just log the error 431 + } 392 432 } 393 433 394 434 processedTracks = append(processedTracks, track)
+85 -6
cmd/listenbrainz_test.go
··· 11 11 12 12 "github.com/teal-fm/piper/db" 13 13 "github.com/teal-fm/piper/models" 14 + "github.com/teal-fm/piper/service/musicbrainz" 14 15 "github.com/teal-fm/piper/session" 15 16 ) 16 17 ··· 102 103 rr := httptest.NewRecorder() 103 104 104 105 // Call handler 105 - handler := apiSubmitListensHandler(database) 106 + handler := apiSubmitListensHandler(database, nil, nil, nil) 106 107 handler(rr, req) 107 108 108 109 // Check response ··· 186 187 req = req.WithContext(ctx) 187 188 188 189 rr := httptest.NewRecorder() 189 - handler := apiSubmitListensHandler(database) 190 + handler := apiSubmitListensHandler(database, nil, nil, nil) 190 191 handler(rr, req) 191 192 192 193 if rr.Code != http.StatusOK { ··· 263 264 req = req.WithContext(ctx) 264 265 265 266 rr := httptest.NewRecorder() 266 - handler := apiSubmitListensHandler(database) 267 + handler := apiSubmitListensHandler(database, nil, nil, nil) 267 268 handler(rr, req) 268 269 269 270 if rr.Code != http.StatusOK { ··· 324 325 req = req.WithContext(ctx) 325 326 326 327 rr := httptest.NewRecorder() 327 - handler := apiSubmitListensHandler(database) 328 + handler := apiSubmitListensHandler(database, nil, nil, nil) 328 329 handler(rr, req) 329 330 330 331 if rr.Code != http.StatusOK { ··· 419 420 req = req.WithContext(ctx) 420 421 421 422 rr := httptest.NewRecorder() 422 - handler := apiSubmitListensHandler(database) 423 + handler := apiSubmitListensHandler(database, nil, nil, nil) 423 424 handler(rr, req) 424 425 425 426 if rr.Code != tc.expectedStatus { ··· 462 463 // No Authorization header 463 464 464 465 rr := httptest.NewRecorder() 465 - handler := apiSubmitListensHandler(database) 466 + handler := apiSubmitListensHandler(database, nil, nil, nil) 466 467 handler(rr, req) 467 468 468 469 if rr.Code != http.StatusUnauthorized { ··· 537 538 t.Errorf("Second artist MBID not set correctly") 538 539 } 539 540 } 541 + 542 + func TestListenBrainzSubmission_WithMusicBrainzHydration(t *testing.T) { 543 + database := setupTestDB(t) 544 + defer database.Close() 545 + 546 + userID, apiKey := createTestUser(t, database) 547 + 548 + // Create a MusicBrainz service for hydration 549 + mbService := musicbrainz.NewMusicBrainzService(database) 550 + 551 + // Create minimal submission (artist and track name only) 552 + submission := models.ListenBrainzSubmission{ 553 + ListenType: "single", 554 + Payload: []models.ListenBrainzPayload{ 555 + { 556 + ListenedAt: func() *int64 { i := int64(1704067200); return &i }(), 557 + TrackMetadata: models.ListenBrainzTrackMetadata{ 558 + ArtistName: "Daft Punk", 559 + TrackName: "One More Time", 560 + // No MBIDs provided - should be hydrated 561 + }, 562 + }, 563 + }, 564 + } 565 + 566 + jsonData, err := json.Marshal(submission) 567 + if err != nil { 568 + t.Fatalf("Failed to marshal submission: %v", err) 569 + } 570 + 571 + req := httptest.NewRequest(http.MethodPost, "/1/submit-listens", bytes.NewReader(jsonData)) 572 + req.Header.Set("Content-Type", "application/json") 573 + req.Header.Set("Authorization", "Token "+apiKey) 574 + 575 + ctx := withUserContext(req.Context(), userID) 576 + req = req.WithContext(ctx) 577 + 578 + rr := httptest.NewRecorder() 579 + 580 + // Call handler with MusicBrainz service 581 + handler := apiSubmitListensHandler(database, nil, nil, mbService) 582 + handler(rr, req) 583 + 584 + if rr.Code != http.StatusOK { 585 + t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, rr.Code, rr.Body.String()) 586 + } 587 + 588 + // Verify track was saved 589 + tracks, err := database.GetRecentTracks(userID, 10) 590 + if err != nil { 591 + t.Fatalf("Failed to get tracks from database: %v", err) 592 + } 593 + 594 + if len(tracks) != 1 { 595 + t.Fatalf("Expected 1 track in database, got %d", len(tracks)) 596 + } 597 + 598 + track := tracks[0] 599 + 600 + // The track should have been hydrated with MusicBrainz data 601 + // Note: This test requires network access to MusicBrainz API 602 + // In a real test environment, you might want to mock the HTTP client 603 + if track.RecordingMBID != nil { 604 + t.Logf("Track was hydrated with recording MBID: %s", *track.RecordingMBID) 605 + } 606 + 607 + if track.ReleaseMBID != nil { 608 + t.Logf("Track was hydrated with release MBID: %s", *track.ReleaseMBID) 609 + } 610 + 611 + // Even if hydration fails, the track should still be saved with original data 612 + if track.Name != "One More Time" { 613 + t.Errorf("Expected track name 'One More Time', got %s", track.Name) 614 + } 615 + if len(track.Artist) == 0 || track.Artist[0].Name != "Daft Punk" { 616 + t.Errorf("Expected artist 'Daft Punk', got %v", track.Artist) 617 + } 618 + }
+1 -1
cmd/routes.go
··· 40 40 mux.HandleFunc("/api/v1/musicbrainz/search", apiMusicBrainzSearch(app.mbService)) // MusicBrainz (public?) 41 41 42 42 // ListenBrainz-compatible endpoint 43 - mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database), app.sessionManager)) 43 + mux.HandleFunc("/1/submit-listens", session.WithAPIAuth(apiSubmitListensHandler(app.database, app.atprotoService, app.playingNowService, app.mbService), app.sessionManager)) 44 44 45 45 serverUrlRoot := viper.GetString("server.root_url") 46 46 atpClientId := viper.GetString("atproto.client_id")
+136
service/atproto/submission.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + "github.com/spf13/viper" 13 + "github.com/teal-fm/piper/api/teal" 14 + "github.com/teal-fm/piper/db" 15 + "github.com/teal-fm/piper/models" 16 + atprotoauth "github.com/teal-fm/piper/oauth/atproto" 17 + ) 18 + 19 + // SubmitPlayToPDS submits a track play to the ATProto PDS as a feed.play record 20 + func SubmitPlayToPDS(ctx context.Context, did string, track *models.Track, atprotoService *atprotoauth.ATprotoAuthService) error { 21 + if did == "" { 22 + return fmt.Errorf("DID cannot be empty") 23 + } 24 + 25 + // Get ATProto client 26 + client, err := atprotoService.GetATProtoClient() 27 + if err != nil || client == nil { 28 + return fmt.Errorf("failed to get ATProto client: %w", err) 29 + } 30 + 31 + xrpcClient := atprotoService.GetXrpcClient() 32 + if xrpcClient == nil { 33 + return fmt.Errorf("xrpc client is not available") 34 + } 35 + 36 + // Get user session 37 + sess, err := atprotoService.DB.GetAtprotoSession(did, ctx, *client) 38 + if err != nil { 39 + return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 40 + } 41 + 42 + // Convert track to feed.play record 43 + playRecord, err := TrackToPlayRecord(track) 44 + if err != nil { 45 + return fmt.Errorf("failed to convert track to play record: %w", err) 46 + } 47 + 48 + // Create the record 49 + input := atproto.RepoCreateRecord_Input{ 50 + Collection: "fm.teal.alpha.feed.play", 51 + Repo: sess.DID, 52 + Record: &lexutil.LexiconTypeDecoder{Val: playRecord}, 53 + } 54 + 55 + authArgs := db.AtpSessionToAuthArgs(sess) 56 + var out atproto.RepoCreateRecord_Output 57 + if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 58 + return fmt.Errorf("failed to create play record for DID %s: %w", did, err) 59 + } 60 + 61 + log.Printf("Successfully submitted play to PDS for DID %s: %s - %s", did, track.Artist[0].Name, track.Name) 62 + return nil 63 + } 64 + 65 + // TrackToPlayRecord converts a models.Track to teal.AlphaFeedPlay 66 + func TrackToPlayRecord(track *models.Track) (*teal.AlphaFeedPlay, error) { 67 + if track.Name == "" { 68 + return nil, fmt.Errorf("track name cannot be empty") 69 + } 70 + 71 + // Convert artists 72 + artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 73 + for _, a := range track.Artist { 74 + artist := &teal.AlphaFeedDefs_Artist{ 75 + ArtistName: a.Name, 76 + ArtistMbId: a.MBID, 77 + } 78 + artists = append(artists, artist) 79 + } 80 + 81 + // Prepare optional fields 82 + var durationPtr *int64 83 + if track.DurationMs > 0 { 84 + durationSeconds := track.DurationMs / 1000 85 + durationPtr = &durationSeconds 86 + } 87 + 88 + var playedTimeStr *string 89 + if !track.Timestamp.IsZero() { 90 + timeStr := track.Timestamp.Format(time.RFC3339) 91 + playedTimeStr = &timeStr 92 + } 93 + 94 + var isrcPtr *string 95 + if track.ISRC != "" { 96 + isrcPtr = &track.ISRC 97 + } 98 + 99 + var originUrlPtr *string 100 + if track.URL != "" { 101 + originUrlPtr = &track.URL 102 + } 103 + 104 + var servicePtr *string 105 + if track.ServiceBaseUrl != "" { 106 + servicePtr = &track.ServiceBaseUrl 107 + } 108 + 109 + var releaseNamePtr *string 110 + if track.Album != "" { 111 + releaseNamePtr = &track.Album 112 + } 113 + 114 + // Get submission client agent 115 + submissionAgent := viper.GetString("app.submission_agent") 116 + if submissionAgent == "" { 117 + submissionAgent = "piper/v0.0.1" 118 + } 119 + 120 + playRecord := &teal.AlphaFeedPlay{ 121 + LexiconTypeID: "fm.teal.alpha.feed.play", 122 + TrackName: track.Name, 123 + Artists: artists, 124 + Duration: durationPtr, 125 + PlayedTime: playedTimeStr, 126 + RecordingMbId: track.RecordingMBID, 127 + ReleaseMbId: track.ReleaseMBID, 128 + ReleaseName: releaseNamePtr, 129 + Isrc: isrcPtr, 130 + OriginUrl: originUrlPtr, 131 + MusicServiceBaseDomain: servicePtr, 132 + SubmissionClientAgent: &submissionAgent, 133 + } 134 + 135 + return playRecord, nil 136 + }
+3 -77
service/lastfm/lastfm.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 - "errors" 7 6 "fmt" 8 7 "io" 9 8 "log" ··· 14 13 "sync" 15 14 "time" 16 15 17 - "github.com/bluesky-social/indigo/api/atproto" 18 - lexutil "github.com/bluesky-social/indigo/lex/util" 19 - "github.com/bluesky-social/indigo/xrpc" 20 - "github.com/spf13/viper" 21 - "github.com/teal-fm/piper/api/teal" 22 16 "github.com/teal-fm/piper/db" 23 17 "github.com/teal-fm/piper/models" 24 18 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 19 + atprotoservice "github.com/teal-fm/piper/service/atproto" 25 20 "github.com/teal-fm/piper/service/musicbrainz" 26 21 "golang.org/x/time/rate" 27 22 ) ··· 419 414 } 420 415 421 416 func (l *LastFMService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error { 422 - client, err := l.atprotoService.GetATProtoClient() 423 - if err != nil || client == nil { 424 - return err 425 - } 426 - 427 - xrpcClient := l.atprotoService.GetXrpcClient() 428 - if xrpcClient == nil { 429 - return errors.New("xrpc client is kil") 430 - } 431 - 432 - // we check for client above 433 - sess, err := l.db.GetAtprotoSession(did, ctx, *client) 434 - if err != nil { 435 - return fmt.Errorf("Couldn't get Atproto session: %s", err) 436 - } 437 - 438 - // printout the session details 439 - l.logger.Printf("Submitting track for the did: %+v\n", sess.DID) 440 - 441 - artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 442 - for _, a := range track.Artist { 443 - artist := &teal.AlphaFeedDefs_Artist{ 444 - ArtistName: a.Name, 445 - ArtistMbId: a.MBID, 446 - } 447 - artists = append(artists, artist) 448 - } 449 - 450 - var durationPtr *int64 451 - if track.DurationMs > 0 { 452 - durationSeconds := track.DurationMs / 1000 453 - durationPtr = &durationSeconds 454 - } 455 - 456 - playedTimeStr := track.Timestamp.Format(time.RFC3339) 457 - submissionAgent := viper.GetString("app.submission_agent") 458 - if submissionAgent == "" { 459 - submissionAgent = "piper/v0.0.1" // Default if not configured 460 - } 461 - 462 - // track -> tealfm track 463 - tfmTrack := teal.AlphaFeedPlay{ 464 - LexiconTypeID: "fm.teal.alpha.feed.play", // Assuming this is the correct Lexicon ID 465 - // tfm specifies duration in seconds 466 - Duration: durationPtr, // Pointer required 467 - TrackName: track.Name, 468 - // should be unix timestamp 469 - PlayedTime: &playedTimeStr, // Pointer required 470 - Artists: artists, 471 - ReleaseMbId: track.ReleaseMBID, // Pointer required 472 - ReleaseName: &track.Album, // Pointer required 473 - RecordingMbId: track.RecordingMBID, // Pointer required 474 - SubmissionClientAgent: &submissionAgent, // Pointer required 475 - } 476 - 477 - input := atproto.RepoCreateRecord_Input{ 478 - Collection: "fm.teal.alpha.feed.play", 479 - Repo: sess.DID, 480 - Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack}, 481 - } 482 - 483 - authArgs := db.AtpSessionToAuthArgs(sess) 484 - 485 - var out atproto.RepoCreateRecord_Output 486 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 487 - return err 488 - } 489 - 490 - // submit track to PDS 491 - 492 - return nil 417 + // Use shared atproto service for submission 418 + return atprotoservice.SubmitPlayToPDS(ctx, did, track, l.atprotoService) 493 419 } 494 420 495 421 // convertLastFMTrackToModelsTrack converts a Last.fm Track to models.Track format
+7 -74
service/spotify/spotify.go
··· 16 16 17 17 "context" // Added for context.Context 18 18 19 - "github.com/bluesky-social/indigo/api/atproto" // Added for atproto.RepoCreateRecord_Input 20 - lexutil "github.com/bluesky-social/indigo/lex/util" // Added for lexutil.LexiconTypeDecoder 21 - "github.com/bluesky-social/indigo/xrpc" // Added for xrpc.Client 22 - "github.com/spf13/viper" 23 - "github.com/teal-fm/piper/api/teal" // Added for teal.AlphaFeedPlay 19 + // Added for atproto.RepoCreateRecord_Input 20 + // Added for lexutil.LexiconTypeDecoder 21 + // Added for xrpc.Client 22 + "github.com/spf13/viper" // Added for teal.AlphaFeedPlay 24 23 "github.com/teal-fm/piper/db" 25 24 "github.com/teal-fm/piper/models" 26 25 atprotoauth "github.com/teal-fm/piper/oauth/atproto" 26 + atprotoservice "github.com/teal-fm/piper/service/atproto" 27 27 "github.com/teal-fm/piper/service/musicbrainz" 28 28 "github.com/teal-fm/piper/session" 29 29 ) ··· 60 60 } 61 61 62 62 func (s *SpotifyService) SubmitTrackToPDS(did string, track *models.Track, ctx context.Context) error { 63 - client, err := s.atprotoService.GetATProtoClient() 64 - if err != nil || client == nil { 65 - s.logger.Printf("Error getting ATProto client: %v", err) 66 - return fmt.Errorf("failed to get ATProto client: %w", err) 67 - } 68 - 69 - xrpcClient := s.atprotoService.GetXrpcClient() 70 - if xrpcClient == nil { 71 - return errors.New("xrpc client is not available") 72 - } 73 - 74 - sess, err := s.DB.GetAtprotoSession(did, ctx, *client) 75 - if err != nil { 76 - return fmt.Errorf("couldn't get Atproto session for DID %s: %w", did, err) 77 - } 78 - 79 - artists := make([]*teal.AlphaFeedDefs_Artist, 0, len(track.Artist)) 80 - for _, a := range track.Artist { 81 - artist := &teal.AlphaFeedDefs_Artist{ 82 - ArtistName: a.Name, 83 - ArtistMbId: a.MBID, 84 - } 85 - artists = append(artists, artist) 86 - } 87 - 88 - var durationPtr *int64 89 - if track.DurationMs > 0 { 90 - durationSeconds := track.DurationMs / 1000 91 - durationPtr = &durationSeconds 92 - } 93 - 94 - playedTimeStr := track.Timestamp.Format(time.RFC3339) 95 - submissionAgent := viper.GetString("app.submission_agent") 96 - if submissionAgent == "" { 97 - submissionAgent = "piper/v0.0.1" // Default if not configured 98 - } 99 - 100 63 //Had a empty feed.play get submitted not sure why. Tracking here 101 64 if track.Name == "" { 102 65 s.logger.Println("Track name is empty. Skipping submission. Please record the logs before and send to the teal.fm Discord") 103 66 return nil 104 67 } 105 68 106 - tfmTrack := teal.AlphaFeedPlay{ 107 - LexiconTypeID: "fm.teal.alpha.feed.play", 108 - Duration: durationPtr, 109 - TrackName: track.Name, 110 - PlayedTime: &playedTimeStr, 111 - Artists: artists, 112 - ReleaseMbId: track.ReleaseMBID, 113 - ReleaseName: &track.Album, 114 - RecordingMbId: track.RecordingMBID, 115 - // Optional: Spotify specific data if your lexicon supports it 116 - // SpotifyTrackID: &track.ServiceID, 117 - // SpotifyAlbumID: &track.ServiceAlbumID, 118 - // SpotifyArtistIDs: track.ServiceArtistIDs, // Assuming this is a []string 119 - SubmissionClientAgent: &submissionAgent, 120 - } 121 - 122 - input := atproto.RepoCreateRecord_Input{ 123 - Collection: "fm.teal.alpha.feed.play", // Ensure this collection is correct 124 - Repo: sess.DID, 125 - Record: &lexutil.LexiconTypeDecoder{Val: &tfmTrack}, 126 - } 127 - 128 - authArgs := db.AtpSessionToAuthArgs(sess) 129 - 130 - var out atproto.RepoCreateRecord_Output 131 - if err := xrpcClient.Do(ctx, authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.createRecord", nil, input, &out); err != nil { 132 - s.logger.Printf("Error creating record for DID %s: %v. Input: %+v", did, err, input) 133 - return fmt.Errorf("failed to create record on PDS for DID %s: %w", did, err) 134 - } 135 - 136 - s.logger.Printf("Successfully submitted track '%s' to PDS for DID %s. Record URI: %s", track.Name, did, out.Uri) 137 - return nil 69 + // Use shared atproto service for submission 70 + return atprotoservice.SubmitPlayToPDS(ctx, did, track, s.atprotoService) 138 71 } 139 72 140 73 func (s *SpotifyService) SetAccessToken(token string, refreshToken string, userId int64, hasSession bool) (int64, error) {