feat: per-sub-track scrobbling for long mixes - #365
Conversation
MixTracklist model (Sources/Kaset/Models/MixTracklist.swift): - MixTrackEntry: startTime, endTime, title, artist, source - init(fromChapterTitle:) splits on ' - ' for artist/title - MixTracklist: videoId, sorted entries, entry(at:) binary search - isMix check: 3+ entries required MixTracklistParser (Sources/Kaset/Services/Scrobbling/MixTracklistParser.swift): - Two-tier parsing: chapters first (Tier 1), description fallback (Tier 2, TODO) - Tier 1: calls YouTubeClient.getWatchNext(videoId:) for chapter data via WatchNextParser.chapters(of:) — confirmed working via API exploration on dZ2NQ-xQINI (20 chapters, clean Artist - Title format) - In-memory cache by videoId - 3+ chapters required to treat as a mix - Computes endTime from next chapter's startTime API exploration confirmed (2026-07-10): - YTMusic next endpoint: 0 chapters, 0 description (dead end) - Regular YouTube next endpoint: 20 chapterRenderers + description text - Chapter titles are 'Artist - Title' format, simple split works - Description available in engagementPanels[5] for Tier 2 fallback
ScrobblingCoordinator gains per-sub-track scrobbling for long mix videos: New state: - mixTracklist: MixTracklist? (set when tracklist is loaded) - currentMixEntry: MixTrackEntry? (current sub-track) - Per-sub-track accumulated time, scrobbled flag, now-playing flag Flow: - startTrackingNewTrack: if duration > 600s and parser available, async-fetch tracklist via YouTubeClient.getWatchNext - pollPlayerState: if mixTracklist is set, branches to handleMixPlayback instead of single-track accumulation logic - handleMixPlayback: detects sub-track boundary crossings via tracklist.entry(at: progress), finalizes previous sub-track, starts new one, sends per-sub-track 'now playing', checks threshold - Seek detection: >5s progress jump resets sub-track accumulated time - finalizeMixEntry: final threshold check before sub-track ends - checkMixEntryScrobbleThreshold: uses sub-track duration from chapters, falls back to minSeconds for entries without endTime - ScrobbleTrack constructed with entry.title/artist, not Song metadata Wiring (KasetApp.swift): - MixTracklistParser created with youtubeClient - Passed to ScrobblingCoordinator init Fallback: if no tracklist (parser returns nil or duration < 600s), the coordinator operates in single-track mode exactly as before.
Mix detection was a one-shot duration check at track-start, but YouTube reports duration a beat later, so the >600s gate always missed and every mix scrobbled as a single whole-video track. Retry the gated fetch from the poll loop until duration is known, latched to fire once per track. Extract PlaybackScrobbleTracker: the coordinator carried two drifted copies of accumulate/threshold/now-playing/finalize (single-track vs mix sub-track). Unify into one pure state machine used by both paths; closes the missing wall-clock guard the mix path had dropped. Also: centralize the ">=3 = mix" threshold on MixTracklist.minEntryCount; extract Artist-Title parsing to tested MixTrackEntry.parseArtistTitle (now trims); drop dead var; reword --strict TODO. Tests: +2 coordinator regression, +12 tracker, +6 model. Full suite 1800 pass.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4a10dadb77
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: af53c85114
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
- defer whole-track scrobble while mix parse in flight (no double scrobble) - entry(at:) returns nil past an explicit endTime (outro after final chapter) - isMix requires >=50% entries with parsed artist, not just chapter count - seek within scrobbled sub-track keeps latch; backward jump replays with fresh timestamp - parse en/em dash artist separators, leftmost occurrence wins
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
|
@codex review |
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ea70a63021
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7ef8583faf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0003b857ee
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Tier 2: when chapters yield no mix tracklist, parse timestamped description lines (regex + longest monotonic run) into MixTrackEntry, gated by the same isMix checks. Description text comes from the same watch-next response (attributedDescription, engagement-panel fallback), so both tiers share one fetch; misses cache only after both tiers fail.
Track a block id per description line so the monotonic-run selection can't stitch timestamps scattered across prose into a fake tracklist. Blank lines keep a block; any other non-timestamped line ends it.
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Resolve conflicts from sozercan#365 (per-sub-track scrobbling) merging first. sozercan#365 is the refined, merged version of the mix-scrobbling foundation this branch forked from an earlier state of. - Foundation (MixTracklist, MixTracklistParser, PlaybackScrobbleTracker + their tests): take main's sozercan#365 version. - ScrobblingCoordinator: take main's sozercan#365 self-parsing architecture; drop this branch's provider-consuming variant. Coordinator now shares the MixTracklistParser instance with the seek bar's NowPlayingTracklistProvider. - KasetApp: keep seek-bar provider wiring; single shared mixTracklistParser feeds both provider and coordinator. - MockYouTubeClient: take main's additive getWatchNext counter. - NowPlayingTracklistProvider: minMixDuration is now provider-local (sozercan#365 removed MixTracklist.minMixDuration). - ScrobblingCoordinatorTests: drop this branch's provider-based coordinator mix tests (dead API); sozercan#365 covers the same behavior in dedicated files.
Description
Extends scrobbling (ADR-0011) to long mix videos (DJ sets, compilations). Instead of scrobbling a 2-hour mix as one track, Kaset parses the mix's tracklist from YouTube chapters (with a description-timestamp fallback) and scrobbles each sub-track individually as it plays — matching how Last.fm expects listens to be reported.
AI Prompt (Optional)
🤖 AI Prompt Used
The feature was developed interactively with Claude Code. The API-exploration prompt that grounded the design (per the AGENTS.md api-explorer rule):
AI Tool: Claude Code
Type of Change
Related Issues
Relates to #74 / #99 (the original Last.fm scrobbling feature requests) — this extends that scrobbling support to the sub-tracks of continuous mixes.
Changes Made
MixTracklist/MixTrackEntrymodel: sub-track entries with time ranges, artist/title parsed from chapter labels;entry(at:)lookup; a video only counts as a mix above a minimum duration with a plausible tracklistMixTracklistParser: builds the tracklist from YouTube chapters viaYouTubeClient, falling back to description timestampsScrobblingCoordinatormix mode: when the current video has a tracklist, per-sub-track play time is accumulated and each sub-track is scrobbled once its threshold is met; sub-track transitions finalize the previous entryPlaybackScrobbleTrackerextracted to own accumulate/threshold/finalize state for both single-track and mix entries; fixes a mix-detection race where duration arrives after the track objectTesting
swift test --skip KasetUITests) — 1796 passed, 150 suitesChecklist
swiftlint --strict && swiftformat .Screenshots
Additional Notes