Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
80 changes: 80 additions & 0 deletions Plugins/SiriusXM/APImetadata.pm
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ use JSON::XS;
use Date::Parse;
use Time::HiRes;

use Plugins::SiriusXM::TrackDurationDB;
use Plugins::SiriusXM::MusicBrainzAPI;

my $log = logger('plugin.siriusxm');
my $prefs = preferences('plugin.siriusxm');

Expand Down Expand Up @@ -116,6 +119,7 @@ sub _processResponse {
my $track_info = $latest_track->{track};
my $spotify_info = $latest_track->{spotify};
my $timestamp = $latest_track->{timestamp};
my $xmplaylist_id = $latest_track->{id};

return unless $track_info;

Expand Down Expand Up @@ -151,6 +155,21 @@ sub _processResponse {
}
}

# Handle track duration processing if we're using xmplaylists metadata
if ($use_xmplaylists_metadata && $track_info->{title} && $track_info->{artists}) {
my $title = $track_info->{title};
my $artist = ref($track_info->{artists}) eq 'ARRAY' ?
join(', ', @{$track_info->{artists}}) :
$track_info->{artists};

# Start async duration lookup and continue with metadata building
$class->_lookupTrackDuration($xmplaylist_id, $title, $artist, sub {
my ($duration) = @_;
# Duration will be stored in database, but we don't wait for it
# to complete the current metadata response
});
}

# Build new metadata
my $new_meta = {};

