refactor(media): centralize image/video/document detection in Media\Type

Media-type detection was duplicated across Media, MediaItem (byte-identical copies), HasMedia::getMediaType, the ContentTypeCompatibleWithMedia rule, and six publishers — each hardcoding MIME prefixes and divergent extension lists (Media's video list even had avi/webm/mkv, contradicting the Type enum's mp4/mov).

Add classify(), fromExtension(), and isGif() to Media\Type as the single source for 'what kind is this?' (broad classification), distinct from fromMime()/allowedMimeTypes() (the strict upload allow-list). Every detector now delegates to the enum; the broad extension lists and MIME prefixes live only there. Behavior-preserving (full suite green); adds direct tests for classify/fromExtension/isGif.
This commit is contained in:
Paulo Castellano 2026-06-25 10:57:57 -03:00
parent 6a46bee469
commit 54fcaa7263
12 changed files with 131 additions and 88 deletions

View file

@ -5,6 +5,7 @@
namespace App\DataTransferObjects;
use App\Enums\Media\Source;
use App\Enums\Media\Type;
class MediaItem
{
@ -23,33 +24,17 @@ public function __construct(
public function isVideo(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'video/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']);
return Type::classify($this->mime_type, $this->path) === Type::Video;
}
public function isImage(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'image/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
return Type::classify($this->mime_type, $this->path) === Type::Image;
}
public function isDocument(): bool
{
if ($this->mime_type) {
return $this->mime_type === 'application/pdf';
}
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
return Type::classify($this->mime_type, $this->path) === Type::Document;
}
/**

View file

@ -93,4 +93,50 @@ public static function fromMime(string $mime): ?self
return null;
}
/**
* Classify media by what it *is* for "is this an image/video/PDF?" checks,
* as opposed to fromMime() which is the strict upload allow-list. Any
* `image/*`, `video/*`, or `application/pdf` MIME maps to its type; when the
* MIME is missing it falls back to the filename extension so already-stored
* files still resolve.
*/
public static function classify(?string $mimeType, ?string $path = null): ?self
{
if (filled($mimeType)) {
return match (true) {
str_starts_with($mimeType, 'image/') => self::Image,
str_starts_with($mimeType, 'video/') => self::Video,
$mimeType === 'application/pdf' => self::Document,
default => null,
};
}
return self::fromExtension($path ? pathinfo($path, PATHINFO_EXTENSION) : null);
}
/**
* Classify by filename extension. Broader than extensions() (the upload
* allow-list) so already-stored files in legacy formats still resolve.
*/
public static function fromExtension(?string $extension): ?self
{
$extension = strtolower((string) $extension);
return match (true) {
in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif'], true) => self::Image,
in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v'], true) => self::Video,
$extension === 'pdf' => self::Document,
default => null,
};
}
/**
* Whether the MIME is an animated GIF several publishers handle it
* specially (skipped from optimization, or posted as video).
*/
public static function isGif(?string $mimeType): bool
{
return $mimeType === 'image/gif';
}
}

View file

@ -61,33 +61,17 @@ protected function url(): Attribute
public function isVideo(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'video/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['mp4', 'mov', 'avi', 'wmv', 'webm', 'mkv', 'm4v']);
return MediaType::classify($this->mime_type, $this->path) === MediaType::Video;
}
public function isImage(): bool
{
if ($this->mime_type) {
return str_starts_with($this->mime_type, 'image/');
}
$extension = strtolower(pathinfo($this->path, PATHINFO_EXTENSION));
return in_array($extension, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic', 'heif']);
return MediaType::classify($this->mime_type, $this->path) === MediaType::Image;
}
public function isDocument(): bool
{
if ($this->mime_type) {
return $this->mime_type === 'application/pdf';
}
return strtolower(pathinfo($this->path, PATHINFO_EXTENSION)) === 'pdf';
return MediaType::classify($this->mime_type, $this->path) === MediaType::Document;
}
public function getTemporaryUrl(int $expirationMinutes = 60): string

View file

@ -4,6 +4,7 @@
namespace App\Models\Traits;
use App\Enums\Media\Type;
use App\Models\Media;
use App\Models\User;
use App\Models\Workspace;
@ -156,19 +157,8 @@ public function isSingleMediaCollection(string $collection): bool
private function getMediaType(string $mimeType): string
{
if (str_starts_with($mimeType, 'image/')) {
return 'image';
}
if (str_starts_with($mimeType, 'video/')) {
return 'video';
}
if ($mimeType === 'application/pdf') {
return 'document';
}
throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}");
return (Type::classify($mimeType)
?? throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}"))->value;
}
private function getMediaMeta(UploadedFile $file, string $type): array

