Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
06f9250
Port gapless playback and SPA navigation from fork
tui2019 May 7, 2026
40a5c98
Restore pre-loading home page for faster SPA routing
tui2019 May 7, 2026
764b016
Refactor next() and previous() to use native YouTube Music built-in q…
tui2019 May 9, 2026
e60078e
Fix web page preload on first launch by conditionally rendering Persi…
tui2019 May 9, 2026
0fa771d
Fix cloud-synced queue restoration on first launch
tui2019 May 9, 2026
8744f0a
Block autoplay during initial home page preload and unblock on manual…
tui2019 May 9, 2026
13a221e
Apply swiftformat
May 9, 2026
a274c1b
Merge remote-tracking branch 'origin/main' into feature/implement-queues
sozercan Jul 7, 2026
8c4fef0
chore: clarify feedback token local name
sozercan Jul 7, 2026
f49ee0b
fix: confirm web queue injection before trusting it
sozercan Jul 7, 2026
b4cbccf
fix: make manual next deterministic
sozercan Jul 8, 2026
b2f23e3
docs: document gapless playback architecture
sozercan Jul 8, 2026
e5d087f
fix: route player bar next through Kaset queue
sozercan Jul 8, 2026
1788859
fix(player): materialize standalone next queue
sozercan Jul 8, 2026
f1253ec
fix(player): keep manual next target guarded
sozercan Jul 8, 2026
80591e4
fix(player): protect manual queue skips from stale metadata
sozercan Jul 8, 2026
45cd44a
chore: merge main into feature/implement-queues
sozercan Jul 10, 2026
a81cf4d
fix(player): harden native queue handoff
sozercan Jul 11, 2026
6777d5e
fix(player): serialize playback bridge events
sozercan Jul 11, 2026
f81529a
fix(player): address queue handoff review
sozercan Jul 11, 2026
07d9f2a
fix(player): preserve deferred restore state
sozercan Jul 11, 2026
b8824f0
fix(player): stabilize manual queue transitions
sozercan Jul 11, 2026
053ab8b
fix(player): invalidate stale queue sync state
sozercan Jul 11, 2026
bb24065
fix(player): mount guest playback webview
sozercan Jul 11, 2026
1fc3f96
fix(player): handle shuffled queue terminal state
sozercan Jul 11, 2026
f520fdc
fix(player): bound autoplay blocker polling
sozercan Jul 11, 2026
2c2b10f
fix(player): end exhausted mix continuations
sozercan Jul 11, 2026
6929a82
fix(player): end manual seek after empty continuation
sozercan Jul 11, 2026
f37fad1
fix(player): coalesce stale navigation recovery
sozercan Jul 11, 2026
6f1ff65
fix(player): guard canceled mix maintenance
sozercan Jul 11, 2026
f777106
fix(player): preserve replacement mix fetch
sozercan Jul 11, 2026
066b8ed
fix(player): invalidate native maintenance on queue replacement
sozercan Jul 11, 2026
ce95e28
fix(player): bind events to media occurrence
sozercan Jul 11, 2026
67a5721
refactor(player): remove redundant feedback aliases
sozercan Jul 11, 2026
67614f1
fix(player): close playback transition races
sozercan Jul 11, 2026
7795266
fix(player): address queue handoff follow-ups
sozercan Jul 11, 2026
f0076aa
fix(player): bound queue navigation recovery
sozercan Jul 11, 2026
35a0c86
fix(player): discard canceled radio queues
sozercan Jul 11, 2026
e73fe08
test(equalizer): await debounced persistence
sozercan Jul 11, 2026
f72c4de
docs(player): document native queue handoff
sozercan Jul 12, 2026
8fa6a0b
fix(player): handle queue transition edge cases
sozercan Jul 12, 2026
1aa8027
test: replace timing-based async waits
sozercan Jul 12, 2026
5c50eae
fix(player): preserve maintenance during enrichment
sozercan Jul 12, 2026
68cb81c
test(equalizer): await round-trip persistence
sozercan Jul 12, 2026
51784f6
test(player): cover stale navigation safeguards
sozercan Jul 12, 2026
e629ae8
fix(player): bind async navigation to playback context
sozercan Jul 12, 2026
c3e9811
chore: merge main into feature/implement-queues
sozercan Jul 12, 2026
4a784f7
fix(scrobbling): retain playback observation provenance
sozercan Jul 12, 2026
53f569c
fix(player): preserve manual intent and media identity
sozercan Jul 12, 2026
4e18c91
fix(player): prevent post-navigation playback rollback
sozercan Jul 12, 2026
a653765
chore: merge main into feature/implement-queues
sozercan Aug 1, 2026
f38ac45
fix(player): harden merged queue playback integration
sozercan Aug 2, 2026
6803411
fix(player): address final queue review feedback
sozercan Aug 2, 2026
aef7178
test(player): relax mix continuation start deadline
sozercan Aug 2, 2026
f2c4fef
fix(player): close overlapping next navigation races
sozercan Aug 2, 2026
9ab2be8
fix(player): preserve late media identity correction
sozercan Aug 2, 2026
31c208a
fix(player): gate native handoff queue commits
sozercan Aug 2, 2026
cf885f6
fix(player): harden native queue transition recovery
sozercan Aug 2, 2026
1f92217
fix(player): bind preload and fence recovery
sozercan Aug 2, 2026
f03a060
fix(player): bind media identity on time reset
sozercan Aug 2, 2026
364049d
fix(player): preserve backward-seek media identity
sozercan Aug 2, 2026
a9c43e2
fix(player): gate queue reconciliation by generation
sozercan Aug 2, 2026
2369893
fix(player): harden handoff recovery transitions
sozercan Aug 2, 2026
508bd05
fix(player): refresh resolved track-ended retries
sozercan Aug 2, 2026
2574879
fix(player): harden queue handoff validation
sozercan Aug 2, 2026
fcffdea
fix(player): bound uncertain track-ended recovery
sozercan Aug 2, 2026
35d7e42
fix(player): advance deferred identity refresh
sozercan Aug 2, 2026
a3741d7
fix(player): add identity deadline fallback
sozercan Aug 2, 2026
eb92b4f
fix(player): commit redirected document identity
sozercan Aug 2, 2026
f3405d8
test(equalizer): await persistence directly
sozercan Aug 2, 2026
a914a0b
chore: merge main into feature/implement-queues
sozercan Aug 6, 2026
f9a702c
fix(player): serialize playback bridge transitions
sozercan Aug 6, 2026
fc08dc8
chore: merge main into feature/implement-queues
sozercan Sep 5, 2026
a657fd4
fix(playback): preserve track openings and recovery
sozercan Sep 5, 2026
944e479
chore: merge main into feature/implement-queues
sozercan Sep 5, 2026
b343df3
fix: redact queue diagnostics and repair restoration test
sozercan Sep 5, 2026
58315c3
test: await favorites recovery tasks
sozercan Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/playback-webview-debugging/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Use this skill when playback, auth recovery, queue sync, or hidden WebView state
- `docs/playback.md`
- `Sources/Kaset/Views/MiniPlayerWebView.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+ObserverScript.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+NavigationState.swift`
- `Sources/Kaset/Views/SingletonPlayerWebView+QueueInjection.swift`
- `Sources/Kaset/Services/Player/PlayerService.swift`
- `Sources/Kaset/Services/Player/PlayerService+WebPlaybackIdentity.swift`
- `Sources/Kaset/Services/Player/PlayerService+WebQueueSync.swift`
- `Sources/Kaset/Services/Player/PlayerService+QueueNavigationRecovery.swift`
- `Sources/Kaset/Services/WebKit/WebKitManager.swift`
192 changes: 188 additions & 4 deletions Sources/APIExplorer/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
// help - Show this help message
//
// Options:
// -v, --verbose - Show raw JSON, or expanded search-audit samples
// -o, --output <file> - Save raw JSON response to a file
// -v, --verbose - Show raw JSON (queue-probe output is sanitized)
// -o, --output <file> - Save JSON (queue-probe output is sanitized)
// --client-version <version> - Override the resolved InnerTube client version
// --confirm-live-ai - Required acknowledgement for live AI requests
// --prompt-file <path|-> - Read a private free-text prompt from a mode-0600 file or stdin
Expand Down Expand Up @@ -1604,6 +1604,177 @@ func analyzeResponse(
return output
}

