diff --git a/Plugins/SiriusXM/APImetadata.pm b/Plugins/SiriusXM/APImetadata.pm index 71bc0da..b754e38 100644 --- a/Plugins/SiriusXM/APImetadata.pm +++ b/Plugins/SiriusXM/APImetadata.pm @@ -9,14 +9,15 @@ use Slim::Utils::Cache; use Slim::Networking::SimpleAsyncHTTP; use JSON::XS; use Date::Parse; -use Time::HiRes; +use File::Spec; +use Errno qw(ENOENT); my $log = logger('plugin.siriusxm'); my $prefs = preferences('plugin.siriusxm'); my $cache = Slim::Utils::Cache->new(); -use constant METADATA_STALE_TIME => 230; use constant STATION_CACHE_TIMEOUT => 21600; # 6 hours +use constant MIN_NEXT_UPDATE_DELAY_SECONDS => 10; # xmplaylists.com API JSON Schema: # { @@ -234,43 +235,95 @@ sub _processResponse { return; } - my $latest_track = $results->[0]; - my $track_info = $latest_track->{track}; - my $spotify_info = $latest_track->{spotify}; - my $timestamp = $latest_track->{timestamp}; + my $selected_track = $results->[0]; + my $selected_reason = 'default latest xmplaylist record'; + my $pdt_timestamp_available = 0; + my $next_update_delay; + my $next_track; - return unless $track_info; + if ($channel_info && $channel_info->{id}) { + my $channel_id = $channel_info->{id}; + unless ($channel_id =~ /^[A-Za-z0-9_-]+$/ && $channel_id !~ /\.\./) { + $log->warn("Invalid channel id '$channel_id' for PDT lookup, falling back to channel metadata"); + $selected_reason = 'invalid siriusxm channel id'; + $channel_id = undef; + } - # Determine whether to use xmplaylists metadata or fallback to channel info - # based on timestamp (if metadata is 0-230 seconds old, use it; otherwise use channel info) - my $use_xmplaylists_metadata = 0; - my $metadata_is_fresh = 0; - - # Only consider xmplaylists metadata if metadata is enabled - if ($prefs->get('enable_metadata') && $timestamp) { - eval { - # Use Date::Parse to handle UTC timestamp format: 2025-08-09T15:57:41.586Z - my $track_time = str2time($timestamp); - die "Failed to parse timestamp" unless defined $track_time; - - my $current_time = time(); - my $age_seconds = $current_time - $track_time; - - $log->debug("Track timestamp: $timestamp, age: ${age_seconds}s"); - - # Use xmplaylists metadata if timestamp is (200 seconds) or newer - if ($age_seconds <= METADATA_STALE_TIME) { - $use_xmplaylists_metadata = 1; - $metadata_is_fresh = 1; + if ($channel_id) { + my $tmp_dir = $ENV{TMPDIR} || $ENV{TEMP} || File::Spec->tmpdir() || '/tmp'; + my $pdt_file = File::Spec->catfile($tmp_dir, 'siriusxm', 'pdt_' . $channel_id . '.txt'); + $log->debug("Checking PDT file for SiriusXM channel id $channel_id: $pdt_file"); + my $play_ts = _readPlayTimestampFromFile($pdt_file); + + if (defined $play_ts) { + $pdt_timestamp_available = 1; + my $matched_track; + my $matched_ts; + my $next_track_ts; + + for my $result (@$results) { + next unless $result && ref($result) eq 'HASH'; + + my $result_ts = _parseTimestampToEpoch($result->{timestamp}); + next unless defined $result_ts; + + if ($result_ts > $play_ts) { + # Track the nearest upcoming record so ProtocolHandler can + # schedule the next metadata refresh near the transition time. + if (!defined $next_track_ts || $result_ts < $next_track_ts) { + $next_track_ts = $result_ts; + $next_track = $result; + } + next; + } + + if (!defined $matched_ts || $result_ts > $matched_ts) { + $matched_track = $result; + $matched_ts = $result_ts; + } + } + + if (defined $next_track_ts) { + my $raw_next_update_delay = $next_track_ts - $play_ts; + $next_update_delay = int($raw_next_update_delay); + if ($raw_next_update_delay > $next_update_delay) { + $next_update_delay++; + } + if ($next_update_delay < MIN_NEXT_UPDATE_DELAY_SECONDS) { + $next_update_delay = MIN_NEXT_UPDATE_DELAY_SECONDS; + } + $log->debug("Next xmplaylist track timestamp raw delay=${raw_next_update_delay}s rounded=${next_update_delay}s"); + } + + if ($matched_track) { + $selected_track = $matched_track; + $selected_reason = 'matched record at/before play timestamp'; + $log->debug("Selected xmplaylist record timestamp " . ($selected_track->{timestamp} || 'unknown') . " for play timestamp $play_ts"); + } else { + $log->debug("No xmplaylist record timestamp <= play timestamp $play_ts, falling back to latest record"); + } + } else { + $selected_reason = 'no usable play timestamp from pdt file'; + $log->debug("No usable play timestamp from $pdt_file, falling back to channel metadata"); } - }; - - if ($@) { - $log->warn("Failed to parse timestamp '$timestamp': $@"); - # Default to using xmplaylists metadata if we can't parse timestamp - $use_xmplaylists_metadata = 1; - $metadata_is_fresh = 1; } + } else { + $selected_reason = 'missing siriusxm channel id'; + $log->debug("Missing SiriusXM channel id in channel info, falling back to channel metadata"); + } + + my $track_info = $selected_track->{track}; + my $spotify_info = $selected_track->{spotify}; + $log->debug("Metadata source selection: $selected_reason"); + + # Determine whether to use xmplaylist metadata or channel metadata. + # xmplaylist metadata is only used when we have a usable playback timestamp from the pdt file. + my $use_xmplaylists_metadata = 0; + my $metadata_is_fresh = 0; + + if ($prefs->get('enable_metadata') && $pdt_timestamp_available && $track_info) { + $use_xmplaylists_metadata = 1; + $metadata_is_fresh = 1; } # Build new metadata @@ -303,7 +356,7 @@ sub _processResponse { $new_meta->{bitrate} = ''; } else { - # Fall back to basic channel info when metadata is too old or disabled + # Fall back to basic channel info when xmplaylist metadata is unavailable or disabled if ($channel_info) { # Fall back to basic channel info $new_meta->{artist} = $channel_info->{name}; @@ -315,14 +368,95 @@ sub _processResponse { } } + my $next_meta; + if ($next_track && ref($next_track) eq 'HASH') { + my $next_track_info = $next_track->{track}; + my $next_spotify_info = $next_track->{spotify}; + + if ($next_track_info) { + $next_meta = {}; + + if ($next_track_info->{title}) { + $next_meta->{title} = $next_track_info->{title}; + } + + if ($next_track_info->{artists} && ref($next_track_info->{artists}) eq 'ARRAY') { + my @artists = @{$next_track_info->{artists}}; + if (@artists) { + $next_meta->{artist} = join(', ', @artists); + } + } + + if ($next_spotify_info && $next_spotify_info->{albumImageLarge}) { + $next_meta->{cover} = $next_spotify_info->{albumImageLarge}; + $next_meta->{icon} = $next_spotify_info->{albumImageLarge}; + } + + $next_meta->{album} = $channel_info->{name} || 'SiriusXM'; + $next_meta->{bitrate} = ''; + } + } + # Return metadata and freshness info through callback if ($callback) { $callback->({ metadata => $new_meta, next => $data->{next}, is_fresh => $metadata_is_fresh, + next_update_delay => $next_update_delay, + next_metadata => $next_meta, }); } } +sub _readPlayTimestampFromFile { + my ($pdt_file) = @_; + + open(my $fh, '<', $pdt_file) or do { + if ($!{ENOENT}) { + $log->debug("PDT file not found: $pdt_file"); + } else { + $log->warn("Unable to read PDT file $pdt_file: $!"); + } + return; + }; + + my $raw_ts = <$fh>; + close($fh); + + unless (defined $raw_ts) { + $log->debug("PDT file is empty: $pdt_file"); + return; + } + + $raw_ts =~ s/^\s+|\s+$//g; + unless (length $raw_ts) { + $log->debug("PDT file contains no timestamp text: $pdt_file"); + return; + } + + my $play_ts = _parseTimestampToEpoch($raw_ts); + unless (defined $play_ts) { + $log->debug("Failed to parse play timestamp '$raw_ts' from $pdt_file"); + return; + } + + $play_ts -= 20; # This is when the segment is pulled by FFMpeg. Playing segment is 2 segments behind. + + $log->debug("Read play timestamp '$raw_ts' ($play_ts) from $pdt_file"); + return $play_ts; +} + +sub _parseTimestampToEpoch { + my ($timestamp) = @_; + return unless defined $timestamp; + + # Handle epoch seconds (optionally fractional) before trying Date::Parse formats. + if ($timestamp =~ /^\s*(\d+(?:\.\d+)?)\s*$/) { + return $1 + 0; + } + + return str2time($timestamp); +} + 1; diff --git a/Plugins/SiriusXM/ProtocolHandler.pm b/Plugins/SiriusXM/ProtocolHandler.pm index 3f49761..913aa6d 100644 --- a/Plugins/SiriusXM/ProtocolHandler.pm +++ b/Plugins/SiriusXM/ProtocolHandler.pm @@ -12,6 +12,8 @@ use Slim::Utils::Cache; use Slim::Utils::Timers; use Slim::Networking::SimpleAsyncHTTP; use Slim::Player::Playlist; +use Scalar::Util qw(refaddr); +use Time::HiRes qw(time); use JSON::XS; use Data::Dumper; use Date::Parse; @@ -21,6 +23,7 @@ use Plugins::SiriusXM::APImetadata; my $log = logger('plugin.siriusxm'); my $prefs = preferences('plugin.siriusxm'); +my $json_encoder = JSON::XS->new->canonical(1); # Metadata update interval (25 seconds) use constant METADATA_UPDATE_INTERVAL => 25; @@ -162,16 +165,24 @@ sub onPlayerEvent { } } - # Initialize Player Metatadata + # Initialize player metadata my $state = $playerStates{$clientId}; if (!$state) { + unless ($client->isPlaying()) { + $log->debug("Client $clientId is not playing, skipping metadata state initialization"); + return; + } + $log->debug("No current player state, configuring"); my $channel_info = __PACKAGE__->getChannelInfoFromUrl($url); # Initialize player state $playerStates{$clientId} = { url => $url, channel_info => $channel_info, - last_next => undef, + last_metadata_signature => undef, + metadata_request_token => undef, + metadata_request_seq => 0, + pending_metadata_result => undef, timer => undef, }; _fetchMetadataFromAPI($client); @@ -205,19 +216,15 @@ sub _startMetadataTimer { $playerStates{$clientId} = { url => $url, channel_info => $channel_info, - last_next => undef, + last_metadata_signature => undef, + metadata_request_token => undef, + metadata_request_seq => 0, + pending_metadata_result => undef, timer => undef, }; # Start immediate metadata fetch _fetchMetadataFromAPI($client); - - # Schedule periodic updates - $playerStates{$clientId}->{timer} = Slim::Utils::Timers::setTimer( - $client, - time() + METADATA_UPDATE_INTERVAL, - \&_onMetadataTimer - ); } # Stop metadata update timer for a client @@ -248,6 +255,13 @@ sub _onMetadataTimer { return unless $client; my $clientId = $client->id(); + my $state = $playerStates{$clientId}; + + unless ($state) { + $log->debug("No metadata state for client $clientId, stopping timer"); + _stopMetadataTimer($client); + return; + } # Verify client is still playing my $isPlaying = $client->isPlaying(); @@ -256,25 +270,26 @@ sub _onMetadataTimer { _stopMetadataTimer($client); return; } - - # Fetch metadata update - _fetchMetadataFromAPI($client); - - # Let the meta data refresh one more time, to return player screens to channel artwork. + unless ($prefs->get('enable_metadata')) { $log->debug("Metadata updates disabled by user preference, stopping timer"); _stopMetadataTimer($client); return; } - # Schedule next update if still playing - if (exists $playerStates{$clientId}) { - $playerStates{$clientId}->{timer} = Slim::Utils::Timers::setTimer( - $client, - time() + METADATA_UPDATE_INTERVAL, - \&_onMetadataTimer - ); + if ($state && $state->{pending_metadata_result}) { + my $pending_result = delete $state->{pending_metadata_result}; + $log->debug("Applying cached next-track metadata for client $clientId"); + _updateClientMetadata($client, $pending_result); + + _scheduleNextMetadataUpdate($client, METADATA_UPDATE_INTERVAL); + return; } + + # Fetch metadata update + _fetchMetadataFromAPI($client); + + # Let the meta data refresh one more time, to return player screens to channel artwork. } # Fetch metadata from xmplaylist.com API using APImetadata module @@ -289,15 +304,95 @@ sub _fetchMetadataFromAPI { return unless $state && $state->{channel_info}; my $channel_info = $state->{channel_info}; + my $request_seq = ++$state->{metadata_request_seq}; + my $request_token = join(':', $clientId, refaddr($state), $request_seq, time()); + my $request_channel_id = $channel_info->{id}; + $state->{metadata_request_token} = $request_token; Plugins::SiriusXM::APImetadata->fetchMetadata($client, $channel_info, sub { my $result = shift; - return unless $result; - - _updateClientMetadata($client, $result); + my $next_delay = METADATA_UPDATE_INTERVAL; + my $current_state = $playerStates{$clientId}; + + unless ($current_state) { + $log->debug("Ignoring async metadata response for client $clientId: no active player state"); + return; + } + + my $current_request_token = $current_state->{metadata_request_token}; + if (!defined $current_request_token) { + $log->debug("Ignoring stale async metadata response for client $clientId: missing request token"); + return; + } + + if ($current_request_token ne $request_token) { + $log->debug("Ignoring stale async metadata response for client $clientId token $request_token"); + return; + } + + unless ($client->isPlaying()) { + $log->debug("Ignoring async metadata response for client $clientId: client no longer playing"); + _stopMetadataTimer($client); + return; + } + + my $current_channel_id = $current_state->{channel_info} + ? $current_state->{channel_info}->{id} + : undef; + if ( + defined $request_channel_id && defined $current_channel_id + && $request_channel_id ne $current_channel_id + ) { + $log->debug("Ignoring stale async metadata response for client $clientId channel $request_channel_id"); + return; + } + + if ($result) { + _updateClientMetadata($client, $result); + delete $current_state->{pending_metadata_result}; + + if ( + _isValidDelay($result->{next_update_delay}) + && $result->{next_metadata} + ) { + $next_delay = $result->{next_update_delay}; + $current_state->{pending_metadata_result} = { + metadata => $result->{next_metadata}, + is_fresh => 1, + }; + } + } + + unless ($prefs->get('enable_metadata')) { + _stopMetadataTimer($client); + return; + } + + _scheduleNextMetadataUpdate($client, $next_delay); }); } +sub _scheduleNextMetadataUpdate { + my ($client, $delay) = @_; + return unless $client; + + my $clientId = $client->id(); + return unless exists $playerStates{$clientId}; + + $delay = METADATA_UPDATE_INTERVAL unless _isValidDelay($delay); + $log->debug("Scheduling next metadata update for client $clientId in ${delay}s"); + + if ($playerStates{$clientId}->{timer}) { + Slim::Utils::Timers::killTimers($client, \&_onMetadataTimer); + } + + $playerStates{$clientId}->{timer} = Slim::Utils::Timers::setTimer( + $client, + time() + $delay, + \&_onMetadataTimer + ); +} + # Update client with new metadata sub _updateClientMetadata { my ($client, $result) = @_; @@ -310,11 +405,17 @@ sub _updateClientMetadata { return unless $state; my $new_meta = $result->{metadata}; - my $next = $result->{next}; my $metadata_is_fresh = $result->{is_fresh}; - - # Check if metadata has changed using "next" field - if (defined $state->{last_next} && defined $next && $state->{last_next} eq $next) { + my $metadata_signature = _metadataSignature($new_meta, "client $clientId"); + + # Check if metadata content has changed. + # Using metadata signature avoids lag when xmplaylist's "next" token + # stays constant while the selected record for play-behind-live changes. + if ( + defined $state->{last_metadata_signature} + && defined $metadata_signature + && $state->{last_metadata_signature} eq $metadata_signature + ) { # Only skip update if metadata is fresh - if stale, we need to update display if ($metadata_is_fresh) { $log->debug("No new metadata available and current metadata is fresh - skipping update"); @@ -324,8 +425,7 @@ sub _updateClientMetadata { } } - # Update the last_next value - $state->{last_next} = $next; + $state->{last_metadata_signature} = $metadata_signature; # Update the current song's metadata if we have new information if ($new_meta && keys %$new_meta) { @@ -356,6 +456,28 @@ sub _updateClientMetadata { } } +sub _metadataSignature { + my ($meta, $context) = @_; + return unless $meta && ref($meta) eq 'HASH'; + $context ||= 'unknown context'; + + my $signature; + eval { + $signature = $json_encoder->encode($meta); + }; + if ($@) { + $log->warn("Failed to encode metadata signature for $context: $@"); + return; + } + + return $signature; +} + +sub _isValidDelay { + my ($delay) = @_; + return defined $delay && $delay =~ /^\d+(?:\.\d+)?$/ && $delay > 0; +} + # Handle sxm: protocol URLs by converting them to HTTP proxy URLs sub getFormatForURL { my ($class, $url) = @_;