diff --git a/config/controller_config.php b/config/controller_config.php index 456feabe..7e47ab63 100644 --- a/config/controller_config.php +++ b/config/controller_config.php @@ -10,6 +10,7 @@ 'export' => ZK\Controllers\ExportPlaylist::class, 'afile' => ZK\Controllers\ExportAfile::class, 'opensearch' => ZK\Controllers\OpenSearch::class, + 'artwork' => ZK\Controllers\ArtworkControl::class, 'cache' => ZK\Controllers\CacheControl::class, 'daily' => ZK\Controllers\RunDaily::class, 'print' => ZK\Controllers\PrintTags::class, diff --git a/controllers/ArtworkControl.php b/controllers/ArtworkControl.php new file mode 100644 index 00000000..794d9e16 --- /dev/null +++ b/controllers/ArtworkControl.php @@ -0,0 +1,93 @@ + + * @copyright Copyright (C) 1997-2023 Jim Mason + * @link https://zookeeper.ibinx.com/ + * @license GPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License, + * version 3, along with this program. If not, see + * http://www.gnu.org/licenses/ + * + */ + +namespace ZK\Controllers; + +use ZK\Engine\Engine; +use ZK\Engine\IArtwork; +use ZK\Engine\ILibrary; +use ZK\Engine\IPlaylist; +use ZK\Engine\PlaylistEntry; +use ZK\Engine\PlaylistObserver; + +use GuzzleHttp\Client; +use GuzzleHttp\RequestOptions; + +class ArtworkControl implements IController { + protected $verbose = false; + + protected function refreshList($playlist) { + $count = 0; + $imageApi = Engine::api(IArtwork::class); + Engine::api(IPlaylist::class)->getTracksWithObserver($playlist, + (new PlaylistObserver())->on('spin', function($entry) use($imageApi, &$count) { + if(!$entry->getTag() && $entry->getCreated()) { + $artist = PlaylistEntry::swapNames($entry->getArtist()); + if($this->verbose) + echo " deleting $artist\n"; + $imageApi->deleteArtistArt($artist); + $count++; + } + }) + ); + + if($count) { + echo "$count images queued for reload (please wait)\n"; + PushServer::lazyLoadImages($playlist); + } else + echo "No artist artwork found. No change.\n"; + } + + public function processRequest() { + if(php_sapi_name() != "cli") { + http_response_code(400); + return; + } + + // The heavy lifting is done by the push notification server. + // If it is not enabled, there is no point in proceeding. + if(!Engine::param('push_enabled', true)) { + echo "Push notification is disabled. No change.\n"; + return; + } + + $this->verbose = $_REQUEST["verbose"] ?? false; + + switch($_REQUEST["action"] ?? "") { + case "reload": + if($tag = $_REQUEST["tag"] ?? null) { + echo "Album queued for reload (please wait)\n"; + PushServer::lazyReloadAlbum($tag, $_REQUEST["master"] ?? 1); + break; + } else if($list = $_REQUEST["list"] ?? null) { + $this->refreshList($list); + break; + } + // fall through... + default: + echo "Usage: zk artwork:reload {tag|list}=id [master=0]\n"; + break; + } + } +} diff --git a/controllers/PushServer.php b/controllers/PushServer.php index 49e960ba..480fd87e 100644 --- a/controllers/PushServer.php +++ b/controllers/PushServer.php @@ -27,6 +27,7 @@ use ZK\Engine\DBO; use ZK\Engine\Engine; use ZK\Engine\IArtwork; +use ZK\Engine\ILibrary; use ZK\Engine\IPlaylist; use ZK\Engine\OnNowFilter; use ZK\Engine\PlaylistEntry; @@ -96,6 +97,25 @@ public static function toJson($show, $spin) { return json_encode($val); } + /** + * perform artist name comparison, accounting for use of ampersand + * + * adapted from ZootopiaListener::testArtist + * + * @param $albumArtist haystack + * @param $trackArtist needle + * @returns true iff needle appears somewhere in haystack + */ + protected static function testArtist($albumArtist, $trackArtist) { + if(strpos($trackArtist, '&') !== false && + ($i = stripos($albumArtist, ' and ')) !== false) + $albumArtist = substr_replace($albumArtist, '&', $i + 1, 3); + else if(strpos($albumArtist, '&') !== false && + ($i = stripos($trackArtist, ' and ')) !== false) + $trackArtist = substr_replace($trackArtist, '&', $i + 1, 3); + return stripos($albumArtist, $trackArtist) !== false; + } + public function __construct($loop) { $this->clients = new \SplObjectStorage; $this->imageQ = new \SplQueue; @@ -194,18 +214,32 @@ public function onOpen(ConnectionInterface $conn) { // echo "New connection {$conn->resourceId}\n"; } + /** + * query discogs for album or artist + * + * To search for an album, supply both artist and album; + * to search for an artist, supply only the artist name. + * (To search for an artist by name and album, use the method + * `queryDiscogsArtistByAlbum`.) + * + * @param $artist artist name + * @param $album album name (optional; if supplied, does album search) + * @returns false iff communications error, result otherwise (can be empty) + */ protected function queryDiscogs($artist, $album = null) { $success = true; + $retval = new \stdClass(); + $retval->imageUrl = $retval->infoUrl = $retval->resourceUrl = null; try { $query = $album ? [ "artist" => $artist, - "release_title" => $album, + "release_title" => preg_replace('/\(.*$/', '', $album), "per_page" => 40 ] : [ - "query" => $artist, + "title" => $artist, "type" => "artist", - "per_page" => 1 + "per_page" => 40 ]; $response = $this->discogs->get('', [ @@ -242,19 +276,88 @@ function($carry, $item) { $result = $r; break; } + } else { + // advance to the first artist with artwork + foreach($json->results as $r) { + if($r->cover_image && + !preg_match('|/spacer.gif$|', $r->cover_image)) { + $result = $r; + break; + } + } } if($result->cover_image && !preg_match('|/spacer.gif$|', $result->cover_image)) - $imageUrl = $result->cover_image; - $infoUrl = self::DISCOGS_BASE . $result->uri; + $retval->imageUrl = $result->cover_image; + $retval->infoUrl = self::DISCOGS_BASE . $result->uri; + $retval->resourceUrl = $result->resource_url; } } catch(\Exception $e) { $success = false; - error_log("getImageData: ".$e->getMessage()); + error_log("queryDiscogs: ".$e->getMessage()); } - return [ $imageUrl ?? null, $infoUrl ?? null, $success ]; + return $success ? $retval : false; + } + + /** + * query discogs for artist by {album, artist} tuple + * + * @param $artist artist name + * @param $album album name + * @returns result if found, false if no match or error + */ + protected function queryDiscogsArtistByAlbum($artist, $album) { + $success = false; + $retval = new \stdClass(); + $retval->imageUrl = null; + + $albumRec = $this->queryDiscogs($artist, $album); + + if($albumRec && $albumRec->resourceUrl) { + try { + $response = $this->discogs->get($albumRec->resourceUrl); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + + if($json) { + $artists = $json->artists ?? null; + if($artists && count($artists)) { + foreach($artists as $candidate) { + if(self::testArtist($candidate->name, $artist)) { + $response = $this->discogs->get($candidate->resource_url); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + + if($json) { + $images = $json->images ?? null; + if($images && count($images)) { + $retval->imageUrl = $images[0]->uri; + foreach($images as $image) { + if($image->type == "primary") { + $retval->imageUrl = $image->uri; + break; + } + } + } + $retval->infoUrl = $json->uri; + $retval->resourceUrl = $json->resource_url; + $retval->album = $albumRec; + $success = true; + } + break; + } + } + } + } + } catch(\Exception $e) { + error_log("queryDiscogsArtistByAlbum: ".$e->getMessage()); + } + } + return $success ? $retval : false; } protected function injectImageData($msg) { @@ -272,10 +375,12 @@ protected function injectImageData($msg) { $infoUrl = $image['info_url']; } else { // otherwise, query Discogs - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($entry['track_artist'], $entry['track_album']); + $result = $this->queryDiscogs($entry['track_artist'], $entry['track_album']); - if($success) - $imageUuid = $imageApi->insertAlbumArt($entry['track_tag'], $imageUrl, $infoUrl); + if($result) { + $imageUuid = $imageApi->insertAlbumArt($entry['track_tag'], $result->imageUrl, $result->infoUrl); + $infoUrl = $result->infoUrl; + } } } @@ -289,10 +394,15 @@ protected function injectImageData($msg) { $infoUrl = $image['info_url']; } else { // otherwise, query Discogs - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($entry['track_artist']); - - if($success) - $imageUuid = $imageApi->insertArtistArt($entry['track_artist'], $imageUrl, $infoUrl); + $result = strlen(trim($entry['track_album'])) ? + $this->queryDiscogsArtistByAlbum($entry['track_artist'], $entry['track_album']) : null; + if(!$result) + $result = $this->queryDiscogs($entry['track_artist']); + + if($result) { + $imageUuid = $imageApi->insertArtistArt($entry['track_artist'], $result->imageUrl, $result->infoUrl); + $infoUrl = $result->infoUrl; + } } } @@ -313,17 +423,21 @@ protected function processImageQueue() { $artist = $entry->getArtist(); if($entry->getTag()) { - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($artist, $entry->getAlbum()); + $result = $this->queryDiscogs($artist, $entry->getAlbum()); - if($success) - $imageUuid = $imageApi->insertAlbumArt($entry->getTag(), $imageUrl, $infoUrl); + if($result) + $imageUuid = $imageApi->insertAlbumArt($entry->getTag(), $result->imageUrl, $result->infoUrl); } if(!isset($imageUuid)) { - [ $imageUrl, $infoUrl, $success ] = $this->queryDiscogs($artist); - - if($success) - $imageUuid = $imageApi->insertArtistArt($artist, $imageUrl, $infoUrl); + $album = $entry->getAlbum(); + $result = strlen(trim($album)) ? + $this->queryDiscogsArtistByAlbum($artist, $album) : null; + if(!$result) + $result = $this->queryDiscogs($artist); + + if($result) + $imageUuid = $imageApi->insertArtistArt($artist, $result->imageUrl, $result->infoUrl); } if(!$this->imageQ->isEmpty()) { @@ -340,7 +454,7 @@ protected function enqueueEntry($entry, $imageApi) { return; } - if(preg_match('/(\.gov|\.org|GED|Literacy|NIH|Ad\ Council)/', implode(' ', $entry->asArray())) || empty(trim($entry->getArtist()))) { + if(preg_match('/(\.gov|\.org|GED|Literacy|NIH|Ad\ Council|Lift\ Jesus)/', implode(' ', $entry->asArray())) || empty(trim($entry->getArtist()))) { // it's probably a PSA coded as a spin; let's skip it return; } @@ -366,7 +480,8 @@ public function loadImages($playlist, $track) { if($track) { $entry = new PlaylistEntry($listApi->getTrack($track)); - $this->enqueueEntry($entry, $imageApi); + if($entry->isType(PlaylistEntry::TYPE_SPIN)) + $this->enqueueEntry($entry, $imageApi); } else { $visited = []; $listApi->getTracksWithObserver($playlist, @@ -392,6 +507,97 @@ public function loadImages($playlist, $track) { } } + public function reloadAlbum($tag, $master) { + $albums = Engine::api(ILibrary::class)->search(ILibrary::ALBUM_KEY, 0, 1, $tag); + if(!count($albums)) { + echo "reloadAlbum($tag): tag not found\n"; + return; + } + + $album = $albums[0]; + + try { + switch($album["medium"] ?? null) { + case 'S': + $format = "Vinyl, 7\""; + break; + case 'T': + case 'V': + $format = "Vinyl"; + break; + case 'M': + $format = "Cassette"; + break; + default: + $format = "CD"; + break; + } + + $params = [ + "artist" => $album["iscoll"] ? + "Various" : PlaylistEntry::swapNames($album["artist"]), + "release_title" => $album["album"], + "per_page" => 20 + ]; + + if($master) + $params["type"] = "master"; + else + $params["format"] = $format; + + $response = $this->discogs->get('', [ + RequestOptions::QUERY => $params + ]); + + $page = $response->getBody()->getContents(); + $json = json_decode($page); + if($json->results && ($result2 = $json->results[0])) { + foreach($json->results as $r) { + if($r->master_id != $result2->master_id) + continue; + + // master releases are definitive + if($r->type == "master") { + $result2 = $r; + break; + } + + // ignore promos and limited/special editions + if(array_reduce($r->format, + function($carry, $item) { + return $carry || + $item == "Promo" || + strpos($item, "Edition") !== false; + })) + continue; + + // prefer CD or vinyl + switch($r->format[0]) { + case "CD": + case "Vinyl": + $result2 = $r; + break; + } + } + + $imageUrl = $result2->cover_image && + !preg_match('|/spacer.gif$|', $result2->cover_image) ? + $result2->cover_image : null; + $infoUrl = self::DISCOGS_BASE . $result2->uri; + } + + if($imageUrl) { + $imageApi = Engine::api(IArtwork::class); + $imageApi->deleteAlbumArt($tag); + $uuid = $imageApi->insertAlbumArt($tag, $imageUrl, $infoUrl); + echo "reloadAlbum($tag): ".($master?'master':$format)." loaded $uuid\n"; + } else + echo "reloadAlbum($tag): no image found\n"; + } catch(\Exception $e) { + echo $e->getMessage() . "\n"; + } + } + public function sendNotification($msg = null, $client = null) { if($msg) { if($this->current != $msg) @@ -465,6 +671,20 @@ public static function lazyLoadImages($playlistId, $trackId = 0) { socket_close($socket); } + public static function lazyReloadAlbum($tag, $master = 1) { + if(!Engine::param('push_enabled', true) || + !($config = Engine::param('discogs')) || + !$config['apikey'] && !$config['client_id']) + return; + + $data = "reloadAlbum($tag, $master)"; + + $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_sendto($socket, $data, strlen($data), 0, + PushServer::WSSERVER_HOST, PushServer::WSSERVER_PORT); + socket_close($socket); + } + public function processRequest() { if(php_sapi_name() != "cli") { http_response_code(400); @@ -503,6 +723,8 @@ function(\React\Datagram\Socket $client) use($nas) { if(preg_match("/^loadImages\((\d+)(,\s*(\d+))?\)$/", $message, $matches)) $nas->loadImages($matches[1], $matches[3] ?? 0); + else if(preg_match("/^reloadAlbum\((\d+)(,\s*(\d+))?\)$/", $message, $matches)) + $nas->reloadAlbum($matches[1], $matches[3] ?? 1); else if($message && $message[0] == '{') $nas->sendNotification($message); else // empty message means poll database diff --git a/engine/IArtwork.php b/engine/IArtwork.php index e3e6e4ee..2ef49383 100644 --- a/engine/IArtwork.php +++ b/engine/IArtwork.php @@ -34,6 +34,7 @@ function insertAlbumArt($tag, $imageUrl, $infoUrl); function insertArtistArt($artist, $imageUrl, $infoUrl); function getCachePath($key); function deleteAlbumArt($tag); + function deleteArtistArt($artist); function expireCache($days=10, $expireAlbums=false); function expireEmpty($days=1); } diff --git a/engine/impl/Artwork.php b/engine/impl/Artwork.php index adf1d3c9..14ee82e7 100644 --- a/engine/impl/Artwork.php +++ b/engine/impl/Artwork.php @@ -197,7 +197,23 @@ public function deleteAlbumArt($tag) { "WHERE image_id = ?"; $stmt = $this->prepare($query); $stmt->bindValue(1, $image['image_id']); - if($stmt->execute()) { + if($stmt->execute() && $image['image_uuid']) { + $cacheDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR; + $path = $cacheDir . $this->getCachePath($image['image_uuid']); + unlink(realpath($path)); + } + } + } + + public function deleteArtistArt($artist) { + $image = $this->getArtistArt($artist); + if($image) { + $query = "DELETE FROM artistmap, artwork USING artistmap " . + "LEFT JOIN artwork ON artistmap.image_id = artwork.id " . + "WHERE image_id = ?"; + $stmt = $this->prepare($query); + $stmt->bindValue(1, $image['image_id']); + if($stmt->execute() && $image['image_uuid']) { $cacheDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR; $path = $cacheDir . $this->getCachePath($image['image_uuid']); unlink(realpath($path));