// MARK: - QueueProbeSong

private struct QueueProbeSong {
let videoId: String
let title: String
let artists: String
}

private func playlistPanelRenderer(in data: [String: Any]) -> [String: Any]? {
guard let contents = data["contents"] as? [String: Any],
let watchNextRenderer = contents["singleColumnMusicWatchNextResultsRenderer"] as? [String: Any],
let tabbedRenderer = watchNextRenderer["tabbedRenderer"] as? [String: Any],
let watchNextTabbedResults = tabbedRenderer["watchNextTabbedResultsRenderer"] as? [String: Any],
let tabs = watchNextTabbedResults["tabs"] as? [[String: Any]],
let firstTab = tabs.first,
let tabRenderer = firstTab["tabRenderer"] as? [String: Any],
let tabContent = tabRenderer["content"] as? [String: Any],
let musicQueueRenderer = tabContent["musicQueueRenderer"] as? [String: Any],
let queueContent = musicQueueRenderer["content"] as? [String: Any]
else {
return nil
}

return queueContent["playlistPanelRenderer"] as? [String: Any]
}

private func playlistPanelVideoRenderer(from item: [String: Any]) -> [String: Any]? {
if let direct = item["playlistPanelVideoRenderer"] as? [String: Any] {
return direct
}

if let wrapper = item["playlistPanelVideoWrapperRenderer"] as? [String: Any],
let primary = wrapper["primaryRenderer"] as? [String: Any],
let wrapped = primary["playlistPanelVideoRenderer"] as? [String: Any]
{
return wrapped
}

return nil
}