Expand Down Expand Up @@ -180,6 +199,22 @@ sub _processResponse {
$new_meta->{album} = $channel_info->{name} || 'SiriusXM';
$new_meta->{bitrate} = '';

# Add duration if available from database
if ($xmplaylist_id) {
my $duration = Plugins::SiriusXM::TrackDurationDB->getDuration($xmplaylist_id);
if (defined $duration && $duration > 0) {
$new_meta->{duration} = $duration;
$new_meta->{secs} = $duration;
$log->debug("Added cached duration to metadata: ${duration}s");
}
}

# Add track start time info for timing calculations
if ($timestamp) {
$new_meta->{track_timestamp} = $timestamp;
$new_meta->{xmplaylist_id} = $xmplaylist_id if $xmplaylist_id;
}

} else {
# Fall back to basic channel info when metadata is too old or disabled
if ($channel_info) {
Expand All @@ -203,4 +238,49 @@ sub _processResponse {
}
}

# Lookup track duration with database cache and MusicBrainz fallback
sub _lookupTrackDuration {
my ($class, $xmplaylist_id, $title, $artist, $callback) = @_;

return unless $xmplaylist_id && $title && $artist;

# First check database cache by ID
my $cached_duration = Plugins::SiriusXM::TrackDurationDB->getDuration($xmplaylist_id);
if (defined $cached_duration) {
$callback->($cached_duration) if $callback;
return;
}

# Also check by title/artist to avoid duplicate API calls for same track with different IDs
$cached_duration = Plugins::SiriusXM::TrackDurationDB->findDurationByTrack($title, $artist);
if (defined $cached_duration) {
# Store this ID mapping for future use
Plugins::SiriusXM::TrackDurationDB->storeDuration(
$xmplaylist_id, $title, $artist, $cached_duration, 0 # Score 0 indicates from cache
);
$callback->($cached_duration) if $callback;
return;
}

# Fallback to MusicBrainz API search only if not in database
$log->debug("Looking up duration for: $title by $artist (ID: $xmplaylist_id)");

Plugins::SiriusXM::MusicBrainzAPI->searchTrackDuration($title, $artist, sub {
my ($duration, $score) = @_;

if (defined $duration && $score) {
# Store in database for future use
Plugins::SiriusXM::TrackDurationDB->storeDuration(
$xmplaylist_id, $title, $artist, $duration, $score
);

$log->info("Found and cached duration for '$title' by '$artist': ${duration}s (score: $score%)");
$callback->($duration) if $callback;
} else {
$log->debug("No suitable duration found for: $title by $artist");
$callback->() if $callback;
}
});
}

1;
176 changes: 176 additions & 0 deletions Plugins/SiriusXM/MusicBrainzAPI.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package Plugins::SiriusXM::MusicBrainzAPI;

use strict;
use warnings;

use Slim::Utils::Log;
use Slim::Networking::SimpleAsyncHTTP;
use JSON::XS;
use URI::Escape;

my $log = logger('plugin.siriusxm.musicbrainz');

# MusicBrainz API endpoint
use constant MUSICBRAINZ_URL => 'https://musicbrainz.org/ws/2/recording';

# Minimum score threshold (75%)
use constant MIN_SCORE_THRESHOLD => 75;

# Search for track duration using MusicBrainz API
sub searchTrackDuration {
my ($class, $title, $artist, $callback) = @_;

return unless $title && $artist && $callback;

# Clean up search terms
$title = _cleanSearchTerm($title);
$artist = _cleanSearchTerm($artist);

# Build search query
my $query = uri_escape("recording:\"$title\" AND artist:\"$artist\"");
my $url = MUSICBRAINZ_URL . "?query=$query&limit=10&fmt=json";

$log->debug("Searching MusicBrainz for: $title by $artist");

my $http = Slim::Networking::SimpleAsyncHTTP->new(
sub {
my $response = shift;
$class->_processResponse($title, $artist, $response, $callback);
},
sub {
my ($http, $error) = @_;
$log->warn("MusicBrainz API error: $error");
$callback->();
},
{
timeout => 20,
# User-Agent required by MusicBrainz
'User-Agent' => 'LMS-SiriusXM-Plugin/1.0 (https://github.com/paul-1/plugin-SiriusXM)',
}
);

$http->get($url);
}

# Process MusicBrainz API response
sub _processResponse {
my ($class, $title, $artist, $response, $callback) = @_;

my $content = $response->content;
my $data;

eval {
$data = decode_json($content);
};

if ($@) {
$log->warn("Failed to parse MusicBrainz response: $@");
$callback->();
return;
}

my $recordings = $data->{recordings};
unless ($recordings && @$recordings) {
$log->debug("No recordings found for: $title by $artist");
$callback->();
return;
}

# Find the best match based on score and release date
my $best_match;
my $best_score = 0;
my $best_release_date;

foreach my $recording (@$recordings) {
my $score = $recording->{score} || 0;

# Skip if below threshold
next if $score < MIN_SCORE_THRESHOLD;

# Check if this recording has a duration
my $length = $recording->{length};
next unless defined $length;

# Convert milliseconds to seconds
my $duration_seconds = int($length / 1000);
next if $duration_seconds <= 0;

# Get earliest release date
my $release_date = _getEarliestReleaseDate($recording);

# Update best match if score is higher, or if score is equal and this has an older release date
my $is_better = ($score > $best_score);
if ($score == $best_score && defined $release_date && defined $best_release_date) {
$is_better = ($release_date lt $best_release_date);
} elsif ($score == $best_score && defined $release_date && !defined $best_release_date) {
$is_better = 1;
}

if ($is_better) {
$best_score = $score;
$best_release_date = $release_date;
$best_match = {
duration => $duration_seconds,
score => $score,
title => $recording->{title},
length_ms => $length,
release_date => $release_date,
};
}
}

if ($best_match) {
$log->info("Found MusicBrainz match for '$title' by '$artist': " .
$best_match->{duration} . "s (score: $best_score%)");

$callback->($best_match->{duration}, $best_score);
} else {
$log->debug("No suitable MusicBrainz matches found for: $title by $artist (threshold: " . MIN_SCORE_THRESHOLD . "%)");
$callback->();
}
}

# Clean search terms for better matching
sub _cleanSearchTerm {
my $term = shift || '';

# Remove common noise
$term =~ s/\s*\(.*?\)\s*//g; # Remove parentheses content
$term =~ s/\s*\[.*?\]\s*//g; # Remove brackets content
$term =~ s/\s*feat\.?\s+.*$//i; # Remove featuring
$term =~ s/\s*ft\.?\s+.*$//i; # Remove ft.
$term =~ s/\s*with\s+.*$//i; # Remove with
$term =~ s/^\s+|\s+$//g; # Trim whitespace
$term =~ s/\s+/ /g; # Normalize whitespace

return $term;
}

# Get the earliest release date from a recording's releases
sub _getEarliestReleaseDate {
my $recording = shift;

my $releases = $recording->{releases};
return unless $releases && @$releases;

my $earliest_date;

foreach my $release (@$releases) {
my $date = $release->{'first-release-date'} || $release->{date};
next unless $date;

# Normalize date format (handle partial dates like "1975" or "1975-06")
if ($date =~ /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?/) {
my ($year, $month, $day) = ($1, $2 || '01', $3 || '01');
$date = sprintf("%04d-%02d-%02d", $year, $month, $day);
}

if (!defined $earliest_date || $date lt $earliest_date) {
$earliest_date = $date;
}
}

return $earliest_date;
}

1;
7 changes: 7 additions & 0 deletions Plugins/SiriusXM/Plugin.pm
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use Proc::Background;
use Plugins::SiriusXM::API;
use Plugins::SiriusXM::Settings;
use Plugins::SiriusXM::ProtocolHandler;
use Plugins::SiriusXM::TrackDurationDB;

my $prefs = preferences('plugin.siriusxm');
my $log = Slim::Utils::Log->addLogCategory({
Expand Down Expand Up @@ -60,6 +61,9 @@ sub initPlugin {
# Initialize the API module
Plugins::SiriusXM::API->init();

# Initialize track duration database
Plugins::SiriusXM::TrackDurationDB->init();

# Add to music services menu
Slim::Menu::TrackInfo->registerInfoProvider( siriusxm => (
parent => 'moreinfo',
Expand Down Expand Up @@ -94,6 +98,9 @@ sub shutdownPlugin {
# Clean up API connections
Plugins::SiriusXM::API->cleanup();

# Shutdown track duration database
Plugins::SiriusXM::TrackDurationDB->shutdown();

}

sub validateHLSSupport {
Expand Down
Loading