···439 s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err)
440 }
441442- // Check if this is a new track (by PlayParams.id)
0000000443 if len(lastTracks) > 0 {
444 lastTrack := lastTracks[0]
445- // If the URL matches, it's the same track
446- if lastTrack.URL == currentAppleTrack.Attributes.URL {
447 s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName)
448 return nil
449 }
···439 s.logger.Printf("failed to get last tracks for user %d: %v", user.ID, err)
440 }
441442+ // Pre-compute the hash for uploaded tracks so comparisons against stored
443+ // latest tracks will work
444+ currentURL := currentAppleTrack.Attributes.URL
445+ if currentURL == "" {
446+ currentURL = generateUploadHash(currentAppleTrack)
447+ }
448+449+ // Check if this is a new track (by URL / upload hash)
450 if len(lastTracks) > 0 {
451 lastTrack := lastTracks[0]
452+ if lastTrack.URL == currentURL {
0453 s.logger.Printf("track unchanged for user %d: %s by %s", user.ID, currentAppleTrack.Attributes.Name, currentAppleTrack.Attributes.ArtistName)
454 return nil
455 }
+168
service/applemusic/applemusic_test.go
···1package applemusic
23import (
0000004 "strings"
5 "testing"
00006)
70000000000000000008// Helper to create AppleRecentTrack for testing
9func makeTestTrack(name, album, artist string) *AppleRecentTrack {
10 track := &AppleRecentTrack{}
···12 track.Attributes.AlbumName = album
13 track.Attributes.ArtistName = artist
14 return track
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000015}
1617func TestGenerateUploadHash(t *testing.T) {
···1package applemusic
23import (
4+ "context"
5+ "encoding/base64"
6+ "encoding/json"
7+ "io"
8+ "log"
9+ "net/http"
10 "strings"
11 "testing"
12+ "time"
13+14+ "github.com/teal-fm/piper/db"
15+ "github.com/teal-fm/piper/models"
16)
1718+// createTestJWT creates a minimal JWT for testing that will pass structural validation
19+func createTestJWT(teamID string, expiry time.Time) string {
20+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"ES256","typ":"JWT"}`))
21+22+ claims := map[string]any{
23+ "iss": teamID,
24+ "iat": time.Now().Unix(),
25+ "exp": expiry.Unix(),
26+ }
27+ claimsJSON, _ := json.Marshal(claims)
28+ payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
29+30+ // Signature doesn't need to be valid for structural validation
31+ signature := base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
32+33+ return header + "." + payload + "." + signature
34+}
35+36// Helper to create AppleRecentTrack for testing
37func makeTestTrack(name, album, artist string) *AppleRecentTrack {
38 track := &AppleRecentTrack{}
···40 track.Attributes.AlbumName = album
41 track.Attributes.ArtistName = artist
42 return track
43+}
44+45+// trackResponseTransport returns a fixed JSON response simulating the Apple Music API.
46+type trackResponseTransport struct {
47+ response string
48+}
49+50+func (t *trackResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
51+ return &http.Response{
52+ StatusCode: http.StatusOK,
53+ Body: io.NopCloser(strings.NewReader(t.response)),
54+ Header: make(http.Header),
55+ }, nil
56+}
57+58+// newTestDB creates an in-memory SQLite database for testing.
59+func newTestDB(t *testing.T) *db.DB {
60+ t.Helper()
61+ testDB, err := db.New(":memory:")
62+ if err != nil {
63+ t.Fatalf("failed to create test db: %v", err)
64+ }
65+ if err := testDB.Initialize(); err != nil {
66+ t.Fatalf("failed to initialize test db: %v", err)
67+ }
68+ t.Cleanup(func() { testDB.Close() })
69+ return testDB
70+}
71+72+// createTestUser creates a user in the DB with an Apple Music token set.
73+func createTestUser(t *testing.T, testDB *db.DB) *models.User {
74+ t.Helper()
75+ userID, err := testDB.CreateUser(&models.User{})
76+ if err != nil {
77+ t.Fatalf("failed to create user: %v", err)
78+ }
79+ if err := testDB.UpdateAppleMusicUserToken(userID, "fake-token"); err != nil {
80+ t.Fatalf("failed to set apple music token: %v", err)
81+ }
82+ user, err := testDB.GetUserByID(userID)
83+ if err != nil {
84+ t.Fatalf("failed to get user: %v", err)
85+ }
86+ return user
87+}
88+89+// newTestService creates a Service backed by an in-memory DB and the given transport.
90+func newTestService(t *testing.T, testDB *db.DB, transport http.RoundTripper) *Service {
91+ t.Helper()
92+ tokenExpiry := time.Now().Add(1 * time.Hour)
93+ return &Service{
94+ DB: testDB,
95+ httpClient: &http.Client{Transport: transport},
96+ logger: log.New(io.Discard, "", 0),
97+ teamID: "test-team",
98+ keyID: "test-key",
99+ cachedToken: createTestJWT("test-team", tokenExpiry),
100+ cachedExpiry: tokenExpiry,
101+ }
102+}
103+104+// uploadedTrackJSON builds an Apple Music API response for an uploaded track (no URL).
105+func uploadedTrackJSON(name, artist, album string) string {
106+ track := map[string]any{
107+ "id": "1",
108+ "attributes": map[string]string{
109+ "name": name,
110+ "artistName": artist,
111+ "albumName": album,
112+ },
113+ }
114+ data, _ := json.Marshal(map[string]any{"data": []any{track}})
115+ return string(data)
116+}
117+118+// processUserTestEnv sets up a DB, user, and service wired to the given API response.
119+type processUserTestEnv struct {
120+ testDB *db.DB
121+ user *models.User
122+ svc *Service
123+}
124+125+func newProcessUserTestEnv(t *testing.T, apiResponse string) *processUserTestEnv {
126+ t.Helper()
127+ testDB := newTestDB(t)
128+ user := createTestUser(t, testDB)
129+ transport := &trackResponseTransport{response: apiResponse}
130+ svc := newTestService(t, testDB, transport)
131+ return &processUserTestEnv{testDB: testDB, user: user, svc: svc}
132+}
133+134+// seedUploadedTrack saves an uploaded track to the DB, using its upload hash as the URL.
135+func (env *processUserTestEnv) seedUploadedTrack(t *testing.T, name, artist, album string) {
136+ t.Helper()
137+ hash := generateUploadHash(makeTestTrack(name, album, artist))
138+ _, err := env.testDB.SaveTrack(env.user.ID, &models.Track{
139+ Name: name,
140+ Artist: []models.Artist{{Name: artist}},
141+ Album: album,
142+ URL: hash,
143+ })
144+ if err != nil {
145+ t.Fatalf("failed to seed track: %v", err)
146+ }
147+}
148+149+// trackCount returns the number of tracks stored for the test user.
150+func (env *processUserTestEnv) trackCount(t *testing.T) int {
151+ t.Helper()
152+ tracks, err := env.testDB.GetRecentTracks(env.user.ID, 100)
153+ if err != nil {
154+ t.Fatalf("failed to get recent tracks: %v", err)
155+ }
156+ return len(tracks)
157+}
158+159+func TestProcessUserSkipsDuplicateUploadedTrack(t *testing.T) {
160+ env := newProcessUserTestEnv(t, uploadedTrackJSON("My Upload", "Local Artist", "Local Album"))
161+ env.seedUploadedTrack(t, "My Upload", "Local Artist", "Local Album")
162+163+ if err := env.svc.ProcessUser(context.Background(), env.user); err != nil {
164+ t.Fatalf("ProcessUser returned error: %v", err)
165+ }
166+167+ if got := env.trackCount(t); got != 1 {
168+ t.Errorf("expected 1 track (no duplicate save), got %d", got)
169+ }
170+}
171+172+func TestProcessUserSavesDifferentUploadedTrack(t *testing.T) {
173+ env := newProcessUserTestEnv(t, uploadedTrackJSON("New Upload", "New Artist", "New Album"))
174+ env.seedUploadedTrack(t, "Old Upload", "Old Artist", "Old Album")
175+176+ if err := env.svc.ProcessUser(context.Background(), env.user); err != nil {
177+ t.Fatalf("ProcessUser returned error: %v", err)
178+ }
179+180+ if got := env.trackCount(t); got != 2 {
181+ t.Errorf("expected 2 tracks (new upload saved), got %d", got)
182+ }
183}
184185func TestGenerateUploadHash(t *testing.T) {