Skip to content

Commit 8abefe5

Browse files
authored
fix: resolve multiple scrobbling bugs causing lost scrobbles (#107)
1 parent e246847 commit 8abefe5

File tree

4 files changed

+172
-26
lines changed

4 files changed

+172
-26
lines changed

Core/Services/Scrobbling/LastFMService.swift

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -325,25 +325,29 @@ final class LastFMService: ScrobbleServiceProtocol {
325325

326326
// swiftformat:disable modifierOrder
327327
nonisolated private func checkForErrors(_ response: [String: Any]) throws {
328-
guard let errorCode = response["error"] as? Int else {
329-
return // No error
328+
// Handle Last.fm integer error codes
329+
if let errorCode = response["error"] as? Int {
330+
let message = response["message"] as? String ?? "Unknown error"
331+
332+
switch errorCode {
333+
case 4:
334+
throw ScrobbleError.sessionExpired
335+
case 9:
336+
throw ScrobbleError.invalidCredentials
337+
case 11, 16:
338+
throw ScrobbleError.serviceUnavailable
339+
case 17:
340+
throw ScrobbleError.serviceUnavailable
341+
case 29:
342+
throw ScrobbleError.rateLimited(retryAfter: nil)
343+
default:
344+
throw ScrobbleError.invalidResponse("Last.fm error \(errorCode): \(message)")
345+
}
330346
}
331347

332-
let message = response["message"] as? String ?? "Unknown error"
333-
334-
switch errorCode {
335-
case 4:
336-
throw ScrobbleError.sessionExpired
337-
case 9:
338-
throw ScrobbleError.invalidCredentials
339-
case 11, 16:
340-
throw ScrobbleError.serviceUnavailable
341-
case 17:
342-
throw ScrobbleError.serviceUnavailable
343-
case 29:
344-
throw ScrobbleError.rateLimited(retryAfter: nil)
345-
default:
346-
throw ScrobbleError.invalidResponse("Last.fm error \(errorCode): \(message)")
348+
// Handle string errors from the Cloudflare Worker proxy
349+
if let errorMessage = response["error"] as? String {
350+
throw ScrobbleError.invalidResponse("Worker error: \(errorMessage)")
347351
}
348352
}
349353

@@ -356,8 +360,8 @@ final class LastFMService: ScrobbleServiceProtocol {
356360
) -> [ScrobbleResult] {
357361
// Last.fm returns scrobbles.scrobble (single object or array)
358362
guard let scrobblesWrapper = response["scrobbles"] as? [String: Any] else {
359-
// If no scrobbles key, assume all accepted
360-
return tracks.map { ScrobbleResult(track: $0, accepted: true) }
363+
// No scrobbles key — response is malformed; mark all as rejected for retry
364+
return tracks.map { ScrobbleResult(track: $0, accepted: false, errorMessage: "Malformed response: missing scrobbles key") }
361365
}
362366

363367
// Normalize to array
@@ -374,8 +378,11 @@ final class LastFMService: ScrobbleServiceProtocol {
374378
for (index, track) in tracks.enumerated() {
375379
if index < scrobbleEntries.count {
376380
let entry = scrobbleEntries[index]
377-
let ignoredMessage = (entry["ignoredMessage"] as? [String: Any])?["#text"] as? String
378-
let accepted = ignoredMessage == nil || ignoredMessage?.isEmpty == true
381+
let ignoredObj = entry["ignoredMessage"] as? [String: Any]
382+
let ignoredCode = ignoredObj?["code"] as? String
383+
let ignoredText = ignoredObj?["#text"] as? String
384+
// Code "0" means accepted; any other code means rejected
385+
let accepted = ignoredCode == nil || ignoredCode == "0"
379386

380387
let correctedArtistFlag = (entry["artist"] as? [String: Any])?["corrected"] as? String
381388
let correctedTrackFlag = (entry["track"] as? [String: Any])?["corrected"] as? String
@@ -391,7 +398,7 @@ final class LastFMService: ScrobbleServiceProtocol {
391398
accepted: accepted,
392399
correctedArtist: correctedArtist,
393400
correctedTrack: correctedTrack,
394-
errorMessage: accepted ? nil : ignoredMessage
401+
errorMessage: accepted ? nil : (ignoredText?.isEmpty == false ? ignoredText : "Ignored (code \(ignoredCode ?? "unknown"))")
395402
))
396403
} else {
397404
results.append(ScrobbleResult(track: track, accepted: true))

Core/Services/Scrobbling/ScrobblingCoordinator.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ final class ScrobblingCoordinator {
2323
/// The video ID of the track currently being tracked.
2424
private var currentTrackVideoId: String?
2525

26+
/// Title of the track currently being tracked (for change detection when videoId is stale).
27+
private var currentTrackTitle: String?
28+
29+
/// Artist of the track currently being tracked (for change detection when videoId is stale).
30+
private var currentTrackArtist: String?
31+
32+
/// Snapshot of the tracked Song at the time tracking started (for finalization).
33+
private var trackedSong: Song?
34+
2635
/// When the current track started playing (for scrobble timestamp).
2736
private var trackStartTime: Date?
2837

@@ -154,7 +163,12 @@ final class ScrobblingCoordinator {
154163

155164
// Track change detection
156165
if let track = currentTrack {
157-
if track.videoId != self.currentTrackVideoId {
166+
let videoIdChanged = track.videoId != self.currentTrackVideoId
167+
// Also detect by title/artist for natural transitions where videoId is stale
168+
let metadataChanged = self.currentTrackVideoId != nil
169+
&& (track.title != self.currentTrackTitle || track.artistsDisplay != self.currentTrackArtist)
170+
171+
if videoIdChanged || metadataChanged {
158172
// Track changed — finalize previous, start tracking new
159173
self.finalizeCurrentTrack()
160174
self.startTrackingNewTrack(track)
@@ -194,6 +208,9 @@ final class ScrobblingCoordinator {
194208

195209
private func startTrackingNewTrack(_ track: Song) {
196210
self.currentTrackVideoId = track.videoId
211+
self.currentTrackTitle = track.title
212+
self.currentTrackArtist = track.artistsDisplay
213+
self.trackedSong = track
197214
self.trackStartTime = Date()
198215
self.accumulatedPlayTime = 0
199216
self.lastProgress = self.playerService.progress
@@ -207,10 +224,21 @@ final class ScrobblingCoordinator {
207224
// Nothing to finalize if no track was being tracked
208225
guard self.currentTrackVideoId != nil else { return }
209226

227+
// Final threshold check before discarding accumulated play time
228+
if !self.hasScrobbled, let song = self.trackedSong {
229+
let duration = song.duration ?? self.playerService.duration
230+
if duration > 0 {
231+
self.checkScrobbleThreshold(track: song, duration: duration)
232+
}
233+
}
234+
210235
self.logger.debug("Finalized track (accumulated: \(String(format: "%.1f", self.accumulatedPlayTime))s, scrobbled: \(self.hasScrobbled))")
211236

212237
// Reset tracking state
213238
self.currentTrackVideoId = nil
239+
self.currentTrackTitle = nil
240+
self.currentTrackArtist = nil
241+
self.trackedSong = nil
214242
self.trackStartTime = nil
215243
self.accumulatedPlayTime = 0
216244
self.lastProgress = 0

Tests/KasetTests/LastFMServiceTests.swift

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ struct LastFMServiceTests {
239239
#expect(results[0].correctedTrack == nil)
240240
}
241241

242-
@Test("parseScrobbleResponse detects rejected scrobbles via ignoredMessage")
242+
@Test("parseScrobbleResponse detects rejected scrobbles via ignoredMessage code")
243243
func parseScrobbleResponseRejected() {
244244
let track = ScrobbleTrack(title: "Song", artist: "Artist")
245245

@@ -259,6 +259,62 @@ struct LastFMServiceTests {
259259
#expect(results[0].errorMessage == "Track was ignored")
260260
}
261261

262+
@Test("parseScrobbleResponse accepts when ignoredMessage code is 0")
263+
func parseScrobbleResponseAcceptedWithCode0() {
264+
let track = ScrobbleTrack(title: "Song", artist: "Artist")
265+
266+
let response: [String: Any] = [
267+
"scrobbles": [
268+
"scrobble": [
269+
"artist": ["corrected": "0", "#text": "Artist"],
270+
"track": ["corrected": "0", "#text": "Song"],
271+
"ignoredMessage": ["#text": "", "code": "0"],
272+
] as [String: Any],
273+
] as [String: Any],
274+
]
275+
276+
let results = LastFMService.parseScrobbleResponse(response, tracks: [track])
277+
278+
#expect(results[0].accepted)
279+
#expect(results[0].errorMessage == nil)
280+
}
281+
282+
@Test("parseScrobbleResponse rejects when ignoredMessage code is non-zero with empty text")
283+
func parseScrobbleResponseRejectedEmptyText() {
284+
let track = ScrobbleTrack(title: "Song", artist: "Artist")
285+
286+
let response: [String: Any] = [
287+
"scrobbles": [
288+
"scrobble": [
289+
"artist": ["corrected": "0", "#text": "Artist"],
290+
"track": ["corrected": "0", "#text": "Song"],
291+
"ignoredMessage": ["#text": "", "code": "2"],
292+
] as [String: Any],
293+
] as [String: Any],
294+
]
295+
296+
let results = LastFMService.parseScrobbleResponse(response, tracks: [track])
297+
298+
#expect(!results[0].accepted)
299+
#expect(results[0].errorMessage?.contains("code 2") == true)
300+
}
301+
302+
@Test("parseScrobbleResponse marks all rejected when scrobbles key is missing")
303+
func parseScrobbleResponseMissingScrobblesKey() {
304+
let track = ScrobbleTrack(title: "Song", artist: "Artist")
305+
306+
// Malformed response (e.g., worker error that passed checkForErrors)
307+
let response: [String: Any] = [
308+
"status": "ok",
309+
]
310+
311+
let results = LastFMService.parseScrobbleResponse(response, tracks: [track])
312+
313+
#expect(results.count == 1)
314+
#expect(!results[0].accepted)
315+
#expect(results[0].errorMessage?.contains("Malformed") == true)
316+
}
317+
262318
@Test("parseScrobbleResponse handles batch with mixed results")
263319
func parseScrobbleResponseBatch() {
264320
let tracks = [
@@ -272,12 +328,12 @@ struct LastFMServiceTests {
272328
[
273329
"artist": ["corrected": "0", "#text": "Artist"],
274330
"track": ["corrected": "0", "#text": "Accepted Song"],
275-
"ignoredMessage": ["#text": ""],
331+
"ignoredMessage": ["#text": "", "code": "0"],
276332
] as [String: Any],
277333
[
278334
"artist": ["corrected": "0", "#text": "Artist"],
279335
"track": ["corrected": "0", "#text": "Rejected Song"],
280-
"ignoredMessage": ["#text": "Artist was ignored"],
336+
"ignoredMessage": ["#text": "Artist was ignored", "code": "1"],
281337
] as [String: Any],
282338
] as [[String: Any]],
283339
] as [String: Any],

Tests/KasetTests/ScrobblingCoordinatorTests.swift

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,4 +401,59 @@ struct ScrobblingCoordinatorTests {
401401
let isReplay = hasScrobbled && newProgress < lastProgress - threshold
402402
#expect(!isReplay, "Backward jump before scrobbling should not trigger replay (could be a seek)")
403403
}
404+
405+
// MARK: - Fix: Title/artist-based track change detection
406+
407+
@Test("Title change triggers track change detection even with same videoId")
408+
func titleChangeDetectedWithSameVideoId() {
409+
// When videoId is stale (reused from previous track), title/artist change
410+
// should still be detected as a track change
411+
let currentVideoId = "video123"
412+
let currentTitle = "Song A"
413+
let currentArtist = "Artist A"
414+
415+
let newVideoId = "video123" // Same stale videoId
416+
let newTitle = "Song B"
417+
let newArtist = "Artist B"
418+
419+
let videoIdChanged = newVideoId != currentVideoId
420+
let metadataChanged = (newTitle != currentTitle || newArtist != currentArtist)
421+
422+
#expect(!videoIdChanged, "videoId should be the same (stale)")
423+
#expect(metadataChanged, "Title/artist change should be detected")
424+
#expect(videoIdChanged || metadataChanged, "Track change should be detected via metadata fallback")
425+
}
426+
427+
@Test("Same title and artist does not trigger false track change")
428+
func sameMetadataNoFalseChange() {
429+
let currentVideoId = "video123"
430+
let currentTitle = "Song A"
431+
let currentArtist = "Artist A"
432+
433+
let newVideoId = "video123"
434+
let newTitle = "Song A"
435+
let newArtist = "Artist A"
436+
437+
let videoIdChanged = newVideoId != currentVideoId
438+
let metadataChanged = (newTitle != currentTitle || newArtist != currentArtist)
439+
440+
#expect(!videoIdChanged)
441+
#expect(!metadataChanged)
442+
#expect(!(videoIdChanged || metadataChanged), "No change should be detected")
443+
}
444+
445+
// MARK: - Fix: Final threshold check on finalize
446+
447+
@Test("Finalize performs threshold check before discarding play time")
448+
func finalizeThresholdCheck() {
449+
// If accumulated play time is at threshold when track changes,
450+
// the finalize should trigger the scrobble before resetting state
451+
let duration = 200.0
452+
let percentThreshold = 0.5
453+
let minSeconds: TimeInterval = 240
454+
let accumulated: TimeInterval = 100.0 // Exactly 50%
455+
456+
let thresholdMet = accumulated >= duration * percentThreshold || accumulated >= minSeconds
457+
#expect(thresholdMet, "Threshold should be met at exactly 50% of duration")
458+
}
404459
}

0 commit comments

Comments
 (0)