package Slim::Plugin::RadioArtwork::Plugin;

# Logitech Media Server Copyright 2001-2024 Logitech.
# Lyrion Music Server Copyright 2024-2025 Lyrion Community.
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License,
# version 2.

=pod
	This is a simple artwork lookup plugin for radio stations not providing artwork for the tracks they're playing.
	It can be used as a standalone plugin handling the popular "artist - title" metadata format provided in ICY tags,
	or as the base plugin for an enhanced version.

	The plugin is structured in to logical methods which you should be able to selectively override if wanted:

	* registerTrackInfoMenu: register a handler for the stream's track info menu
	* handleTrackCover: the entry point which will be called by LMS with client, stream URL and parsed title info
	* validateRequest: run some checks, whether we have an artist and track title etc. Returns falsy if we should
	  ignore the request (eg. only station name provided).
	* requestIsQueued: check whether we already have a lookup going on for this title. Puts the new client on a
	  queue. This is important to avoid parallel lookups if multiple clients are playing the same station.
	* useCachedIfAvailable: use data from an earlier request if available. Return truthy if data has been found.
	* lookupArtwork: go online to find the artwork, evaluate the response, find the one artwork URL we want to use
	* gotArtwork: would be called by useCachedIfAvailable and lookupArtwork, updates all queued clients
	* titleCleanup, artistCleanup: helper to remove unwanted stuff from titles/names
=cut

use strict;
use JSON::XS::VersionOneAndTwo;
use Tie::RegexpHash;
use URI;
use URI::QueryParam;
use URI::Escape qw(uri_escape_utf8);

use Slim::Formats::RemoteMetadata;
use Slim::Menu::TrackInfo;
use Slim::Utils::Cache;
use Slim::Utils::Log;
use Slim::Utils::Prefs;

my $cache = Slim::Utils::Cache->new();
my $log = Slim::Utils::Log->addLogCategory({
	'category'     => 'plugin.radioartwork',
	'defaultLevel' => 'ERROR',
	'description'  => 'PLUGIN_RADIO_ARTWORK',
});
my $prefs = preferences('plugin.radioartwork');

use constant API_BASE_URL     => 'https://api.lms-community.org/music/';
use constant COVER_SEARCH_URL => API_BASE_URL . 'track/%s/%s/cover';
use constant KILLWORDS_URL    => API_BASE_URL . 'metadata/killwords';
use constant FALLBACK_ARTWORK => 'https://i1.sndcdn.com/artworks-x8zI2HVC2pnkK7F5-4xKLyA-t1080x1080.jpg';

my %queue;
my $killWords = {};
tie my %radioUrlsToIgnore, 'Tie::RegexpHash';

sub initPlugin {
	my ($class) = @_;

	Slim::Formats::RemoteMetadata->registerArtworkHandler( sub {
		$class->handleTrackCover(@_);
	} );

	$class->registerTrackInfoMenu();

	$class->registerSettingsHandler();

	$prefs->init({
		ignoreStations => [],
		killWords => {},
	});

	$killWords = $prefs->get('killWords') || {};

	Slim::Utils::Timers::setTimer( $class, Time::HiRes::time() + rand(5), \&updateKillWords );
}

sub handleTrackCover {
	my $class = shift;
	my ($client, $url, $titleInfo) = @_;

	main::DEBUGLOG && $log->is_debug && $log->debug("Called to get artwork for $titleInfo, $url");

	my $args = $class->validateRequest(@_);
	return unless $args;

	return if $class->requestIsQueued($args, @_);

	return if $class->useCachedIfAvailable($args, @_);

	$class->lookupArtwork($args, @_);
}

sub registerTrackInfoMenu {
	my $class = shift;
	require Slim::Plugin::RadioArtwork::TrackInfo;

	Slim::Menu::TrackInfo->registerInfoProvider( blockStream => (
		before => 'top',
		func   => \&Slim::Plugin::RadioArtwork::TrackInfo::trackInfoMenu,
	) );
}

sub registerSettingsHandler {
	if (main::WEBUI) {
		require Slim::Plugin::RadioArtwork::Settings;
		Slim::Plugin::RadioArtwork::Settings->new();
	}
}