private func parseQueueProbeSongs(from data: [String: Any]) -> [QueueProbeSong] {
guard let renderer = playlistPanelRenderer(in: data),
let contents = renderer["contents"] as? [[String: Any]]
else { return [] }

return contents.compactMap { item in
guard let videoRenderer = playlistPanelVideoRenderer(from: item),
let videoId = videoRenderer["videoId"] as? String
else { return nil }

let title = joinedRunsText(videoRenderer["title"] as? [String: Any]) ?? "Unknown"
let artists = joinedRunsText(videoRenderer["longBylineText"] as? [String: Any]) ?? ""
return QueueProbeSong(videoId: videoId, title: title, artists: artists)
}
}

private func queueProbeContinuationToken(in data: [String: Any]) -> String? {
guard let renderer = playlistPanelRenderer(in: data),
let continuations = renderer["continuations"] as? [[String: Any]],
let firstContinuation = continuations.first,
let nextRadioData = firstContinuation["nextRadioContinuationData"] as? [String: Any]
else { return nil }

return nextRadioData["continuation"] as? String
}

private func queueProbeAutoplayVideoId(in data: [String: Any]) -> String? {
guard let autoplay = data["playerOverlays"] as? [String: Any],
let playerOverlayRenderer = autoplay["playerOverlayRenderer"] as? [String: Any],
let autoplayRenderer = playerOverlayRenderer["autoplay"] as? [String: Any],
let playerOverlayAutoplayRenderer = autoplayRenderer["playerOverlayAutoplayRenderer"] as? [String: Any],
let item = playerOverlayAutoplayRenderer["item"] as? [String: Any],
let compactVideoRenderer = item["compactVideoRenderer"] as? [String: Any]
else { return nil }

return compactVideoRenderer["videoId"] as? String
}

private let queueProbeRedactedDiagnosticValue = "[REDACTED]"

/// Preserve response structure without exporting personalized identifiers, text, or opaque values.
private func sanitizedQueueProbeDiagnosticValue(_ value: Any) -> Any {
if let dictionary = value as? [String: Any] {
return sanitizedQueueProbeDiagnosticResponse(dictionary)
}

if let array = value as? [Any] {
return array.map(sanitizedQueueProbeDiagnosticValue)
}

if value is NSNull {
return value
}

return queueProbeRedactedDiagnosticValue
}

private func sanitizedQueueProbeDiagnosticResponse(_ data: [String: Any]) -> [String: Any] {
data.mapValues(sanitizedQueueProbeDiagnosticValue)
}

private func prettyPrintedSanitizedQueueProbeResponse(_ data: [String: Any]) throws -> Data {
try JSONSerialization.data(
withJSONObject: sanitizedQueueProbeDiagnosticResponse(data),
options: [.prettyPrinted, .sortedKeys]
)
}

