Skip to content

Commit 56f0cdc

Browse files
pukubaclaudesozercan
authored
fix: real-time like status sync for Liked Music (#154)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Sertaç Özercan <852750+sozercan@users.noreply.github.com> Co-authored-by: Sertac Ozercan <sozercan@gmail.com>
1 parent 3f80f03 commit 56f0cdc

16 files changed

Lines changed: 735 additions & 117 deletions

Sources/Kaset/Services/API/Parsers/SongMetadataParser.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ enum SongMetadataParser {
88
struct MenuParseResult {
99
var feedbackTokens: FeedbackTokens?
1010
var isInLibrary: Bool
11-
var likeStatus: LikeStatus
11+
var likeStatus: LikeStatus?
1212
}
1313

1414
/// Parses song metadata from the "next" endpoint response.
@@ -153,7 +153,7 @@ enum SongMetadataParser {
153153

154154
/// Parses menu data (feedbackTokens, library status, like status) from the panel video renderer.
155155
static func parseMenuData(from renderer: [String: Any]) -> MenuParseResult {
156-
var result = MenuParseResult(feedbackTokens: nil, isInLibrary: false, likeStatus: .indifferent)
156+
var result = MenuParseResult(feedbackTokens: nil, isInLibrary: false, likeStatus: nil)
157157

158158
guard let menu = renderer["menu"] as? [String: Any],
159159
let menuRenderer = menu["menuRenderer"] as? [String: Any],

Sources/Kaset/Services/Auth/AccountService.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ final class AccountService {
124124
self.logger.debug("AccountService: Using API-selected account")
125125
}
126126

127+
SongLikeStatusManager.shared.setActiveAccountID(self.currentAccount?.id)
128+
127129
let currentLabel = self.currentAccount?.brandId ?? "primary"
128130
self.logger.info("AccountService: Fetched \(self.accounts.count) accounts, current: \(self.currentAccount?.name ?? "none") (brandId=\(currentLabel))")
129131
} catch {
@@ -157,17 +159,18 @@ final class AccountService {
157159
// Currently we only update local state. Server-side switching can be added
158160
// when the selectActiveIdentity endpoint is explored and documented.
159161

160-
// Update local state
161-
self.currentAccount = account
162-
163162
if UITestConfig.isUITestMode,
164163
UITestConfig.environmentValue(for: UITestConfig.mockAccountSwitchFailKey) == "true"
165164
{
166165
throw YTMusicError.apiError(message: "Mock account switch failure", code: nil)
167166
}
168167

168+
// Update local state
169+
self.currentAccount = account
170+
169171
// Reset client session state to avoid leaking continuations across accounts
170172
self.ytMusicClient.resetSessionStateForAccountSwitch()
173+
SongLikeStatusManager.shared.setActiveAccountID(account.id)
171174

172175
let brandLabel = account.brandId ?? "primary"
173176
self.logger.info("AccountService: Active account brandId=\(brandLabel)")
@@ -180,6 +183,7 @@ final class AccountService {
180183
} catch {
181184
self.logger.error("AccountService: Failed to switch account: \(error.localizedDescription)")
182185
self.currentAccount = previousAccount
186+
SongLikeStatusManager.shared.setActiveAccountID(previousAccount?.id)
183187
self.lastError = error
184188
self.lastErrorWasFetch = false
185189
self.errorSequence += 1
@@ -196,6 +200,8 @@ final class AccountService {
196200
self.accounts = []
197201
self.currentAccount = nil
198202
UserDefaults.standard.removeObject(forKey: self.selectedBrandIdKey)
203+
SongLikeStatusManager.shared.clearCache()
204+
SongLikeStatusManager.shared.setActiveAccountID(nil)
199205

200206
self.logger.debug("AccountService: Accounts cleared")
201207
}

Sources/Kaset/Services/Player/PlayerService+Library.swift

Lines changed: 92 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,55 +5,88 @@ import Foundation
55
@MainActor
66
extension PlayerService {
77
/// Likes the current track (thumbs up).
8+
/// Delegates to SongLikeStatusManager for unified state management and real-time sync.
89
func likeCurrentTrack() {
910
guard let track = currentTrack else { return }
1011
self.logger.info("Liking current track: \(track.videoId)")
12+
let activeAccountID = SongLikeStatusManager.shared.activeAccountID
13+
let client = self.ytMusicClient
1114

1215
// Toggle: if already liked, remove the like
1316
let newStatus: LikeStatus = self.currentTrackLikeStatus == .like ? .indifferent : .like
14-
let previousStatus = self.currentTrackLikeStatus
17+
// Optimistic UI update for PlayerBar
1518
self.currentTrackLikeStatus = newStatus
1619

17-
// Use API call for reliable rating
20+
// Delegate to SongLikeStatusManager for API call + cache sync + event emission
1821
Task {
19-
do {
20-
try await self.ytMusicClient?.rateSong(videoId: track.videoId, rating: newStatus)
21-
self.logger.info("Successfully rated song as \(newStatus.rawValue)")
22-
} catch {
23-
self.logger.error("Failed to rate song: \(error.localizedDescription)")
24-
// Revert on failure
25-
self.currentTrackLikeStatus = previousStatus
22+
let finalStatus: LikeStatus = if newStatus == .like {
23+
await SongLikeStatusManager.shared.like(
24+
track,
25+
accountID: activeAccountID,
26+
client: client
27+
)
28+
} else {
29+
await SongLikeStatusManager.shared.unlike(
30+
track,
31+
accountID: activeAccountID,
32+
client: client
33+
)
34+
}
35+
36+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID,
37+
self.currentTrack?.videoId == track.videoId
38+
else {
39+
return
2640
}
41+
42+
self.currentTrackLikeStatus = finalStatus
2743
}
2844
}
2945

3046
/// Dislikes the current track (thumbs down).
47+
/// Delegates to SongLikeStatusManager for unified state management and real-time sync.
3148
func dislikeCurrentTrack() {
3249
guard let track = currentTrack else { return }
3350
self.logger.info("Disliking current track: \(track.videoId)")
51+
let activeAccountID = SongLikeStatusManager.shared.activeAccountID
52+
let client = self.ytMusicClient
3453

3554
// Toggle: if already disliked, remove the dislike
3655
let newStatus: LikeStatus = self.currentTrackLikeStatus == .dislike ? .indifferent : .dislike
37-
let previousStatus = self.currentTrackLikeStatus
56+
// Optimistic UI update for PlayerBar
3857
self.currentTrackLikeStatus = newStatus
3958

40-
// Use API call for reliable rating
59+
// Delegate to SongLikeStatusManager for API call + cache sync + event emission
4160
Task {
42-
do {
43-
try await self.ytMusicClient?.rateSong(videoId: track.videoId, rating: newStatus)
44-
self.logger.info("Successfully rated song as \(newStatus.rawValue)")
45-
} catch {
46-
self.logger.error("Failed to rate song: \(error.localizedDescription)")
47-
// Revert on failure
48-
self.currentTrackLikeStatus = previousStatus
61+
let finalStatus: LikeStatus = if newStatus == .dislike {
62+
await SongLikeStatusManager.shared.dislike(
63+
track,
64+
accountID: activeAccountID,
65+
client: client
66+
)
67+
} else {
68+
await SongLikeStatusManager.shared.undislike(
69+
track,
70+
accountID: activeAccountID,
71+
client: client
72+
)
73+
}
74+
75+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID,
76+
self.currentTrack?.videoId == track.videoId
77+
else {
78+
return
4979
}
80+
81+
self.currentTrackLikeStatus = finalStatus
5082
}
5183
}
5284

5385
/// Toggles the library status of the current track.
5486
func toggleLibraryStatus() {
5587
guard let track = currentTrack else { return }
5688
self.logger.info("Toggling library status for current track: \(track.videoId)")
89+
let activeAccountID = SongLikeStatusManager.shared.activeAccountID
5790

5891
// Determine which token to use based on current state
5992
let isCurrentlyInLibrary = self.currentTrackInLibrary
@@ -90,9 +123,16 @@ extension PlayerService {
90123
// The browse metadata can lag briefly, so delay the refresh and keep
91124
// the optimistic library state if the response is still stale.
92125
try? await Task.sleep(for: .milliseconds(500))
126+
127+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID else { return }
128+
93129
await self.fetchSongMetadata(videoId: track.videoId)
94130

95-
guard self.currentTrack?.videoId == track.videoId else { return }
131+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID,
132+
self.currentTrack?.videoId == track.videoId
133+
else {
134+
return
135+
}
96136

97137
if self.currentTrackInLibrary != expectedInLibrary {
98138
self.updateCurrentTrackLibraryState(
@@ -103,7 +143,11 @@ extension PlayerService {
103143
} catch {
104144
self.logger.error("Failed to toggle library status: \(error.localizedDescription)")
105145
// Revert on failure
106-
guard self.currentTrack?.videoId == track.videoId else { return }
146+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID,
147+
self.currentTrack?.videoId == track.videoId
148+
else {
149+
return
150+
}
107151

108152
self.updateCurrentTrackLibraryState(
109153
isInLibrary: previousState,
@@ -114,8 +158,18 @@ extension PlayerService {
114158
}
115159

116160
/// Updates the like status from WebView observation.
161+
/// Only updates PlayerService state for UI; does NOT overwrite SongLikeStatusManager cache
162+
/// because WebView often reports INDIFFERENT as a default when the actual status is unknown.
117163
func updateLikeStatus(_ status: LikeStatus) {
118-
self.currentTrackLikeStatus = status
164+
// Only accept WebView status if SongLikeStatusManager has no cached value
165+
// (cache is more authoritative than WebView's observation)
166+
if let videoId = self.currentTrack?.videoId,
167+
let cachedStatus = SongLikeStatusManager.shared.status(for: videoId)
168+
{
169+
self.currentTrackLikeStatus = cachedStatus
170+
} else {
171+
self.currentTrackLikeStatus = status
172+
}
119173
}
120174

121175
/// Resets like/library status when track changes.
@@ -145,8 +199,14 @@ extension PlayerService {
145199
return
146200
}
147201

202+
let activeAccountID = SongLikeStatusManager.shared.activeAccountID
203+
148204
do {
149205
let songData = try await client.getSong(videoId: videoId)
206+
guard SongLikeStatusManager.shared.activeAccountID == activeAccountID else { return }
207+
208+
let cachedLikeStatus = SongLikeStatusManager.shared.status(for: videoId)
209+
let resolvedLikeStatus = songData.likeStatus ?? cachedLikeStatus
150210

151211
// Update current track with full metadata if it's still the same song
152212
if self.currentTrack?.videoId == videoId {
@@ -163,14 +223,19 @@ extension PlayerService {
163223
thumbnailURL: songData.thumbnailURL ?? self.currentTrack?.thumbnailURL,
164224
videoId: videoId,
165225
musicVideoType: songData.musicVideoType,
166-
likeStatus: songData.likeStatus,
226+
likeStatus: resolvedLikeStatus,
167227
isInLibrary: songData.isInLibrary,
168228
feedbackTokens: songData.feedbackTokens
169229
)
170230

171-
// Update service state
231+
// Update service state and sync with SongLikeStatusManager.
232+
// Unknown like status stays out of the cache so it cannot override
233+
// a known rating from the WebView or a prior user action.
172234
if let likeStatus = songData.likeStatus {
173235
self.currentTrackLikeStatus = likeStatus
236+
SongLikeStatusManager.shared.setStatus(likeStatus, for: videoId)
237+
} else if let cachedLikeStatus {
238+
self.currentTrackLikeStatus = cachedLikeStatus
174239
}
175240
self.currentTrackInLibrary = songData.isInLibrary ?? false
176241
self.currentTrackFeedbackTokens = songData.feedbackTokens
@@ -196,8 +261,10 @@ extension PlayerService {
196261
currentQueueSong.thumbnailURL == nil
197262

198263
if needsUpdate {
199-
self.queue[queueIndex] = songData
200-
self.logger.debug("Enriched queue entry at index \(queueIndex): '\(songData.title)' with artists: \(songData.artistsDisplay)")
264+
var enrichedQueueSong = songData
265+
enrichedQueueSong.likeStatus = resolvedLikeStatus
266+
self.queue[queueIndex] = enrichedQueueSong
267+
self.logger.debug("Enriched queue entry at index \(queueIndex): '\(enrichedQueueSong.title)' with artists: \(enrichedQueueSong.artistsDisplay)")
201268
// Save the enriched queue to persistence
202269
self.saveQueueForPersistence()
203270
}

Sources/Kaset/Services/Player/PlayerService+PlaybackRestoration.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,24 @@ extension PlayerService {
6262
} else {
6363
self.resetTrackStatus()
6464
}
65+
66+
// Seed the SongLikeStatusManager cache from the persisted song's likeStatus
67+
// so that fetchSongMetadata won't overwrite it with a parsed .indifferent default.
68+
if let persistedLikeStatus = currentSong.likeStatus, persistedLikeStatus != .indifferent {
69+
SongLikeStatusManager.shared.setStatus(persistedLikeStatus, for: currentSong.videoId)
70+
self.currentTrackLikeStatus = persistedLikeStatus
71+
}
72+
73+
// SongLikeStatusManager cache is the most up-to-date source for like status
74+
if let cachedStatus = SongLikeStatusManager.shared.status(for: currentSong.videoId) {
75+
self.currentTrackLikeStatus = cachedStatus
76+
}
77+
78+
// At app launch the cache may be empty and the persisted song may lack likeStatus.
79+
// Fetch metadata from the API to get the correct like status.
80+
Task { [videoId = currentSong.videoId] in
81+
await self.fetchSongMetadata(videoId: videoId)
82+
}
6583
}
6684

6785
/// Clears one-shot state used while reconciling a restored playback session.

Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ extension PlayerService {
341341
if queueIndexChanged || self.shouldKeepQueueMetadata(title: title, artist: artist, song: matchingSong) {
342342
if self.currentTrack?.videoId != matchingSong.videoId {
343343
self.resetTrackStatus()
344+
// Immediately restore like status from SongLikeStatusManager cache
345+
if let cachedStatus = SongLikeStatusManager.shared.status(for: matchingSong.videoId) {
346+
self.currentTrackLikeStatus = cachedStatus
347+
}
344348
}
345349
self.keepQueueSongVisible(matchingSong, thumbnailUrl: thumbnailUrl)
346350
return true
@@ -522,6 +526,10 @@ extension PlayerService {
522526

523527
if trackChanged {
524528
self.resetTrackStatus()
529+
// Immediately restore like status from SongLikeStatusManager cache
530+
if let cachedStatus = SongLikeStatusManager.shared.status(for: resolvedVideoId) {
531+
self.currentTrackLikeStatus = cachedStatus
532+
}
525533
}
526534
}
527535
}

Sources/Kaset/Services/Player/PlayerService.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,12 @@ final class PlayerService: NSObject, PlayerServiceProtocol {
440440
}
441441
}
442442

443+
// SongLikeStatusManager cache is the most up-to-date source for like status;
444+
// use it to correct stale/missing song.likeStatus immediately.
445+
if let cachedStatus = SongLikeStatusManager.shared.status(for: song.videoId) {
446+
self.currentTrackLikeStatus = cachedStatus
447+
}
448+
443449
self.pendingPlayVideoId = song.videoId
444450

445451
// If user has already interacted this session, auto-play without popup

0 commit comments

Comments
 (0)