Skip to content

feat: per-sub-track scrobbling for long mixes - #365

Merged
sozercan merged 12 commits into
sozercan:mainfrom
tsibog:pr/mix-scrobbling
Jul 12, 2026
Merged

feat: per-sub-track scrobbling for long mixes#365
sozercan merged 12 commits into
sozercan:mainfrom
tsibog:pr/mix-scrobbling

Conversation

@tsibog

@tsibog tsibog commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

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):

Kaset mix scrobbling — API exploration tasks

I need you to run the Kaset API explorer (swift run api-explorer) against a
known YouTube Music mix video to answer these questions. Use video ID
dZ2NQ-xQINI (a known DJ mix) for all tests, or substitute any 1hr+ mix you
know has a tracklist.

Task 1: Regular YouTube next endpoint — chapters
[queries for chapter renderers, macro markers, description data, ...]

AI Tool: Claude Code

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

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 / MixTrackEntry model: 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 tracklist
  • MixTracklistParser: builds the tracklist from YouTube chapters via YouTubeClient, falling back to description timestamps
  • ScrobblingCoordinator mix 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 entry
  • PlaybackScrobbleTracker extracted to own accumulate/threshold/finalize state for both single-track and mix entries; fixes a mix-detection race where duration arrives after the track object
  • Unit tests for the model, parser, and coordinator mix mode

Testing

  • Unit tests pass (swift test --skip KasetUITests) — 1796 passed, 150 suites
  • Manual testing performed — played multi-hour DJ mixes and verified per-sub-track scrobbles on Last.fm (correct titles, artists, and timestamps; no whole-mix scrobble)
  • UI tested on macOS 26+

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict && swiftformat .
  • I have added tests that prove my fix/feature works
  • New and existing unit tests pass locally
  • I have updated documentation if needed
  • I have checked for any performance implications
  • My changes generate no new warnings

Screenshots

Additional Notes

  • No behavior change for regular tracks: mix mode only activates when a tracklist is found for a long video.
  • Follow-up PR planned on top of this: a YouTube-style segmented seek bar in the player driven by the same tracklist data.

tsibog and others added 3 commits July 11, 2026 00:33
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.
@tsibog

tsibog commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Kaset/Services/Scrobbling/MixTracklistParser.swift Outdated
Comment thread Sources/Kaset/Services/Scrobbling/ScrobblingCoordinator.swift Outdated
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
@sozercan

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Kaset/Services/Scrobbling/ScrobblingCoordinator.swift Outdated
Comment thread Sources/Kaset/Models/MixTracklist.swift Outdated
Comment thread Sources/Kaset/Models/MixTracklist.swift Outdated
Comment thread Sources/Kaset/Services/Scrobbling/ScrobblingCoordinator.swift Outdated
Comment thread Sources/Kaset/Models/MixTracklist.swift
Daniel Andersson and others added 2 commits July 11, 2026 10:40
- 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>
@sozercan

Copy link
Copy Markdown
Owner

@codex review

Signed-off-by: Sertac Ozercan <sozercan@gmail.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Kaset/Services/Scrobbling/MixTracklistParser.swift Outdated
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
@sozercan

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Kaset/Services/Scrobbling/MixTracklistParser.swift
Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
@sozercan

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/Kaset/Services/Scrobbling/MixTracklistParser.swift Outdated
Daniel Andersson and others added 3 commits July 11, 2026 23:42
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>
@sozercan

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: f5fec049ac

ℹ️ 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".

@tsibog
tsibog requested a review from sozercan July 12, 2026 00:10
@sozercan
sozercan merged commit 481a56d into sozercan:main Jul 12, 2026
8 checks passed
tsibog added a commit to tsibog/kaset that referenced this pull request Jul 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants