From a817dfc8fe27e09c1569712083e3ee3f8839bcd3 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Sat, 9 May 2026 11:42:38 +0100 Subject: [PATCH 1/5] webm: resync parser, dont kill the stream EBML element bigger than MAX_EBML used to return WEBM_ERROR. callers ignore the return value so playback just stops a few seconds in. people see it as "song cuts off early". new behaviour: scan the buffer for the next CLUSTER id and pick up there. if no cluster boundary is in the buffer yet, drop most of the buffer and wait for more bytes. we might lose a fragment but the song keeps going, which is what users want. --- plugin/WebM.pm | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/plugin/WebM.pm b/plugin/WebM.pm index 4a8dab8..fbafcbd 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -228,22 +228,41 @@ sub getAudio { my $id; my $size; - # first need to acquired an ID + # first need to acquired an ID if ( !defined $v->{id} ) { $v->{position} += getEBML(\$v->{inBuf}, \$id, \$size); - + # skip id that are not CLUSTER *except* the SEGMENT ! main::DEBUGLOG && $log->is_debug && $log->debug("cluster of $size byte (last audio $v->{timecode} ms)") if ($id eq ID_CLUSTER); next if $id eq ID_CLUSTER || $id eq ID_SEGMENT; - - $v->{need} = $size; + + $v->{need} = $size; $v->{id} = $id; - + + # parser desync. YT sometimes drops garbage in the stream. + # look for next CLUSTER id and continue from there. better + # than killing the whole song. if ($size > MAX_EBML) { - $log->error("EBML too large: $size"); - return WEBM_ERROR; + $log->warn("EBML too large: $size, attempting resync"); + my $sync = index($v->{inBuf}, ID_CLUSTER); + if ($sync >= 0) { + substr($v->{inBuf}, 0, $sync, ''); + $v->{position} += $sync; + $v->{need} = EBML_NEED; + undef $v->{id}; + next; + } + # no cluster in buffer yet. drop most of it, ask for more + my $drop = length($v->{inBuf}) - length(ID_CLUSTER) + 1; + if ($drop > 0) { + substr($v->{inBuf}, 0, $drop, ''); + $v->{position} += $drop; + } + $v->{need} = EBML_NEED; + undef $v->{id}; + return WEBM_MORE; } - + next; } From bc32bf017db6ee48494c3abcd26c3156ca4e9f52 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 20 May 2026 11:38:09 +0100 Subject: [PATCH 2/5] webm: keep video id around for error logs pass the youtube video id into WebM->new and store it in _webm. later commits will use it to tag warn lines so the log is greppable without hunting up to the matching new() line. --- plugin/ProtocolHandler.pm | 3 ++- plugin/WebM.pm | 14 +++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 15ac0e0..923d3b5 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -757,9 +757,10 @@ sub _getNextTrack { # What type of stream do we have? if (!$track->{manifest_url}) { + my $videoId = $class->getId($masterUrl); my $handler = $config->{'format'} =~ /aac/ ? Plugins::YouTube::M4a->new($track->{url}) : - Plugins::YouTube::WebM->new($track->{url}); + Plugins::YouTube::WebM->new($track->{url}, $videoId); $config->{'handler'} = $handler; $config->{'sysread'} = \&sysread_URL; diff --git a/plugin/WebM.pm b/plugin/WebM.pm index fbafcbd..d5b8c84 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -76,19 +76,19 @@ my $log = logger('plugin.youtube'); } sub new { - my ($class, $url) = @_; + my ($class, $url, $videoId) = @_; my $self = $class->SUPER::new; - + # _context is to be flushed/initialized each time the getAudio is restarted - # but _webm is to be used for the duration of the objects, i.e. when seeking + # but _webm is to be used for the duration of the objects, i.e. when seeking $self->init_accessor( url => $url, _context => {}, - _webm => {}, - ); - + _webm => { videoId => $videoId }, + ); + return bless $self, $class; -} +} sub flush { $_[0]->_context( { } ); From 91973c5b3110b2cd9056cc0ba95a9eb5629da372 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 20 May 2026 11:48:33 +0100 Subject: [PATCH 3/5] webm: tag error lines with video id [videoId] prefix on every warn and error so grep yt:videoId server.log shows everything that went wrong with one track. also split EBML too large into in cues / in headers / resync so the failure stage is visible. --- plugin/WebM.pm | 91 +++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 42 deletions(-) diff --git a/plugin/WebM.pm b/plugin/WebM.pm index d5b8c84..c0c0cfd 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -108,30 +108,31 @@ sub duration { } sub getCues { - my ($v) = @_; - + my ($v, $videoId) = @_; + my $tag = $videoId ? "[$videoId] " : ""; + # just in case caller sends less than EBML_NEED at first call ... stupid but who knows return WEBM_MORE if ($v->{need} > length $v->{inBuf}); - + if ( !defined $v->{id} ) { my $id; my $size; - + getEBML(\$v->{inBuf}, \$id, \$size); - + if ($id ne ID_CUES) { - $log->error("wrong cues offset" ); + $log->error("${tag}wrong cues offset" ); return WEBM_ERROR; - } - + } + $v->{id} = $id; $v->{need} = $size; - + if ($size > MAX_EBML) { - $log->error("EBML too large: $size"); + $log->error("${tag}EBML too large in cues: $size"); return WEBM_ERROR; } - } + } if ( $v->{need} <= length $v->{inBuf} ) { $v->{outBuf} = substr( $v->{inBuf}, 0, $v->{need} ); @@ -144,18 +145,19 @@ sub getCues { sub getHeaders { my ($self, $v) = @_; my $webm = $self->_webm; - + my $tag = $webm->{videoId} ? "[$webm->{videoId}] " : ""; + # process all we can while ($v->{need} <= length $v->{inBuf}) { my $id; my $size; - - # first need to acquired an ID + + # first need to acquired an ID if ( !defined $v->{id} ) { $v->{position} += getEBML(\$v->{inBuf}, \$id, \$size); - + if ($id eq ID_CLUSTER) { - $log->error("no info found"); + $log->error("${tag}no info found"); return WEBM_ERROR; } elsif ($id eq ID_SEGMENT) { $webm->{segment_offset} = $v->{position}; @@ -163,15 +165,15 @@ sub getHeaders { } elsif ($id eq ID_TRACKS) { next; } - + $v->{id} = $id; $v->{need} = $size; - + if ($size > MAX_EBML) { - $log->error("EBML too large: $size"); + $log->error("${tag}EBML too large in headers: $size"); return WEBM_ERROR; } - + next; } @@ -212,7 +214,8 @@ sub getAudio { my ($self, $outBuf) = @_; my $v = $self->_context; my $webm = $self->_webm; - + my $tag = $webm->{videoId} ? "[$webm->{videoId}] " : ""; + $v->{need} //= EBML_NEED; # number of bytes in buffr to allow processing $v->{position} //= 0; # number of bytes processed from buffer since 1st call @@ -243,7 +246,7 @@ sub getAudio { # look for next CLUSTER id and continue from there. better # than killing the whole song. if ($size > MAX_EBML) { - $log->warn("EBML too large: $size, attempting resync"); + $log->warn("${tag}EBML too large: $size, attempting resync"); my $sync = index($v->{inBuf}, ID_CLUSTER); if ($sync >= 0) { substr($v->{inBuf}, 0, $sync, ''); @@ -679,26 +682,28 @@ sub getStartOffset { onStream => sub { my ($http, $dataref) = @_; - $var->{'inBuf'} .= $$dataref; - my $res = getCues($var); - + $var->{'inBuf'} .= $$dataref; + my $res = getCues($var, $webm->{videoId}); + if ( $res eq WEBM_MORE ) { return 1 if $$dataref ne ''; $cb->( $webm->{offset}->{clusters} ); - return 0; - } elsif ( $res eq WEBM_DONE ) { + return 0; + } elsif ( $res eq WEBM_DONE ) { my $offset = getCueOffset($var->{outBuf}, $startTime*($webm->{timecode_scale}/1000000)*1000) + $webm->{segment_offset}; $cb->($offset); return 0; } elsif ( $res eq WEBM_ERROR ) { - $log->warn( "could not find start offset" ); + my $tag = $webm->{videoId} ? "[$webm->{videoId}] " : ""; + $log->warn( "${tag}could not find start offset" ); $cb->( $webm->{offset}->{clusters} ); return 0; } }, onError => sub { my ($self, $error) = @_; - $log->warn( "could not find start offset $error" ); + my $tag = $webm->{videoId} ? "[$webm->{videoId}] " : ""; + $log->warn( "${tag}could not find start offset $error" ); $cb->( $webm->{offset}->{clusters} ); }, } ); @@ -706,26 +711,28 @@ sub getStartOffset { sub initialize { my ($self, $cb, $ecb) = @_; + my $webm = $self->_webm; + my $tag = $webm->{videoId} ? "[$webm->{videoId}] " : ""; my $var = { 'inBuf' => '', - 'id' => undef, - 'need' => EBML_NEED, + 'id' => undef, + 'need' => EBML_NEED, }; - + my $http = Slim::Networking::Async::HTTP->new; - + $http->send_request( { request => HTTP::Request->new( GET => $self->url ), - onStream => sub { + onStream => sub { my ($http, $dataref) = @_; - + $var->{'inBuf'} .= $$dataref; my $res = $self->getHeaders($var); - + if ( $res eq WEBM_MORE ) { return 1; - } elsif ( $res eq WEBM_DONE ) { + } elsif ( $res eq WEBM_DONE ) { my $info = $self->_webm->{track}; - + $self->channels($info->{channels}); $self->samplerate($info->{samplerate}); $self->bitrate($info->{bitrate}); @@ -737,17 +744,17 @@ sub initialize { $cb->(); return 0; } elsif ( $res eq WEBM_ERROR ) { - $log->error( "could not get webm headers" ); + $log->error( "${tag}could not get webm headers" ); $ecb->(); return 0; - } + } }, onError => sub { my ($self, $error) = @_; - $log->warn("could not get codec info $error"); + $log->warn("${tag}could not get codec info $error"); $ecb->(); } - } ); + } ); } 1; From f928789dab4dbacae8d821827af80c2d0dbcd78e Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 20 May 2026 12:09:21 +0100 Subject: [PATCH 4/5] webm: bail resync after 4 MB instead of stalling when EBML reports a bogus huge size and there is no ID_CLUSTER in the next few MB of data, the prebuffer dries out and the player stops anyway. count dropped bytes, give up past 4 MB, propagate WEBM_ERROR up so sysread_URL ends the stream cleanly and LMS skips to the next track instead of just sitting there. --- plugin/ProtocolHandler.pm | 7 ++++++- plugin/WebM.pm | 22 ++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 923d3b5..228f4a4 100644 --- a/plugin/ProtocolHandler.pm +++ b/plugin/ProtocolHandler.pm @@ -346,11 +346,16 @@ sub sysread_URL { } # process all available data - $handler->getAudio(\$v->{outBuf}) if $handler->bufferLength; + my $audioRes = $handler->bufferLength ? $handler->getAudio(\$v->{outBuf}) : undef; if ( my $bytes = min(length $v->{outBuf}, $_[2]) ) { $_[1] = substr($v->{outBuf}, 0, $bytes, ''); return $bytes; + } elsif ( defined $audioRes && $audioRes eq Plugins::YouTube::WebM::WEBM_ERROR() ) { + # parser gave up. end the stream so LMS skips to next track + # instead of stalling on an empty buffer + $log->warn("parser error, ending stream early"); + return 0; } elsif ( $v->{streaming} && $v->{retry} > 0 ) { $! = EINTR; return undef; diff --git a/plugin/WebM.pm b/plugin/WebM.pm index c0c0cfd..bd0f279 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -31,8 +31,11 @@ my $cache = Slim::Utils::Cache->new; use constant HEADER_CHUNK => 8192; -use constant MAX_EBML => 128*1024; -use constant EBML_NEED => 12; +use constant MAX_EBML => 128*1024; +use constant EBML_NEED => 12; +# give up resync after this many dropped bytes. prebuffer empties +# around here anyway so further searching just delays the skip. +use constant MAX_RESYNC_DROP => 4 * 1024 * 1024; use constant WEBM_ERROR => 0; use constant WEBM_DONE => 1; @@ -246,20 +249,31 @@ sub getAudio { # look for next CLUSTER id and continue from there. better # than killing the whole song. if ($size > MAX_EBML) { - $log->warn("${tag}EBML too large: $size, attempting resync"); + $v->{resyncDropped} //= 0; + $log->warn("${tag}EBML too large: $size, attempting resync (dropped so far: $v->{resyncDropped})"); my $sync = index($v->{inBuf}, ID_CLUSTER); if ($sync >= 0) { substr($v->{inBuf}, 0, $sync, ''); $v->{position} += $sync; + $v->{resyncDropped} += $sync; $v->{need} = EBML_NEED; undef $v->{id}; + main::INFOLOG && $log->is_info && $log->info("${tag}resync ok, dropped $v->{resyncDropped} bytes total"); + $v->{resyncDropped} = 0; next; } - # no cluster in buffer yet. drop most of it, ask for more + # no cluster in buffer yet. drop most of it, ask for more. + # but if we have already dropped a lot give up so the + # player can move on instead of stalling silently. my $drop = length($v->{inBuf}) - length(ID_CLUSTER) + 1; if ($drop > 0) { substr($v->{inBuf}, 0, $drop, ''); $v->{position} += $drop; + $v->{resyncDropped} += $drop; + } + if ($v->{resyncDropped} > MAX_RESYNC_DROP) { + $log->error("${tag}resync failed after $v->{resyncDropped} bytes, giving up"); + return WEBM_ERROR; } $v->{need} = EBML_NEED; undef $v->{id}; From 470b27fe47cc9e9807042840389ba5a06d46f677 Mon Sep 17 00:00:00 2001 From: Alexander Adam Date: Wed, 20 May 2026 14:54:08 +0100 Subject: [PATCH 5/5] webm: getEBML desync drops 1 byte not 12 on a bad element_id length walk the parser was throwing away EBML_NEED (12) bytes and leaving id/size stale. that confused the next iteration into a cascade of EBML too large warns and around 57 KB of dropped opus, which sounded like a clear stutter. drop 1 byte instead, clear id/size, let the caller loop. cluster resync usually catches up within a packet, so the audio gap shrinks from ~3s to under 100ms. --- plugin/WebM.pm | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/plugin/WebM.pm b/plugin/WebM.pm index bd0f279..e3540e8 100644 --- a/plugin/WebM.pm +++ b/plugin/WebM.pm @@ -575,11 +575,17 @@ sub getEBML { for ($len = 1; !($c & 0x80) && $len <= 4; $len++) { $c <<= 1 } if ($len == 5) { - $log->error("wrong len: $len, $c"); - # arbitrary, but at least won't get stuck in the an infinite loop and will not go beyond available data - $len = EBML_NEED; - substr($$in, 0, $len, ''); - return $len; + # desync. element_id length walk fell off the end, the first byte + # does not look like an EBML id. drop just one byte and let the + # caller try again. earlier we used to drop EBML_NEED (12) which + # threw away around 3s of audio every desync. now we lose one + # byte and the cluster resync in getAudio normally picks up + # within a packet or two. + main::INFOLOG && $log->is_info && $log->info("getEBML desync, dropping 1 byte"); + substr($$in, 0, 1, ''); + $$id = undef; + $$size = 0; + return 1; } $$id = substr($$in, 0, $len, '');