View file

@ -173,11 +173,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
*/
private function isImage(array $item): bool
{
if (data_get($item, 'type') === MediaType::Image->value) {
return true;
}
return str_starts_with((string) data_get($item, 'mime_type', ''), 'image/');
return $this->isType($item, MediaType::Image);
}
/**
@ -185,11 +181,7 @@ private function isImage(array $item): bool
*/
private function isVideo(array $item): bool
{
if (data_get($item, 'type') === MediaType::Video->value) {
return true;
}
return str_starts_with((string) data_get($item, 'mime_type', ''), 'video/');
return $this->isType($item, MediaType::Video);
}
/**
@ -197,10 +189,18 @@ private function isVideo(array $item): bool
*/
private function isDocument(array $item): bool
{
if (data_get($item, 'type') === MediaType::Document->value) {
return true;
}
return $this->isType($item, MediaType::Document);
}
return data_get($item, 'mime_type') === 'application/pdf';
/**
* A media item matches a type when it carries that explicit `type`, or when
* its MIME classifies as that type.
*
* @param array<string, mixed> $item
*/
private function isType(array $item, MediaType $type): bool
{
return data_get($item, 'type') === $type->value
|| MediaType::classify(data_get($item, 'mime_type')) === $type;
}
}

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\TokenExpiredException;
@ -237,17 +238,11 @@ private function resolveDocumentTitle(PostPlatform $postPlatform): string
private function uploadMedia($mediaItem): ?string
{
$mimeType = $mediaItem->mime_type;
if (str_starts_with($mimeType, 'video/')) {
return $this->uploadVideo($mediaItem);
}
if (str_starts_with($mimeType, 'image/')) {
return $this->uploadImage($mediaItem);
}
return null;
return match (true) {
$mediaItem->isVideo() => $this->uploadVideo($mediaItem),
$mediaItem->isImage() => $this->uploadImage($mediaItem),
default => null,
};
}
private function uploadImage($mediaItem): ?string
@ -276,7 +271,7 @@ private function uploadImage($mediaItem): ?string
$this->downloadToTempFile($mediaItem->url, $tempFile);
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) {
$optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, $this->platform());
@unlink($tempFile);
$tempFile = $optimizedPath;

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\BlueskyPublishException;
use App\Models\PostPlatform;
@ -151,7 +152,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url
try {
// Optimize images for Bluesky's 1MB limit (GIFs are passed through untouched).
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
if (MediaType::classify($mimeType) === MediaType::Image && ! MediaType::isGif($mimeType)) {
$optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Bluesky);
@unlink($tempFile);
$tempFile = $optimizedPath;

View file

@ -5,6 +5,7 @@
namespace App\Services\Social\Discord;
use App\DataTransferObjects\MediaItem;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\DiscordPublishException;
use App\Exceptions\Social\ErrorCategory;
@ -156,7 +157,7 @@ private function downloadMedia(MediaItem $item): string
}
// Images are downsized to Discord's limit; videos/gifs are sent as-is.
if ($item->mime_type && str_starts_with($item->mime_type, 'image/') && ! str_contains($item->mime_type, 'gif')) {
if ($item->isImage() && ! MediaType::isGif($item->mime_type)) {
try {
return app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Discord);
} catch (Throwable) {

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\MastodonPublishException;
use App\Models\PostPlatform;
@ -86,7 +87,7 @@ private function uploadMedia(SocialAccount $account, string $instance, string $u
// Optimize images (skip GIFs)
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Mastodon);
@unlink($tempFile);

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
@ -85,7 +86,7 @@ private function publishImagePin(PostPlatform $postPlatform, ?string $content):
}
$detectedMime = mime_content_type($tempFile) ?: '';
if (str_starts_with($detectedMime, 'image/') && ! str_starts_with($detectedMime, 'image/gif')) {
if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Pinterest);
@unlink($tempFile);

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\XPublishException;
use App\Models\PostPlatform;
@ -117,7 +118,7 @@ private function uploadMedia($mediaItem): ?array
}
// Optimize images (skip GIFs — they need special handling)
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
if ($mediaItem->isImage() && ! MediaType::isGif($mimeType)) {
$optimizer = app(MediaOptimizer::class);
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X);
@unlink($tempFile);
@ -128,8 +129,8 @@ private function uploadMedia($mediaItem): ?array
$fileSize = filesize($tempFile);
$mediaCategory = $this->getMediaCategory($mimeType, $fileSize);
$isVideo = str_starts_with($mimeType, 'video/');
$isGif = $mimeType === 'image/gif';
$isVideo = MediaType::classify($mimeType) === MediaType::Video;
$isGif = MediaType::isGif($mimeType);
$useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024;
@ -256,7 +257,7 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
$finalizeData = $finalizeResponse->json();
// Wait for processing (videos need transcoding)
if (isset($finalizeData['processing_info']) || str_starts_with($mimeType, 'video/')) {
if (isset($finalizeData['processing_info']) || MediaType::classify($mimeType) === MediaType::Video) {
$this->waitForProcessing($mediaId);
}
@ -270,15 +271,15 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
private function getMediaCategory(string $mimeType, int $fileSize): ?string
{
if (str_starts_with($mimeType, 'video/')) {
if (MediaType::classify($mimeType) === MediaType::Video) {
return $fileSize > 15 * 1024 * 1024 ? 'amplify_video' : 'tweet_video';
}
if ($mimeType === 'image/gif') {
if (MediaType::isGif($mimeType)) {
return 'tweet_gif';
}
if (str_starts_with($mimeType, 'image/')) {
if (MediaType::classify($mimeType) === MediaType::Image) {
return 'tweet_image';
}

View file

@ -53,3 +53,41 @@
expect(Type::Document->maxSizeInMb())->toBe(100);
});
test('classify resolves the type from any matching mime, broadly', function () {
expect(Type::classify('image/jpeg'))->toBe(Type::Image);
expect(Type::classify('image/heic'))->toBe(Type::Image); // not in the upload allow-list, but still an image
expect(Type::classify('video/quicktime'))->toBe(Type::Video);
expect(Type::classify('video/x-msvideo'))->toBe(Type::Video); // legacy avi, still a video
expect(Type::classify('application/pdf'))->toBe(Type::Document);
expect(Type::classify('application/zip'))->toBeNull();
});
test('classify falls back to the file extension when the mime is missing', function () {
expect(Type::classify(null, 'photo.PNG'))->toBe(Type::Image);
expect(Type::classify(null, 'clip.mkv'))->toBe(Type::Video);
expect(Type::classify(null, 'deck.pdf'))->toBe(Type::Document);
expect(Type::classify(null, 'archive.zip'))->toBeNull();
expect(Type::classify(null, null))->toBeNull();
});
test('classify prefers the mime over the extension', function () {
// A mismatched extension never overrides a present, recognized mime.
expect(Type::classify('video/mp4', 'thing.png'))->toBe(Type::Video);
// A present but unrecognized mime resolves to null without consulting the extension.
expect(Type::classify('application/zip', 'clip.mp4'))->toBeNull();
});
test('fromExtension classifies broadly and is case-insensitive', function () {
expect(Type::fromExtension('JPG'))->toBe(Type::Image);
expect(Type::fromExtension('webm'))->toBe(Type::Video);
expect(Type::fromExtension('pdf'))->toBe(Type::Document);
expect(Type::fromExtension('txt'))->toBeNull();
expect(Type::fromExtension(null))->toBeNull();
});
test('isGif only matches the gif mime', function () {
expect(Type::isGif('image/gif'))->toBeTrue();
expect(Type::isGif('image/png'))->toBeFalse();
expect(Type::isGif(null))->toBeFalse();
});