From 2257736ad97ca9fa7e634295747569673578f488 Mon Sep 17 00:00:00 2001 From: James Collins Date: Thu, 31 Aug 2023 23:21:20 +1000 Subject: [PATCH] bug fixes --- app/Conductors/MediaConductor.php | 6 +- app/Conductors/MediaJobConductor.php | 66 +---- app/Http/Controllers/Api/MediaController.php | 234 ++++++++++-------- .../Controllers/Api/MediaJobController.php | 23 +- app/Http/Requests/MediaRequest.php | 14 +- app/Jobs/MediaWorkerJob.php | 182 +++++++------- app/Models/Media.php | 120 ++++----- app/Models/MediaJob.php | 186 ++++++++------ ...3_08_29_211400_create_media_jobs_table.php | 6 +- resources/js/components/SMSelectFile.vue | 88 +++++-- resources/js/helpers/api.ts | 104 +++++++- resources/js/helpers/api.types.ts | 13 + resources/js/helpers/media.ts | 79 +++--- resources/js/views/dashboard/MediaEdit.vue | 122 +++++++-- routes/api.php | 2 + 15 files changed, 741 insertions(+), 504 deletions(-) diff --git a/app/Conductors/MediaConductor.php b/app/Conductors/MediaConductor.php index 590b590..b5dd98b 100644 --- a/app/Conductors/MediaConductor.php +++ b/app/Conductors/MediaConductor.php @@ -33,9 +33,9 @@ class MediaConductor extends Conductor * * @var array */ - protected $defaultFilters = [ - 'status' => 'OK' - ]; + // protected $defaultFilters = [ + // 'status' => 'OK' + // ]; /** diff --git a/app/Conductors/MediaJobConductor.php b/app/Conductors/MediaJobConductor.php index e71eb7d..9c50c76 100644 --- a/app/Conductors/MediaJobConductor.php +++ b/app/Conductors/MediaJobConductor.php @@ -29,60 +29,6 @@ class MediaJobConductor extends Conductor protected $includes = ['user']; - /** - * Return an array of model fields visible to the current user. - * - * @param Model $model The model in question. - * @return array The array of field names. - */ - public function fields(Model $model): array - { - $fields = parent::fields($model); - - /** @var \App\Models\User */ - $user = auth()->user(); - if ($user === null || $user->hasPermission('admin/media') === false) { - $fields = arrayRemoveItem($fields, ['permission', 'storage']); - } - - return $fields; - } - - /** - * Run a scope query on the collection before anything else. - * - * @param Builder $builder The builder in use. - * @return void - */ - public function scope(Builder $builder): void - { - $user = auth()->user(); - if ($user === null) { - $builder->where('permission', ''); - } else { - $builder->where('permission', '')->orWhereIn('permission', $user->permissions); - } - } - - /** - * Return if the current model is visible. - * - * @param Model $model The model. - * @return boolean Allow model to be visible. - */ - public static function viewable(Model $model): bool - { - if ($model->permission !== '') { - /** @var \App\Models\User */ - $user = auth()->user(); - if ($user === null || $user->hasPermission($model->permission) === false) { - return false; - } - } - - return true; - } - /** * Return if the current model is creatable. * @@ -90,8 +36,7 @@ class MediaJobConductor extends Conductor */ public static function creatable(): bool { - $user = auth()->user(); - return ($user !== null); + return false; } /** @@ -102,10 +47,7 @@ class MediaJobConductor extends Conductor */ public static function updatable(Model $model): bool { - /** @var \App\Models\User */ - $user = auth()->user(); - return ($user !== null && (strcasecmp($model->user_id, $user->id) === 0 || - $user->hasPermission('admin/media') === true)); + return false; } /** @@ -116,9 +58,7 @@ class MediaJobConductor extends Conductor */ public static function destroyable(Model $model): bool { - /** @var \App\Models\User */ - $user = auth()->user(); - return ($user !== null && ($model->user_id === $user->id || $user->hasPermission('admin/media') === true)); + return false; } /** diff --git a/app/Http/Controllers/Api/MediaController.php b/app/Http/Controllers/Api/MediaController.php index ff2e7de..4319a41 100644 --- a/app/Http/Controllers/Api/MediaController.php +++ b/app/Http/Controllers/Api/MediaController.php @@ -82,88 +82,7 @@ class MediaController extends ApiController return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']); } - // validate file object - if ($file->isValid() !== true) { - switch ($file->getError()) { - case UPLOAD_ERR_INI_SIZE: - case UPLOAD_ERR_FORM_SIZE: - return $this->respondTooLarge(); - case UPLOAD_ERR_PARTIAL: - return $this->respondWithErrors([$file => 'The file upload was interrupted.']); - default: - return $this->respondWithErrors([$file => 'An error occurred uploading the file to the server.']); - } - } - - if ($file->getSize() > Media::getMaxUploadSize()) { - return $this->respondTooLarge(); - } - - // create/get media job - $mediaJob = null; - $filename = ''; - $data = []; - - if($request->missing('job_id') === true) { - /** @var \App\Models\User */ - $user = auth()->user(); - - $mediaJob = new MediaJob(); - $mediaJob->user_id = $user->id; - - $data['title'] = $request->get('title', ''); - $data['name'] = $request->has('chunk') === true ? $request->get('name', '') : $file->getClientOriginalName(); - $data['size'] = $request->has('chunk') === true ? 0 : $file->getSize(); - $data['mime_type'] = $request->has('chunk') === true ? '' : $file->getMimeType(); - $data['storage'] = $request->get('storage', ''); - - if($request->has('transform') === true) { - $data['transform'] = $request->get('transform'); - } - - $filename = $request->get('name', ''); - $mediaJob->setStatusWaiting(); - } else { - $mediaJob = MediaJob::find($request->get('job_id')); - if($mediaJob === null || $mediaJob->exists() === false) { - $this->respondNotFound(); - } - - $data = json_decode($mediaJob->data); - if($data === null) { - Log::error(`{$mediaJob->id} contains no data`); - return $this->respondServerError(); - } - - if(array_key_exists('name', $data) === false) { - Log::error(`{$mediaJob->id} data does not contain the name key`); - return $this->respondServerError(); - } - } - - if($mediaJob === null || $filename === '') { - Log::error(`media job or filename does not exist`); - return $this->respondServerError(); - } - - // save uploaded file - $temporaryFilePath = generateTempFilePath(pathinfo($filename, PATHINFO_EXTENSION), $request->get('chunk', '')); - copy($file->path(), $temporaryFilePath); - - if($request->has('chunk') === true) { - $data['chunks'][$request->get('chunk', '1')] = $temporaryFilePath; - } else { - $data['file'] = $temporaryFilePath; - } - - $mediaJob->data = json_encode($data, true); - $mediaJob->save(); - $mediaJob->process(); - - return $this->respondAsResource( - MediaJobConductor::model($request, $mediaJob), - ['respondCode' => HttpResponseCodes::HTTP_ACCEPTED] - ); + return $this->storeOrUpdate($request, null); } /** @@ -175,45 +94,150 @@ class MediaController extends ApiController */ public function update(MediaRequest $request, Media $medium) { + // allowed to update a media item if (MediaConductor::updatable($medium) === false) { return $this->respondForbidden(); } + return $this->storeOrUpdate($request, $medium); + } + + /** + * Store a new media resource + * + * @param \App\Http\Requests\MediaRequest $request The uploaded media. + * @param \App\Models\Media|null $medium The specified media. + * @return \Illuminate\Http\Response + */ + public function storeOrUpdate(MediaRequest $request, Media|null $medium) + { $file = $request->file('file'); if ($file !== null) { - $jsonResult = $this->validateFileItem($file); - if ($jsonResult !== null) { - return $jsonResult; + // validate file object + if ($file->isValid() !== true) { + switch ($file->getError()) { + case UPLOAD_ERR_INI_SIZE: + case UPLOAD_ERR_FORM_SIZE: + return $this->respondTooLarge(); + case UPLOAD_ERR_PARTIAL: + return $this->respondWithErrors([$file => 'The file upload was interrupted.']); + default: + return $this->respondWithErrors([$file => 'An error occurred uploading the file to the server.']); + } + } + + if ($file->getSize() > Media::getMaxUploadSize()) { + return $this->respondTooLarge(); } } - $medium->status('Updating Media'); - $medium->update($request->except(['file','transform'])); + // create/get media job + $mediaJob = null; + $data = []; - $transformData = []; + if ($request->missing('job_id') === true) { + /** @var \App\Models\User */ + $user = auth()->user(); + + $mediaJob = new MediaJob(); + $mediaJob->user_id = $user->id; + if ($medium !== null) { + $mediaJob->media_id = $medium->id; + } + + if ($request->has('title') === true || $file !== null) { + $data['title'] = $request->get('title', ''); + } + + if ($request->has('name') === true || $file !== null) { + $data['name'] = $request->has('chunk') === true ? $request->get('name', '') : $file->getClientOriginalName(); + } + + if ($file !== null) { + $data['size'] = $request->has('chunk') === true ? 0 : $file->getSize(); + $data['mime_type'] = $request->has('chunk') === true ? '' : $file->getMimeType(); + } + + if ($request->has('storage') === true || $file !== null) { + $data['storage'] = $request->get('storage', ''); + } + + if ($request->has('transform') === true) { + $transform = []; + + foreach ($request->get('transform') as $key => $value) { + if (is_string($value) === true) { + if (preg_match('/^rotate-(-?\d+)$/', $value, $matches) !== false) { + unset($transform[$key]); + $transform['rotate'] = $matches[1]; + } elseif (preg_match('/^flip-([vh]|vh|hv)$/', $value, $matches) !== false) { + unset($transform[$key]); + $transform['flip'] = $matches[1]; + } elseif (preg_match('/^crop-(\d+)-(\d+)$/', $value, $matches) !== false) { + unset($transform[$key]); + $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2]]; + } elseif (preg_match('/^crop-(\d+)-(\d+)-(\d+)-(\d+)$/', $value, $matches) !== false) { + unset($transform[$key]); + $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2], 'x' => $matches[3], 'y' => $matches[4]]; + } + } + } + + if (count($transform) > 0) { + $data['transform'] = $transform; + } + }//end if + + $mediaJob->setStatusWaiting(); + } else { + $mediaJob = MediaJob::find($request->get('job_id')); + if ($mediaJob === null || $mediaJob->exists() === false) { + $this->respondNotFound(); + } + + $data = json_decode($mediaJob->data, true); + if ($data === null) { + Log::error(`{$mediaJob->id} contains no data`); + return $this->respondServerError(); + } + + if (array_key_exists('name', $data) === false) { + Log::error(`{$mediaJob->id} data does not contain the name key`); + return $this->respondServerError(); + } + }//end if + + if ($mediaJob === null) { + Log::error(`media job does not exist`); + return $this->respondServerError(); + } + + // save uploaded file if ($file !== null) { - $temporaryFilePath = generateTempFilePath(); + if ($data['name'] === '') { + Log::error(`filename does not exist`); + return $this->respondServerError(); + } + + $temporaryFilePath = generateTempFilePath(pathinfo($data['name'], PATHINFO_EXTENSION), $request->get('chunk', '')); copy($file->path(), $temporaryFilePath); - $transformData = array_merge($transformData, ['file' => [ - 'path' => $temporaryFilePath, - 'size' => $file->getSize(), - 'mime_type' => $file->getMimeType(), - ] - ]); + if ($request->has('chunk') === true) { + $data['chunks'][$request->get('chunk', '1')] = $temporaryFilePath; + $data['chunk_count'] = $request->get('chunk_count', 1); + } else { + $data['file'] = $temporaryFilePath; + } } - if ($request->has('transform') === true) { - $transformData = array_merge($transformData, array_map('trim', explode(',', $request->get('transform')))); - } + $mediaJob->data = json_encode($data, true); + $mediaJob->save(); + $mediaJob->process(); - if (count($transformData) > 0) { - $medium->transform($transformData); - } else { - $medium->ok(); - } - - return $this->respondAsResource(MediaConductor::model($request, $medium)); + return $this->respondAsResource( + MediaJobConductor::model($request, $mediaJob), + ['resourceName' => 'media_job', 'respondCode' => HttpResponseCodes::HTTP_ACCEPTED] + ); } /** diff --git a/app/Http/Controllers/Api/MediaJobController.php b/app/Http/Controllers/Api/MediaJobController.php index 8768422..96767f0 100644 --- a/app/Http/Controllers/Api/MediaJobController.php +++ b/app/Http/Controllers/Api/MediaJobController.php @@ -1,10 +1,27 @@ respondAsResource(MediaJobConductor::model($request, $mediaJob), ['resourceName' => 'media_job']); + } + + return $this->respondForbidden(); + } } diff --git a/app/Http/Requests/MediaRequest.php b/app/Http/Requests/MediaRequest.php index a63bf2c..9c5ca7d 100644 --- a/app/Http/Requests/MediaRequest.php +++ b/app/Http/Requests/MediaRequest.php @@ -13,9 +13,6 @@ class MediaRequest extends BaseRequest Rule::requiredIf(function () { return request()->has('chunk') && request('chunk') != 1; }), - Rule::forbiddenUnless(function () { - return request()->has('chunk') && request('chunk') != 1; - }), 'string', ], 'name' => [ @@ -24,16 +21,7 @@ class MediaRequest extends BaseRequest }), 'string', ], - 'chunk' => [ - 'required_with:chunk_count', - 'integer', - 'min:1', - 'max:99', - Rule::passes(function ($attribute, $value) { - $chunkCount = request('chunk_count'); - return $value <= $chunkCount; - })->withMessage('The chunk must be less than or equal to chunk_count.'), - ], + 'chunk' => 'required_with:chunk_count|integer|min:1|max:99|lte:chunk_count', 'chunk_count' => 'required_with:chunk|integer|min:1', ]; } diff --git a/app/Jobs/MediaWorkerJob.php b/app/Jobs/MediaWorkerJob.php index 62409b3..7cf4623 100644 --- a/app/Jobs/MediaWorkerJob.php +++ b/app/Jobs/MediaWorkerJob.php @@ -18,6 +18,7 @@ use FFMpeg\FFProbe; use FFMpeg\Format\VideoInterface; use Intervention\Image\Facades\Image; +/** @property on $format */ class MediaWorkerJob implements ShouldQueue { use Dispatchable; @@ -32,25 +33,16 @@ class MediaWorkerJob implements ShouldQueue */ protected $mediaJob; - /** - * Actions should be silent (not update the status field) - * - * @var boolean - */ - protected $silent; /** * Create a new job instance. * - * @param MediaJob $mediaJob The mediaJob model. - * @param array $actions The media actions to make. - * @param boolean $silent Update the media status with progress. + * @param MediaJob $mediaJob The mediaJob model. * @return void */ - public function __construct(MediaJob $mediaJob, bool $silent = false) + public function __construct(MediaJob $mediaJob) { - $mediaJob = $mediaJob; - $this->silent = $silent; + $this->mediaJob = $mediaJob; } /** @@ -60,29 +52,21 @@ class MediaWorkerJob implements ShouldQueue */ public function handle(): void { - $media = null; + $media = $this->mediaJob->media()->first(); + $newMedia = false; $data = json_decode($this->mediaJob->data, true); - // new Media(); - // $this->mediaJob->media_id = $media->id; - - try { // FILE if (array_key_exists('file', $data) === true) { - if(file_exists($data['file']) === false) { - $errorStr = 'temporary upload file no longer exists'; - $this->mediaJob->setStatusFailed($errorStr); - Log::info($errorStr); - throw new \Exception($errorStr); + if (file_exists($data['file']) === false) { + $this->throwMediaJobFailure('temporary upload file no longer exists'); } // convert HEIC files to JPG $fileExtension = File::extension($data['file']); if ($fileExtension === 'heic') { - if ($this->silent === false) { - $this->mediaJob->setStatusProcessing(0, 'converting image'); - } + $this->mediaJob->setStatusProcessing(0, 'converting image'); // Get the path without the file name $uploadedFileDirectory = dirname($data['file']); @@ -91,22 +75,26 @@ class MediaWorkerJob implements ShouldQueue $jpgFileName = pathinfo($data['file'], PATHINFO_FILENAME) . '.jpg'; $jpgFilePath = $uploadedFileDirectory . '/' . $jpgFileName; if (file_exists($jpgFilePath) === true) { - $errorStr = 'file already exists on server'; - $this->mediaJob->setStatusFailed($errorStr); - Log::info($errorStr); - throw new \Exception($errorStr); + $this->throwMediaJobFailure('file already exists on server'); } Image::make($data['file'])->save($jpgFilePath); // Update the uploaded file path and file name unlink($data['file']); - $filePath = $jpgFilePath; $data['file'] = $jpgFileName; }//end if // get storage - $storage = $data['storage']; + $storage = ''; + if ($media === null) { + if (array_key_exists('storage', $data) === true) { + $storage = $data['storage']; + } + } else { + $storage = $media->storage; + } + if ($storage === '') { if (strpos($data['mime_type'], 'image/') === 0) { $storage = 'local'; @@ -118,46 +106,31 @@ class MediaWorkerJob implements ShouldQueue // Check if file already exists if (Storage::disk($storage)->exists($data['name']) === true) { if (array_key_exists('replace', $data) === false || isTrue($data['replace']) === false) { - $this->mediaJob->setStatusFailed('file already exists on server'); - $errorStr = "cannot upload file " . $storage . " " . // phpcs:ignore - "/ " . $data['name'] . " as it already exists"; - Log::info($errorStr); - throw new \Exception($errorStr); + $this->throwMediaJobFailure('file already exists on server'); } } - $media = new Media([ - 'user_id' => $this->mediaJob->user_id, - 'title' => $data['title'], - 'name' => $data['name'], - 'mime_type' => $data['mime_type'], - 'size' => $data['size'], - 'storage' => $storage - ]); + if ($media === null) { + $newMedia = true; + $media = new Media([ + 'user_id' => $this->mediaJob->user_id, + 'title' => $data['title'], + 'name' => $data['name'], + 'mime_type' => $data['mime_type'], + 'size' => $data['size'], + 'storage' => $storage + ]); + }//end if - $media->setStagingFile($filePath); + $media->setStagingFile($data['file']); } else { - $media = $this->mediaJob->media()->first(); - if($media === null || $media->exists() === false) { - $errorStr = 'The media item no longer exists'; - $this->mediaJob->setStatusFailed($errorStr); - Log::info($errorStr); - throw new \Exception($errorStr); + if ($media === null) { + $this->throwMediaJobFailure('The media item no longer exists'); } - } + }//end if - // TODO: - // mime_type may not be in data if we are just doing a transform... - // if fails, need to delete the staging file - // do not delete the file straight away incase we fail the transform - // delete the media object if we fail and it is a new media object - // UPDATE IN CONTROLLER NEEDS TO BE FIXED - // STATUS field can be removed in Media object - // Front end needs to support non status field and media jobs - - if(array_key_exists('transform', $data) === true) { + if (array_key_exists('transform', $data) === true) { $media->createStagingFile(); - $media->deleteFile(); // Modifications if (strpos($media->mime_type, 'image/') === 0) { @@ -168,9 +141,7 @@ class MediaWorkerJob implements ShouldQueue if (array_key_exists("rotate", $data['transform']) === true) { $rotate = intval($data['transform']['rotate']); if ($rotate !== 0) { - if ($this->silent === false) { - $this->mediaJob->setStatusProcessing(0, 'rotating image'); - } + $this->mediaJob->setStatusProcessing(0, 'rotating image'); $image = $image->rotate($rotate); $modified = true; } @@ -179,17 +150,13 @@ class MediaWorkerJob implements ShouldQueue // FLIP-H/V if (array_key_exists('flip', $data['transform']) === true) { if (stripos($data['transform']['flip'], 'h') !== false) { - if ($this->silent === false) { - $this->mediaJob->setStatusProcessing(0, 'flipping image'); - } + $this->mediaJob->setStatusProcessing(0, 'flipping image'); $image = $image->flip('h'); $modified = true; } if (stripos($data['transform']['flip'], 'v') !== false) { - if ($this->silent === false) { - $this->mediaJob->setStatusProcessing(0, 'flipping image'); - } + $this->mediaJob->setStatusProcessing(0, 'flipping image'); $image = $image->flip('v'); $modified = true; } @@ -203,9 +170,7 @@ class MediaWorkerJob implements ShouldQueue $x = intval(arrayDefaultValue("x", $cropData, 0)); $y = intval(arrayDefaultValue("y", $cropData, 0)); - if ($this->silent === false) { - $this->mediaJob->setStatusProcessing(0, 'cropping image'); - } + $this->mediaJob->setStatusProcessing(0, 'cropping image'); $image = $image->crop($width, $height, $x, $y); $modified = true; }//end if @@ -253,17 +218,13 @@ class MediaWorkerJob implements ShouldQueue // FLIP-H/V if (array_key_exists('flip', $data['transform']) === true) { if (stripos($data['transform']['flip'], 'h') !== false) { - if ($this->silent === false) { - $media->status('Flipping video'); - } + $this->mediaJob->setStatusProcessing(0, 'flipping video'); $filters->hflip()->synchronize(); $modified = true; } if (stripos($data['transform']['flip'], 'v') !== false) { - if ($this->silent === false) { - $media->status('Flipping video'); - } + $this->mediaJob->setStatusProcessing(0, 'flipping video'); $filters->vflip()->synchronize(); $modified = true; } @@ -281,52 +242,67 @@ class MediaWorkerJob implements ShouldQueue $cropDimension = new Dimension($width, $height); - if ($this->silent === false) { - $media->status('Cropping video'); - } + $this->mediaJob->setStatusProcessing(0, 'cropping video'); $filters->crop($cropDimension, $x, $y)->synchronize(); $modified = true; }//end if $tempFilePath = generateTempFilePath(pathinfo($stagingFilePath, PATHINFO_EXTENSION)); if (method_exists($format, 'on') === true) { - $media = $media; - $format->on('progress', function ($video, $format, $percentage) use ($media) { - $media->status("{$percentage}% transcoded"); + $mediaJob = $this->mediaJob; + $format->on('progress', function ($video, $format, $percentage) use ($mediaJob) { + $mediaJob->setStatusProcessing($percentage, 'transcoded'); }); } - if($modified === true) { + if ($modified === true) { $video->save($format, $tempFilePath); $media->changeStagingFile($tempFilePath); } }//end if // Move file - if (array_key_exists("move", $data['transform']) === true) { - if (array_key_exists("storage", $data['transform']['move']) === true) { - $newStorage = $data['transform']['move"]["storage']; + if (array_key_exists('move', $data['transform']) === true) { + if (array_key_exists('storage', $data['transform']['move']) === true) { + $newStorage = $data['transform']['move']['storage']; if ($media->storage !== $newStorage) { if (Storage::has($newStorage) === true) { + $media->createStagingFile(); $media->storage = $newStorage; } else { - $media->error("Cannot move file to '{$newStorage}' as it does not exist"); + $this->throwMediaJobFailure("Cannot move file to '{$newStorage}' as it does not exist"); } } } } + }//end if + + // Update attributes + if (array_key_exists('title', $data) === true) { + $media->title = $data['title']; } // Finish media object - $media->saveStagingFile(true); + if ($media->hasStagingFile() === true) { + $this->mediaJob->setStatusProcessing(-1, 'uploading to cdn'); + $media->deleteFile(); + $media->saveStagingFile(true); + } + $media->save(); + $this->mediaJob->media_id = $media->id; $this->mediaJob->setStatusComplete(); } catch (\Exception $e) { - $media->deleteStagingFile(); + if ($this->mediaJob->status !== 'failed') { + $this->mediaJob->setStatusFailed('Unexpected server error occurred'); + } - // if (strpos($media->status, 'Error') !== 0) { - // $media->error('Failed to process the file'); - // } + if ($media !== null) { + $media->deleteStagingFile(); + if ($newMedia === true) { + $media->delete(); + } + } Log::error($e->getMessage() . "\n" . $e->getFile() . " - " . $e->getLine() . "\n" . $e->getTraceAsString()); $this->fail($e); @@ -373,4 +349,16 @@ class MediaWorkerJob implements ShouldQueue return new $formatClassName(); } + + /** + * Set failure status of MediaJob and throw exception. + * + * @param string $error The failure message. + * @return void + */ + private function throwMediaJobFailure(string $error): void + { + $this->mediaJob->setStatusFailed($error); + throw new \Exception($error); + } } diff --git a/app/Models/Media.php b/app/Models/Media.php index 46081a1..a462d02 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -3,35 +3,24 @@ namespace App\Models; use App\Enum\HttpResponseCodes; -use App\Jobs\MediaJob; +use App\Jobs\MediaWorkerJob; use App\Jobs\MoveMediaJob; -use App\Jobs\StoreUploadedFileJob; use App\Traits\Uuids; -use Exception; use FFMpeg\Coordinate\TimeCode; use FFMpeg\FFMpeg; use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\InvalidCastException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; -use Intervention\Image\Exception\NotSupportedException; -use Intervention\Image\Exception\NotWritableException; -use ImagickException; use Intervention\Image\Facades\Image; -use InvalidArgumentException; -use Psr\Container\NotFoundExceptionInterface; -use Psr\Container\ContainerExceptionInterface; use SplFileInfo; -use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\StreamedResponse; class Media extends Model @@ -59,7 +48,6 @@ class Media extends Model 'description', 'name', 'size', - 'status', ]; /** @@ -367,7 +355,7 @@ class Media extends Model public function moveToStorage(string $storage): void { if ($storage !== $this->storage && Config::has("filesystems.disks.$storage") === true) { - $this->status = "Processing media"; + // $this->status = "Processing media"; MoveMediaJob::dispatch($this, $storage)->onQueue('media'); $this->save(); } @@ -376,36 +364,26 @@ class Media extends Model /** * Transform the media through the Media Job Queue * - * @param array $transform The transform data. - * @param boolean $silent Update the medium progress through its status field. - * @return void + * @param array $transform The transform data. + * @return MediaJob */ - public function transform(array $transform, bool $silent = false): void + public function transform(array $transform): MediaJob { - foreach ($transform as $key => $value) { - if (is_string($value) === true) { - if (preg_match('/^rotate-(-?\d+)$/', $value, $matches) !== false) { - unset($transform[$key]); - $transform['rotate'] = $matches[1]; - } elseif (preg_match('/^flip-([vh]|vh|hv)$/', $value, $matches) !== false) { - unset($transform[$key]); - $transform['flip'] = $matches[1]; - } elseif (preg_match('/^crop-(\d+)-(\d+)$/', $value, $matches) !== false) { - unset($transform[$key]); - $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2]]; - } elseif (preg_match('/^crop-(\d+)-(\d+)-(\d+)-(\d+)$/', $value, $matches) !== false) { - unset($transform[$key]); - $transform['crop'] = ['width' => $matches[1], 'height' => $matches[2], 'x' => $matches[3], 'y' => $matches[4]]; - } - } - } + $mediaJob = new MediaJob([ + 'media_id' => $this->media, + 'user_id' => auth()->user()?->id, + 'data' => json_encode(['transform' => $transform]), + ]); try { - MediaJob::dispatch($this, $transform, $silent)->onQueue('media'); + MediaWorkerJob::dispatch($mediaJob)->onQueue('media'); + return $mediaJob; } catch (\Exception $e) { $this->error('Failed to transform media'); throw $e; }//end try + + return null; } /** @@ -498,8 +476,8 @@ class Media extends Model if ( static::fileNameHasSuffix($fileName) === true || - static::fileExistsInStorage("$fileName.$extension") === true || - Media::where('name', "$fileName.$extension")->where('status', 'not like', 'failed%')->exists() === true + static::fileExistsInStorage("$fileName.$extension") === true //|| + // Media::where('name', "$fileName.$extension")->where('status', 'not like', 'failed%')->exists() === true ) { $fileName .= '-'; for ($i = 1; $i < $maxTries; $i++) { @@ -507,7 +485,7 @@ class Media extends Model if ( static::fileExistsInStorage("$fileNameIndex.$extension") !== true && Media::where('name', "$fileNameIndex.$extension") - ->where('status', 'not like', 'Failed%') + // ->where('status', 'not like', 'Failed%') ->exists() !== true ) { return "$fileNameIndex.$extension"; @@ -721,32 +699,34 @@ class Media extends Model */ public function saveStagingFile(bool $delete = true, bool $silent = false): void { - if (strlen($this->storage) > 0 && strlen($this->name) > 0) { - if (Storage::disk($this->storage)->exists($this->name) === true) { - Storage::disk($this->storage)->delete($this->name); + if ($this->stagingFilePath !== '') { + if (strlen($this->storage) > 0 && strlen($this->name) > 0) { + if (Storage::disk($this->storage)->exists($this->name) === true) { + Storage::disk($this->storage)->delete($this->name); + } + + /** @var Illuminate\Filesystem\FilesystemAdapter */ + $fileSystem = Storage::disk($this->storage); + // if ($silent === false) { + // $this->status('Uploading to CDN'); + // } + $fileSystem->putFileAs('/', $this->stagingFilePath, $this->name); } - /** @var Illuminate\Filesystem\FilesystemAdapter */ - $fileSystem = Storage::disk($this->storage); - if ($silent === false) { - $this->status('Uploading to CDN'); + // if ($silent === false) { + // $this->status('Generating Thumbnail'); + // } + $this->generateThumbnail(); + + // if ($silent === false) { + // $this->status('Generating Variants'); + // } + $this->generateVariants(); + + if ($delete === true) { + $this->deleteStagingFile(); } - $fileSystem->putFileAs('/', $this->stagingFilePath, $this->name); - } - - if ($silent === false) { - $this->status('Generating Thumbnail'); - } - $this->generateThumbnail(); - - if ($silent === false) { - $this->status('Generating Variants'); - } - $this->generateVariants(); - - if ($delete === true) { - $this->deleteStagingFile(); - } + }//end if } /** @@ -777,6 +757,16 @@ class Media extends Model $this->stagingFilePath = $newFile; } + /** + * Is a staging file present + * + * @return boolean + */ + public function hasStagingFile(): bool + { + return $this->stagingFilePath !== ""; + } + /** * Generate a Thumbnail for this media. * @@ -1019,7 +1009,7 @@ class Media extends Model */ public function ok(): void { - $this->status = "OK"; + // $this->status = "OK"; $this->save(); } @@ -1030,7 +1020,7 @@ class Media extends Model */ public function error(string $error = ""): void { - $this->status = "Error" . ($error !== "" ? ": {$error}" : ""); + // $this->status = "Error" . ($error !== "" ? ": {$error}" : ""); $this->save(); } @@ -1041,7 +1031,7 @@ class Media extends Model */ public function status(string $status = ""): void { - $this->status = "Info: " . $status; + // $this->status = "Info: " . $status; $this->save(); } } diff --git a/app/Models/MediaJob.php b/app/Models/MediaJob.php index 0b30f75..9513782 100644 --- a/app/Models/MediaJob.php +++ b/app/Models/MediaJob.php @@ -13,73 +13,147 @@ class MediaJob extends Model use HasFactory; use Uuids; - public function setStatusFailed(string $statusText = ''): void { + + /** + * The default attributes. + * + * @var string[] + */ + protected $attributes = [ + 'user_id' => null, + 'media_id' => null, + 'status' => '', + 'status_text' => '', + 'progress' => 0, + 'data' => '', + ]; + + + /** + * Set MediaJob status to failed. + * + * @param string $statusText The failed reason. + * @return void + */ + public function setStatusFailed(string $statusText = ''): void + { $this->setStatus('failed', $statusText, 0); } - public function setStatusQueued(): void { + /** + * Set MediaJob status to queued. + * + * @return void + */ + public function setStatusQueued(): void + { $this->setStatus('queued', '', 0); } - public function setStatusWaiting(): void { + /** + * Set MediaJob status to waiting. + * + * @return void + */ + public function setStatusWaiting(): void + { $this->setStatus('waiting', '', 0); } - public function setStatusProcessing(int $progress = 0, string $statusText = ''): void { - if($statusText === '') { + /** + * Set MediaJob status to processing. + * + * @param integer $progress The processing percentage. + * @param string $statusText The processing status text. + * @return void + */ + public function setStatusProcessing(int $progress = 0, string $statusText = ''): void + { + if ($statusText === '') { $statusText = $this->status_text; } $this->setStatus('processing', $statusText, $progress); } - public function setStatusComplete(): void { + /** + * Set MediaJob status to complete. + * + * @return void + */ + public function setStatusComplete(): void + { $this->setStatus('complete'); } - public function setStatusInvalid(): void { - $this->setStatus('invalid'); + /** + * Set MediaJon status to invalid. + * + * @param string $text The status text. + * @return void + */ + public function setStatusInvalid(string $text = ''): void + { + $this->setStatus('invalid', $text); } - public function setStatus(string $status, string $text = '', int $progress = 0): void { + /** + * Set MediaJob status details. + * + * @param string $status The status string. + * @param string $text The status text. + * @param integer $progress The status percentage. + * @return void + */ + protected function setStatus(string $status, string $text = '', int $progress = 0): void + { $this->status = $status; $this->status_text = $text; $this->progress = $progress; $this->save(); } - + /** + * Process the MediaJob. + * + * @return void + */ public function process(): void { $data = json_decode($this->data, true); - if($data !== null) { - if(array_key_exists('chunks', $data) === true) { - if(array_key_exists('chunk_count', $data) === false || array_key_exists('name', $data) === false) { - $this->setStatusInvalid(); + if ($data !== null) { + if (array_key_exists('chunks', $data) === true) { + if (array_key_exists('chunk_count', $data) === false) { + $this->setStatusInvalid('chunk_count is missing'); + return; + } + + if (array_key_exists('name', $data) === false) { + $this->setStatusInvalid('name is missing'); return; } $numChunks = count($data['chunks']); $maxChunks = intval($data['chunk_count']); - if($numChunks >= $maxChunks) { + if ($numChunks >= $maxChunks) { // merge file and dispatch $percentage = 0; - $percentageStep = 100 / $maxChunks; + $percentageStep = (100 / $maxChunks); $this->setStatusProcessing($percentage, 'combining chunks'); $newFile = generateTempFilePath(pathinfo($data['name'], PATHINFO_EXTENSION)); $failed = false; - - for($index = 1; $index <= $maxChunks; $index++) { - if(array_key_exists($index, $data['chunks']) === false) { - $failed = true; + + for ($index = 1; $index <= $maxChunks; $index++) { + if (array_key_exists($index, $data['chunks']) === false) { + $failed = `{$index} chunk is missing`; } else { $tempFileName = $data['chunks'][$index]; - if($failed === false) { + if ($failed === false) { $chunkContents = file_get_contents($tempFileName); - if($chunkContents === false) { - $failed = true; + if ($chunkContents === false) { + $failed = `{$index} chunk is empty`; } else { file_put_contents($newFile, $chunkContents, FILE_APPEND); } @@ -93,67 +167,29 @@ class MediaJob extends Model unset($data['chunks']); $this->data = json_encode($data); - if($failed === false) { - $this->setStatusInvalid(); + if ($failed !== false) { + $this->setStatusInvalid($failed); } else { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $newFile); finfo_close($finfo); - - $data['file']['path'] = $newFile; - $data['file']['size'] = filesize($newFile); - $data['file']['mime_type'] = $mime; - + + $data['file'] = $newFile; + $data['size'] = filesize($newFile); + $data['mime_type'] = $mime; + + $this->data = json_encode($data); $this->setStatusQueued(); - MediaWorkerJob::dispatch($this); + MediaWorkerJob::dispatch($this)->onQueue('media'); } - } - } else if(array_key_exists('file', $data) || array_key_exists('transform', $data)) { + }//end if + } else { $this->setStatusQueued(); - MediaWorkerJob::dispatch($this); - } - } + MediaWorkerJob::dispatch($this)->onQueue('media'); + }//end if + }//end if } - // public const INVALID_FILE_ERROR = 1; - // public const FILE_SIZE_EXCEEDED_ERROR = 2; - // public const FILE_NAME_EXISTS_ERROR = 3; - // public const TEMP_FILE_ERROR = 4; - - // /** - // * Set the Media Job to failed - // * - // * @var string $msg The status message to save. - // * @return void - // */ - // public function failed(string $msg = ''): void - // { - // $data = []; - - // try { - // $data = json_decode($this->data, true); - // } catch(\Exception $e) { - // /* empty */ - // } - - // if(array_key_exists('chunks', $data) === true) { - // foreach($data['chunks'] as $num => $path) { - // if(file_exists($path) === true) { - // unlink($path); - // } - // } - - // unset($data['chunks']); - // $this->data = json_encode($data); - // } - - // $this->status = 'failed'; - // $this->status_text = $msg; - // $this->progress = 0; - // $this->save(); - // } - - /** * Return the job owner * diff --git a/database/migrations/2023_08_29_211400_create_media_jobs_table.php b/database/migrations/2023_08_29_211400_create_media_jobs_table.php index 24b9f77..e51e30d 100644 --- a/database/migrations/2023_08_29_211400_create_media_jobs_table.php +++ b/database/migrations/2023_08_29_211400_create_media_jobs_table.php @@ -12,10 +12,10 @@ return new class extends Migration public function up(): void { Schema::create('media_jobs', function (Blueprint $table) { - $table->uuid()->primary(); + $table->uuid('id')->primary(); $table->timestamps(); - $table->uuid('user_id'); - $table->uuid('media_id'); // Add a foreign key for the media model + $table->uuid('user_id')->nullable(); + $table->uuid('media_id')->nullable(); // Add a foreign key for the media model $table->string('status'); $table->string('status_text'); $table->text('data'); diff --git a/resources/js/components/SMSelectFile.vue b/resources/js/components/SMSelectFile.vue index 37819f9..d9c9eed 100644 --- a/resources/js/components/SMSelectFile.vue +++ b/resources/js/components/SMSelectFile.vue @@ -4,7 +4,7 @@
@@ -13,11 +13,11 @@ fill="currentColor" /> + @load="handleImageLoaded" + @error="handleImageError" + :style="{ display: image == '' ? 'none' : 'block' }" + :src="image" />
+
@@ -84,7 +83,11 @@ import { api } from "../../helpers/api"; import { Form, FormControl } from "../../helpers/form"; import { bytesReadable } from "../../helpers/types"; import { And, Required } from "../../helpers/validate"; -import { Media, MediaResponse } from "../../helpers/api.types"; +import { + Media, + MediaJobResponse, + MediaResponse, +} from "../../helpers/api.types"; import { openDialog } from "../../components/SMDialog"; import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue"; import SMForm from "../../components/SMForm.vue"; @@ -96,6 +99,7 @@ import SMPageStatus from "../../components/SMPageStatus.vue"; import SMSelectFile from "../../components/SMSelectFile.vue"; import { userHasPermission } from "../../helpers/utils"; import SMImageGallery from "../../components/SMImageGallery.vue"; +import { toTitleCase } from "../../helpers/string"; const route = useRoute(); const router = useRouter(); @@ -190,8 +194,10 @@ const handleLoad = async () => { }; const handleSubmit = async (enableFormCallBack) => { + let processing = false; + form.loading(true); + try { - form.loading(true); if (editMultiple === false) { let submitData = new FormData(); @@ -210,8 +216,9 @@ const handleSubmit = async (enableFormCallBack) => { form.controls.description.value as string, ); + let result = null; if (route.params.id) { - await api.put({ + result = await api.put({ url: "/media/{id}", params: { id: route.params.id, @@ -226,12 +233,13 @@ const handleSubmit = async (enableFormCallBack) => { )}%`), }); } else { - await api.post({ + result = await api.chunk({ url: "/media", body: submitData, headers: { "Content-Type": "multipart/form-data", }, + chunk: "file", progress: (progressEvent) => (progressText.value = `Uploading File: ${Math.floor( (progressEvent.loaded / progressEvent.total) * 100, @@ -239,13 +247,79 @@ const handleSubmit = async (enableFormCallBack) => { }); } - useToastStore().addToast({ - title: route.params.id ? "Media Updated" : "Media Created", - content: route.params.id - ? "The media item has been updated." - : "The media item been created.", - type: "success", - }); + const mediaJobId = result.data.media_job.id; + const mediaJobUpdate = async () => { + api.get({ + url: "/media/job/{id}", + params: { + id: mediaJobId, + }, + }) + .then((result) => { + const data = result.data as MediaJobResponse; + + // queued + // complete + // waiting + // processing - txt - prog + + // invalid - err + // failed - err + + if (data.media_job.status != "complete") { + if (data.media_job.status == "queued") { + progressText.value = "Queued for processing"; + } else if (data.media_job.status == "processing") { + if (data.media_job.progress != -1) { + progressText.value = `${toTitleCase( + data.media_job.status_text, + )} ${data.media_job.progress}%`; + } else { + progressText.value = `${toTitleCase( + data.media_job.status_text, + )}`; + } + } else if ( + data.media_job.status == "invalid" || + data.media_job.status == "failed" + ) { + useToastStore().addToast({ + title: "Error Processing Media", + content: toTitleCase( + data.media_job.status_text, + ), + type: "danger", + }); + + progressText.value = ""; + form.loading(false); + return; + } + + window.setTimeout(mediaJobUpdate, 500); + } else { + useToastStore().addToast({ + title: route.params.id + ? "Media Updated" + : "Media Created", + content: route.params.id + ? "The media item has been updated." + : "The media item been created.", + type: "success", + }); + + progressText.value = ""; + form.loading(false); + return; + } + }) + .catch((e) => { + console.log("error", e); + }); + }; + + processing = true; + mediaJobUpdate(); } else { let successCount = 0; let errorCount = 0; @@ -292,14 +366,16 @@ const handleSubmit = async (enableFormCallBack) => { } } - const urlParams = new URLSearchParams(window.location.search); - const returnUrl = urlParams.get("return"); - if (returnUrl) { - router.push(decodeURIComponent(returnUrl)); - } else { - router.push({ name: "dashboard-media-list" }); - } + // const urlParams = new URLSearchParams(window.location.search); + // const returnUrl = urlParams.get("return"); + // if (returnUrl) { + // router.push(decodeURIComponent(returnUrl)); + // } else { + // router.push({ name: "dashboard-media-list" }); + // } } catch (error) { + processing = false; + useToastStore().addToast({ title: "Server error", content: "An error occurred saving the media.", @@ -308,8 +384,10 @@ const handleSubmit = async (enableFormCallBack) => { enableFormCallBack(); } finally { - progressText.value = ""; - form.loading(false); + if (processing == false) { + progressText.value = ""; + form.loading(false); + } } }; diff --git a/routes/api.php b/routes/api.php index 5789e3d..01f0287 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\EventController; use App\Http\Controllers\Api\InfoController; use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\MediaController; +use App\Http\Controllers\Api\MediaJobController; use App\Http\Controllers\Api\OCRController; use App\Http\Controllers\Api\ArticleController; use App\Http\Controllers\Api\ShortlinkController; @@ -40,6 +41,7 @@ Route::post('/users/resendVerifyEmailCode', [UserController::class, 'resendVerif Route::post('/users/verifyEmail', [UserController::class, 'verifyEmail']); Route::get('/users/{user}/events', [UserController::class, 'eventList']); +Route::get('media/job/{mediaJob}', [MediaJobController::class, 'show']); Route::apiResource('media', MediaController::class); Route::get('media/{medium}/download', [MediaController::class, 'download']);