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:
parent
6a46bee469
commit
54fcaa7263
12 changed files with 131 additions and 88 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue