Skip to content

fix(playback): identity-keyed stream resolve, probed URLs, and deferred metadata - #285

Merged
ajisth69 merged 2 commits into
Clash-Projects:mainfrom
shaktixdev:fix/playback-resolver-startup
Oct 9, 2026
Merged

ajisth69 merged 2 commits into
Clash-Projects:mainfrom
shaktixdev:fix/playback-resolver-startup

Conversation

@shaktixdev

Copy link
Copy Markdown
Contributor

Summary

Playback startup now treats youtubeVideoId as the only stream identity. A cached URL starts the player immediately, one extraction serves every waiter for that video, and a URL is installed only after a byte probe accepts it. Lyrics, artwork, Apple Music, Tidal, and recommendations wait until audio is already playing.

Device log lastwave-diagnostics-1791481694558.txt (build before the probe, lossless-on-cache-hit, and metadata-delay changes) already showed cache-hit startup at 176–179 ms and a joined in-flight extract at 817 ms. The 403 replay, lossless upgrade, and metadata-storm fixes are in this branch and covered by unit tests. They still need a fresh device log.

Root cause

  • Stream identity was not stable. Title, artist, and album were available to callers, and a second resolve for the same youtubeVideoId could start another extract or overwrite the URL the player was using.
  • Prefetch and playback raced. Cancelling a prefetch dropped work that had already produced a URL, and every track change cancelled and restarted the next-track job even when that video was already extracting or cached.
  • The direct InnerTube clients (VISIONOS, ANDROID_VR, TVHTML5) return HTTP 200 in ~150–240 ms with no playable audio URL. The player then waited on a serial NewPipe fallback. NewPipe URLs were cached and installed without a byte probe, so a 403 was replayed: Breakup Song (kd5KqlmcHNo) was extracted three times and took 11.8 s.
  • Lossless upgrade was scheduled only after a cold resolve. The cache-hit path returned after prepare and never called scheduleQualityUpgrade, so a cached song stayed on the YouTube codec.
  • Lyrics, canvas, high-res artwork, Apple Music, Tidal, and recommendations started with the extract. That traffic overlapped the first buffer and was repeated on every skip.

Changes

  • Identity. TrackIdentity is trackId + youtubeVideoId. Cache lookup and publish match on youtubeVideoId only. A result is published only while that identity is still the requested one (StaleResolveException otherwise).
  • Single flight. StreamExtraction keeps one Deferred per youtubeVideoId. Later callers log EXTRACT_JOIN. Cancelling prefetch does not cancel the extract; putIfAbsent keeps the URL.
  • Cache. Memory plus cacheDir/playback_stream_cache/streams.json (128 entries). Entries expire 2 minutes before expiresAt. Prefetch cannot replace a fresh playback URL.
  • Prefetch. One next-track job. PREFETCH_SKIPPED_ALREADY_ACTIVE and PREFETCH_SKIPPED_CACHED return without cancelling. The next URL is byte-cached before replaceMediaItem. A 403 or 410 invalidates the cache and is not installed.
  • Playable URL. The direct-fast path races a probed InnerTubeX candidate and a probed NewPipe candidate. NewPipe tries up to 4 audio streams (m4a/itag 140, then opus/itag 251). A URL that fails the Range: bytes=0-1 probe is not returned.
  • Lossless. A lossy YouTube stream, including a cache hit, schedules the upgrade as soon as it is installed. The upgrade is skipped when the setting is YouTube-only or the addon returns nothing, and that reason is logged. It does not block the first audible frame.
  • Metadata. After the player is audible, lyrics, canvas, high-res artwork, thumbnails, and recommendations wait 3 seconds (MetadataLog.SETTLE_MS). A skip cancels them (METADATA_SKIPPED). End-of-queue radio/discover still fetches immediately so the queue does not stop.
  • Traffic log. TrafficPriorityInterceptor logs each OkHttp call as LastWaveTraffic with source, video id, priority, and elapsed time.
  • Startup log. LastWaveStartup records TAP_TRACK, cache, video-id lookup, extract, prepare, and a [STARTUP] line with totalMs.

Acceptance criteria

  • The track that was tapped is the track that plays. A resolve that finishes for a previous video is ignored.
  • Two requests for the same youtubeVideoId produce one extraction (EXTRACT_JOIN on the second).
  • Prefetch of the current video does not replace the URL playback just published.
  • A second prefetch of a video that is already extracting or cached logs PREFETCH_SKIPPED_ALREADY_ACTIVE or PREFETCH_SKIPPED_CACHED and does not start another extract.
  • A URL that answers 403 is not installed on the queue item. The player does not play that URL and then extract again.
  • Cache-hit [STARTUP] totalMs stays under 200 ms.
  • A lossy cache hit logs [STREAM UPGRADE] starting (or an explicit skipped / No upgrade stream found when the setting or addon blocks it).
  • Lyrics, artwork, Apple Music, Tidal, and recommendations log METADATA_DELAYED and start only after ~3 seconds of playback. A skip before that logs METADATA_SKIPPED.
  • ./gradlew :app:testDebugUnitTest --tests com.lastwave.app.playback.resolve.StreamResolveTest passes.

Performance validation

Measured on device in log/lastwave-diagnostics-1791481694558.txt (Nothing A001, app 4.2.4, before probe / lossless-on-cache-hit / metadata delay):

Case Video totalMs Notes
Cache hit zAEA3h529ZU 176 cacheHit=true
Cache hit + byte cache jCEdTq3j-0U 179 bufferedPos=57261
Joined in-flight extract AlvUuGJccKs 817 extractMs=311
Cold NewPipe 2gWbNcgZFM0 3154 extractMs=2614, stage=newpipe
Cold NewPipe kPmAJPUVY8I 3703 extractMs=2759
403 replay (fixed after this log) kd5KqlmcHNo 11751 three extracts; probe now rejects the URL before install

Unit tests (StreamResolveTest, 15 cases) passed on this branch: cache key, expiry, disk reload, stale token, mismatch retry, cache hit skips network, cancel drops an in-flight publish, prefetch cannot claim playback identity, one extraction per video, cancelled prefetch still caches, second prefetch does not extract again.

./gradlew :app:assembleDebug succeeded. APK: app/build/outputs/apk/debug/app-debug.apk.

Out of scope / remaining limits

  • A song that has never been resolved still needs one network extract. Direct InnerTube clients still return no playable URL, so that extract is NewPipe (~2.5–3.5 s plus the probe). The following skip should be a cache hit.
  • visitor_id and youtubei/player during a cold extract are the stream resolve itself. They are not deferred.
  • Lossless still depends on preferLosslessStreaming, quality other than YouTube (-1), and a configured addon. It swaps after YouTube audio has started.
  • A URL already stored by an older build can 403 once. The retry path invalidates it and re-extracts.

Test plan

  • Install the debug APK and play a cached song. Confirm [STARTUP] cacheHit=true and totalMs under 200.
  • Skip to a song that is not cached, wait until it plays, then skip back. The return trip should be a cache hit and one EXTRACT_JOIN or PREFETCH_SKIPPED_CACHED, not a second extract.
  • Skip several tracks inside 3 seconds. Confirm METADATA_SKIPPED and no lyrics / Tidal / Apple Music burst for the skipped songs.
  • Leave a lossy song playing. Confirm [STREAM UPGRADE] starting and, when the addon is enabled, a later lossless swap at the same position.
  • Force a bad URL if possible (or watch a previously cached 403). Confirm next-cache does not install it and the next resolve probes another itag.
  • ./gradlew :app:testDebugUnitTest --tests com.lastwave.app.playback.resolve.StreamResolveTest

…mmediately

YouTube startup was re-extracting the same video, installing URLs that 403, and racing metadata against the first buffer. An identity-keyed cache, single-flight extraction, and a probed URL keep the tapped track playing while lyrics and artwork wait.
@ajisth69
ajisth69 merged commit 7b34fac into Clash-Projects:main Oct 9, 2026
ajisth69 added a commit that referenced this pull request Oct 9, 2026
Resolve conflicts in InnerTubeMusicApi.kt and MusicPlayer.kt. Main's playback resolver (#285) landed after this branch forked and deliberately supersedes its parallel fast-path race (single POST per client, sequential, with a whole-path budget and maxAttempts=1), the lossless head-start race in resolveRemoteTrackAudioStream (lossless now promotes via background upgrade), and focus-aware buffer durations. Keep main's implementation for those paths; retain this branch's non-conflicting changes: findBestMatch hybrid gate and stricter stream-upgrade/audition duration guards.
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