Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ceb151a
Initial plan
Copilot Apr 19, 2026
0eccc8e
Support play-behind-live metadata selection using pdt timestamp files
Copilot Apr 19, 2026
4971b85
Harden PDT timestamp parsing and selection safeguards
Copilot Apr 19, 2026
aca5a70
Apply PR feedback for channel-metadata fallback and stale-check removal
Copilot Apr 19, 2026
72abed5
Fix metadata refresh lag by comparing metadata content signatures
Copilot Apr 19, 2026
06ec31b
Fix delayed metadata updates by using metadata signatures
Copilot Apr 19, 2026
a50bd5e
Schedule metadata checks using next xmplaylist track offset
Copilot Apr 19, 2026
fc1f000
Use xmplaylist future-track offset to schedule metadata refresh
Copilot Apr 19, 2026
6091179
Adjust metadata delay for PDT file age and dedupe timers
Copilot Apr 19, 2026
2b95d0f
Clarify PDT age fallback in delay calculation
Copilot Apr 19, 2026
82fb5a3
Log PDT clock skew and document stat mtime index
Copilot Apr 19, 2026
e16d047
Log raw and adjusted metadata delay values
Copilot Apr 19, 2026
fb07a2d
Use timestamp-only delay rounding and drop mtime adjustment
Copilot Apr 19, 2026
5d4f0b7
Avoid reinitializing metadata when stream is stopped
Copilot Apr 20, 2026
7cc5f2e
Tighten stop-loop fix and clean nearby comment text
Copilot Apr 20, 2026
207f9c2
Guard async metadata callbacks against stale player state
Copilot Apr 20, 2026
800e4e2
Refine async token checks from review feedback
Copilot Apr 20, 2026
29f39e7
Use unique request tokens for async metadata callbacks
Copilot Apr 20, 2026
54c35b3
Refine async request token generation and checks
Copilot Apr 20, 2026
e58c605
Import hi-res time and make token init explicit undef
Copilot Apr 20, 2026
e01d866
Add per-state request sequence for async token uniqueness
Copilot Apr 20, 2026
ae4bf10
Move Current playing pointer 20 behind last segment pulled.
paul-1 Apr 21, 2026
36c4fdd
Cache next-track metadata for delayed local apply
Copilot Apr 21, 2026
49273db
Respect metadata preference before applying cached metadata
Copilot Apr 21, 2026
b51fd3b
Guard metadata timer callback when player state is missing
Copilot Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 170 additions & 36 deletions Plugins/SiriusXM/APImetadata.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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:
# {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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};
Expand All @@ -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;
Loading
Loading