func probeQueue(videoId: String, playlistId: String? = nil, verbose: Bool = false, outputFile: String? = nil) async {
let resolvedPlaylistId = playlistId ?? "RDAMVM\(videoId)"
let body: [String: Any] = [
"videoId": videoId,
"playlistId": resolvedPlaylistId,
"enablePersistentPlaylistPanel": true,
"isAudioOnly": true,
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
]

print("🎧 Probing queue (identifiers and text stay hidden)")
print(" playlist: \(playlistId == nil ? "derived from seed" : "supplied")")
print(" endpoint: next")
if loadCookiesFromAppBackup() != nil, !forceUnauthenticatedRequests {
print(" auth: cookies available")
} else {
print(" auth: guest/no cookies")
}
print()

do {
let (data, statusCode) = try await makeRequest(endpoint: "next", body: body, authenticated: !forceUnauthenticatedRequests && loadCookiesFromAppBackup() != nil)
print("✅ HTTP \(statusCode)")
if statusCode == 401 || statusCode == 403 {
print("❌ Authentication required")
return
}

let songs = parseQueueProbeSongs(from: data)
let ids = songs.map(\.videoId)
let seedPositions = ids.enumerated().compactMap { index, id in id == videoId ? index : nil }
let firstPlayable = songs.first
let nextPlayable = songs.dropFirst().first
print("Queue summary:")
print(" • Parsed songs: \(songs.count)")
print(" • Seed positions: \(seedPositions.isEmpty ? "none" : seedPositions.map(String.init).joined(separator: ", "))")
print(" • Has first parsed song: \(firstPlayable == nil ? "no" : "yes")")
print(" • Has second parsed song: \(nextPlayable == nil ? "no" : "yes")")
print(" • Has autoplay overlay: \(queueProbeAutoplayVideoId(in: data) == nil ? "no" : "yes")")
print(" • Has continuation: \(queueProbeContinuationToken(in: data) == nil ? "no" : "yes")")

if verbose || outputFile != nil {
let sanitizedData = try prettyPrintedSanitizedQueueProbeResponse(data)

if verbose,
let sanitizedString = String(data: sanitizedData, encoding: .utf8)
{
print("\n📄 Sanitized response (all scalar values redacted):")
print(sanitizedString)
}

if let outputFile {
let url = URL(fileURLWithPath: outputFile)
try writePrivateOutput(sanitizedData, to: url.path)
print("\n💾 Saved sanitized response to: \(outputFile)")
}
}
} catch {
print("❌ Queue probe failed (error code: \((error as NSError).code))")
}
}

// MARK: - Commands

/// Known endpoints that require authentication
Expand Down Expand Up @@ -3458,6 +3629,8 @@ func showHelp() {
Validate one server-commanded free-text request
search-audit <query> Audit live Music search shapes, filters, and continuations
continuation <token> [ep] Explore a continuation (ep: 'browse', 'search', or 'next')
queue-probe <videoId> [playlistId]
Summarize the Music next/radio queue shape
analyze-file <path> Safely summarize a saved JSON response
list List all known endpoints
auth Check authentication status
Expand All @@ -3472,8 +3645,8 @@ func showHelp() {
help Show this help message

Options:
-v, --verbose Show raw JSON for browse/action/continuation; expand audits
-o, --output <file> Save raw output with owner-only permissions (mode 0600)
-v, --verbose Show raw JSON or expand audits; queue-probe stays sanitized
-o, --output <file> Save mode-0600 output; queue-probe stays sanitized
--body-file <path|-> Read a sensitive JSON body from a chmod-600 file or stdin
--prompt-file <path|-> Read a private prompt from a mode-0600 file or stdin
--confirm-live-ai Required acknowledgement for live Ask commands
Expand Down Expand Up @@ -3526,6 +3699,7 @@ func showHelp() {
swift run api-explorer action search '{"query":"never gonna give you up"}'
swift run api-explorer action player '{"videoId":"dQw4w9WgXcQ"}'
swift run api-explorer action next '{"playlistId":"RDEM...","videoId":"abc123"}'
swift run api-explorer queue-probe dQw4w9WgXcQ

# Deeply audit YouTube Music search response coverage
swift run api-explorer --guest search-audit "ambient electronic mix"
Expand Down Expand Up @@ -3893,6 +4067,16 @@ func runMain() async {
token, endpoint: endpoint, verbose: verbose, outputFile: outputFile
)

case "queue-probe":
guard filteredArgs.count >= 2 else {
print("❌ Usage: queue-probe <videoId> [playlistId]")
print(" playlistId defaults to RDAMVM<videoId>, matching YTMusicClient.getRadioQueue")
return
}
let videoId = filteredArgs[1]
let playlistId = filteredArgs.count >= 3 ? filteredArgs[2] : nil
await probeQueue(videoId: videoId, playlistId: playlistId, verbose: verbose, outputFile: outputFile)

case "analyze-file":
guard filteredArgs.count >= 2 else {
print("❌ Usage: analyze-file <path>")
Expand Down
5 changes: 5 additions & 0 deletions Sources/Kaset/Services/Audio/EqualizerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,11 @@ final class EqualizerService {
}
}

func awaitPendingPersistence() async {
let task = self.persistTask
await task?.value
}

private func persist() {
do {
let data = try Self.encoder.encode(self.settings)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Kaset/Services/Player/AlbumPlaybackActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ enum AlbumPlaybackActions {
return Task { @MainActor in
do {
let response = try await client.getPlaylist(id: album.id)
guard playerService.acceptsMusicPlaybackIntent(intent) else { return }
guard playerService.acceptsMusicPlaybackIntent(intent) else {
DiagnosticsLogger.ui.info("Discarding stale album playback request after newer playback intent")
return
}
let songs = QueueSongMetadata.albumSongs(
response.detail.tracks,
album: album,
Expand Down
Loading
Loading