Add Support for Remote Album Art on APIs and Media Uploads (#3680)
This commit is contained in:
parent
9d3c10a5ef
commit
686f480d7c
|
@ -5,6 +5,11 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
## New Features/Changes
|
||||
|
||||
- **Remote Album Art Retrieval**: If enabled in the system settings panel, AzuraCast will now check remote services to
|
||||
attempt to retrieve album art if it is missing, or not provided (i.e. for live DJs). By default, this system uses the
|
||||
MusicBrainz database, which is comprehensive but can be slow; if you provide an API key for the last.fm API, AzuraCast
|
||||
will prefer the last.fm API for album art instead.
|
||||
|
||||
- **Media Manager Improvements:** Some changes have been made to the media manager to improve the user experience and
|
||||
accessibility:
|
||||
- You can now edit the playlists associated with a track from directly within the "Edit" modal dialog box for that
|
||||
|
|
|
@ -151,6 +151,28 @@ return function (App\EventDispatcher $dispatcher) {
|
|||
App\Notification\Check\SyncTaskCheck::class
|
||||
);
|
||||
|
||||
$dispatcher->addCallableListener(
|
||||
Event\Media\GetAlbumArt::class,
|
||||
App\Media\AlbumArtHandler\LastFmAlbumArtHandler::class,
|
||||
'__invoke',
|
||||
10
|
||||
);
|
||||
$dispatcher->addCallableListener(
|
||||
Event\Media\GetAlbumArt::class,
|
||||
App\Media\AlbumArtHandler\MusicBrainzAlbumArtHandler::class,
|
||||
'__invoke',
|
||||
-10
|
||||
);
|
||||
|
||||
$dispatcher->addCallableListener(
|
||||
Event\Media\ReadMetadata::class,
|
||||
App\Media\MetadataService\GetId3MetadataService::class
|
||||
);
|
||||
$dispatcher->addCallableListener(
|
||||
Event\Media\WriteMetadata::class,
|
||||
App\Media\MetadataService\GetId3MetadataService::class
|
||||
);
|
||||
|
||||
$dispatcher->addServiceSubscriber(
|
||||
[
|
||||
App\Console\ErrorHandler::class,
|
||||
|
|
|
@ -18,7 +18,7 @@ return [
|
|||
'system' => __('Settings'),
|
||||
'security' => __('Security'),
|
||||
'privacy' => __('Privacy'),
|
||||
'updates' => __('Updates'),
|
||||
'services' => __('Services'),
|
||||
],
|
||||
|
||||
'groups' => [
|
||||
|
@ -32,7 +32,9 @@ return [
|
|||
'url',
|
||||
[
|
||||
'label' => __('Site Base URL'),
|
||||
'description' => __('The base URL where this service is located. Use either the external IP address or fully-qualified domain name (if one exists) pointing to this server.'),
|
||||
'description' => __(
|
||||
'The base URL where this service is located. Use either the external IP address or fully-qualified domain name (if one exists) pointing to this server.'
|
||||
),
|
||||
'required' => true,
|
||||
'form_group_class' => 'col-md-6',
|
||||
],
|
||||
|
@ -42,7 +44,9 @@ return [
|
|||
'text',
|
||||
[
|
||||
'label' => __('AzuraCast Instance Name'),
|
||||
'description' => __('This name will appear as a sub-header next to the AzuraCast logo, to help identify this server.'),
|
||||
'description' => __(
|
||||
'This name will appear as a sub-header next to the AzuraCast logo, to help identify this server.'
|
||||
),
|
||||
'form_group_class' => 'col-md-6',
|
||||
],
|
||||
],
|
||||
|
@ -51,7 +55,9 @@ return [
|
|||
'toggle',
|
||||
[
|
||||
'label' => __('Prefer Browser URL (If Available)'),
|
||||
'description' => __('If this setting is set to "Yes", the browser URL will be used instead of the base URL when it\'s available. Set to "No" to always use the base URL.'),
|
||||
'description' => __(
|
||||
'If this setting is set to "Yes", the browser URL will be used instead of the base URL when it\'s available. Set to "No" to always use the base URL.'
|
||||
),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => true,
|
||||
|
@ -63,7 +69,9 @@ return [
|
|||
'toggle',
|
||||
[
|
||||
'label' => __('Use Web Proxy for Radio'),
|
||||
'description' => __('By default, radio stations broadcast on their own ports (i.e. 8000). If you\'re using a service like CloudFlare or accessing your radio station by SSL, you should enable this feature, which routes all radio through the web ports (80 and 443).'),
|
||||
'description' => __(
|
||||
'By default, radio stations broadcast on their own ports (i.e. 8000). If you\'re using a service like CloudFlare or accessing your radio station by SSL, you should enable this feature, which routes all radio through the web ports (80 and 443).'
|
||||
),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => true,
|
||||
|
@ -75,7 +83,9 @@ return [
|
|||
'radio',
|
||||
[
|
||||
'label' => __('Days of Playback History to Keep'),
|
||||
'description' => __('Set longer to preserve more playback history and listener metadata for stations. Set shorter to save disk space. '),
|
||||
'description' => __(
|
||||
'Set longer to preserve more playback history and listener metadata for stations. Set shorter to save disk space. '
|
||||
),
|
||||
'choices' => [
|
||||
14 => __('Last 14 Days'),
|
||||
30 => __('Last 30 Days'),
|
||||
|
@ -93,7 +103,9 @@ return [
|
|||
'toggle',
|
||||
[
|
||||
'label' => __('Use WebSockets for Now Playing Updates'),
|
||||
'description' => __('Enables or disables the use of the newer and faster WebSocket-based system for receiving live updates on public players. You may need to disable this if you encounter problems with it.'),
|
||||
'description' => __(
|
||||
'Enables or disables the use of the newer and faster WebSocket-based system for receiving live updates on public players. You may need to disable this if you encounter problems with it.'
|
||||
),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
|
@ -114,7 +126,9 @@ return [
|
|||
'toggle',
|
||||
[
|
||||
'label' => __('Always Use HTTPS'),
|
||||
'description' => __('Set to "Yes" to always use "https://" secure URLs, and to automatically redirect to the secure URL when an insecure URL is visited.'),
|
||||
'description' => __(
|
||||
'Set to "Yes" to always use "https://" secure URLs, and to automatically redirect to the secure URL when an insecure URL is visited.'
|
||||
),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
|
@ -127,8 +141,10 @@ return [
|
|||
[
|
||||
'label' => __('API "Access-Control-Allow-Origin" header'),
|
||||
'class' => 'advanced',
|
||||
'description' => __('<a href="%s" target="_blank">Learn more about this header</a>. Set to * to allow all sources, or specify a list of origins separated by a comma (,).',
|
||||
'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin'),
|
||||
'description' => __(
|
||||
'<a href="%s" target="_blank">Learn more about this header</a>. Set to * to allow all sources, or specify a list of origins separated by a comma (,).',
|
||||
'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin'
|
||||
),
|
||||
'default' => '',
|
||||
'form_group_class' => 'col-md-12',
|
||||
],
|
||||
|
@ -145,11 +161,17 @@ return [
|
|||
'radio',
|
||||
[
|
||||
'label' => __('Listener Analytics Collection'),
|
||||
'description' => __('Aggregate listener statistics are used to show station reports across the system. IP-based listener statistics are used to view live listener tracking and may be required for royalty reports.'),
|
||||
'description' => __(
|
||||
'Aggregate listener statistics are used to show station reports across the system. IP-based listener statistics are used to view live listener tracking and may be required for royalty reports.'
|
||||
),
|
||||
|
||||
'choices' => [
|
||||
Entity\Analytics::LEVEL_ALL => __('<b>Full:</b> Collect aggregate listener statistics and IP-based listener statistics'),
|
||||
Entity\Analytics::LEVEL_NO_IP => __('<b>Limited:</b> Only collect aggregate listener statistics'),
|
||||
Entity\Analytics::LEVEL_ALL => __(
|
||||
'<b>Full:</b> Collect aggregate listener statistics and IP-based listener statistics'
|
||||
),
|
||||
Entity\Analytics::LEVEL_NO_IP => __(
|
||||
'<b>Limited:</b> Only collect aggregate listener statistics'
|
||||
),
|
||||
Entity\Analytics::LEVEL_NONE => __('<b>None:</b> Do not collect any listener analytics'),
|
||||
],
|
||||
'default' => Entity\Analytics::LEVEL_ALL,
|
||||
|
@ -159,7 +181,9 @@ return [
|
|||
],
|
||||
|
||||
'channels' => [
|
||||
'tab' => 'updates',
|
||||
'tab' => 'services',
|
||||
'legend' => __('AzuraCast Update Checks'),
|
||||
|
||||
'elements' => [
|
||||
|
||||
'release_channel' => [
|
||||
|
@ -186,6 +210,51 @@ return [
|
|||
],
|
||||
],
|
||||
|
||||
'thirdPartyServices' => [
|
||||
'tab' => 'services',
|
||||
'use_grid' => true,
|
||||
'legend' => __('Third-Party Services'),
|
||||
|
||||
'elements' => [
|
||||
|
||||
'useExternalAlbumArtInApis' => [
|
||||
'toggle',
|
||||
[
|
||||
'label' => __('Check Web Services for Album Art for "Now Playing" Tracks'),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
'form_group_class' => 'col-md-6',
|
||||
],
|
||||
],
|
||||
|
||||
'useExternalAlbumArtWhenProcessingMedia' => [
|
||||
'toggle',
|
||||
[
|
||||
'label' => __('Check Web Services for Album Art When Uploading Media'),
|
||||
'selected_text' => __('Yes'),
|
||||
'deselected_text' => __('No'),
|
||||
'default' => false,
|
||||
'form_group_class' => 'col-md-6',
|
||||
],
|
||||
],
|
||||
|
||||
'lastFmApiKey' => [
|
||||
'text',
|
||||
[
|
||||
'label' => __('Last.fm API Key'),
|
||||
'description' => __(
|
||||
'<a href="%s" target="_blank">Apply for an API key here</a>. This service can provide album art for tracks where none is available locally.',
|
||||
'https://www.last.fm/api/account/create'
|
||||
),
|
||||
'default' => '',
|
||||
'form_group_class' => 'col-md-12',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
],
|
||||
|
||||
'submit' => [
|
||||
'legend' => '',
|
||||
'elements' => [
|
||||
|
|
|
@ -408,5 +408,11 @@ return [
|
|||
);
|
||||
},
|
||||
|
||||
App\Media\MetadataManagerInterface::class => DI\get(App\Media\GetId3\GetId3MetadataManager::class),
|
||||
App\Media\MetadataService\MetadataServiceInterface::class => DI\get(
|
||||
App\Media\MetadataService\GetId3MetadataService::class
|
||||
),
|
||||
|
||||
App\Media\AlbumArtHandler\AlbumArtServiceInterface::class => DI\get(
|
||||
App\Media\AlbumArtHandler\LastFmAlbumArtHandler::class
|
||||
),
|
||||
];
|
||||
|
|
|
@ -55,11 +55,13 @@ class NowPlayingApiGenerator
|
|||
|
||||
$np = new Entity\Api\NowPlaying();
|
||||
$np->station = ($this->stationApiGenerator)($station, $baseUri);
|
||||
$np->listeners = new Entity\Api\NowPlayingListeners([
|
||||
'current' => $npResult->listeners->current,
|
||||
'unique' => $npResult->listeners->unique,
|
||||
'total' => $npResult->listeners->total,
|
||||
]);
|
||||
$np->listeners = new Entity\Api\NowPlayingListeners(
|
||||
[
|
||||
'current' => $npResult->listeners->current,
|
||||
'unique' => $npResult->listeners->unique,
|
||||
'total' => $npResult->listeners->total,
|
||||
]
|
||||
);
|
||||
|
||||
// Pull from current NP data if song details haven't changed .
|
||||
if ($npOld instanceof Entity\Api\NowPlaying && $this->tracksMatch($npResult, $npOld)) {
|
||||
|
@ -81,12 +83,17 @@ class NowPlayingApiGenerator
|
|||
|
||||
$np->song_history = $this->songHistoryApiGenerator->fromArray(
|
||||
$this->historyRepo->getVisibleHistory($station),
|
||||
$baseUri
|
||||
$baseUri,
|
||||
true
|
||||
);
|
||||
|
||||
$nextVisible = $this->queueRepo->getNextVisible($station);
|
||||
if ($nextVisible instanceof Entity\StationQueue) {
|
||||
$np->playing_next = ($this->stationQueueApiGenerator)($nextVisible, $baseUri);
|
||||
$np->playing_next = ($this->stationQueueApiGenerator)(
|
||||
$nextVisible,
|
||||
$baseUri,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -108,11 +115,10 @@ class NowPlayingApiGenerator
|
|||
$np->live = new Entity\Api\NowPlayingLive(false);
|
||||
}
|
||||
|
||||
// Register a new item in song history.
|
||||
$apiSongHistory = ($this->songHistoryApiGenerator)($sh_obj, $baseUri);
|
||||
|
||||
$apiSongHistory = ($this->songHistoryApiGenerator)($sh_obj, $baseUri, true);
|
||||
$apiCurrentSong = new Entity\Api\NowPlayingCurrentSong();
|
||||
$apiCurrentSong->fromParentObject($apiSongHistory);
|
||||
|
||||
$np->now_playing = $apiCurrentSong;
|
||||
|
||||
$np->update();
|
||||
|
@ -149,21 +155,30 @@ class NowPlayingApiGenerator
|
|||
$np->station = ($this->stationApiGenerator)($station, $baseUri);
|
||||
$np->listeners = new Entity\Api\NowPlayingListeners();
|
||||
|
||||
$songObj = Entity\Song::createFromText('Stream Offline');
|
||||
$songObj = Entity\Song::createOffline();
|
||||
|
||||
$offlineApiNowPlaying = new Entity\Api\NowPlayingCurrentSong();
|
||||
$offlineApiNowPlaying->sh_id = 0;
|
||||
$offlineApiNowPlaying->song = ($this->songApiGenerator)($songObj, $station, $baseUri);
|
||||
$offlineApiNowPlaying->song = ($this->songApiGenerator)(
|
||||
$songObj,
|
||||
$station,
|
||||
$baseUri
|
||||
);
|
||||
$np->now_playing = $offlineApiNowPlaying;
|
||||
|
||||
$np->song_history = $this->songHistoryApiGenerator->fromArray(
|
||||
$this->historyRepo->getVisibleHistory($station),
|
||||
$baseUri
|
||||
$baseUri,
|
||||
true
|
||||
);
|
||||
|
||||
$nextVisible = $this->queueRepo->getNextVisible($station);
|
||||
if ($nextVisible instanceof Entity\StationQueue) {
|
||||
$np->playing_next = ($this->stationQueueApiGenerator)($nextVisible, $baseUri);
|
||||
$np->playing_next = ($this->stationQueueApiGenerator)(
|
||||
$nextVisible,
|
||||
$baseUri,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
$np->live = new Entity\Api\NowPlayingLive(false);
|
||||
|
|
|
@ -4,8 +4,10 @@ namespace App\Entity\ApiGenerator;
|
|||
|
||||
use App\Entity;
|
||||
use App\Http\Router;
|
||||
use App\Media\RemoteAlbumArt;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use GuzzleHttp\Psr7\UriResolver;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
class SongApiGenerator
|
||||
|
@ -18,22 +20,27 @@ class SongApiGenerator
|
|||
|
||||
protected Entity\Repository\CustomFieldRepository $customFieldRepo;
|
||||
|
||||
protected RemoteAlbumArt $remoteAlbumArt;
|
||||
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
Router $router,
|
||||
Entity\Repository\StationRepository $stationRepo,
|
||||
Entity\Repository\CustomFieldRepository $customFieldRepo
|
||||
Entity\Repository\CustomFieldRepository $customFieldRepo,
|
||||
RemoteAlbumArt $remoteAlbumArt
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->router = $router;
|
||||
$this->stationRepo = $stationRepo;
|
||||
$this->customFieldRepo = $customFieldRepo;
|
||||
$this->remoteAlbumArt = $remoteAlbumArt;
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
Entity\SongInterface $song,
|
||||
?Entity\Station $station = null,
|
||||
?UriInterface $baseUri = null
|
||||
?UriInterface $baseUri = null,
|
||||
bool $allowRemoteArt = false
|
||||
): Entity\Api\Song {
|
||||
$response = new Entity\Api\Song();
|
||||
$response->id = (string)$song->getSongId();
|
||||
|
@ -46,56 +53,51 @@ class SongApiGenerator
|
|||
$response->genre = (string)$song->getGenre();
|
||||
$response->lyrics = (string)$song->getLyrics();
|
||||
|
||||
$response->art = $this->getAlbumArtUrl(
|
||||
$station,
|
||||
$song->getUniqueId(),
|
||||
$song->getArtUpdatedAt(),
|
||||
$baseUri
|
||||
);
|
||||
$response->custom_fields = $this->getCustomFields($song->getId());
|
||||
} else {
|
||||
$response->art = $this->getDefaultAlbumArtUrl($station, $baseUri);
|
||||
$response->custom_fields = $this->getCustomFields();
|
||||
}
|
||||
|
||||
$response->art = $this->getAlbumArtUrl($song, $station, $baseUri, $allowRemoteArt);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
|
||||
protected function getAlbumArtUrl(
|
||||
Entity\SongInterface $song,
|
||||
?Entity\Station $station = null,
|
||||
string $mediaUniqueId,
|
||||
int $mediaUpdatedTimestamp,
|
||||
?UriInterface $baseUri = null
|
||||
?UriInterface $baseUri = null,
|
||||
bool $allowRemoteArt = false
|
||||
): UriInterface {
|
||||
if (null === $station || 0 === $mediaUpdatedTimestamp) {
|
||||
return $this->getDefaultAlbumArtUrl($station, $baseUri);
|
||||
}
|
||||
|
||||
if ($baseUri === null) {
|
||||
if (null === $baseUri) {
|
||||
$baseUri = $this->router->getBaseUrl();
|
||||
}
|
||||
|
||||
$path = $this->router->named(
|
||||
'api:stations:media:art',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'media_id' => $mediaUniqueId . '-' . $mediaUpdatedTimestamp,
|
||||
]
|
||||
);
|
||||
if (null !== $station && $song instanceof Entity\StationMedia) {
|
||||
$mediaUpdatedTimestamp = $song->getArtUpdatedAt();
|
||||
|
||||
return UriResolver::resolve($baseUri, $path);
|
||||
}
|
||||
if (0 !== $mediaUpdatedTimestamp) {
|
||||
$path = $this->router->named(
|
||||
'api:stations:media:art',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'media_id' => $song->getUniqueId() . '-' . $mediaUpdatedTimestamp,
|
||||
]
|
||||
);
|
||||
|
||||
protected function getDefaultAlbumArtUrl(
|
||||
?Entity\Station $station = null,
|
||||
?UriInterface $baseUri = null
|
||||
): UriInterface {
|
||||
if ($baseUri === null) {
|
||||
$baseUri = $this->router->getBaseUrl();
|
||||
return UriResolver::resolve($baseUri, $path);
|
||||
}
|
||||
}
|
||||
|
||||
return UriResolver::resolve($baseUri, $this->stationRepo->getDefaultAlbumArtUrl($station));
|
||||
$path = ($allowRemoteArt && $this->remoteAlbumArt->enableForApis())
|
||||
? ($this->remoteAlbumArt)($song)
|
||||
: null;
|
||||
|
||||
if (null === $path) {
|
||||
$path = $this->stationRepo->getDefaultAlbumArtUrl($station);
|
||||
}
|
||||
|
||||
return UriResolver::resolve($baseUri, Utils::uriFor($path));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -14,8 +14,11 @@ class SongHistoryApiGenerator
|
|||
$this->songApiGenerator = $songApiGenerator;
|
||||
}
|
||||
|
||||
public function __invoke(Entity\SongHistory $record, ?UriInterface $baseUri = null): Entity\Api\SongHistory
|
||||
{
|
||||
public function __invoke(
|
||||
Entity\SongHistory $record,
|
||||
?UriInterface $baseUri = null,
|
||||
bool $allowRemoteArt = false
|
||||
): Entity\Api\SongHistory {
|
||||
$response = new Entity\Api\SongHistory();
|
||||
$response->sh_id = $record->getId();
|
||||
$response->played_at = (0 === $record->getTimestampStart())
|
||||
|
@ -36,9 +39,19 @@ class SongHistoryApiGenerator
|
|||
}
|
||||
|
||||
if (null !== $record->getMedia()) {
|
||||
$response->song = ($this->songApiGenerator)($record->getMedia(), $record->getStation(), $baseUri);
|
||||
$response->song = ($this->songApiGenerator)(
|
||||
$record->getMedia(),
|
||||
$record->getStation(),
|
||||
$baseUri,
|
||||
$allowRemoteArt
|
||||
);
|
||||
} else {
|
||||
$response->song = ($this->songApiGenerator)($record, $record->getStation(), $baseUri);
|
||||
$response->song = ($this->songApiGenerator)(
|
||||
$record,
|
||||
$record->getStation(),
|
||||
$baseUri,
|
||||
$allowRemoteArt
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
@ -47,14 +60,18 @@ class SongHistoryApiGenerator
|
|||
/**
|
||||
* @param Entity\SongHistory[] $records
|
||||
* @param UriInterface|null $baseUri
|
||||
* @param bool $allowRemoteArt
|
||||
*
|
||||
* @return Entity\Api\SongHistory[]
|
||||
*/
|
||||
public function fromArray(array $records, ?UriInterface $baseUri = null): array
|
||||
{
|
||||
public function fromArray(
|
||||
array $records,
|
||||
?UriInterface $baseUri = null,
|
||||
bool $allowRemoteArt = false
|
||||
): array {
|
||||
$apiRecords = [];
|
||||
foreach ($records as $record) {
|
||||
$apiRecords[] = ($this)($record, $baseUri);
|
||||
$apiRecords[] = ($this)($record, $baseUri, $allowRemoteArt);
|
||||
}
|
||||
return $apiRecords;
|
||||
}
|
||||
|
|
|
@ -14,8 +14,11 @@ class StationQueueApiGenerator
|
|||
$this->songApiGenerator = $songApiGenerator;
|
||||
}
|
||||
|
||||
public function __invoke(Entity\StationQueue $record, ?UriInterface $baseUri = null): Entity\Api\StationQueue
|
||||
{
|
||||
public function __invoke(
|
||||
Entity\StationQueue $record,
|
||||
?UriInterface $baseUri = null,
|
||||
bool $allowRemoteArt = false
|
||||
): Entity\Api\StationQueue {
|
||||
$response = new Entity\Api\StationQueue();
|
||||
$response->cued_at = $record->getTimestampCued();
|
||||
$response->duration = (int)$record->getDuration();
|
||||
|
@ -27,9 +30,19 @@ class StationQueueApiGenerator
|
|||
}
|
||||
|
||||
if ($record->getMedia()) {
|
||||
$response->song = ($this->songApiGenerator)($record->getMedia(), $record->getStation(), $baseUri);
|
||||
$response->song = ($this->songApiGenerator)(
|
||||
$record->getMedia(),
|
||||
$record->getStation(),
|
||||
$baseUri,
|
||||
$allowRemoteArt
|
||||
);
|
||||
} else {
|
||||
$response->song = ($this->songApiGenerator)($record, $record->getStation(), $baseUri);
|
||||
$response->song = ($this->songApiGenerator)(
|
||||
$record,
|
||||
$record->getStation(),
|
||||
$baseUri,
|
||||
$allowRemoteArt
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media;
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
|
@ -10,8 +10,7 @@ use App\Environment;
|
|||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Flysystem\Filesystem;
|
||||
use App\Flysystem\FilesystemManager;
|
||||
use App\Media\MetadataManagerInterface;
|
||||
use App\Media\MimeType;
|
||||
use App\Media\MetadataManager;
|
||||
use App\Service\AudioWaveform;
|
||||
use Exception;
|
||||
use Generator;
|
||||
|
@ -36,7 +35,7 @@ class StationMediaRepository extends Repository
|
|||
|
||||
protected UnprocessableMediaRepository $unprocessableMediaRepo;
|
||||
|
||||
protected MetadataManagerInterface $metadataManager;
|
||||
protected MetadataManager $metadataManager;
|
||||
|
||||
protected FilesystemManager $filesystem;
|
||||
|
||||
|
@ -47,7 +46,7 @@ class StationMediaRepository extends Repository
|
|||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger,
|
||||
MetadataManagerInterface $metadataManager,
|
||||
MetadataManager $metadataManager,
|
||||
CustomFieldRepository $customFieldRepo,
|
||||
StationPlaylistMediaRepository $spmRepo,
|
||||
StorageLocationRepository $storageLocationRepo,
|
||||
|
@ -265,18 +264,8 @@ class StationMediaRepository extends Repository
|
|||
*/
|
||||
public function loadFromFile(Entity\StationMedia $media, string $filePath): void
|
||||
{
|
||||
if (!MimeType::isFileProcessable($filePath)) {
|
||||
$mimeType = MimeType::getMimeTypeFromFile($filePath);
|
||||
throw CannotProcessMediaException::forPath(
|
||||
$filePath,
|
||||
sprintf('MIME type "%s" is not processable.', $mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
// Load metadata from supported files.
|
||||
$metadata = $this->metadataManager->getMetadata($filePath);
|
||||
|
||||
$media->fromMetadata($metadata);
|
||||
$metadata = $this->metadataManager->getMetadata($media, $filePath);
|
||||
|
||||
// Persist the media record for later custom field operations.
|
||||
$this->em->persist($media);
|
||||
|
@ -327,19 +316,6 @@ class StationMediaRepository extends Repository
|
|||
$media->updateSongId();
|
||||
}
|
||||
|
||||
public function readAlbumArt(Entity\StationMedia $media): ?string
|
||||
{
|
||||
$fs = $this->getFilesystem($media);
|
||||
|
||||
$albumArtPath = Entity\StationMedia::getArtPath($media->getUniqueId());
|
||||
|
||||
if (!$fs->has($albumArtPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $fs->read($albumArtPath);
|
||||
}
|
||||
|
||||
public function writeAlbumArt(Entity\StationMedia $media, string $rawArtString): bool
|
||||
{
|
||||
$media->setArtUpdatedAt(time());
|
||||
|
|
|
@ -306,6 +306,55 @@ class Settings
|
|||
$this->defaultAlbumArtUrl = $defaultAlbumArtUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Property(example="false")
|
||||
* @var bool Attempt to fetch album art from external sources when processing media.
|
||||
*/
|
||||
protected bool $useExternalAlbumArtWhenProcessingMedia = false;
|
||||
|
||||
public function getUseExternalAlbumArtWhenProcessingMedia(): bool
|
||||
{
|
||||
return $this->useExternalAlbumArtWhenProcessingMedia;
|
||||
}
|
||||
|
||||
public function setUseExternalAlbumArtWhenProcessingMedia(bool $useExternalAlbumArtWhenProcessingMedia): void
|
||||
{
|
||||
$this->useExternalAlbumArtWhenProcessingMedia = $useExternalAlbumArtWhenProcessingMedia;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Property(example="false")
|
||||
* @var bool Attempt to fetch album art from external sources in API requests.
|
||||
*/
|
||||
protected bool $useExternalAlbumArtInApis = false;
|
||||
|
||||
public function getUseExternalAlbumArtInApis(): bool
|
||||
{
|
||||
return $this->useExternalAlbumArtInApis;
|
||||
}
|
||||
|
||||
public function setUseExternalAlbumArtInApis(bool $useExternalAlbumArtInApis): void
|
||||
{
|
||||
$this->useExternalAlbumArtInApis = $useExternalAlbumArtInApis;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Property(example="SAMPLE-API-KEY")
|
||||
* @var string|null An API key to connect to Last.fm services, if provided.
|
||||
*/
|
||||
protected ?string $lastFmApiKey = null;
|
||||
|
||||
public function getLastFmApiKey(): ?string
|
||||
{
|
||||
return $this->lastFmApiKey;
|
||||
}
|
||||
|
||||
public function setLastFmApiKey(?string $lastFmApiKey): void
|
||||
{
|
||||
$lastFmApiKey = trim($lastFmApiKey);
|
||||
$this->lastFmApiKey = (!empty($lastFmApiKey)) ? $lastFmApiKey : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Property(example="false")
|
||||
* @var bool Hide AzuraCast Branding on Public Pages
|
||||
|
|
|
@ -39,11 +39,13 @@ class Song implements SongInterface
|
|||
}
|
||||
|
||||
if (!is_string($songText)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'$songText parameter must be a string, array, or instance of %s or %s.',
|
||||
self::class,
|
||||
CurrentSong::class
|
||||
));
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
'$songText parameter must be a string, array, or instance of %s or %s.',
|
||||
self::class,
|
||||
CurrentSong::class
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Strip non-alphanumeric characters
|
||||
|
@ -78,9 +80,9 @@ class Song implements SongInterface
|
|||
public static function createFromArray(array $songRow): self
|
||||
{
|
||||
$currentSong = new CurrentSong(
|
||||
$songRow['text'] ?? null,
|
||||
$songRow['title'] ?? null,
|
||||
$songRow['artist'] ?? null
|
||||
$songRow['text'] ?? '',
|
||||
$songRow['title'] ?? '',
|
||||
$songRow['artist'] ?? ''
|
||||
);
|
||||
return self::createFromNowPlayingSong($currentSong);
|
||||
}
|
||||
|
@ -90,4 +92,9 @@ class Song implements SongInterface
|
|||
$currentSong = new CurrentSong($songText);
|
||||
return self::createFromNowPlayingSong($currentSong);
|
||||
}
|
||||
|
||||
public static function createOffline(): self
|
||||
{
|
||||
return self::createFromText('Stream Offline');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ namespace App\Entity;
|
|||
|
||||
use App\Annotations\AuditLog;
|
||||
use App\Flysystem\FilesystemManager;
|
||||
use App\Media\Metadata;
|
||||
use App\Normalizer\Annotation\DeepNormalize;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Event\Media;
|
||||
|
||||
use App\Entity;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class GetAlbumArt extends Event
|
||||
{
|
||||
protected Entity\SongInterface $song;
|
||||
|
||||
protected ?string $albumArt = null;
|
||||
|
||||
public function __construct(Entity\SongInterface $song)
|
||||
{
|
||||
$this->song = $song;
|
||||
}
|
||||
|
||||
public function getSong(): Entity\SongInterface
|
||||
{
|
||||
return $this->song;
|
||||
}
|
||||
|
||||
public function setAlbumArt(?string $albumArt): void
|
||||
{
|
||||
$this->albumArt = !empty($albumArt)
|
||||
? $albumArt
|
||||
: null;
|
||||
|
||||
if (null !== $this->albumArt) {
|
||||
$this->stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
public function getAlbumArt(): ?string
|
||||
{
|
||||
return $this->albumArt;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Event\Media;
|
||||
|
||||
use App\Entity;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class ReadMetadata extends Event
|
||||
{
|
||||
protected string $path;
|
||||
|
||||
protected ?Entity\Metadata $metadata = null;
|
||||
|
||||
public function __construct(string $path)
|
||||
{
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function setMetadata(Entity\Metadata $metadata): void
|
||||
{
|
||||
$this->metadata = $metadata;
|
||||
}
|
||||
|
||||
public function getMetadata(): Entity\Metadata
|
||||
{
|
||||
return $this->metadata ?? new Entity\Metadata();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Event\Media;
|
||||
|
||||
use App\Entity;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
|
||||
class WriteMetadata extends Event
|
||||
{
|
||||
protected Entity\Metadata $metadata;
|
||||
|
||||
protected string $path;
|
||||
|
||||
public function __construct(Entity\Metadata $metadata, string $path)
|
||||
{
|
||||
$this->metadata = $metadata;
|
||||
$this->path = $path;
|
||||
}
|
||||
|
||||
public function getMetadata(): ?Entity\Metadata
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media\AlbumArtHandler;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\GetAlbumArt;
|
||||
use App\Service\LastFm;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class LastFmAlbumArtHandler
|
||||
{
|
||||
protected LastFm $lastFm;
|
||||
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
public function __construct(LastFm $lastFm, LoggerInterface $logger)
|
||||
{
|
||||
$this->lastFm = $lastFm;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function __invoke(GetAlbumArt $event): void
|
||||
{
|
||||
if (!$this->lastFm->hasApiKey()) {
|
||||
$this->logger->info('No last.fm API key specified; skipping last.fm album art check.');
|
||||
return;
|
||||
}
|
||||
|
||||
$song = $event->getSong();
|
||||
|
||||
try {
|
||||
$albumArt = $this->getAlbumArt($song);
|
||||
if (!empty($albumArt)) {
|
||||
$event->setAlbumArt($albumArt);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(
|
||||
sprintf('Last.fm Album Art Error: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
'song' => $song->getText(),
|
||||
'songId' => $song->getSongId(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getAlbumArt(Entity\SongInterface $song): ?string
|
||||
{
|
||||
if ($song instanceof Entity\StationMedia && !empty($song->getAlbum())) {
|
||||
$response = $this->lastFm->makeRequest(
|
||||
'album.getInfo',
|
||||
[
|
||||
'artist' => $song->getArtist(),
|
||||
'album' => $song->getAlbum(),
|
||||
]
|
||||
);
|
||||
|
||||
if (isset($response['album'])) {
|
||||
return $this->getImageFromArray($response['album']['image'] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->lastFm->makeRequest(
|
||||
'track.getInfo',
|
||||
[
|
||||
'artist' => $song->getArtist(),
|
||||
'track' => $song->getTitle(),
|
||||
]
|
||||
);
|
||||
|
||||
if (isset($response['album'])) {
|
||||
return $this->getImageFromArray($response['album']['image'] ?? []);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getImageFromArray(array $images): ?string
|
||||
{
|
||||
$imagesBySize = [];
|
||||
foreach ($images as $image) {
|
||||
$size = ('' === $image['size']) ? 'default' : $image['size'];
|
||||
$imagesBySize[$size] = $image['#text'];
|
||||
}
|
||||
|
||||
return $imagesBySize['large']
|
||||
?? $imagesBySize['extralarge']
|
||||
?? $imagesBySize['default']
|
||||
?? null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media\AlbumArtHandler;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\GetAlbumArt;
|
||||
use App\Service\MusicBrainz;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class MusicBrainzAlbumArtHandler
|
||||
{
|
||||
protected MusicBrainz $musicBrainz;
|
||||
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
public function __construct(MusicBrainz $musicBrainz, LoggerInterface $logger)
|
||||
{
|
||||
$this->musicBrainz = $musicBrainz;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function __invoke(GetAlbumArt $event): void
|
||||
{
|
||||
$song = $event->getSong();
|
||||
|
||||
try {
|
||||
$albumArt = $this->getAlbumArt($song);
|
||||
|
||||
if (!empty($albumArt)) {
|
||||
$event->setAlbumArt($albumArt);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(
|
||||
sprintf('MusicBrainz Album Art Error: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
'song' => $song->getText(),
|
||||
'songId' => $song->getSongId(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getAlbumArt(Entity\SongInterface $song): ?string
|
||||
{
|
||||
$searchQuery = [];
|
||||
|
||||
$searchQuery[] = $this->quoteQuery($song->getTitle());
|
||||
if (!empty($song->getArtist())) {
|
||||
$searchQuery[] = 'artist:' . $this->quoteQuery($song->getArtist());
|
||||
}
|
||||
|
||||
if ($song instanceof Entity\StationMedia) {
|
||||
if (!empty($song->getAlbum())) {
|
||||
$searchQuery[] = 'release:' . $this->quoteQuery($song->getAlbum());
|
||||
}
|
||||
|
||||
if (!empty($song->getIsrc())) {
|
||||
$searchQuery[] = 'isrc:' . $this->quoteQuery($song->getIsrc());
|
||||
}
|
||||
}
|
||||
|
||||
$response = $this->musicBrainz->makeRequest(
|
||||
'recording/',
|
||||
[
|
||||
'query' => implode(' AND ', $searchQuery),
|
||||
'inc' => 'releases',
|
||||
'limit' => 5,
|
||||
]
|
||||
);
|
||||
|
||||
if (empty($response['recordings'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$releaseGroupIds = [];
|
||||
foreach ($response['recordings'] as $recording) {
|
||||
if (empty($recording['releases'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($recording['releases'] as $release) {
|
||||
if (isset($release['release-group']['id'])) {
|
||||
$releaseGroupId = $release['release-group']['id'];
|
||||
|
||||
if (isset($releaseGroupIds[$releaseGroupId])) {
|
||||
continue; // Already been checked.
|
||||
}
|
||||
$releaseGroupIds[$releaseGroupId] = $releaseGroupId;
|
||||
|
||||
$groupAlbumArt = $this->musicBrainz->getCoverArt('release-group', $releaseGroupId);
|
||||
|
||||
if (!empty($groupAlbumArt)) {
|
||||
return $groupAlbumArt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function quoteQuery(string $query): string
|
||||
{
|
||||
return '"' . str_replace('"', '\'', $query) . '"';
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\ReadMetadata;
|
||||
use App\Event\Media\WriteMetadata;
|
||||
use App\EventDispatcher;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Media\AlbumArtHandler\AlbumArtServiceInterface;
|
||||
use App\Media\MetadataService\MetadataServiceInterface;
|
||||
use App\Version;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
|
||||
class MetadataManager
|
||||
{
|
||||
protected EventDispatcher $eventDispatcher;
|
||||
|
||||
protected Client $httpClient;
|
||||
|
||||
protected RemoteAlbumArt $remoteAlbumArt;
|
||||
|
||||
public function __construct(
|
||||
EventDispatcher $eventDispatcher,
|
||||
Client $httpClient,
|
||||
RemoteAlbumArt $remoteAlbumArt
|
||||
) {
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->remoteAlbumArt = $remoteAlbumArt;
|
||||
}
|
||||
|
||||
public function getMetadata(Entity\StationMedia $media, string $filePath): Entity\Metadata
|
||||
{
|
||||
if (!MimeType::isFileProcessable($filePath)) {
|
||||
$mimeType = MimeType::getMimeTypeFromFile($filePath);
|
||||
throw CannotProcessMediaException::forPath(
|
||||
$filePath,
|
||||
sprintf('MIME type "%s" is not processable.', $mimeType)
|
||||
);
|
||||
}
|
||||
|
||||
$event = new ReadMetadata($filePath);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$metadata = $event->getMetadata();
|
||||
$media->fromMetadata($metadata);
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
if (empty($artwork) && $this->remoteAlbumArt->enableForMedia()) {
|
||||
$metadata->setArtwork($this->getExternalArtwork($media));
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
protected function getExternalArtwork(Entity\StationMedia $media): ?string
|
||||
{
|
||||
$artUri = ($this->remoteAlbumArt)($media);
|
||||
if (empty($artUri)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch external artwork.
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
$artUri,
|
||||
[
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
RequestOptions::HEADERS => [
|
||||
'User-Agent' => 'AzuraCast ' . Version::FALLBACK_VERSION,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return (string)$response->getBody();
|
||||
}
|
||||
|
||||
public function writeMetadata(Entity\Metadata $metadata, string $filePath): void
|
||||
{
|
||||
$event = new WriteMetadata($metadata, $filePath);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
}
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media;
|
||||
|
||||
interface MetadataManagerInterface
|
||||
{
|
||||
/**
|
||||
* @param string $path
|
||||
*
|
||||
*/
|
||||
public function getMetadata(string $path): Metadata;
|
||||
|
||||
/**
|
||||
* @param Metadata $metadata
|
||||
* @param string $path
|
||||
*
|
||||
* @return bool Whether the write operation completed successfully.
|
||||
*/
|
||||
public function writeMetadata(Metadata $metadata, string $path): bool;
|
||||
}
|
|
@ -1,19 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media\GetId3;
|
||||
namespace App\Media\MetadataService;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\ReadMetadata;
|
||||
use App\Event\Media\WriteMetadata;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Media\Metadata;
|
||||
use App\Media\MetadataManagerInterface;
|
||||
use App\Utilities;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
use voku\helper\UTF8;
|
||||
|
||||
class GetId3MetadataManager implements MetadataManagerInterface
|
||||
class GetId3MetadataService
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getMetadata(string $path): Metadata
|
||||
public function __invoke(Event $event): void
|
||||
{
|
||||
if ($event instanceof ReadMetadata) {
|
||||
$metadata = $this->readMetadata($event->getPath());
|
||||
$event->setMetadata($metadata);
|
||||
} elseif ($event instanceof WriteMetadata) {
|
||||
if ($this->writeMetadata($event->getMetadata(), $event->getPath())) {
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function readMetadata(string $path): Entity\Metadata
|
||||
{
|
||||
$id3 = new \getID3();
|
||||
|
||||
|
@ -33,7 +44,7 @@ class GetId3MetadataManager implements MetadataManagerInterface
|
|||
);
|
||||
}
|
||||
|
||||
$metadata = new Metadata();
|
||||
$metadata = new Entity\Metadata();
|
||||
|
||||
if (is_numeric($info['playtime_seconds'])) {
|
||||
$metadata->setDuration($info['playtime_seconds']);
|
||||
|
@ -89,10 +100,7 @@ class GetId3MetadataManager implements MetadataManagerInterface
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function writeMetadata(Metadata $metadata, string $path): bool
|
||||
public function writeMetadata(Entity\Metadata $metadata, string $path): bool
|
||||
{
|
||||
$getID3 = new \getID3();
|
||||
$getID3->setOption(['encoding' => 'UTF8']);
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Media;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\GetAlbumArt;
|
||||
use App\EventDispatcher;
|
||||
use App\Media\AlbumArtHandler\AlbumArtServiceInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
class RemoteAlbumArt
|
||||
{
|
||||
public const CACHE_LIFETIME = 86400 * 14; // Two Weeks
|
||||
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
protected CacheInterface $cache;
|
||||
|
||||
protected Entity\Repository\SettingsRepository $settingsRepo;
|
||||
|
||||
protected EventDispatcher $eventDispatcher;
|
||||
|
||||
public function __construct(
|
||||
LoggerInterface $logger,
|
||||
CacheInterface $cache,
|
||||
Entity\Repository\SettingsRepository $settingsRepo,
|
||||
EventDispatcher $eventDispatcher
|
||||
) {
|
||||
$this->logger = $logger;
|
||||
$this->cache = $cache;
|
||||
$this->settingsRepo = $settingsRepo;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
}
|
||||
|
||||
public function enableForApis(): bool
|
||||
{
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
return $settings->getUseExternalAlbumArtInApis();
|
||||
}
|
||||
|
||||
public function enableForMedia(): bool
|
||||
{
|
||||
$settings = $this->settingsRepo->readSettings();
|
||||
return $settings->getUseExternalAlbumArtWhenProcessingMedia();
|
||||
}
|
||||
|
||||
public function __invoke(Entity\SongInterface $song): ?string
|
||||
{
|
||||
// Avoid tracks that shouldn't ever hit remote APIs.
|
||||
$offlineSong = Entity\Song::createOffline();
|
||||
if ($song->getSongId() === $offlineSong->getSongId()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Catch the default error track and derivatives.
|
||||
if (false !== mb_stripos($song->getText(), 'AzuraCast')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for cached API hits for the same song ID before.
|
||||
$cacheKey = 'album_art.' . $song->getSongId();
|
||||
|
||||
if ($this->cache->has($cacheKey)) {
|
||||
$cacheResult = $this->cache->get($cacheKey);
|
||||
|
||||
$this->logger->debug(
|
||||
'Cached entry found for track.',
|
||||
[
|
||||
'result' => $cacheResult,
|
||||
'song' => $song->getText(),
|
||||
'songId' => $song->getSongId(),
|
||||
]
|
||||
);
|
||||
|
||||
if ($cacheResult['success']) {
|
||||
return $cacheResult['url'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dispatch new event to various registered handlers.
|
||||
$event = new GetAlbumArt($song);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$albumArtUrl = $event->getAlbumArt();
|
||||
|
||||
if (null !== $albumArtUrl) {
|
||||
$this->cache->set(
|
||||
$cacheKey,
|
||||
[
|
||||
'success' => true,
|
||||
'url' => $albumArtUrl,
|
||||
],
|
||||
self::CACHE_LIFETIME
|
||||
);
|
||||
}
|
||||
|
||||
return $albumArtUrl;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity;
|
||||
use App\Version;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
|
||||
class LastFm
|
||||
{
|
||||
public const API_BASE_URL = 'https://ws.audioscrobbler.com/2.0/';
|
||||
|
||||
protected Client $httpClient;
|
||||
|
||||
protected ?string $apiKey = null;
|
||||
|
||||
public function __construct(
|
||||
Client $client,
|
||||
Entity\Repository\SettingsRepository $settingsRepo
|
||||
) {
|
||||
$this->httpClient = $client;
|
||||
|
||||
$settings = $settingsRepo->readSettings();
|
||||
$this->apiKey = $settings->getLastFmApiKey();
|
||||
}
|
||||
|
||||
public function hasApiKey(): bool
|
||||
{
|
||||
return !empty($this->apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $apiMethod API method to call.
|
||||
* @param mixed[] $query Query string parameters to supplement defaults.
|
||||
*
|
||||
* @return mixed[] The decoded JSON response.
|
||||
*/
|
||||
public function makeRequest(
|
||||
string $apiMethod,
|
||||
array $query = []
|
||||
): array {
|
||||
$apiKey = $this->apiKey;
|
||||
if (empty($apiKey)) {
|
||||
throw new \InvalidArgumentException('No last.fm API key provided.');
|
||||
}
|
||||
|
||||
$query = array_merge(
|
||||
$query,
|
||||
[
|
||||
'method' => $apiMethod,
|
||||
'api_key' => $apiKey,
|
||||
'format' => 'json',
|
||||
]
|
||||
);
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
self::API_BASE_URL,
|
||||
[
|
||||
RequestOptions::HTTP_ERRORS => true,
|
||||
RequestOptions::HEADERS => [
|
||||
'User-Agent' => 'AzuraCast ' . Version::FALLBACK_VERSION,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
RequestOptions::QUERY => $query,
|
||||
]
|
||||
);
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
$responseJson = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
if (!empty($responseJson['error'])) {
|
||||
if (!empty($responseJson['message'])) {
|
||||
throw new \RuntimeException(
|
||||
'last.fm API error: ' . $responseJson['message'],
|
||||
(int)$responseJson['error']
|
||||
);
|
||||
}
|
||||
|
||||
throw $this->createExceptionFromErrorCode((int)$responseJson['error']);
|
||||
}
|
||||
|
||||
return $responseJson;
|
||||
}
|
||||
|
||||
protected function createExceptionFromErrorCode(int $errorCode): \RuntimeException
|
||||
{
|
||||
$errorDescriptions = [
|
||||
2 => 'Invalid service - This service does not exist',
|
||||
3 => 'Invalid Method - No method with that name in this package',
|
||||
4 => 'Authentication Failed - You do not have permissions to access the service',
|
||||
5 => 'Invalid format - This service doesn\'t exist in that format',
|
||||
6 => 'Invalid parameters - Your request is missing a required parameter',
|
||||
7 => 'Invalid resource specified',
|
||||
8 => 'Operation failed - Something else went wrong',
|
||||
9 => 'Invalid session key - Please re-authenticate',
|
||||
10 => 'Invalid API key - You must be granted a valid key by last.fm',
|
||||
11 => 'Service Offline - This service is temporarily offline. try again later.',
|
||||
13 => 'Invalid method signature supplied',
|
||||
16 => 'There was a temporary error processing your request. Please try again',
|
||||
26 => 'Suspended API key - Access for your account has been suspended, please contact Last.fm',
|
||||
29 => 'Rate limit exceeded - Your IP has made too many requests in a short period',
|
||||
];
|
||||
|
||||
return new \RuntimeException(
|
||||
'last.fm API error: ' . ($errorDescriptions[$errorCode] ?? 'Unknown Error'),
|
||||
$errorCode
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Version;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Psr7\UriResolver;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
class MusicBrainz
|
||||
{
|
||||
public const API_BASE_URL = 'https://musicbrainz.org/ws/2/';
|
||||
|
||||
public const COVER_ART_ARCHIVE_BASE_URL = 'https://coverartarchive.org/';
|
||||
|
||||
protected Client $httpClient;
|
||||
|
||||
public function __construct(Client $httpClient)
|
||||
{
|
||||
$this->httpClient = $httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|UriInterface $uri
|
||||
* @param mixed[] $query Query string parameters to supplement defaults.
|
||||
*
|
||||
* @return mixed[] The decoded JSON response.
|
||||
*/
|
||||
public function makeRequest(
|
||||
$uri,
|
||||
array $query = []
|
||||
): array {
|
||||
$query = array_merge(
|
||||
$query,
|
||||
[
|
||||
'fmt' => 'json',
|
||||
]
|
||||
);
|
||||
|
||||
$uri = UriResolver::resolve(
|
||||
Utils::uriFor(self::API_BASE_URL),
|
||||
Utils::uriFor($uri)
|
||||
);
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
$uri,
|
||||
[
|
||||
RequestOptions::TIMEOUT => 7,
|
||||
RequestOptions::HTTP_ERRORS => true,
|
||||
RequestOptions::HEADERS => [
|
||||
'User-Agent' => 'AzuraCast ' . Version::FALLBACK_VERSION,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
RequestOptions::QUERY => $query,
|
||||
]
|
||||
);
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
public function getCoverArt(
|
||||
string $recordType,
|
||||
string $mbid
|
||||
): ?string {
|
||||
$uri = '/' . $recordType . '/' . $mbid;
|
||||
|
||||
$uri = UriResolver::resolve(
|
||||
Utils::uriFor(self::COVER_ART_ARCHIVE_BASE_URL),
|
||||
Utils::uriFor($uri)
|
||||
);
|
||||
|
||||
$response = $this->httpClient->request(
|
||||
'GET',
|
||||
$uri,
|
||||
[
|
||||
RequestOptions::ALLOW_REDIRECTS => true,
|
||||
RequestOptions::HEADERS => [
|
||||
'User-Agent' => 'AzuraCast ' . Version::FALLBACK_VERSION,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$responseBody = (string)$response->getBody();
|
||||
if (empty($responseBody)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$jsonBody = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($jsonBody['images'])) {
|
||||
return null; // API returned no "images".
|
||||
}
|
||||
|
||||
foreach ($jsonBody['images'] as $image) {
|
||||
if (!$image['front']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$imageUrl = $image['thumbnails'][1200]
|
||||
?? $image['thumbnails']['large']
|
||||
?? $image['image']
|
||||
?? null;
|
||||
|
||||
if (!empty($imageUrl)) {
|
||||
return $imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
<?php //[STAMP] 01d26489a71b65ea4dabb9ecd8a0f95b
|
||||
<?php //[STAMP] 7db5db0338068d6dd173afbcecda5964
|
||||
namespace _generated;
|
||||
|
||||
// This class was automatically generated by build task
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?php //[STAMP] 39f1e9ae714770bed9e356e659952e51
|
||||
<?php //[STAMP] f6796b4429a3dc71f3b89daa9b43789c
|
||||
namespace _generated;
|
||||
|
||||
// This class was automatically generated by build task
|
||||
|
|
Loading…
Reference in New Issue