Move ID3 read/write to its own standalone process.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-07-20 16:35:39 -05:00
parent 26fd358c40
commit d40b8b9b2b
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
12 changed files with 471 additions and 284 deletions

29
bin/metadata Normal file
View File

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

View File

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

View File

@ -92,7 +92,7 @@ class SongApiGenerator
}
$path = ($allowRemoteArt && $this->remoteAlbumArt->enableForApis())
? ($this->remoteAlbumArt)($song)
? $this->remoteAlbumArt->getUrlForSong($song)
: null;
if (null === $path) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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