sub validateRequest {
	my ($class, $client, $url, $titleInfo) = @_;

	if (!($titleInfo && $url && $client)) {
		main::INFOLOG && $log->is_info && $log->info("Missing titleInfo, url or client");
		return;
	}

	if ($class->ignoreStation($url)) {
		main::INFOLOG && $log->is_info && $log->info("Station is in ignore list, not looking up artwork for $url");
		return;
	}

	my $track = Slim::Player::Playlist::track($client);
	if (!($track && $track->url eq $url)) {
		main::INFOLOG && $log->is_info && $log->info("Avoiding lookup for track not currently playing");
		return;
	}

	utf8::decode($titleInfo);
	my ($artist, $title) = split(' - ', $titleInfo);

	if (!$artist || !$title) {
		main::INFOLOG && $log->is_info && $log->info("Title info not in expected format 'artist - title': $titleInfo");
		return;
	}

	$artist = $class->artistCleanup($artist);
	$title  = $class->titleCleanup($title);

	if (!$artist || !$title) {
		main::INFOLOG && $log->is_info && $log->info("Title info not available after cleanup: title \"$title\", artist \"$artist\"");
		return;
	}

	if (length($artist) > 100 || length($title) > 100 || ($artist =~ /^\s*\d+\s*$/ && $title =~ /^\s*\d+\s*$/)) {
		main::INFOLOG && $log->is_info && $log->info("Title info unlikely to be resolvable: title \"$title\", artist \"$artist\" - skipping lookup");
		return;
	}

	if ($killWords->{lc($title)} || $killWords->{lc($artist)}) {
		main::INFOLOG && $log->is_info && $log->info("Title or artist contains kill word(s), not looking up artwork for $titleInfo");
		return;
	}

	my $artworkLookupUrl = sprintf(COVER_SEARCH_URL, uri_escape_utf8($title), uri_escape_utf8($artist));

	return {
		title => $title,
		artist => $artist,
		artworkLookupUrl => $artworkLookupUrl,
	};
}

# check whether we already have a request for the same artwork in the queue
# if so, add the client to the queue and return truthy
sub requestIsQueued {
	my ($class, $args, $client, $url, $titleInfo) = @_;

	my $artworkLookupUrl = $args->{artworkLookupUrl};
	if (scalar @{$queue{$artworkLookupUrl} || []}) {
		main::DEBUGLOG && $log->is_debug && $log->debug("Already looking up artwork for $artworkLookupUrl");
		push @{$queue{$artworkLookupUrl}}, $client if !grep { $_ == $client } @{$queue{$artworkLookupUrl} || []};
		return 1;
	}

	$queue{$artworkLookupUrl} = [ $client ];
	return;
}

# check whether we have a cached result - return truthy if we did
sub useCachedIfAvailable {
	my ($class, $args, $client, $url, $titleInfo) = @_;

	my $cached = $cache->get($class->getCacheKey($args, @_));
	if (defined $cached) {
		main::INFOLOG && $log->is_info && $log->info("Using cached title cover: $cached");

		$args->{imageUrl} = $cached;
		$class->gotArtwork($args, $client, $url, $titleInfo);

		return 1;
	}

	return;
}

sub cacheFoundArtwork {
	my ($class, $args, $client, $url, $titleInfo) = @_;
	$cache->set($class->getCacheKey($args, @_), $args->{imageUrl}, 86400 * 30);
}

sub getCacheKey {
	my ($class, $args, $client, $url, $titleInfo) = @_;
	return 'rart_' . ($args->{artworkLookupUrl} || $titleInfo);
}

# do the actual lookup for artwork and evaluation of the result returned by the lookup
# call $class->gotArtwork($args) with the image url in $args->{imageUrl} on success
sub lookupArtwork {
	my ($class, $args, $client, $url, $titleInfo) = @_;

	my $title = $args->{title};
	my $artist = $args->{artist};
	my $artworkLookupUrl = $args->{artworkLookupUrl};

	main::INFOLOG && $log->is_info && $log->info("Getting artwork for $title by $artist");

	my $headers = $class->getHeaders($args, $client, $url, $titleInfo);

	Slim::Networking::SimpleAsyncHTTP->new(
		sub {
			my $response = shift;

			my $json = eval { from_json($response->content) };

			$log->warn($@) if $@;

			if ( $json && $json->{picture} && $json->{picture} ne FALLBACK_ARTWORK ) {
				main::DEBUGLOG && $log->is_debug && $log->debug("Found title artwork: " . $json->{picture});
				$args->{imageUrl} = $json->{picture};

				$class->cacheFoundArtwork($args, $client, $url, $titleInfo);

				return $class->gotArtwork($args, $client, $url, $titleInfo);
			}
			elsif ( $json ) {
				$args->{imageUrl} = $json->{picture};
				$class->cacheFoundArtwork($args, $client, $url, $titleInfo);

				main::DEBUGLOG && $log->is_debug && $log->debug("Nothing found: " . $response->content);
			}
			else {
				main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($response));
			}

			delete $queue{$artworkLookupUrl};
		},
		sub {
			delete $queue{$artworkLookupUrl};
			$log->error("HTTP error looking up artwork for $artworkLookupUrl");
			main::INFOLOG && $log->error(Data::Dump::dump(shift));
		},
		{
			cache => 1,
		}
	)->get($artworkLookupUrl, %$headers);
}

