diff --git a/oauth/atproto/atproto.go b/oauth/atproto/atproto.go index 7bbe340..d825f79 100644 --- a/oauth/atproto/atproto.go +++ b/oauth/atproto/atproto.go @@ -32,7 +32,7 @@ type AuthService struct { func NewATprotoAuthService(database *db.DB, sessionManager *session.Manager, clientSecretKey string, clientId string, callbackUrl string, clientSecretId string, allowedDids []string) (*AuthService, error) { fmt.Println(clientId, callbackUrl) - scopes := []string{"atproto", "repo:fm.teal.feed.play", "repo:fm.teal.actor.status"} + scopes := atprotoOAuthScopes() var config oauth.ClientConfig config = oauth.NewPublicConfig(clientId, callbackUrl, scopes) @@ -61,6 +61,14 @@ func NewATprotoAuthService(database *db.DB, sessionManager *session.Manager, cli return svc, nil } +func atprotoOAuthScopes() []string { + return []string{ + "atproto", + "repo:fm.teal.feed.play?action=create", + "repo:fm.teal.actor.status?action=create&action=update", + } +} + func (a *AuthService) GetATProtoClient(accountDID string, sessionID string, ctx context.Context) (*client.APIClient, error) { did, err := syntax.ParseDID(accountDID) if err != nil { diff --git a/oauth/atproto/atproto_test.go b/oauth/atproto/atproto_test.go new file mode 100644 index 0000000..35ca807 --- /dev/null +++ b/oauth/atproto/atproto_test.go @@ -0,0 +1,18 @@ +package atproto + +import ( + "slices" + "testing" +) + +func TestATProtoOAuthScopesIncludeRequiredWriteActions(t *testing.T) { + want := []string{ + "atproto", + "repo:fm.teal.feed.play?action=create", + "repo:fm.teal.actor.status?action=create&action=update", + } + + if got := atprotoOAuthScopes(); !slices.Equal(got, want) { + t.Fatalf("atprotoOAuthScopes() = %q, want %q", got, want) + } +} diff --git a/service/applemusic/applemusic.go b/service/applemusic/applemusic.go index 2158ef6..4cd87f2 100644 --- a/service/applemusic/applemusic.go +++ b/service/applemusic/applemusic.go @@ -298,9 +298,43 @@ type recentPlayedResponse struct { Data []AppleRecentTrack `json:"data"` } +type appleMusicErrorResponse struct { + Errors []struct { + Status string `json:"status"` + Code string `json:"code"` + Title string `json:"title"` + Detail string `json:"detail"` + } `json:"errors"` +} + +func newAppleMusicAPIError(status string, body []byte) error { + var parsed appleMusicErrorResponse + if err := json.Unmarshal(body, &parsed); err == nil && len(parsed.Errors) > 0 { + apiErr := parsed.Errors[0] + message := strings.TrimSpace(apiErr.Title) + if detail := strings.TrimSpace(apiErr.Detail); detail != "" { + if message != "" { + message += ": " + } + message += detail + } + if code := strings.TrimSpace(apiErr.Code); code != "" { + if message != "" { + message += " " + } + message += "[" + code + "]" + } + if message != "" { + return fmt.Errorf("apple music api error: %s: %s", status, message) + } + } + + return fmt.Errorf("apple music api error: %s", status) +} + // FetchRecentPlayedTracks calls Apple Music API for a user token func (s *Service) FetchRecentPlayedTracks(ctx context.Context, userToken string, limit int) ([]AppleRecentTrack, error) { - if limit <= 0 || limit > 50 { + if limit <= 0 || limit > 30 { limit = 25 } devToken, _, err := s.GenerateDeveloperToken() @@ -310,6 +344,7 @@ func (s *Service) FetchRecentPlayedTracks(ctx context.Context, userToken string, endpoint := &url.URL{Scheme: "https", Host: "api.music.apple.com", Path: "/v1/me/recent/played/tracks"} q := endpoint.Query() q.Set("limit", fmt.Sprintf("%d", limit)) + q.Set("types", "songs,library-songs") endpoint.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) @@ -330,14 +365,13 @@ func (s *Service) FetchRecentPlayedTracks(ctx context.Context, userToken string, } }(resp.Body) - // Read the full response body to log it bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("apple music api error: %s", resp.Status) + return nil, newAppleMusicAPIError(resp.Status, bodyBytes) } var parsed recentPlayedResponse diff --git a/service/applemusic/applemusic_test.go b/service/applemusic/applemusic_test.go index 6059673..c9c54f2 100644 --- a/service/applemusic/applemusic_test.go +++ b/service/applemusic/applemusic_test.go @@ -55,6 +55,12 @@ func (t *trackResponseTransport) RoundTrip(req *http.Request) (*http.Response, e }, nil } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + // newTestDB creates an in-memory SQLite database for testing. func newTestDB(t *testing.T) *db.DB { t.Helper() @@ -182,6 +188,63 @@ func TestProcessUserSavesDifferentUploadedTrack(t *testing.T) { } } +func TestFetchRecentPlayedTracksRequestsLibrarySongs(t *testing.T) { + testDB := newTestDB(t) + var gotTypes, gotLimit string + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotTypes = req.URL.Query().Get("types") + gotLimit = req.URL.Query().Get("limit") + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"data":[]}`)), + Header: make(http.Header), + }, nil + }) + svc := newTestService(t, testDB, transport) + + if _, err := svc.FetchRecentPlayedTracks(context.Background(), "user-token", 1); err != nil { + t.Fatalf("FetchRecentPlayedTracks returned error: %v", err) + } + + if gotTypes != "songs,library-songs" { + t.Errorf("types query = %q, want %q", gotTypes, "songs,library-songs") + } + if gotLimit != "1" { + t.Errorf("limit query = %q, want %q", gotLimit, "1") + } +} + +func TestFetchRecentPlayedTracksIncludesAppleErrorDetails(t *testing.T) { + testDB := newTestDB(t) + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Status: "403 Forbidden", + Body: io.NopCloser(strings.NewReader(`{ + "errors":[{ + "status":"403", + "code":"AUTHORIZATION_ERROR", + "title":"Forbidden", + "detail":"The music user token is invalid or expired." + }] + }`)), + Header: make(http.Header), + }, nil + }) + svc := newTestService(t, testDB, transport) + + _, err := svc.FetchRecentPlayedTracks(context.Background(), "user-token", 1) + if err == nil { + t.Fatal("FetchRecentPlayedTracks returned nil error") + } + for _, want := range []string{"403 Forbidden", "Forbidden", "invalid or expired", "AUTHORIZATION_ERROR"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err, want) + } + } +} + func TestGenerateUploadHash(t *testing.T) { tests := []struct { name string