Move Metadata management back to main library; use FFProbe exclusively for metadata reading; harden error handling and resilience for misprocessed files.

This commit is contained in:
Buster "Silver Eagle" Neece 2022-05-12 12:40:35 -05:00
parent 6f1402c65f
commit 24ecda99ec
No known key found for this signature in database
GPG Key ID: 9FC8B9E008872109
12 changed files with 383 additions and 200 deletions

View File

@ -29,7 +29,6 @@
"azuracast/doctrine-batch-utilities": "dev-main",
"azuracast/doctrine-entity-normalizer": "dev-main",
"azuracast/flysystem-v2-extensions": "dev-main",
"azuracast/metadata-manager": "dev-main",
"azuracast/nowplaying": "dev-main",
"azuracast/slim-callable-eventdispatcher": "dev-main",
"bacon/bacon-qr-code": "^2.0",
@ -48,6 +47,7 @@
"guzzlehttp/oauth-subscriber": "^0.6.0",
"http-interop/http-factory-guzzle": "^1.0",
"intervention/image": "^2.6",
"james-heinrich/getid3": "v2.0.0-beta4",
"league/csv": "^9.6",
"league/flysystem-aws-s3-v3": "^3.0",
"league/flysystem-sftp-v3": "^3.0",
@ -65,6 +65,7 @@
"pagerfanta/doctrine-orm-adapter": "^3",
"php-di/php-di": "^6.0",
"php-di/slim-bridge": "^3.0",
"php-ffmpeg/php-ffmpeg": "^1.0",
"phpmyadmin/motranslator": "^5.3",
"phpseclib/phpseclib": "^3.0",
"psr/http-factory": ">1",
@ -95,6 +96,7 @@
"symfony/yaml": "^6",
"theiconic/php-ga-measurement-protocol": "^2.9",
"vlucas/phpdotenv": "^5.3",
"voku/portable-utf8": "^6.0",
"wikimedia/composer-merge-plugin": "dev-master",
"zircote/swagger-php": "^4.3.0"
},

