Update media processing to include cover art handling.

This commit is contained in:
Buster Neece 2022-10-29 11:57:45 -05:00
parent 468c4fe940
commit e388541594
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
21 changed files with 520 additions and 229 deletions

View File

@ -5,6 +5,11 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **Cover Art Files Support**: Many users keep the cover art for their media alongside the media in a separate image
file. AzuraCast now detects image files in the same folder as your media and uses it as the default album art for that
media. Because cover art files are often named a variety of things, we currently will use _any_ image file that exists
alongside media. You can also now view cover art via the Media Manager UI.
## Code Quality/Technical Changes
## Bug Fixes

View File

@ -6,8 +6,9 @@ use App\Sync\Task;
use Symfony\Component\Mailer;
return [
Message\AddNewMediaMessage::class => Task\CheckMediaTask::class,
Message\ReprocessMediaMessage::class => Task\CheckMediaTask::class,
Message\AddNewMediaMessage::class => App\Media\MediaProcessor::class,
Message\ReprocessMediaMessage::class => App\Media\MediaProcessor::class,
Message\ProcessCoverArtMessage::class => App\Media\MediaProcessor::class,
Message\WritePlaylistFileMessage::class => Liquidsoap\PlaylistFileWriter::class,

View File

@ -58,6 +58,9 @@
<span class="file-icon" v-if="row.item.is_dir">
<icon icon="folder"></icon>
</span>
<span class="file-icon" v-else-if="row.item.is_cover_art">
<icon icon="photo"></icon>
</span>
<span class="file-icon" v-else>
<icon icon="note"></icon>
</span>
@ -90,6 +93,8 @@
<album-art v-if="row.item.media_art" :src="row.item.media_art"
class="flex-shrink-1 pl-2"></album-art>
<album-art v-else-if="row.item.is_cover_art" :src="row.item.links_download"
class="flex-shrink-1 pl-2"></album-art>
</div>
</template>
<template #cell(media_genre)="row">

View File

@ -9,6 +9,7 @@ use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\OpenApi;
use Azura\Files\ExtendedFilesystemInterface;
use OpenApi\Attributes as OA;
use Psr\Http\Message\ResponseInterface;
@ -53,29 +54,59 @@ final class GetArtAction
): ResponseInterface {
$station = $request->getStation();
$fsMedia = (new StationFilesystems($station))->getMediaFilesystem();
$defaultArtRedirect = $response->withRedirect((string)$this->stationRepo->getDefaultAlbumArtUrl($station), 302);
if (str_contains($media_id, '-')) {
$response = $response->withCacheLifetime(Response::CACHE_ONE_YEAR);
}
// If a timestamp delimiter is added, strip it automatically.
$media_id = explode('-', $media_id, 2)[0];
$fsMedia = (new StationFilesystems($station))->getMediaFilesystem();
$mediaPath = $this->getMediaPath($station, $fsMedia, $media_id);
if (null !== $mediaPath) {
return $response->streamFilesystemFile(
$fsMedia,
$mediaPath,
null,
'inline',
false
);
}
return $response->withRedirect((string)$this->stationRepo->getDefaultAlbumArtUrl($station), 302);
}
private function getMediaPath(
Entity\Station $station,
ExtendedFilesystemInterface $fsMedia,
string $media_id
): ?string {
if (Entity\StationMedia::UNIQUE_ID_LENGTH === strlen($media_id)) {
$response = $response->withCacheLifetime(Response::CACHE_ONE_YEAR);
$mediaPath = Entity\StationMedia::getArtPath($media_id);
} else {
$media = $this->mediaRepo->findForStation($media_id, $station);
if ($media instanceof Entity\StationMedia) {
$mediaPath = Entity\StationMedia::getArtPath($media->getUniqueId());
} else {
return $defaultArtRedirect;
if ($fsMedia->fileExists($mediaPath)) {
return $mediaPath;
}
}
if ($fsMedia->fileExists($mediaPath)) {
return $response->streamFilesystemFile($fsMedia, $mediaPath, null, 'inline', false);
$media = $this->mediaRepo->findForStation($media_id, $station);
if (!($media instanceof Entity\StationMedia)) {
return null;
}
return $defaultArtRedirect;
$mediaPath = Entity\StationMedia::getArtPath($media->getUniqueId());
if ($fsMedia->fileExists($mediaPath)) {
return $mediaPath;
}
$folderPath = Entity\StationMedia::getFolderArtPath(
Entity\StationMedia::getFolderHashForPath($media->getPath())
);
if ($fsMedia->fileExists($folderPath)) {
return $folderPath;
}
return null;
}
}

View File

@ -388,6 +388,7 @@ final class BatchAction
if (!isset($queuedMediaUpdates[$mediaId])) {
$message = new Message\ReprocessMediaMessage();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->media_id = $mediaId;
$message->force = true;
@ -400,7 +401,7 @@ final class BatchAction
if (!isset($queuedNewFiles[$path])) {
$message = new Message\AddNewMediaMessage();
$message->storage_location_id = (int)$storageLocation->getId();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->path = $unprocessable->getPath();
$this->messageBus->dispatch($message);

View File

@ -9,6 +9,7 @@ use App\Exception\CannotProcessMediaException;
use App\Exception\StorageLocationFullException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Media\MediaProcessor;
use App\Service\Flow;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
@ -18,7 +19,7 @@ final class FlowUploadAction
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly Entity\Repository\StationMediaRepository $mediaRepo,
private readonly MediaProcessor $mediaProcessor,
private readonly Entity\Repository\StationPlaylistMediaRepository $spmRepo,
private readonly LoggerInterface $logger
) {
@ -54,7 +55,13 @@ final class FlowUploadAction
}
try {
$stationMedia = $this->mediaRepo->getOrCreate($station, $destPath, $flowResponse->getUploadedPath());
$tempPath = $flowResponse->getUploadedPath();
$stationMedia = $this->mediaProcessor->processAndUpload(
$mediaStorage,
$destPath,
$tempPath
);
} catch (CannotProcessMediaException $e) {
$this->logger->error(
$e->getMessageWithPath(),
@ -67,7 +74,7 @@ final class FlowUploadAction
}
// If the user is looking at a playlist's contents, add uploaded media to that playlist.
if (!empty($allParams['searchPhrase'])) {
if ($stationMedia instanceof Entity\StationMedia && !empty($allParams['searchPhrase'])) {
$search_phrase = $allParams['searchPhrase'];
if (str_starts_with($search_phrase, 'playlist:')) {

View File

@ -10,6 +10,7 @@ use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\RouterInterface;
use App\Http\ServerRequest;
use App\Media\MimeType;
use App\Paginator;
use App\Utilities;
use Doctrine\Common\Collections\Criteria;
@ -253,7 +254,11 @@ final class ListAction
$files = array_keys($mediaInDir);
}
} else {
$protectedPaths = [Entity\StationMedia::DIR_ALBUM_ART, Entity\StationMedia::DIR_WAVEFORMS];
$protectedPaths = [
Entity\StationMedia::DIR_ALBUM_ART,
Entity\StationMedia::DIR_WAVEFORMS,
Entity\StationMedia::DIR_FOLDER_COVERS,
];
$files = $fs->listContents($currentDir, false)->filter(
function (StorageAttributes $attributes) use ($currentDir, $protectedPaths) {
@ -304,6 +309,9 @@ final class ListAction
__('File Not Processed: %s'),
Utilities\Strings::truncateText($unprocessableMedia[$row->path])
);
} elseif (MimeType::isPathImage($row->path)) {
$row->is_cover_art = true;
$row->text = __('Cover Art');
} else {
$row->text = __('File Processing');
}
@ -337,11 +345,10 @@ final class ListAction
// Add processor-intensive data for just this page.
$stationId = $station->getIdRequired();
$isInternal = (bool)$request->getParam('internal', false);
$defaultAlbumArtUrl = (string)$this->stationRepo->getDefaultAlbumArtUrl($station);
$paginator->setPostprocessor(
static function (Entity\Api\FileList $row) use ($router, $stationId, $defaultAlbumArtUrl, $isInternal) {
return self::postProcessRow($row, $router, $stationId, $defaultAlbumArtUrl, $isInternal);
static function (Entity\Api\FileList $row) use ($router, $stationId, $isInternal) {
return self::postProcessRow($row, $router, $stationId, $isInternal);
}
);
@ -397,19 +404,21 @@ final class ListAction
Entity\Api\FileList $row,
RouterInterface $router,
int $stationId,
string $defaultAlbumArtUrl,
bool $isInternal
): Entity\Api\FileList|array {
if (null !== $row->media->media_id) {
$row->media->art = (0 === $row->media->art_updated_at)
? $defaultAlbumArtUrl
: (string)$router->named(
'api:stations:media:art',
[
'station_id' => $stationId,
'media_id' => $row->media->unique_id . '-' . $row->media->art_updated_at,
]
);
$artMediaId = $row->media->unique_id;
if (0 !== $row->media->art_updated_at) {
$artMediaId .= '-' . $row->media->art_updated_at;
}
$row->media->art = (string)$router->named(
'api:stations:media:art',
[
'station_id' => $stationId,
'media_id' => $artMediaId,
]
);
$row->media->links = [
'play' => (string)$router->named(

View File

@ -24,7 +24,11 @@ final class ListDirectoriesAction
$fsMedia = (new StationFilesystems($station))->getMediaFilesystem();
$protectedPaths = [Entity\StationMedia::DIR_ALBUM_ART, Entity\StationMedia::DIR_WAVEFORMS];
$protectedPaths = [
Entity\StationMedia::DIR_ALBUM_ART,
Entity\StationMedia::DIR_WAVEFORMS,
Entity\StationMedia::DIR_FOLDER_COVERS,
];
$directoriesRaw = $fsMedia->listContents($currentDir, false)->filter(
function (StorageAttributes $attrs) use ($protectedPaths) {

View File

@ -10,6 +10,7 @@ use App\Exception\ValidationException;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Media\MediaProcessor;
use App\Message\WritePlaylistFileMessage;
use App\OpenApi;
use App\Radio\Adapters;
@ -156,6 +157,7 @@ final class FilesController extends AbstractStationApiCrudController
private readonly Entity\Repository\CustomFieldRepository $customFieldsRepo,
private readonly Entity\Repository\StationMediaRepository $mediaRepo,
private readonly Entity\Repository\StationPlaylistMediaRepository $playlistMediaRepo,
private readonly MediaProcessor $mediaProcessor,
ReloadableEntityManagerInterface $em,
Serializer $serializer,
ValidatorInterface $validator
@ -206,13 +208,19 @@ final class FilesController extends AbstractStationApiCrudController
}
// Write file to temp path.
$temp_path = $station->getRadioTempDir() . '/' . $api_record->getSanitizedFilename();
file_put_contents($temp_path, $api_record->getFileContents());
$tempPath = $station->getRadioTempDir() . '/' . $api_record->getSanitizedFilename();
file_put_contents($tempPath, $api_record->getFileContents());
// Process temp path as regular media record.
$record = $this->mediaRepo->getOrCreate($station, $api_record->getSanitizedPath(), $temp_path);
$record = $this->mediaProcessor->processAndUpload(
$mediaStorage,
$api_record->getSanitizedPath(),
$tempPath
);
$return = $this->viewRecord($record, $request);
$return = (null !== $record)
? $this->viewRecord($record, $request)
: Entity\Api\Status::success();
return $response->withJson($return);
}

View File

@ -22,6 +22,8 @@ final class FileList
public bool $is_dir = false;
public bool $is_cover_art = false;
public FileListMedia $media;
public array $playlists = [];

View File

@ -63,15 +63,18 @@ final class SongApiGenerator
): UriInterface {
if (null !== $station && $song instanceof Entity\StationMedia) {
$mediaUpdatedTimestamp = $song->getArtUpdatedAt();
$mediaId = $song->getUniqueId();
if (0 !== $mediaUpdatedTimestamp) {
return $this->router->named(
route_name: 'api:stations:media:art',
route_params: [
'station_id' => $station->getId(),
'media_id' => $song->getUniqueId() . '-' . $mediaUpdatedTimestamp,
]
);
$mediaId .= '-' . $mediaUpdatedTimestamp;
}
return $this->router->named(
route_name: 'api:stations:media:art',
route_params: [
'station_id' => $station->getId(),
'media_id' => $mediaId,
]
);
}
if ($allowRemoteArt && $this->remoteAlbumArt->enableForApis()) {

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Fixture;
use App\Entity;
use App\Media\MediaProcessor;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@ -13,7 +14,7 @@ use Symfony\Component\Finder\Finder;
final class StationMedia extends AbstractFixture implements DependentFixtureInterface
{
public function __construct(
private readonly Entity\Repository\StationMediaRepository $mediaRepo
private readonly MediaProcessor $mediaProcessor
) {
}
@ -46,7 +47,11 @@ final class StationMedia extends AbstractFixture implements DependentFixtureInte
// Copy the file to the station media directory.
$fs->upload($filePath, '/' . $fileBaseName);
$mediaRow = $this->mediaRepo->getOrCreate($mediaStorage, $fileBaseName);
$mediaRow = $this->mediaProcessor->process($mediaStorage, $fileBaseName);
if (null === $mediaRow) {
continue;
}
$manager->persist($mediaRow);
// Add the file to the playlist.

View File

@ -7,7 +7,6 @@ namespace App\Entity\Repository;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Doctrine\Repository;
use App\Entity;
use App\Exception\CannotProcessMediaException;
use App\Exception\NotFoundException;
use App\Media\AlbumArt;
use App\Media\MetadataManager;
@ -33,8 +32,7 @@ final class StationMediaRepository extends Repository
private readonly MetadataManager $metadataManager,
private readonly RemoteAlbumArt $remoteAlbumArt,
private readonly CustomFieldRepository $customFieldRepo,
private readonly StationPlaylistMediaRepository $spmRepo,
private readonly UnprocessableMediaRepository $unprocessableMediaRepo
private readonly StationPlaylistMediaRepository $spmRepo
) {
parent::__construct($em);
}
@ -144,102 +142,6 @@ final class StationMediaRepository extends Repository
return $source;
}
/**
* @param Entity\Station|Entity\StorageLocation $source
* @param string $path
* @param string|null $uploadedFrom The original uploaded path (if this is a new upload).
*
* @throws Exception
*/
public function getOrCreate(
Entity\Station|Entity\StorageLocation $source,
string $path,
?string $uploadedFrom = null
): Entity\StationMedia {
$record = $this->findByPath($path, $source);
$storageLocation = $this->getStorageLocation($source);
$created = false;
if (!($record instanceof Entity\StationMedia)) {
$record = new Entity\StationMedia($storageLocation, $path);
$created = true;
}
try {
$reprocessed = $this->processMedia($record, $created, $uploadedFrom);
} catch (CannotProcessMediaException $e) {
$this->unprocessableMediaRepo->setForPath(
$storageLocation,
$path,
$e->getMessage()
);
throw $e;
}
if ($created || $reprocessed) {
$this->em->flush();
$this->unprocessableMediaRepo->clearForPath($storageLocation, $path);
}
return $record;
}
/**
* Run media through the "processing" steps: loading from file and setting up any missing metadata.
*
* @param Entity\StationMedia $media
* @param bool $force
* @param string|null $uploadedPath The uploaded path (if this is a new upload).
*
* @return bool Whether reprocessing was required for this file.
*/
public function processMedia(
Entity\StationMedia $media,
bool $force = false,
?string $uploadedPath = null
): bool {
$fs = $this->getFilesystem($media);
$path = $media->getPath();
if (null !== $uploadedPath) {
try {
$this->loadFromFile($media, $uploadedPath, $fs);
} finally {
$fs->uploadAndDeleteOriginal($uploadedPath, $path);
}
$mediaMtime = time();
} else {
if (!$fs->fileExists($path)) {
throw CannotProcessMediaException::forPath(
$path,
sprintf('Media path "%s" not found.', $path)
);
}
$mediaMtime = $fs->lastModified($path);
// No need to update if all of these conditions are true.
if (!$force && !$media->needsReprocessing($mediaMtime)) {
return false;
}
$fs->withLocalFile(
$path,
function ($localPath) use ($media, $fs): void {
$this->loadFromFile($media, $localPath, $fs);
}
);
}
$media->setMtime($mediaMtime);
$this->em->persist($media);
return true;
}
/**
* Process metadata information from media file.
*

View File

@ -36,6 +36,7 @@ class StationMedia implements
public const UNIQUE_ID_LENGTH = 24;
public const DIR_ALBUM_ART = '.albumart';
public const DIR_FOLDER_COVERS = '.covers';
public const DIR_WAVEFORMS = '.waveforms';
#[
@ -533,6 +534,19 @@ class StationMedia implements
return self::DIR_ALBUM_ART . '/' . $uniqueId . '.jpg';
}
public static function getFolderArtPath(string $folderHash): string
{
return self::DIR_FOLDER_COVERS . '/' . $folderHash . '.jpg';
}
public static function getFolderHashForPath(string $path): string
{
$folder = dirname($path);
return (!empty($folder))
? md5($folder)
: 'base';
}
public static function getWaveformPath(string $uniqueId): string
{
return self::DIR_WAVEFORMS . '/' . $uniqueId . '.json';

View File

@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Media;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity\Repository\StationMediaRepository;
use App\Entity\Repository\UnprocessableMediaRepository;
use App\Entity\StationMedia;
use App\Entity\StorageLocation;
use App\Exception\CannotProcessMediaException;
use App\Message\AddNewMediaMessage;
use App\Message\ProcessCoverArtMessage;
use App\Message\ReprocessMediaMessage;
use Symfony\Component\Filesystem\Filesystem;
final class MediaProcessor
{
public function __construct(
private readonly ReloadableEntityManagerInterface $em,
private readonly StationMediaRepository $mediaRepo,
private readonly UnprocessableMediaRepository $unprocessableMediaRepo
) {
}
public function __invoke(
ReprocessMediaMessage|AddNewMediaMessage|ProcessCoverArtMessage $message
): void {
$storageLocation = $this->em->find(StorageLocation::class, $message->storage_location_id);
if (!($storageLocation instanceof StorageLocation)) {
return;
}
if ($message instanceof ReprocessMediaMessage) {
$mediaRow = $this->em->find(StationMedia::class, $message->media_id);
if ($mediaRow instanceof StationMedia) {
$this->processMedia($storageLocation, $mediaRow, $message->force);
$this->em->flush();
}
} else {
$this->process($storageLocation, $message->path);
}
}
public function processAndUpload(
StorageLocation $storageLocation,
string $path,
string $localPath
): ?StationMedia {
$fs = $storageLocation->getFilesystem();
if (!(new Filesystem())->exists($localPath)) {
throw CannotProcessMediaException::forPath(
$path,
sprintf('Local file path "%s" not found.', $localPath)
);
}
try {
if (MimeType::isFileProcessable($localPath)) {
$record = $this->mediaRepo->findByPath($path, $storageLocation);
if (!($record instanceof StationMedia)) {
$record = new StationMedia($storageLocation, $path);
}
try {
$this->mediaRepo->loadFromFile($record, $localPath, $fs);
$record->setMtime(time());
$this->em->persist($record);
} catch (CannotProcessMediaException $e) {
$this->unprocessableMediaRepo->setForPath(
$storageLocation,
$path,
$e->getMessage()
);
throw $e;
}
$this->em->flush();
$this->unprocessableMediaRepo->clearForPath($storageLocation, $path);
return $record;
}
if (MimeType::isPathImage($localPath)) {
$this->processCoverArt(
$storageLocation,
$path,
file_get_contents($localPath) ?: ''
);
return null;
}
throw CannotProcessMediaException::forPath(
$path,
'File type cannot be processed.'
);
} finally {
$fs->uploadAndDeleteOriginal($localPath, $path);
}
}
public function process(
StorageLocation $storageLocation,
string $path,
bool $force = false
): ?StationMedia {
if (MimeType::isPathProcessable($path)) {
$record = $this->mediaRepo->findByPath($path, $storageLocation);
$created = false;
if (!($record instanceof StationMedia)) {
$record = new StationMedia($storageLocation, $path);
$created = true;
}
try {
$reprocessed = $this->processMedia($storageLocation, $record, $force);
} catch (CannotProcessMediaException $e) {
$this->unprocessableMediaRepo->setForPath(
$storageLocation,
$path,
$e->getMessage()
);
throw $e;
}
if ($created || $reprocessed) {
$this->em->flush();
$this->unprocessableMediaRepo->clearForPath($storageLocation, $path);
}
return $record;
}
if (MimeType::isPathImage($path)) {
$this->processCoverArt(
$storageLocation,
$path
);
return null;
}
throw CannotProcessMediaException::forPath(
$path,
'File type cannot be processed.'
);
}
public function processMedia(
StorageLocation $storageLocation,
StationMedia $media,
bool $force = false
): bool {
$fs = $storageLocation->getFilesystem();
$path = $media->getPath();
if (!$fs->fileExists($path)) {
throw CannotProcessMediaException::forPath(
$path,
sprintf('Media path "%s" not found.', $path)
);
}
$mediaMtime = $fs->lastModified($path);
// No need to update if all of these conditions are true.
if (!$force && !$media->needsReprocessing($mediaMtime)) {
return false;
}
$fs->withLocalFile(
$path,
function ($localPath) use ($media, $fs): void {
$this->mediaRepo->loadFromFile($media, $localPath, $fs);
}
);
$media->setMtime($mediaMtime);
$this->em->persist($media);
return true;
}
public function processCoverArt(
StorageLocation $storageLocation,
string $path,
?string $contents = null
): void {
$fs = $storageLocation->getFilesystem();
if (null === $contents) {
if (!$fs->fileExists($path)) {
throw CannotProcessMediaException::forPath(
$path,
sprintf('Cover art path "%s" not found.', $path)
);
}
$contents = $fs->read($path);
}
$folderHash = StationMedia::getFolderHashForPath($path);
$destPath = StationMedia::getFolderArtPath($folderHash);
$fs->write(
$destPath,
AlbumArt::resize($contents)
);
}
}

View File

@ -8,39 +8,58 @@ use League\MimeTypeDetection\FinfoMimeTypeDetector;
final class MimeType
{
private static FinfoMimeTypeDetector $detector;
private static array $processableTypes = [
'audio/aiff', // aiff (Audio Interchange File Format)
'audio/flac', // MIME type used by some FLAC files
'audio/mp4', // m4a mp4a
'audio/mpeg', // mpga mp2 mp2a mp3 m2a m3a
'audio/ogg', // oga ogg spx
'audio/s3m', // s3m (ScreamTracker 3 Module)
'audio/wav', // wav
'audio/xm', // xm
'audio/vnd.wave', // alt for wav (RFC 2361)
'audio/x-aac', // aac
'audio/x-aiff', // alt for aiff
'audio/x-flac', // flac
'audio/x-m4a', // alt for m4a/mp4a
'audio/x-mod', // stm, alt for xm
'audio/x-s3m', // alt for s3m
'audio/x-wav', // alt for wav
'audio/x-ms-wma', // wma (Windows Media Audio)
'video/mp4', // some MP4 audio files are recognized as this (#3569)
'video/x-ms-asf', // asf / wmv / alt for wma
];
private static array $imageTypes = [
'image/gif', // gif
'image/jpeg', // jpg/jpeg
'image/png', // png
];
/**
* @return string[]
*/
public static function getProcessableTypes(): array
{
return [
'audio/aiff', // aiff (Audio Interchange File Format)
'audio/flac', // MIME type used by some FLAC files
'audio/mp4', // m4a mp4a
'audio/mpeg', // mpga mp2 mp2a mp3 m2a m3a
'audio/ogg', // oga ogg spx
'audio/s3m', // s3m (ScreamTracker 3 Module)
'audio/wav', // wav
'audio/xm', // xm
'audio/vnd.wave', // alt for wav (RFC 2361)
'audio/x-aac', // aac
'audio/x-aiff', // alt for aiff
'audio/x-flac', // flac
'audio/x-m4a', // alt for m4a/mp4a
'audio/x-mod', // stm, alt for xm
'audio/x-s3m', // alt for s3m
'audio/x-wav', // alt for wav
'audio/x-ms-wma', // wma (Windows Media Audio)
'video/mp4', // some MP4 audio files are recognized as this (#3569)
'video/x-ms-asf', // asf / wmv / alt for wma
];
return self::$processableTypes;
}
public static function getMimeTypeDetector(): FinfoMimeTypeDetector
{
if (!isset(self::$detector)) {
self::$detector = new FinfoMimeTypeDetector(
extensionMap: new MimeTypeExtensionMap()
);
}
return self::$detector;
}
public static function getMimeTypeFromFile(string $path): string
{
$fileMimeType = (new FinfoMimeTypeDetector(
extensionMap: new MimeTypeExtensionMap()
))->detectMimeTypeFromFile($path);
$fileMimeType = self::getMimeTypeDetector()->detectMimeTypeFromFile($path);
if ('application/octet-stream' === $fileMimeType) {
$fileMimeType = null;
@ -51,24 +70,28 @@ final class MimeType
public static function getMimeTypeFromPath(string $path): string
{
$extensionMap = new MimeTypeExtensionMap();
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $extensionMap->lookupMimeType($extension) ?? 'application/octet-stream';
return self::getMimeTypeDetector()->detectMimeTypeFromPath($path)
?? 'application/octet-stream';
}
public static function isPathProcessable(string $path): bool
{
$mimeType = self::getMimeTypeFromPath($path);
return in_array($mimeType, self::getProcessableTypes(), true);
return in_array($mimeType, self::$processableTypes, true);
}
public static function isPathImage(string $path): bool
{
$mimeType = self::getMimeTypeFromPath($path);
return in_array($mimeType, self::$imageTypes, true);
}
public static function isFileProcessable(string $path): bool
{
$mimeType = self::getMimeTypeFromFile($path);
return in_array($mimeType, self::getProcessableTypes(), true);
return in_array($mimeType, self::$processableTypes, true);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Message;
use App\MessageQueue\QueueManagerInterface;
final class ProcessCoverArtMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the StorageLocation entity. */
public int $storage_location_id;
/** @var string The relative path for the cover file to be processed. */
public string $path;
/** @var string The hash of the folder (used for storing and indexing the cover art). */
public string $folder_hash;
public function getQueue(): string
{
return QueueManagerInterface::QUEUE_MEDIA;
}
}

View File

@ -8,6 +8,9 @@ use App\MessageQueue\QueueManagerInterface;
final class ReprocessMediaMessage extends AbstractUniqueMessage
{
/** @var int The numeric identifier for the StorageLocation entity. */
public int $storage_location_id;
/** @var int The numeric identifier for the StationMedia record being processed. */
public int $media_id;

View File

@ -7,10 +7,13 @@ namespace App\Sync\Task;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Media\MimeType;
use App\Message;
use App\Message\AddNewMediaMessage;
use App\Message\ProcessCoverArtMessage;
use App\Message\ReprocessMediaMessage;
use App\MessageQueue\QueueManagerInterface;
use App\Radio\Quota;
use Azura\Files\Attributes\FileAttributes;
use Azura\Files\ExtendedFilesystemInterface;
use Brick\Math\BigInteger;
use Doctrine\ORM\AbstractQuery;
use League\Flysystem\FilesystemException;
@ -42,29 +45,6 @@ final class CheckMediaTask extends AbstractTask
return true;
}
/**
* Handle event dispatch.
*
* @param Message\AbstractMessage $message
*/
public function __invoke(Message\AbstractMessage $message): void
{
if ($message instanceof Message\ReprocessMediaMessage) {
$mediaRow = $this->em->find(Entity\StationMedia::class, $message->media_id);
if ($mediaRow instanceof Entity\StationMedia) {
$this->mediaRepo->processMedia($mediaRow, $message->force);
$this->em->flush();
}
} elseif ($message instanceof Message\AddNewMediaMessage) {
$storageLocation = $this->em->find(Entity\StorageLocation::class, $message->storage_location_id);
if ($storageLocation instanceof Entity\StorageLocation) {
$this->mediaRepo->getOrCreate($storageLocation, $message->path);
}
}
}
public function run(bool $force = false): void
{
$storageLocations = $this->iterateStorageLocations(Entity\Enums\StorageLocationTypes::StationMedia);
@ -93,11 +73,10 @@ final class CheckMediaTask extends AbstractTask
'updated' => 0,
'created' => 0,
'deleted' => 0,
'cover_art' => 0,
'not_processable' => 0,
];
$musicFiles = [];
$total_size = BigInteger::zero();
try {
@ -105,7 +84,8 @@ final class CheckMediaTask extends AbstractTask
function (StorageAttributes $attrs) {
return ($attrs->isFile()
&& !str_starts_with($attrs->path(), Entity\StationMedia::DIR_ALBUM_ART)
&& !str_starts_with($attrs->path(), Entity\StationMedia::DIR_WAVEFORMS));
&& !str_starts_with($attrs->path(), Entity\StationMedia::DIR_WAVEFORMS)
&& !str_starts_with($attrs->path(), Entity\StationMedia::DIR_FOLDER_COVERS));
}
);
} catch (FilesystemException $e) {
@ -118,6 +98,9 @@ final class CheckMediaTask extends AbstractTask
return;
}
$musicFiles = [];
$coverFiles = [];
/** @var FileAttributes $file */
foreach ($fsIterator as $file) {
try {
@ -129,11 +112,23 @@ final class CheckMediaTask extends AbstractTask
continue;
}
$pathHash = md5($file->path());
$musicFiles[$pathHash] = [
StorageAttributes::ATTRIBUTE_PATH => $file->path(),
StorageAttributes::ATTRIBUTE_LAST_MODIFIED => $file->lastModified(),
];
if (MimeType::isPathProcessable($file->path())) {
$pathHash = md5($file->path());
$musicFiles[$pathHash] = [
StorageAttributes::ATTRIBUTE_PATH => $file->path(),
StorageAttributes::ATTRIBUTE_LAST_MODIFIED => $file->lastModified(),
];
} elseif (MimeType::isPathImage($file->path())) {
$stats['cover_art']++;
$dirHash = Entity\StationMedia::getFolderHashForPath($file->path());
$coverFiles[$dirHash] = [
StorageAttributes::ATTRIBUTE_PATH => $file->path(),
StorageAttributes::ATTRIBUTE_LAST_MODIFIED => $file->lastModified(),
];
} else {
$stats['not_processable']++;
}
}
$storageLocation->setStorageUsed($total_size);
@ -146,18 +141,27 @@ final class CheckMediaTask extends AbstractTask
// Check queue for existing pending processing entries.
$queuedMediaUpdates = [];
$queuedNewFiles = [];
$queuedCoverArt = [];
foreach ($this->queueManager->getMessagesInTransport(QueueManagerInterface::QUEUE_MEDIA) as $message) {
if ($message instanceof Message\ReprocessMediaMessage) {
if ($message instanceof ReprocessMediaMessage) {
$queuedMediaUpdates[$message->media_id] = true;
} elseif (
$message instanceof Message\AddNewMediaMessage
$message instanceof AddNewMediaMessage
&& $message->storage_location_id === $storageLocation->getId()
) {
$queuedNewFiles[md5($message->path)] = true;
} elseif (
$message instanceof ProcessCoverArtMessage
&& $message->storage_location_id === $storageLocation->getId()
) {
$queuedCoverArt[$message->folder_hash] = true;
}
}
// Process cover art.
$this->processCoverArt($storageLocation, $fs, $coverFiles, $queuedCoverArt);
// Check queue for existing pending processing entries.
$this->processExistingMediaRows($storageLocation, $queuedMediaUpdates, $musicFiles, $stats);
@ -173,6 +177,41 @@ final class CheckMediaTask extends AbstractTask
$this->logger->debug(sprintf('Media processed for "%s".', $storageLocation), $stats);
}
private function processCoverArt(
Entity\StorageLocation $storageLocation,
ExtendedFilesystemInterface $fs,
array $coverFiles,
array $queuedCoverArt,
): void {
$fsIterator = $fs->listContents(Entity\StationMedia::DIR_FOLDER_COVERS, true)->filter(
fn(StorageAttributes $attrs) => $attrs->isFile()
);
/** @var FileAttributes $file */
foreach ($fsIterator as $file) {
$basename = basename($file->path());
if (!isset($coverFiles[$basename])) {
$fs->delete($file->path());
} elseif ($file->lastModified() > $coverFiles[$basename][StorageAttributes::ATTRIBUTE_LAST_MODIFIED]) {
unset($coverFiles[$basename]);
}
}
foreach ($coverFiles as $folderHash => $coverFile) {
if (isset($queuedCoverArt[$folderHash])) {
continue;
}
$message = new ProcessCoverArtMessage();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->path = $coverFile[StorageAttributes::ATTRIBUTE_PATH];
$message->folder_hash = $folderHash;
$this->messageBus->dispatch($message);
}
}
private function processExistingMediaRows(
Entity\StorageLocation $storageLocation,
array $queuedMediaUpdates,
@ -206,7 +245,8 @@ final class CheckMediaTask extends AbstractTask
empty($mediaRow['unique_id'])
|| Entity\StationMedia::needsReprocessing($mtime, $mediaRow['mtime'] ?? 0)
) {
$message = new Message\ReprocessMediaMessage();
$message = new ReprocessMediaMessage();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->media_id = $mediaRow['id'];
$message->force = empty($mediaRow['unique_id']);
@ -253,7 +293,7 @@ final class CheckMediaTask extends AbstractTask
$mtime = $fileInfo[StorageAttributes::ATTRIBUTE_LAST_MODIFIED] ?? 0;
if (Entity\UnprocessableMedia::needsReprocessing($mtime, $unprocessableRow['mtime'] ?? 0)) {
$message = new Message\AddNewMediaMessage();
$message = new AddNewMediaMessage();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->path = $unprocessableRow['path'];
@ -282,22 +322,10 @@ final class CheckMediaTask extends AbstractTask
foreach ($musicFiles as $pathHash => $newMusicFile) {
$path = $newMusicFile[StorageAttributes::ATTRIBUTE_PATH];
if (!MimeType::isPathProcessable($path)) {
$mimeType = MimeType::getMimeTypeFromPath($path);
$this->unprocessableMediaRepo->setForPath(
$storageLocation,
$path,
sprintf('MIME type "%s" is not processable.', $mimeType)
);
$stats['not_processable']++;
}
if (isset($queuedNewFiles[$pathHash])) {
$stats['already_queued']++;
} else {
$message = new Message\AddNewMediaMessage();
$message = new AddNewMediaMessage();
$message->storage_location_id = $storageLocation->getIdRequired();
$message->path = $path;

View File

@ -102,6 +102,7 @@ final class CleanupStorageTask extends AbstractTask
foreach ($fs->listContents($dirBase, true) as $row) {
$path = $row->path();
$filename = pathinfo($path, PATHINFO_FILENAME);
if (!isset($allUniqueIds[$filename])) {
$fs->delete($path);

View File

@ -7,6 +7,7 @@ use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use App\Enums\GlobalPermissions;
use App\Environment;
use App\Media\MediaProcessor;
use App\Security\SplitToken;
use App\Tests\Module;
use Psr\Container\ContainerInterface;
@ -159,10 +160,10 @@ abstract class CestAbstract
$storageFs = $storageLocation->getFilesystem();
$storageFs->upload($songSrc, 'test.mp3');
/** @var Entity\Repository\StationMediaRepository $mediaRepo */
$mediaRepo = $this->di->get(Entity\Repository\StationMediaRepository::class);
/** @var MediaProcessor $mediaProcessor */
$mediaProcessor = $this->di->get(MediaProcessor::class);
return $mediaRepo->getOrCreate($storageLocation, 'test.mp3');
return $mediaProcessor->process($storageLocation, 'test.mp3');
}
protected function _cleanTables(): void