Move ID3 read/write to its own standalone process.
This commit is contained in:
parent
26fd358c40
commit
d40b8b9b2b
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
error_reporting(E_ALL & ~E_NOTICE & ~E_STRICT);
|
||||
ini_set('display_errors', '1');
|
||||
|
||||
$autoloader = require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$environment = App\AppFactory::buildEnvironment(
|
||||
[
|
||||
App\Environment::BASE_DIR => dirname(__DIR__),
|
||||
]
|
||||
);
|
||||
|
||||
$console = new Silly\Application('AzuraCast Metadata Processor', App\Version::FALLBACK_VERSION);
|
||||
|
||||
$console->command(
|
||||
'read path json-output [--art-output=]',
|
||||
new App\MediaProcessor\Command\ReadCommand
|
||||
);
|
||||
|
||||
$console->command(
|
||||
'write path json-input [--art-input=]',
|
||||
new App\MediaProcessor\Command\WriteCommand
|
||||
);
|
||||
|
||||
$console->run();
|
|
@ -168,17 +168,9 @@ return function (App\EventDispatcher $dispatcher) {
|
|||
-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\Media\MetadataManager::class,
|
||||
App\Console\ErrorHandler::class,
|
||||
App\Radio\AutoDJ\Queue::class,
|
||||
App\Radio\AutoDJ\Annotations::class,
|
||||
|
|
|
@ -92,7 +92,7 @@ class SongApiGenerator
|
|||
}
|
||||
|
||||
$path = ($allowRemoteArt && $this->remoteAlbumArt->enableForApis())
|
||||
? ($this->remoteAlbumArt)($song)
|
||||
? $this->remoteAlbumArt->getUrlForSong($song)
|
||||
: null;
|
||||
|
||||
if (null === $path) {
|
||||
|
|
|
@ -4,11 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
|
||||
class Metadata
|
||||
class Metadata implements \JsonSerializable
|
||||
{
|
||||
protected ArrayCollection $tags;
|
||||
/** @var array<string, mixed> */
|
||||
protected array $tags = [];
|
||||
|
||||
protected float $duration = 0.0;
|
||||
|
||||
|
@ -16,16 +15,21 @@ class Metadata
|
|||
|
||||
protected string $mimeType = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getTags(): ArrayCollection
|
||||
public function getTags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
public function setTags(array $tags): void
|
||||
{
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
public function addTag(string $key, mixed $value): void
|
||||
{
|
||||
$this->tags[$key] = $value;
|
||||
}
|
||||
|
||||
public function getDuration(): float
|
||||
{
|
||||
return $this->duration;
|
||||
|
@ -55,4 +59,31 @@ class Metadata
|
|||
{
|
||||
$this->mimeType = $mimeType;
|
||||
}
|
||||
|
||||
public function jsonSerialize()
|
||||
{
|
||||
// Artwork is not included in this JSON feed.
|
||||
return [
|
||||
'tags' => $this->tags,
|
||||
'duration' => $this->duration,
|
||||
'mimeType' => $this->mimeType,
|
||||
];
|
||||
}
|
||||
|
||||
public static function fromJson(array $data): self
|
||||
{
|
||||
$metadata = new self();
|
||||
|
||||
if (isset($data['tags'])) {
|
||||
$metadata->setTags((array)$data['tags']);
|
||||
}
|
||||
if (isset($data['duration'])) {
|
||||
$metadata->setDuration((float)$data['duration']);
|
||||
}
|
||||
if (isset($data['mimeType'])) {
|
||||
$metadata->setMimeType((string)$data['mimeType']);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ use App\Entity\PodcastEpisode;
|
|||
use App\Entity\PodcastMedia;
|
||||
use App\Environment;
|
||||
use App\Exception\InvalidPodcastMediaFileException;
|
||||
use App\Media\MetadataService\GetId3MetadataService;
|
||||
use App\Media\MetadataManager;
|
||||
use Azura\Files\ExtendedFilesystemInterface;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
|
@ -24,7 +24,7 @@ class PodcastMediaRepository extends Repository
|
|||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger,
|
||||
protected GetId3MetadataService $metadataService,
|
||||
protected MetadataManager $metadataManager,
|
||||
protected ImageManager $imageManager,
|
||||
protected PodcastEpisodeRepository $episodeRepo,
|
||||
) {
|
||||
|
@ -43,7 +43,7 @@ class PodcastMediaRepository extends Repository
|
|||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
// Do an early metadata check of the new media to avoid replacing a valid file with an invalid one.
|
||||
$metadata = $this->metadataService->readMetadata($uploadPath);
|
||||
$metadata = $this->metadataManager->read($uploadPath);
|
||||
|
||||
if (!in_array($metadata->getMimeType(), ['audio/x-m4a', 'audio/mpeg'])) {
|
||||
throw new InvalidPodcastMediaFileException(
|
||||
|
|
|
@ -10,6 +10,7 @@ use App\Entity;
|
|||
use App\Environment;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Media\MetadataManager;
|
||||
use App\Media\RemoteAlbumArt;
|
||||
use App\Service\AudioWaveform;
|
||||
use Azura\Files\ExtendedFilesystemInterface;
|
||||
use Exception;
|
||||
|
@ -26,39 +27,20 @@ use const JSON_UNESCAPED_SLASHES;
|
|||
|
||||
class StationMediaRepository extends Repository
|
||||
{
|
||||
protected CustomFieldRepository $customFieldRepo;
|
||||
|
||||
protected StationPlaylistMediaRepository $spmRepo;
|
||||
|
||||
protected StorageLocationRepository $storageLocationRepo;
|
||||
|
||||
protected UnprocessableMediaRepository $unprocessableMediaRepo;
|
||||
|
||||
protected MetadataManager $metadataManager;
|
||||
|
||||
protected ImageManager $imageManager;
|
||||
|
||||
public function __construct(
|
||||
ReloadableEntityManagerInterface $em,
|
||||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger,
|
||||
MetadataManager $metadataManager,
|
||||
CustomFieldRepository $customFieldRepo,
|
||||
StationPlaylistMediaRepository $spmRepo,
|
||||
StorageLocationRepository $storageLocationRepo,
|
||||
UnprocessableMediaRepository $unprocessableMediaRepo,
|
||||
ImageManager $imageManager
|
||||
protected MetadataManager $metadataManager,
|
||||
protected RemoteAlbumArt $remoteAlbumArt,
|
||||
protected CustomFieldRepository $customFieldRepo,
|
||||
protected StationPlaylistMediaRepository $spmRepo,
|
||||
protected StorageLocationRepository $storageLocationRepo,
|
||||
protected UnprocessableMediaRepository $unprocessableMediaRepo,
|
||||
protected ImageManager $imageManager
|
||||
) {
|
||||
parent::__construct($em, $serializer, $environment, $logger);
|
||||
|
||||
$this->customFieldRepo = $customFieldRepo;
|
||||
$this->spmRepo = $spmRepo;
|
||||
$this->storageLocationRepo = $storageLocationRepo;
|
||||
$this->unprocessableMediaRepo = $unprocessableMediaRepo;
|
||||
|
||||
$this->metadataManager = $metadataManager;
|
||||
$this->imageManager = $imageManager;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -257,7 +239,9 @@ class StationMediaRepository extends Repository
|
|||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
// Load metadata from supported files.
|
||||
$metadata = $this->metadataManager->getMetadata($media, $filePath);
|
||||
$metadata = $this->metadataManager->read($filePath);
|
||||
|
||||
$media->fromMetadata($metadata);
|
||||
|
||||
// Persist the media record for later custom field operations.
|
||||
$this->em->persist($media);
|
||||
|
@ -285,6 +269,11 @@ class StationMediaRepository extends Repository
|
|||
}
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
|
||||
if (empty($artwork) && $this->remoteAlbumArt->enableForMedia()) {
|
||||
$artwork = $this->remoteAlbumArt->getArtwork($media);
|
||||
}
|
||||
|
||||
if (!empty($artwork)) {
|
||||
try {
|
||||
$this->writeAlbumArt($media, $artwork, $fs);
|
||||
|
@ -391,7 +380,7 @@ class StationMediaRepository extends Repository
|
|||
$media->getPath(),
|
||||
function ($path) use ($metadata) {
|
||||
try {
|
||||
$this->metadataManager->writeMetadata($metadata, $path);
|
||||
$this->metadataManager->write($metadata, $path);
|
||||
return true;
|
||||
} catch (CannotProcessMediaException $e) {
|
||||
throw $e;
|
||||
|
|
|
@ -493,22 +493,19 @@ class StationMedia implements SongInterface, ProcessableMediaInterface, PathAwar
|
|||
$metadata = new Metadata();
|
||||
$metadata->setDuration($this->getLength() ?? 0.0);
|
||||
|
||||
$tagsToSet = array_filter(
|
||||
[
|
||||
'title' => $this->getTitle(),
|
||||
'artist' => $this->getArtist(),
|
||||
'album' => $this->getAlbum(),
|
||||
'genre' => $this->getGenre(),
|
||||
'unsynchronised_lyric' => $this->getLyrics(),
|
||||
'isrc' => $this->getIsrc(),
|
||||
]
|
||||
$metadata->setTags(
|
||||
array_filter(
|
||||
[
|
||||
'title' => $this->getTitle(),
|
||||
'artist' => $this->getArtist(),
|
||||
'album' => $this->getAlbum(),
|
||||
'genre' => $this->getGenre(),
|
||||
'unsynchronised_lyric' => $this->getLyrics(),
|
||||
'isrc' => $this->getIsrc(),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$tags = $metadata->getTags();
|
||||
foreach ($tagsToSet as $tagKey => $tagValue) {
|
||||
$tags->set($tagKey, $tagValue);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,24 +5,40 @@ declare(strict_types=1);
|
|||
namespace App\Media;
|
||||
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Event\Media\ReadMetadata;
|
||||
use App\Event\Media\WriteMetadata;
|
||||
use App\EventDispatcher;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Version;
|
||||
use App\Utilities\File;
|
||||
use App\Utilities\Json;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Process\PhpExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class MetadataManager
|
||||
class MetadataManager implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected EventDispatcher $eventDispatcher,
|
||||
protected Client $httpClient,
|
||||
protected RemoteAlbumArt $remoteAlbumArt
|
||||
protected Environment $environment
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMetadata(Entity\StationMedia $media, string $filePath): Entity\Metadata
|
||||
public static function getSubscribedEvents()
|
||||
{
|
||||
return [
|
||||
ReadMetadata::class => [
|
||||
['readFromId3', 0],
|
||||
],
|
||||
WriteMetadata::class => [
|
||||
['writeToId3', 0],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function read(string $filePath): Entity\Metadata
|
||||
{
|
||||
if (!MimeType::isFileProcessable($filePath)) {
|
||||
$mimeType = MimeType::getMimeTypeFromFile($filePath);
|
||||
|
@ -35,42 +51,110 @@ class MetadataManager
|
|||
$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;
|
||||
return $event->getMetadata();
|
||||
}
|
||||
|
||||
protected function getExternalArtwork(Entity\StationMedia $media): ?string
|
||||
public function readFromId3(ReadMetadata $event): void
|
||||
{
|
||||
$artUri = ($this->remoteAlbumArt)($media);
|
||||
if (empty($artUri)) {
|
||||
return null;
|
||||
$sourceFilePath = $event->getPath();
|
||||
|
||||
$jsonOutput = File::generateTempPath('metadata.json');
|
||||
$artOutput = File::generateTempPath('metadata.jpg');
|
||||
|
||||
try {
|
||||
$phpBinaryPath = (new PhpExecutableFinder())->find();
|
||||
if (false === $phpBinaryPath) {
|
||||
throw new \RuntimeException('Could not find PHP executable path.');
|
||||
}
|
||||
|
||||
$scriptPath = $this->environment->getBaseDirectory() . '/bin/metadata';
|
||||
|
||||
$process = new Process(
|
||||
[
|
||||
$phpBinaryPath,
|
||||
$scriptPath,
|
||||
'read',
|
||||
$sourceFilePath,
|
||||
$jsonOutput,
|
||||
'--art-output=' . $artOutput,
|
||||
]
|
||||
);
|
||||
|
||||
$process->mustRun();
|
||||
|
||||
$metadataJson = Json::loadFromFile($jsonOutput);
|
||||
$metadata = Entity\Metadata::fromJson($metadataJson);
|
||||
|
||||
if (is_file($artOutput)) {
|
||||
$artwork = file_get_contents($artOutput) ?: null;
|
||||
$metadata->setArtwork($artwork);
|
||||
}
|
||||
|
||||
$event->setMetadata($metadata);
|
||||
} finally {
|
||||
@unlink($jsonOutput);
|
||||
@unlink($artOutput);
|
||||
}
|
||||
|
||||
// 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
|
||||
public function write(Entity\Metadata $metadata, string $filePath): void
|
||||
{
|
||||
$event = new WriteMetadata($metadata, $filePath);
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
}
|
||||
|
||||
public function writeToId3(WriteMetadata $event): void
|
||||
{
|
||||
$destFilePath = $event->getPath();
|
||||
|
||||
$metadata = $event->getMetadata();
|
||||
if (null === $metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
$jsonInput = File::generateTempPath('metadata.json');
|
||||
$artInput = File::generateTempPath('metadata.jpg');
|
||||
|
||||
try {
|
||||
// Write input files for the metadata process.
|
||||
file_put_contents(
|
||||
$jsonInput,
|
||||
json_encode($metadata, JSON_THROW_ON_ERROR)
|
||||
);
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
if (null !== $artwork) {
|
||||
file_put_contents(
|
||||
$artInput,
|
||||
$artwork
|
||||
);
|
||||
}
|
||||
|
||||
// Run remote process.
|
||||
$phpBinaryPath = (new PhpExecutableFinder())->find();
|
||||
if (false === $phpBinaryPath) {
|
||||
throw new \RuntimeException('Could not find PHP executable path.');
|
||||
}
|
||||
|
||||
$scriptPath = $this->environment->getBaseDirectory() . '/bin/metadata';
|
||||
|
||||
$processCommand = [
|
||||
$phpBinaryPath,
|
||||
$scriptPath,
|
||||
'write',
|
||||
$destFilePath,
|
||||
$jsonInput,
|
||||
];
|
||||
|
||||
if (null !== $artwork) {
|
||||
$processCommand[] = '--art-input=' . $artInput;
|
||||
}
|
||||
|
||||
$process = new Process($processCommand);
|
||||
$process->run();
|
||||
} finally {
|
||||
@unlink($jsonInput);
|
||||
@unlink($artInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,184 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Media\MetadataService;
|
||||
|
||||
use App\Entity;
|
||||
use App\Event\Media\ReadMetadata;
|
||||
use App\Event\Media\WriteMetadata;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Utilities;
|
||||
use getID3;
|
||||
use getid3_writetags;
|
||||
use Symfony\Contracts\EventDispatcher\Event;
|
||||
use voku\helper\UTF8;
|
||||
|
||||
class GetId3MetadataService
|
||||
{
|
||||
public function __invoke(Event $event): void
|
||||
{
|
||||
if ($event instanceof ReadMetadata) {
|
||||
$metadata = $this->readMetadata($event->getPath());
|
||||
$event->setMetadata($metadata);
|
||||
} elseif ($event instanceof WriteMetadata) {
|
||||
$metadata = $event->getMetadata();
|
||||
if (null !== $metadata && $this->writeMetadata($metadata, $event->getPath())) {
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function readMetadata(string $path): Entity\Metadata
|
||||
{
|
||||
$id3 = new getID3();
|
||||
|
||||
$id3->option_md5_data = true;
|
||||
$id3->option_md5_data_source = true;
|
||||
$id3->encoding = 'UTF-8';
|
||||
|
||||
$info = $id3->analyze($path);
|
||||
$id3->CopyTagsToComments($info);
|
||||
|
||||
if (!empty($info['error'])) {
|
||||
throw new CannotProcessMediaException(
|
||||
sprintf(
|
||||
'Cannot process media file at path "%s": %s',
|
||||
pathinfo($path, PATHINFO_FILENAME),
|
||||
json_encode($info['error'], JSON_THROW_ON_ERROR)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$metadata = new Entity\Metadata();
|
||||
|
||||
if (is_numeric($info['playtime_seconds'])) {
|
||||
$metadata->setDuration(
|
||||
Utilities\Time::displayTimeToSeconds($info['playtime_seconds']) ?? 0.0
|
||||
);
|
||||
}
|
||||
|
||||
$metaTags = $metadata->getTags();
|
||||
|
||||
if (!empty($info['comments'])) {
|
||||
foreach ($info['comments'] as $tagName => $tagContents) {
|
||||
if (!empty($tagContents[0]) && !$metaTags->containsKey($tagName)) {
|
||||
$tagValue = $tagContents[0];
|
||||
if (is_array($tagValue)) {
|
||||
$flatValue = Utilities\Arrays::flattenArray($tagValue);
|
||||
$tagValue = implode(', ', $flatValue);
|
||||
}
|
||||
|
||||
$metaTags->set($tagName, $this->cleanUpString($tagValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($info['tags'])) {
|
||||
foreach ($info['tags'] as $tagData) {
|
||||
foreach ($tagData as $tagName => $tagContents) {
|
||||
if (!empty($tagContents[0]) && !$metaTags->containsKey($tagName)) {
|
||||
$tagValue = $tagContents[0];
|
||||
if (is_array($tagValue)) {
|
||||
$flatValue = Utilities\Arrays::flattenArray($tagValue);
|
||||
$tagValue = implode(', ', $flatValue);
|
||||
}
|
||||
|
||||
$metaTags->set($tagName, $this->cleanUpString($tagValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($info['attached_picture'][0])) {
|
||||
$metadata->setArtwork($info['attached_picture'][0]['data']);
|
||||
} elseif (!empty($info['comments']['picture'][0])) {
|
||||
$metadata->setArtwork($info['comments']['picture'][0]['data']);
|
||||
} elseif (!empty($info['id3v2']['APIC'][0]['data'])) {
|
||||
$metadata->setArtwork($info['id3v2']['APIC'][0]['data']);
|
||||
} elseif (!empty($info['id3v2']['PIC'][0]['data'])) {
|
||||
$metadata->setArtwork($info['id3v2']['PIC'][0]['data']);
|
||||
}
|
||||
|
||||
$metadata->setMimeType($info['mime_type']);
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
protected function cleanUpString(?string $original): string
|
||||
{
|
||||
$original ??= '';
|
||||
|
||||
$string = UTF8::encode('UTF-8', $original);
|
||||
$string = UTF8::fix_simple_utf8($string);
|
||||
return UTF8::clean(
|
||||
$string,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public function writeMetadata(Entity\Metadata $metadata, string $path): bool
|
||||
{
|
||||
$getID3 = new getID3();
|
||||
$getID3->setOption(['encoding' => 'UTF8']);
|
||||
|
||||
$tagwriter = new getid3_writetags();
|
||||
$tagwriter->filename = $path;
|
||||
|
||||
$pathExt = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
$tagFormats = match ($pathExt) {
|
||||
'mp3', 'mp2', 'mp1', 'riff' => ['id3v1', 'id3v2.3'],
|
||||
'mpc' => ['ape'],
|
||||
'flac' => ['metaflac'],
|
||||
'real' => ['real'],
|
||||
'ogg' => ['vorbiscomment'],
|
||||
default => null
|
||||
};
|
||||
|
||||
if (null === $tagFormats) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tagwriter->tagformats = $tagFormats;
|
||||
$tagwriter->overwrite_tags = true;
|
||||
$tagwriter->tag_encoding = 'UTF8';
|
||||
$tagwriter->remove_other_tags = true;
|
||||
|
||||
$tags = $metadata->getTags()->toArray();
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
if ($artwork) {
|
||||
$tags['attached_picture'] = [
|
||||
'encodingid' => 0, // ISO-8859-1; 3=UTF8 but only allowed in ID3v2.4
|
||||
'description' => 'cover art',
|
||||
'data' => $artwork,
|
||||
'picturetypeid' => 0x03,
|
||||
'mime' => 'image/jpeg',
|
||||
];
|
||||
}
|
||||
|
||||
$tagData = [];
|
||||
foreach ($tags as $tagKey => $tagValue) {
|
||||
$tagData[$tagKey] = [$tagValue];
|
||||
}
|
||||
|
||||
$tagwriter->tag_data = $tagData;
|
||||
|
||||
$tagwriter->WriteTags();
|
||||
|
||||
if (!empty($tagwriter->errors) || !empty($tagwriter->warnings)) {
|
||||
$messages = array_merge($tagwriter->errors, $tagwriter->warnings);
|
||||
throw CannotProcessMediaException::forPath(
|
||||
$path,
|
||||
implode(', ', $messages)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,9 @@ namespace App\Media;
|
|||
use App\Entity;
|
||||
use App\Event\Media\GetAlbumArt;
|
||||
use App\EventDispatcher;
|
||||
use App\Version;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Throwable;
|
||||
|
@ -21,7 +24,8 @@ class RemoteAlbumArt
|
|||
protected LoggerInterface $logger,
|
||||
protected CacheInterface $cache,
|
||||
protected Entity\Repository\SettingsRepository $settingsRepo,
|
||||
protected EventDispatcher $eventDispatcher
|
||||
protected EventDispatcher $eventDispatcher,
|
||||
protected Client $httpClient
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -35,7 +39,29 @@ class RemoteAlbumArt
|
|||
return $this->settingsRepo->readSettings()->getUseExternalAlbumArtWhenProcessingMedia();
|
||||
}
|
||||
|
||||
public function __invoke(Entity\Interfaces\SongInterface $song): ?string
|
||||
public function getArtwork(Entity\Interfaces\SongInterface $media): ?string
|
||||
{
|
||||
$artUri = $this->getUrlForSong($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 getUrlForSong(Entity\Interfaces\SongInterface $song): ?string
|
||||
{
|
||||
// Avoid tracks that shouldn't ever hit remote APIs.
|
||||
$offlineSong = Entity\Song::createOffline();
|
||||
|
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MediaProcessor\Command;
|
||||
|
||||
use App\Entity;
|
||||
use App\Utilities\Arrays;
|
||||
use App\Utilities\Time;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use voku\helper\UTF8;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
class ReadCommand
|
||||
{
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
OutputInterface $output,
|
||||
string $path,
|
||||
string $jsonOutput,
|
||||
?string $artOutput
|
||||
): int {
|
||||
if (!is_file($path)) {
|
||||
$io->error(sprintf('File not readable: %s', $path));
|
||||
return 1;
|
||||
}
|
||||
|
||||
$id3 = new \getID3();
|
||||
|
||||
$id3->option_md5_data = true;
|
||||
$id3->option_md5_data_source = true;
|
||||
$id3->encoding = 'UTF-8';
|
||||
|
||||
$info = $id3->analyze($path);
|
||||
$id3->CopyTagsToComments($info);
|
||||
|
||||
if (!empty($info['error'])) {
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Cannot process media at path %s: %s',
|
||||
pathinfo($path, PATHINFO_FILENAME),
|
||||
json_encode($info['error'], JSON_THROW_ON_ERROR)
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
$metadata = new Entity\Metadata();
|
||||
|
||||
if (is_numeric($info['playtime_seconds'])) {
|
||||
$metadata->setDuration(
|
||||
Time::displayTimeToSeconds($info['playtime_seconds']) ?? 0.0
|
||||
);
|
||||
}
|
||||
|
||||
$metaTags = [];
|
||||
|
||||
$toProcess = [
|
||||
$info['comments'] ?? null,
|
||||
$info['tags'] ?? null,
|
||||
];
|
||||
|
||||
foreach ($toProcess as $tagSet) {
|
||||
if (empty($tagSet)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($tagSet as $tagName => $tagContents) {
|
||||
if (!empty($tagContents[0]) && !isset($metaTags[$tagName])) {
|
||||
$tagValue = $tagContents[0];
|
||||
if (is_array($tagValue)) {
|
||||
// Skip pictures
|
||||
if (isset($tagValue['data'])) {
|
||||
continue;
|
||||
}
|
||||
$flatValue = Arrays::flattenArray($tagValue);
|
||||
$tagValue = implode(', ', $flatValue);
|
||||
}
|
||||
|
||||
$metaTags[$tagName] = $this->cleanUpString($tagValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$metadata->setTags($metaTags);
|
||||
$metadata->setMimeType($info['mime_type']);
|
||||
|
||||
file_put_contents(
|
||||
$jsonOutput,
|
||||
json_encode($metadata, JSON_THROW_ON_ERROR),
|
||||
);
|
||||
|
||||
if (null !== $artOutput) {
|
||||
$artwork = null;
|
||||
if (!empty($info['attached_picture'][0])) {
|
||||
$artwork = $info['attached_picture'][0]['data'];
|
||||
} elseif (!empty($info['comments']['picture'][0])) {
|
||||
$artwork = $info['comments']['picture'][0]['data'];
|
||||
} elseif (!empty($info['id3v2']['APIC'][0]['data'])) {
|
||||
$artwork = $info['id3v2']['APIC'][0]['data'];
|
||||
} elseif (!empty($info['id3v2']['PIC'][0]['data'])) {
|
||||
$artwork = $info['id3v2']['PIC'][0]['data'];
|
||||
}
|
||||
|
||||
if (!empty($artwork)) {
|
||||
file_put_contents(
|
||||
$artOutput,
|
||||
$artwork
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function cleanUpString(?string $original): string
|
||||
{
|
||||
$original ??= '';
|
||||
|
||||
$string = UTF8::encode('UTF-8', $original);
|
||||
$string = UTF8::fix_simple_utf8($string);
|
||||
return UTF8::clean(
|
||||
$string,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MediaProcessor\Command;
|
||||
|
||||
use App\Entity\Metadata;
|
||||
use App\Utilities\Json;
|
||||
use getID3;
|
||||
use getid3_writetags;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class WriteCommand
|
||||
{
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
OutputInterface $output,
|
||||
string $path,
|
||||
string $jsonInput,
|
||||
?string $artInput
|
||||
): int {
|
||||
$getID3 = new getID3();
|
||||
$getID3->setOption(['encoding' => 'UTF8']);
|
||||
|
||||
$tagwriter = new getid3_writetags();
|
||||
$tagwriter->filename = $path;
|
||||
|
||||
$pathExt = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
$tagFormats = match ($pathExt) {
|
||||
'mp3', 'mp2', 'mp1', 'riff' => ['id3v1', 'id3v2.3'],
|
||||
'mpc' => ['ape'],
|
||||
'flac' => ['metaflac'],
|
||||
'real' => ['real'],
|
||||
'ogg' => ['vorbiscomment'],
|
||||
default => null
|
||||
};
|
||||
|
||||
if (null === $tagFormats) {
|
||||
$io->error('Cannot write tag formats based on file type.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$tagwriter->tagformats = $tagFormats;
|
||||
$tagwriter->overwrite_tags = true;
|
||||
$tagwriter->tag_encoding = 'UTF8';
|
||||
$tagwriter->remove_other_tags = true;
|
||||
|
||||
$json = Json::loadFromFile($jsonInput);
|
||||
$writeTags = Metadata::fromJson($json)->getTags();
|
||||
|
||||
if ($artInput && is_file($artInput)) {
|
||||
$artContents = file_get_contents($artInput);
|
||||
if (false !== $artContents) {
|
||||
$writeTags['attached_picture'] = [
|
||||
'encodingid' => 0, // ISO-8859-1; 3=UTF8 but only allowed in ID3v2.4
|
||||
'description' => 'cover art',
|
||||
'data' => $artContents,
|
||||
'picturetypeid' => 0x03,
|
||||
'mime' => 'image/jpeg',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// All ID3 tags have to be written as ['key' => ['value']] (i.e. with "value" at position 0).
|
||||
$tagData = [];
|
||||
foreach ($writeTags as $tagKey => $tagValue) {
|
||||
$tagData[$tagKey] = [$tagValue];
|
||||
}
|
||||
|
||||
$tagwriter->tag_data = $tagData;
|
||||
$tagwriter->WriteTags();
|
||||
|
||||
if (!empty($tagwriter->errors) || !empty($tagwriter->warnings)) {
|
||||
$messages = array_merge($tagwriter->errors, $tagwriter->warnings);
|
||||
|
||||
$io->error(
|
||||
sprintf(
|
||||
'Cannot process media file %s: %s',
|
||||
$path,
|
||||
implode(', ', $messages)
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue