diff --git a/app/DataTransferObjects/MediaItem.php b/app/DataTransferObjects/MediaItem.php index b85f2741..eae00948 100644 --- a/app/DataTransferObjects/MediaItem.php +++ b/app/DataTransferObjects/MediaItem.php @@ -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; } /** diff --git a/app/Enums/Media/Type.php b/app/Enums/Media/Type.php index 9eb95433..54074aca 100644 --- a/app/Enums/Media/Type.php +++ b/app/Enums/Media/Type.php @@ -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'; + } } diff --git a/app/Models/Media.php b/app/Models/Media.php index 8a379037..fced09bc 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -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 diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index 81316edc..f2e1752d 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -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 diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index 99e2ad12..98372859 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -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 $item + */ + private function isType(array $item, MediaType $type): bool + { + return data_get($item, 'type') === $type->value + || MediaType::classify(data_get($item, 'mime_type')) === $type; } } diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index fa595624..5d669379 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -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; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 95c63fd3..90a36c6f 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -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; diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php index aa35d5e3..d624473b 100644 --- a/app/Services/Social/Discord/DiscordPublisher.php +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -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) { diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 218d099d..05a6eba3 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -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); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 994199fa..8ad9fc18 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -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); diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 625a840b..ee925fde 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -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'; } diff --git a/tests/Unit/Enums/MediaTypeTest.php b/tests/Unit/Enums/MediaTypeTest.php index 2b448521..98ddc807 100644 --- a/tests/Unit/Enums/MediaTypeTest.php +++ b/tests/Unit/Enums/MediaTypeTest.php @@ -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(); +});