sub updateKillWords {
	my $class = shift;

	my $headers = $class->getHeaders();

	Slim::Networking::SimpleAsyncHTTP->new(
		sub {
			my $response = shift;

			my $json = eval { from_json($response->content) };

			$log->warn($@) if $@;

			main::INFOLOG && $log->is_info && $log->info("Received list of kill words");
			main::DEBUGLOG && $log->is_debug && $log->debug(Data::Dump::dump($json));

			if ($json && ref $json eq 'HASH' && scalar @{$json->{killWords} || []}) {
				$killWords = { map { $_ => 1 } @{$json->{killWords}} };
				$prefs->set('killWords', $killWords);

				%radioUrlsToIgnore = map { qr/\Q$_\E/ => 1 } @{$json->{ignoreStations} || []}, @{$prefs->get('ignoreStations') || []};
			}
		},
		sub {
			$log->error("Error updating kill words: " . Data::Dump::dump(shift));
		},
		{
			cache => 1,
		}
	)->get(KILLWORDS_URL, %$headers);
}

sub getHeaders {
	my ($class, $args, $client, $url, $titleInfo) = @_;

	my $headers =  {
		Slim::Utils::Misc::apiHeaders($class),
	};

	if ($url && (my $parsed = URI->new($url))) {
		# only submit host name / path to avoid posting potentially sensitive info like stream keys etc.
		$headers->{'X-LMS-Radio-URL'} = sprintf('%s://%s:%s%s', $parsed->scheme, $parsed->host, $parsed->port, $parsed->path);

		if ($parsed->host =~ /\b(?:tunein|radiotime)\.com$/i) {
			my $queryParams = $parsed->query_form_hash;
			$headers->{'X-LMS-Radio-URL'} .= '?sid=' . ($queryParams->{id} || 'unknown');
		}
	}

	return $headers;
}

# called when we have artwork to set - will update all clients waiting for the same artwork
sub gotArtwork {
	my ($class, $args, $client, $url, $titleInfo) = @_;

	my $imageUrl = $args->{imageUrl};
	my $artworkLookupUrl = $args->{artworkLookupUrl};

	if (!$imageUrl) {
		main::INFOLOG && $log->is_info && $log->info("No artwork found for $titleInfo");
		delete $queue{$artworkLookupUrl};
		return;
	}

	while ( my $c = shift @{$queue{$artworkLookupUrl} || []} ) {
		my $song = $c->playingSong() || next;

		$song->pluginData( httpCover => $imageUrl );
		$c->playingSong->pluginData( wmaMeta => {
			icon   => $imageUrl,
			cover  => $imageUrl,
			artist => $args->{artist},
			title  => $args->{title},
		} );

		Slim::Control::Request::notifyFromArray( $c, [ 'newmetadata' ] );
	}
}

sub ignoreStation {
	my ($class, $url) = @_;
	return 1 if $radioUrlsToIgnore{$url};
	return 1 if grep { lc($_) eq lc($url) } @{$prefs->get('ignoreStations') || []};
	return;
}

sub updateIgnoreStationList {
	my ($class, $ignoreList) = @_;
	$prefs->set('ignoreStations', [
		Slim::Utils::Misc::uniq( grep /^https?:/, sort {
			lc($a) cmp lc($b)
		} @$ignoreList)
	]);
}

# keep these two apart, so we could have optimized versions for each, if we wanted
sub artistCleanup { $_[0]->cleanup($_[1]) }
sub titleCleanup { $_[0]->cleanup($_[1]) }

# simple, generic cleanup of track and artist names - remove additions like "remaster" etc.
sub cleanup {
	my ($class, $text) = @_;

	# music services add too many appendices
	$text =~ s/[([][^)\]]*?(deluxe|edition|remaster|live|anniversary)[^)\]]*?[)\]]//ig;
	$text =~ s/ -[^-]*(deluxe|edition|remaster|live|anniversary).*//ig;

	# specific rule for the "[E]"xplicit flag - it's too specific/short to fit in with the above
	$text =~ s/\[E\]//g;

	# remove trailing non-word characters
	$text =~ s/[\s\W]{2,}$//;
	$text =~ s/\s*$//;

	return $text;
}

1;