From 73b68c07c68b383e7f313c344ccad38d3e24b8a8 Mon Sep 17 00:00:00 2001 From: Marko Jurkovic Date: Sun, 19 Jul 2026 05:07:25 +0200 Subject: [PATCH 1/2] fix(playback): avoid false CUE-track errors Keep late playback errors attached to their originating track and ignore superseded or recoverable seek failures when valid audio follows. --- Audio/AudioPlayer.h | 1 + Audio/AudioPlayer.m | 7 +++++-- Audio/Chain/BufferChain.m | 3 ++- Audio/Chain/InputNode.m | 15 +++++++++++---- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Audio/AudioPlayer.h b/Audio/AudioPlayer.h index 2bd945921..07e33ff9e 100644 --- a/Audio/AudioPlayer.h +++ b/Audio/AudioPlayer.h @@ -122,6 +122,7 @@ - (void)endOfInputPlayed; - (void)reportPlayCount; - (void)reportScrobble; +- (void)setError:(BOOL)status forTrack:(id)userInfo; - (void)sendDelegateMethod:(SEL)selector withVoid:(void *)obj waitUntilDone:(BOOL)wait; - (void)sendDelegateMethod:(SEL)selector withObject:(id)obj waitUntilDone:(BOOL)wait; - (void)sendDelegateMethod:(SEL)selector withObject:(id)obj withObject:(id)obj2 waitUntilDone:(BOOL)wait; diff --git a/Audio/AudioPlayer.m b/Audio/AudioPlayer.m index 7e3e2056d..5f27b9ef9 100644 --- a/Audio/AudioPlayer.m +++ b/Audio/AudioPlayer.m @@ -686,8 +686,11 @@ - (void)sustainHDCD { // [self sendDelegateMethod:@selector(audioPlayer:sustainHDCD:) withObject:[bufferChain userInfo] waitUntilDone:NO]; } -- (void)setError:(BOOL)status { - [self sendDelegateMethod:@selector(audioPlayer:setError:toTrack:) withObject:@(status) withObject:[bufferChain userInfo] waitUntilDone:NO]; +- (void)setError:(BOOL)status forTrack:(id)userInfo { + // Buffer chains overlap while tracks are preloaded and switched. Preserve + // the chain's own track here; consulting the current global bufferChain when + // this asynchronous callback runs can apply a late error to the next track. + [self sendDelegateMethod:@selector(audioPlayer:setError:toTrack:) withObject:@(status) withObject:userInfo waitUntilDone:NO]; } - (void)setPlaybackStatus:(int)status { diff --git a/Audio/Chain/BufferChain.m b/Audio/Chain/BufferChain.m index 2e24acff9..1a2d160b0 100644 --- a/Audio/Chain/BufferChain.m +++ b/Audio/Chain/BufferChain.m @@ -314,7 +314,8 @@ - (void)pushInfo:(NSDictionary *)info { } - (void)setError:(BOOL)status { - [controller setError:status]; + AudioPlayer *audioPlayer = controller; + [audioPlayer setError:status forTrack:userInfo]; } @end diff --git a/Audio/Chain/InputNode.m b/Audio/Chain/InputNode.m index 99b6f1d50..1c4cfcacf 100644 --- a/Audio/Chain/InputNode.m +++ b/Audio/Chain/InputNode.m @@ -204,13 +204,10 @@ - (void)process { DLog(@"Seeked! Resetting Buffer"); initialBufferFilled = NO; - if(seekError) { - [controller setError:YES]; - } - signalReset = YES; if([self hasSeekRequestAfterID:activeSeekRequestID]) { + seekError = NO; continue; } } @@ -229,6 +226,11 @@ - (void)process { } if(chunk && [chunk frameCount]) { + // Some decoders can recover from an inexact or rejected seek and + // immediately return valid audio (cue-sheet fragments do this by + // seeking to their own start). Do not persist a track error when the + // requested playback is still producing audio. + seekError = NO; @autoreleasepool { if(signalReset) { chunk.resetForward = YES; @@ -241,6 +243,11 @@ - (void)process { if([self shouldContinue] == NO) { break; } + if(seekError) { + ALog(@"Decoder seek failed and produced no audio"); + [controller setError:YES]; + seekError = NO; + } if(chunk) { @autoreleasepool { chunk = nil; From 2bbe60d933de7ef4ffff96433c97237c685f1c12 Mon Sep 17 00:00:00 2001 From: Marko Jurkovic Date: Sun, 19 Jul 2026 05:08:15 +0200 Subject: [PATCH 2/2] fix(playback): skip repeated probes for failed multi-track sources Treat fragment URLs from the same underlying resource as one failed source, avoid repeatedly opening each logical track, and publish only the first fallback stream that opens successfully. --- Audio/AudioPlayer.m | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Audio/AudioPlayer.m b/Audio/AudioPlayer.m index 5f27b9ef9..23f7c0b18 100644 --- a/Audio/AudioPlayer.m +++ b/Audio/AudioPlayer.m @@ -15,6 +15,25 @@ #import "Logging.h" +static NSURL *streamURLWithoutFragment(NSURL *url) { + if(!url || ![[url fragment] length]) { + return url; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + components.fragment = nil; + return components.URL; +} + +static BOOL streamURLsShareUnderlyingResource(NSURL *firstURL, NSURL *secondURL) { + NSURL *firstResource = streamURLWithoutFragment(firstURL); + NSURL *secondResource = streamURLWithoutFragment(secondURL); + if(!firstResource || !secondResource) { + return NO; + } + return [firstResource isEqualTo:secondResource]; +} + @implementation AudioPlayer { BOOL stoppedRecently; } @@ -106,7 +125,23 @@ - (void)play:(NSURL *)url withUserInfo:(id)userInfo withRGInfo:(NSDictionary *)r [self notifyStreamChanged:userInfo]; } - while(![bufferChain open:url withOutputFormat:[output format] withUserInfo:userInfo withRGInfo:rgi resetBuffers:YES]) { + NSURL *failedFragmentResource = nil; + BOOL advancedPastFailedStream = NO; + while(YES) { + BOOL skipRepeatedProbe = failedFragmentResource && streamURLsShareUnderlyingResource(failedFragmentResource, url); + if(!skipRepeatedProbe && [bufferChain open:url withOutputFormat:[output format] withUserInfo:userInfo withRGInfo:rgi resetBuffers:YES]) { + break; + } + + if(skipRepeatedProbe) { + DLog(@"Skipping another logical track from failed source: %@", url); + } else { + // Logical tracks with different fragments share one decoder source. If + // that source cannot be opened, probing every remaining fragment repeats + // the same expensive failure while this method blocks the main thread. + failedFragmentResource = [[url fragment] length] ? streamURLWithoutFragment(url) : nil; + } + [self setError:YES forTrack:userInfo]; bufferChain = nil; [self requestNextStream:userInfo]; @@ -122,11 +157,15 @@ - (void)play:(NSURL *)url withUserInfo:(id)userInfo withRGInfo:(NSDictionary *)r userInfo = nextStreamUserInfo; rgi = nextStreamRGInfo; - - [self notifyStreamChanged:userInfo]; + advancedPastFailedStream = YES; bufferChain = [[BufferChain alloc] initWithController:self]; } + if(advancedPastFailedStream) { + // Do not present every failed fallback as the current track. Publish only + // the first stream that actually opened. + [self notifyStreamChanged:userInfo]; + } if(resumeInterval || time > 0.0) { [output seek:time];