Add Support for Remote Album Art on APIs and Media Uploads (#3680)

This commit is contained in:
Buster "Silver Eagle" Neece 2021-01-18 23:44:15 -06:00 committed by GitHub
parent 9d3c10a5ef
commit 686f480d7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1037 additions and 147 deletions

View File

@ -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

View File

@ -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,

View File

@ -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' => [

View File

@ -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
),
];

View File

@ -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);

View File

@ -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));
}
/**

View File

@ -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;
}

View File

@ -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;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Media;
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;

View File

@ -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());

View File

@ -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

View File

@ -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');
}
}

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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) . '"';
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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']);

View File

@ -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;
}
}

111
src/Service/LastFm.php Normal file
View File

@ -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
);
}
}

124
src/Service/MusicBrainz.php Normal file
View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
<?php //[STAMP] 01d26489a71b65ea4dabb9ecd8a0f95b
<?php //[STAMP] 7db5db0338068d6dd173afbcecda5964
namespace _generated;
// This class was automatically generated by build task

View File

@ -1,4 +1,4 @@
<?php //[STAMP] 39f1e9ae714770bed9e356e659952e51
<?php //[STAMP] f6796b4429a3dc71f3b89daa9b43789c
namespace _generated;
// This class was automatically generated by build task