diff --git a/plugin/ProtocolHandler.pm b/plugin/ProtocolHandler.pm index 15ac0e0..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; @@ -757,9 +762,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 4a8dab8..e3540e8 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; @@ -76,19 +79,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( { } ); @@ -108,30 +111,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 +148,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 +168,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 +217,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 @@ -228,22 +234,52 @@ 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; + $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. + # 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}; + return WEBM_MORE; } - + next; } @@ -539,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, ''); @@ -660,26 +702,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} ); }, } ); @@ -687,26 +731,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}); @@ -718,17 +764,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;