74
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "ee39dba284c5c3f58875350e539c3826",
"content-hash": "da5cc239fd0238e0d8fc481e437dcfa0",
"packages": [
{
"name": "aws/aws-crt-php",
@ -341,77 +341,6 @@
],
"time": "2022-05-08T19:35:28+00:00"
},
{
"name": "azuracast/metadata-manager",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/AzuraCast/metadata-manager.git",
"reference": "b03d7f37f9d1e862a47ecc1f7bdc78a996ff9712"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/AzuraCast/metadata-manager/zipball/b03d7f37f9d1e862a47ecc1f7bdc78a996ff9712",
"reference": "b03d7f37f9d1e862a47ecc1f7bdc78a996ff9712",
"shasum": ""
},
"require": {
"ext-json": "*",
"james-heinrich/getid3": "v2.0.0-beta4",
"php": ">=7.4",
"php-ffmpeg/php-ffmpeg": "^1.0",
"symfony/console": ">5.0",
"voku/portable-utf8": "^6"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/phpstan": "^1",
"roave/security-advisories": "dev-latest"
},
"default-branch": true,
"bin": [
"bin/metadata-manager"
],
"type": "library",
"autoload": {
"psr-4": {
"Azura\\MetadataManager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Buster 'Silver Eagle' Neece",
"email": "buster@busterneece.com",
"homepage": "https://dashdev.net/",
"role": "Lead Developer"
}
],
"description": "A command-line wrapper around the PHP GetId3 library.",
"homepage": "https://github.com/AzuraCast/metadata-manager",
"support": {
"source": "https://github.com/AzuraCast/metadata-manager/tree/main"
},
"funding": [
{
"url": "https://github.com/AzuraCast",
"type": "github"
},
{
"url": "https://opencollective.com/azuracast",
"type": "open_collective"
},
{
"url": "https://www.patreon.com/AzuraCast",
"type": "patreon"
}
],
"time": "2022-04-28T11:59:24+00:00"
},
{
"name": "azuracast/nowplaying",
"version": "dev-main",
@ -14400,7 +14329,6 @@
"azuracast/doctrine-batch-utilities": 20,
"azuracast/doctrine-entity-normalizer": 20,
"azuracast/flysystem-v2-extensions": 20,
"azuracast/metadata-manager": 20,
"azuracast/nowplaying": 20,
"azuracast/slim-callable-eventdispatcher": 20,
"lstrojny/fxmlrpc": 20,

View File

@ -182,9 +182,17 @@ return function (CallableEventDispatcherInterface $dispatcher) {
-10
);
$dispatcher->addCallableListener(
Event\Media\ReadMetadata::class,
App\Media\Metadata\Reader::class
);
$dispatcher->addCallableListener(
Event\Media\WriteMetadata::class,
App\Media\Metadata\Writer::class
);
$dispatcher->addServiceSubscriber(
[
App\Media\MetadataManager::class,
App\Console\ErrorHandler::class,
App\Radio\AutoDJ\Queue::class,
App\Radio\AutoDJ\Annotations::class,

View File

@ -205,7 +205,10 @@ class StationMediaRepository extends Repository
$mediaMtime = time();
} else {
if (!$fs->fileExists($path)) {
throw new CannotProcessMediaException(sprintf('Media path "%s" not found.', $path));
throw CannotProcessMediaException::forPath(
$path,
sprintf('Media path "%s" not found.', $path)
);
}
$mediaMtime = $fs->lastModified($path);

View File

@ -8,10 +8,10 @@ use App\Entity\Interfaces\IdentifiableEntityInterface;
use App\Entity\Interfaces\PathAwareInterface;
use App\Entity\Interfaces\ProcessableMediaInterface;
use App\Entity\Interfaces\SongInterface;
use App\Media\Metadata;
use App\Media\MetadataInterface;
use App\OpenApi;
use App\Utilities\Time;
use Azura\MetadataManager\Metadata;
use Azura\MetadataManager\MetadataInterface;
use Azura\Normalizer\Attributes\DeepNormalize;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

View File

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Event\Media;
use Azura\MetadataManager\Metadata;
use Azura\MetadataManager\MetadataInterface;
use App\Media\Metadata;
use App\Media\MetadataInterface;
use Symfony\Contracts\EventDispatcher\Event;
class ReadMetadata extends Event

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Event\Media;
use Azura\MetadataManager\MetadataInterface;
use App\Media\MetadataInterface;
use Symfony\Contracts\EventDispatcher\Event;
class WriteMetadata extends Event

62
src/Media/Metadata.php Normal file
View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Media;
final class Metadata implements MetadataInterface
{
/** @var array<string, mixed> */
private array $tags = [];
private float $duration = 0.0;
private ?string $artwork = null;
private string $mimeType = '';
public function getTags(): array
{
return $this->tags;
}
public function setTags(array $tags): void
{
$this->tags = $tags;
}
public function addTag(string $key, $value): void
{
$this->tags[$key] = $value;
}
public function getDuration(): float
{
return $this->duration;
}
public function setDuration(float $duration): void
{
$this->duration = $duration;
}
public function getArtwork(): ?string
{
return $this->artwork;
}
public function setArtwork(?string $artwork): void
{
$this->artwork = $artwork;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): void
{
$this->mimeType = $mimeType;
}
}

View File

@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Media\Metadata;
use App\Event\Media\ReadMetadata;
use App\Media\Metadata;
use App\Media\MimeType;
use App\Utilities\Arrays;
use App\Utilities\File;
use App\Utilities\Time;
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use FFMpeg\FFProbe\DataMapping\Stream;
use voku\helper\UTF8;
class Reader
{
public function __invoke(ReadMetadata $event): void
{
$path = $event->getPath();
$ffprobe = FFProbe::create();
$format = $ffprobe->format($path);
$metadata = new Metadata();
if (is_numeric($format->get('duration'))) {
$metadata->setDuration(
Time::displayTimeToSeconds($format->get('duration')) ?? 0.0
);
}
$toProcess = [
$format->get('comments'),
$format->get('tags'),
];
$metaTags = $this->aggregateMetaTags($toProcess);
$metadata->setTags($metaTags);
$metadata->setMimeType(MimeType::getMimeTypeFromFile($path));
try {
// Pull album art directly from relevant streams.
$ffmpeg = FFMpeg::create();
/** @var Stream[] $videoStreams */
$videoStreams = $ffprobe->streams($path)->videos()->all();
foreach ($videoStreams as $videoStream) {
$codecName = $videoStream->get('codec_name');
if ($codecName !== 'mjpeg') {
continue;
}
$artOutput = File::generateTempPath('artwork.jpg');
@unlink($artOutput); // Ffmpeg won't overwrite the empty file.
$ffmpeg->getFFMpegDriver()->command([
'-i',
$path,
'-an',
'-vcodec',
'copy',
$artOutput,
]);
$metadata->setArtwork(file_get_contents($artOutput) ?: null);
@unlink($artOutput);
break;
}
} catch (\Throwable $e) {
$metadata->setArtwork(null);
}
$event->setMetadata($metadata);
}
protected function aggregateMetaTags(array $toProcess): array
{
$metaTags = [];
foreach ($toProcess as $tagSet) {
if (empty($tagSet)) {
continue;
}
foreach ($tagSet as $tagName => $tagContents) {
if (!empty($tagContents) && !isset($metaTags[$tagName])) {
$tagValue = $tagContents;
if (is_array($tagValue)) {
// Skip pictures
if (isset($tagValue['data'])) {
continue;
}
$flatValue = Arrays::flattenArray($tagValue);
$tagValue = implode(', ', $flatValue);
}
$metaTags[(string)$tagName] = $this->cleanUpString((string)$tagValue);
}
}
}
return $metaTags;
}
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
);
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Media\Metadata;
use App\Event\Media\WriteMetadata;
use JamesHeinrich\GetID3\GetID3;
use JamesHeinrich\GetID3\WriteTags;
class Writer
{
public function __invoke(WriteMetadata $event): void
{
$path = $event->getPath();
$metadata = $event->getMetadata();
if (null === $metadata) {
return;
}
$getID3 = new GetID3();
$getID3->setOption(['encoding' => 'UTF8']);
$tagwriter = new WriteTags();
$tagwriter->filename = $path;
$pathExt = strtolower(pathinfo($path, PATHINFO_EXTENSION));
$tagFormats = null;
switch ($pathExt) {
case 'mp3':
case 'mp2':
case 'mp1':
case 'riff':
$tagFormats = ['id3v1', 'id3v2.3'];
break;
case 'mpc':
$tagFormats = ['ape'];
break;
case 'flac':
$tagFormats = ['metaflac'];
break;
case 'real':
$tagFormats = ['real'];
break;
case 'ogg':
$tagFormats = ['vorbiscomment'];
break;
}
if (null === $tagFormats) {
throw new \RuntimeException('Cannot write tag formats based on file type.');
}
$tagwriter->tagformats = $tagFormats;
$tagwriter->overwrite_tags = true;
$tagwriter->tag_encoding = 'UTF8';
$tagwriter->remove_other_tags = true;
$writeTags = $metadata->getTags();
if ($metadata->getArtwork()) {
$artContents = $metadata->getArtwork();
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);
throw new \RuntimeException(
sprintf(
'Cannot process media file %s: %s',
$path,
implode(', ', $messages)
)
);
}
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Media;
interface MetadataInterface
{
/**
* @return array<string, mixed>
*/
public function getTags(): array;
/**
* @param array<string, mixed> $tags
*/
public function setTags(array $tags): void;
/**
* @param string $key
* @param mixed $value
*/
public function addTag(string $key, $value): void;
/**
* @return float
*/
public function getDuration(): float;
/**
* @param float $duration
*/
public function setDuration(float $duration): void;
/**
* @return string|null
*/
public function getArtwork(): ?string;
public function setArtwork(?string $artwork): void;
public function getMimeType(): string;
public function setMimeType(string $mimeType): void;
}

View File

@ -4,42 +4,20 @@ declare(strict_types=1);
namespace App\Media;
use App\Environment;
use App\Event\Media\ReadMetadata;
use App\Event\Media\WriteMetadata;
use App\Exception\CannotProcessMediaException;
use App\Utilities\File;
use App\Utilities\Json;
use Azura\MetadataManager\Metadata;
use Azura\MetadataManager\MetadataInterface;
use GuzzleHttp\Client;
use Psr\EventDispatcher\EventDispatcherInterface;
use RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Psr\Log\LoggerInterface;
class MetadataManager implements EventSubscriberInterface
class MetadataManager
{
public function __construct(
protected EventDispatcherInterface $eventDispatcher,
protected Client $httpClient,
protected Environment $environment
protected LoggerInterface $logger,
) {
}
public static function getSubscribedEvents()
{
return [
ReadMetadata::class => [
['readFromId3', 0],
],
WriteMetadata::class => [
['writeToId3', 0],
],
];
}
public function read(string $filePath): MetadataInterface
{
if (!MimeType::isFileProcessable($filePath)) {
@ -50,113 +28,45 @@ class MetadataManager implements EventSubscriberInterface
);
}
$event = new ReadMetadata($filePath);
$this->eventDispatcher->dispatch($event);
return $event->getMetadata();
}
public function readFromId3(ReadMetadata $event): void
{
$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.');
}
$event = new ReadMetadata($filePath);
$this->eventDispatcher->dispatch($event);
$scriptPath = $this->environment->getBaseDirectory() . '/vendor/bin/metadata-manager';
$process = new Process(
return $event->getMetadata();
} catch (\Throwable $e) {
$this->logger->error(
sprintf(
'Cannot read metadata for file "%s": %s',
$filePath,
$e->getMessage()
),
[
$phpBinaryPath,
$scriptPath,
'read',
$sourceFilePath,
$jsonOutput,
$artOutput,
'path' => $filePath,
'exception' => $e,
]
);
$process->mustRun();
$metadataJson = Json::loadFromFile($jsonOutput);
$metadata = Metadata::fromJson($metadataJson);
if (is_file($artOutput)) {
$artwork = file_get_contents($artOutput) ?: null;
$metadata->setArtwork($artwork);
}
$event->setMetadata($metadata);
} finally {
@unlink($jsonOutput);
@unlink($artOutput);
return new Metadata();
}
}
public function write(MetadataInterface $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)
$event = new WriteMetadata($metadata, $filePath);
$this->eventDispatcher->dispatch($event);
} catch (\Throwable $e) {
$this->logger->error(
sprintf(
'Cannot write metadata for file "%s": %s',
$filePath,
$e->getMessage()
),
[
'path' => $filePath,
'exception' => $e,
]
);
$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() . '/vendor/bin/metadata-manager';
$processCommand = [
$phpBinaryPath,
$scriptPath,
'write',
$destFilePath,
$jsonInput,
];
if (null !== $artwork) {
$processCommand[] = $artInput;
}
$process = new Process($processCommand);
$process->run();
} finally {
@unlink($jsonInput);
@unlink($artInput);
}
}
}