diff --git a/models/lexicon_format.go b/models/lexicon_format.go index d848222..b2a724f 100644 --- a/models/lexicon_format.go +++ b/models/lexicon_format.go @@ -58,3 +58,19 @@ func FormatMusicServiceURI(service string) *string { uri := "https://" + normalized return &uri } + +// FormatOriginURI returns uri when it is a syntactically valid URI. Internal +// track identity hashes are not publishable origin URIs and are omitted. +func FormatOriginURI(uriValue string) *string { + trimmed := strings.TrimSpace(uriValue) + if trimmed == "" { + return nil + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" { + return nil + } + + return &trimmed +} diff --git a/models/lexicon_format_test.go b/models/lexicon_format_test.go index 3b919cf..208f2d3 100644 --- a/models/lexicon_format_test.go +++ b/models/lexicon_format_test.go @@ -45,6 +45,26 @@ func TestFormatMusicServiceURI(t *testing.T) { } } +func TestFormatOriginURI(t *testing.T) { + tests := []struct { + name string + in string + want *string + }{ + {name: "empty"}, + {name: "internal hash", in: "am_uploaded_abc123"}, + {name: "https URL", in: "https://music.apple.com/us/song/test", want: strPtr("https://music.apple.com/us/song/test")}, + {name: "app URI", in: "spotify:track:test", want: strPtr("spotify:track:test")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatOriginURI(tt.in) + assertStringPtr(t, got, tt.want) + }) + } +} + func assertStringPtr(t *testing.T, got, want *string) { t.Helper() if got == nil || want == nil { diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index 4cd87f2..0036f32 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -280,8 +280,9 @@ type AppleRecentTrack struct { Isrc *string `json:"isrc"` URL string `json:"url"` PlayParams *struct { - ID string `json:"id"` - Kind string `json:"kind"` + ID string `json:"id"` + Kind string `json:"kind"` + CatalogID string `json:"catalogId"` } `json:"playParams"` } `json:"attributes"` } @@ -440,9 +441,108 @@ func (s *Service) GetCurrentAppleMusicTrack(ctx context.Context, user *models.Us return nil, nil } + // Library songs may omit attributes.url even when they correspond to a + // catalog song. Resolve the catalog URL before the track is persisted. + if err := s.populateCatalogURL(ctx, *user.AppleMusicUserToken, &items[0]); err != nil { + s.logger.Printf("failed to resolve Apple Music catalog URL for %q: %v", items[0].Attributes.Name, err) + } + return &items[0], nil } +// populateCatalogURL fills in the share URL for a library song when Apple +// provides the corresponding catalog ID in its play parameters. +func (s *Service) populateCatalogURL(ctx context.Context, userToken string, track *AppleRecentTrack) error { + if track == nil || track.Attributes.URL != "" || track.Attributes.PlayParams == nil || track.Attributes.PlayParams.CatalogID == "" { + return nil + } + + devToken, _, err := s.GenerateDeveloperToken() + if err != nil { + return err + } + + storefrontEndpoint := &url.URL{Scheme: "https", Host: "api.music.apple.com", Path: "/v1/me/storefront"} + storefrontReq, err := http.NewRequestWithContext(ctx, http.MethodGet, storefrontEndpoint.String(), nil) + if err != nil { + return err + } + storefrontReq.Header.Set("Authorization", "Bearer "+devToken) + storefrontReq.Header.Set("Music-User-Token", userToken) + + storefrontResp, err := s.httpClient.Do(storefrontReq) + if err != nil { + return err + } + defer storefrontResp.Body.Close() + + storefrontBody, err := io.ReadAll(storefrontResp.Body) + if err != nil { + return fmt.Errorf("failed to read storefront response: %w", err) + } + if storefrontResp.StatusCode != http.StatusOK { + return newAppleMusicAPIError(storefrontResp.Status, storefrontBody) + } + + var storefront struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(storefrontBody, &storefront); err != nil { + return fmt.Errorf("failed to decode storefront response: %w", err) + } + if len(storefront.Data) == 0 || storefront.Data[0].ID == "" { + return errors.New("Apple Music storefront response contained no storefront") + } + + catalogEndpoint := &url.URL{ + Scheme: "https", + Host: "api.music.apple.com", + Path: "/v1/catalog/" + url.PathEscape(storefront.Data[0].ID) + "/songs", + } + query := catalogEndpoint.Query() + query.Set("ids", track.Attributes.PlayParams.CatalogID) + catalogEndpoint.RawQuery = query.Encode() + + catalogReq, err := http.NewRequestWithContext(ctx, http.MethodGet, catalogEndpoint.String(), nil) + if err != nil { + return err + } + catalogReq.Header.Set("Authorization", "Bearer "+devToken) + + catalogResp, err := s.httpClient.Do(catalogReq) + if err != nil { + return err + } + defer catalogResp.Body.Close() + + catalogBody, err := io.ReadAll(catalogResp.Body) + if err != nil { + return fmt.Errorf("failed to read catalog song response: %w", err) + } + if catalogResp.StatusCode != http.StatusOK { + return newAppleMusicAPIError(catalogResp.Status, catalogBody) + } + + var catalog struct { + Data []struct { + Attributes struct { + URL string `json:"url"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.Unmarshal(catalogBody, &catalog); err != nil { + return fmt.Errorf("failed to decode catalog song response: %w", err) + } + if len(catalog.Data) == 0 || catalog.Data[0].Attributes.URL == "" { + return errors.New("Apple Music catalog response contained no song URL") + } + + track.Attributes.URL = catalog.Data[0].Attributes.URL + return nil +} + // ProcessUser checks for new Apple Music tracks and processes them func (s *Service) ProcessUser(ctx context.Context, user *models.User) error { if user.AppleMusicUserToken == nil || *user.AppleMusicUserToken == "" { diff --git a/service/applemusic/applemusic_test.go b/service/applemusic/applemusic_test.go index c9c54f2..e48571d 100644 --- a/service/applemusic/applemusic_test.go +++ b/service/applemusic/applemusic_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "fmt" "io" "log" "net/http" @@ -188,6 +189,55 @@ func TestProcessUserSavesDifferentUploadedTrack(t *testing.T) { } } +func TestGetCurrentAppleMusicTrackResolvesCatalogURL(t *testing.T) { + testDB := newTestDB(t) + var paths []string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + switch req.URL.Path { + case "/v1/me/recent/played/tracks": + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"i.library-song","attributes":{"name":"Catalog Song","artistName":"Catalog Artist","albumName":"Catalog Album","playParams":{"id":"i.library-song","kind":"song","catalogId":"123456789"}}}]}`)), + Header: make(http.Header), + }, nil + case "/v1/me/storefront": + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"us"}]}`)), + Header: make(http.Header), + }, nil + case "/v1/catalog/us/songs": + if got := req.URL.Query().Get("ids"); got != "123456789" { + t.Errorf("catalog ids = %q, want %q", got, "123456789") + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"data":[{"attributes":{"url":"https://music.apple.com/us/song/catalog-song/123456789"}}]}`)), + Header: make(http.Header), + }, nil + default: + return nil, fmt.Errorf("unexpected request path %q", req.URL.Path) + } + }) + svc := newTestService(t, testDB, transport) + user := createTestUser(t, testDB) + + track, err := svc.GetCurrentAppleMusicTrack(context.Background(), user) + if err != nil { + t.Fatalf("GetCurrentAppleMusicTrack returned error: %v", err) + } + if track.Attributes.URL != "https://music.apple.com/us/song/catalog-song/123456789" { + t.Fatalf("track URL = %q, want catalog URL", track.Attributes.URL) + } + if got, want := strings.Join(paths, ","), "/v1/me/recent/played/tracks,/v1/me/storefront,/v1/catalog/us/songs"; got != want { + t.Fatalf("request paths = %q, want %q", got, want) + } +} + func TestFetchRecentPlayedTracksRequestsLibrarySongs(t *testing.T) { testDB := newTestDB(t) var gotTypes, gotLimit string diff --git a/service/atproto/submission.go b/service/atproto/submission.go index a0e1adf..8a119f0 100644 --- a/service/atproto/submission.go +++ b/service/atproto/submission.go @@ -81,10 +81,7 @@ func TrackToPlayRecord(track *models.Track) (*teal.FeedPlay, error) { isrcPtr = &track.ISRC } - var originURI *string - if track.URL != "" { - originURI = &track.URL - } + originURI := models.FormatOriginURI(track.URL) serviceURI := models.FormatMusicServiceURI(track.ServiceBaseUrl) diff --git a/service/playingnow/playingnow.go b/service/playingnow/playingnow.go index 7c6c5a4..0f2878d 100644 --- a/service/playingnow/playingnow.go +++ b/service/playingnow/playingnow.go @@ -251,10 +251,7 @@ func (p *Service) trackToPlayView(track *models.Track) (*teal.FeedDefs_PlayView, isrcPtr = &track.ISRC } - var originURI *string - if track.URL != "" { - originURI = &track.URL - } + originURI := models.FormatOriginURI(track.URL) serviceURI := models.FormatMusicServiceURI(track.ServiceBaseUrl)