bug fixes

This commit is contained in:
2023-08-31 23:21:20 +10:00
parent 147b55ae78
commit 2257736ad9
15 changed files with 741 additions and 504 deletions

View File

@@ -33,9 +33,9 @@ class MediaConductor extends Conductor
* *
* @var array * @var array
*/ */
protected $defaultFilters = [ // protected $defaultFilters = [
'status' => 'OK' // 'status' => 'OK'
]; // ];
/** /**

View File

@@ -29,60 +29,6 @@ class MediaJobConductor extends Conductor
protected $includes = ['user']; 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. * Return if the current model is creatable.
* *
@@ -90,8 +36,7 @@ class MediaJobConductor extends Conductor
*/ */
public static function creatable(): bool public static function creatable(): bool
{ {
$user = auth()->user(); return false;
return ($user !== null);
} }
/** /**
@@ -102,10 +47,7 @@ class MediaJobConductor extends Conductor
*/ */
public static function updatable(Model $model): bool public static function updatable(Model $model): bool
{ {
/** @var \App\Models\User */ return false;
$user = auth()->user();
return ($user !== null && (strcasecmp($model->user_id, $user->id) === 0 ||
$user->hasPermission('admin/media') === true));
} }
/** /**
@@ -116,9 +58,7 @@ class MediaJobConductor extends Conductor
*/ */
public static function destroyable(Model $model): bool public static function destroyable(Model $model): bool
{ {
/** @var \App\Models\User */ return false;
$user = auth()->user();
return ($user !== null && ($model->user_id === $user->id || $user->hasPermission('admin/media') === true));
} }
/** /**

View File

@@ -82,6 +82,37 @@ class MediaController extends ApiController
return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']); return $this->respondWithErrors(['file' => 'The browser did not upload the file correctly to the server.']);
} }
return $this->storeOrUpdate($request, null);
}
/**
* Update the media resource in storage.
*
* @param \App\Http\Requests\MediaRequest $request The update request.
* @param \App\Models\Media $medium The specified media.
* @return \Illuminate\Http\Response
*/
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) {
// validate file object // validate file object
if ($file->isValid() !== true) { if ($file->isValid() !== true) {
switch ($file->getError()) { switch ($file->getError()) {
@@ -98,10 +129,10 @@ class MediaController extends ApiController
if ($file->getSize() > Media::getMaxUploadSize()) { if ($file->getSize() > Media::getMaxUploadSize()) {
return $this->respondTooLarge(); return $this->respondTooLarge();
} }
}
// create/get media job // create/get media job
$mediaJob = null; $mediaJob = null;
$filename = '';
$data = []; $data = [];
if ($request->missing('job_id') === true) { if ($request->missing('job_id') === true) {
@@ -110,18 +141,53 @@ class MediaController extends ApiController
$mediaJob = new MediaJob(); $mediaJob = new MediaJob();
$mediaJob->user_id = $user->id; $mediaJob->user_id = $user->id;
if ($medium !== null) {
$data['title'] = $request->get('title', ''); $mediaJob->media_id = $medium->id;
$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', ''); 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(); $mediaJob->setStatusWaiting();
} else { } else {
$mediaJob = MediaJob::find($request->get('job_id')); $mediaJob = MediaJob::find($request->get('job_id'));
@@ -129,7 +195,7 @@ class MediaController extends ApiController
$this->respondNotFound(); $this->respondNotFound();
} }
$data = json_decode($mediaJob->data); $data = json_decode($mediaJob->data, true);
if ($data === null) { if ($data === null) {
Log::error(`{$mediaJob->id} contains no data`); Log::error(`{$mediaJob->id} contains no data`);
return $this->respondServerError(); return $this->respondServerError();
@@ -139,22 +205,30 @@ class MediaController extends ApiController
Log::error(`{$mediaJob->id} data does not contain the name key`); Log::error(`{$mediaJob->id} data does not contain the name key`);
return $this->respondServerError(); return $this->respondServerError();
} }
} }//end if
if($mediaJob === null || $filename === '') { if ($mediaJob === null) {
Log::error(`media job or filename does not exist`); Log::error(`media job does not exist`);
return $this->respondServerError(); return $this->respondServerError();
} }
// save uploaded file // save uploaded file
$temporaryFilePath = generateTempFilePath(pathinfo($filename, PATHINFO_EXTENSION), $request->get('chunk', '')); if ($file !== null) {
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); copy($file->path(), $temporaryFilePath);
if ($request->has('chunk') === true) { if ($request->has('chunk') === true) {
$data['chunks'][$request->get('chunk', '1')] = $temporaryFilePath; $data['chunks'][$request->get('chunk', '1')] = $temporaryFilePath;
$data['chunk_count'] = $request->get('chunk_count', 1);
} else { } else {
$data['file'] = $temporaryFilePath; $data['file'] = $temporaryFilePath;
} }
}
$mediaJob->data = json_encode($data, true); $mediaJob->data = json_encode($data, true);
$mediaJob->save(); $mediaJob->save();
@@ -162,60 +236,10 @@ class MediaController extends ApiController
return $this->respondAsResource( return $this->respondAsResource(
MediaJobConductor::model($request, $mediaJob), MediaJobConductor::model($request, $mediaJob),
['respondCode' => HttpResponseCodes::HTTP_ACCEPTED] ['resourceName' => 'media_job', 'respondCode' => HttpResponseCodes::HTTP_ACCEPTED]
); );
} }
/**
* Update the media resource in storage.
*
* @param \App\Http\Requests\MediaRequest $request The update request.
* @param \App\Models\Media $medium The specified media.
* @return \Illuminate\Http\Response
*/
public function update(MediaRequest $request, Media $medium)
{
if (MediaConductor::updatable($medium) === false) {
return $this->respondForbidden();
}
$file = $request->file('file');
if ($file !== null) {
$jsonResult = $this->validateFileItem($file);
if ($jsonResult !== null) {
return $jsonResult;
}
}
$medium->status('Updating Media');
$medium->update($request->except(['file','transform']));
$transformData = [];
if ($file !== null) {
$temporaryFilePath = generateTempFilePath();
copy($file->path(), $temporaryFilePath);
$transformData = array_merge($transformData, ['file' => [
'path' => $temporaryFilePath,
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
]
]);
}
if ($request->has('transform') === true) {
$transformData = array_merge($transformData, array_map('trim', explode(',', $request->get('transform'))));
}
if (count($transformData) > 0) {
$medium->transform($transformData);
} else {
$medium->ok();
}
return $this->respondAsResource(MediaConductor::model($request, $medium));
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
* *

View File

@@ -1,10 +1,27 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers\Api;
use App\Conductors\MediaJobConductor;
use App\Http\Controllers\Api\ApiController;
use App\Models\MediaJob;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class MediaJobController extends Controller class MediaJobController extends ApiController
{ {
// /**
* Display the specified resource.
*
* @param \Illuminate\Http\Request $request The endpoint request.
* @param \App\Models\MediaJob $mediaJob The request media job.
* @return \Illuminate\Http\Response
*/
public function show(Request $request, MediaJob $mediaJob)
{
if (MediaJobConductor::viewable($mediaJob) === true) {
return $this->respondAsResource(MediaJobConductor::model($request, $mediaJob), ['resourceName' => 'media_job']);
}
return $this->respondForbidden();
}
} }

View File

@@ -13,9 +13,6 @@ class MediaRequest extends BaseRequest
Rule::requiredIf(function () { Rule::requiredIf(function () {
return request()->has('chunk') && request('chunk') != 1; return request()->has('chunk') && request('chunk') != 1;
}), }),
Rule::forbiddenUnless(function () {
return request()->has('chunk') && request('chunk') != 1;
}),
'string', 'string',
], ],
'name' => [ 'name' => [
@@ -24,16 +21,7 @@ class MediaRequest extends BaseRequest
}), }),
'string', 'string',
], ],
'chunk' => [ 'chunk' => 'required_with:chunk_count|integer|min:1|max:99|lte:chunk_count',
'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_count' => 'required_with:chunk|integer|min:1', 'chunk_count' => 'required_with:chunk|integer|min:1',
]; ];
} }

View File

@@ -18,6 +18,7 @@ use FFMpeg\FFProbe;
use FFMpeg\Format\VideoInterface; use FFMpeg\Format\VideoInterface;
use Intervention\Image\Facades\Image; use Intervention\Image\Facades\Image;
/** @property on $format */
class MediaWorkerJob implements ShouldQueue class MediaWorkerJob implements ShouldQueue
{ {
use Dispatchable; use Dispatchable;
@@ -32,25 +33,16 @@ class MediaWorkerJob implements ShouldQueue
*/ */
protected $mediaJob; protected $mediaJob;
/**
* Actions should be silent (not update the status field)
*
* @var boolean
*/
protected $silent;
/** /**
* Create a new job instance. * Create a new job instance.
* *
* @param MediaJob $mediaJob The mediaJob model. * @param MediaJob $mediaJob The mediaJob model.
* @param array $actions The media actions to make.
* @param boolean $silent Update the media status with progress.
* @return void * @return void
*/ */
public function __construct(MediaJob $mediaJob, bool $silent = false) public function __construct(MediaJob $mediaJob)
{ {
$mediaJob = $mediaJob; $this->mediaJob = $mediaJob;
$this->silent = $silent;
} }
/** /**
@@ -60,29 +52,21 @@ class MediaWorkerJob implements ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
$media = null; $media = $this->mediaJob->media()->first();
$newMedia = false;
$data = json_decode($this->mediaJob->data, true); $data = json_decode($this->mediaJob->data, true);
// new Media();
// $this->mediaJob->media_id = $media->id;
try { try {
// FILE // FILE
if (array_key_exists('file', $data) === true) { if (array_key_exists('file', $data) === true) {
if (file_exists($data['file']) === false) { if (file_exists($data['file']) === false) {
$errorStr = 'temporary upload file no longer exists'; $this->throwMediaJobFailure('temporary upload file no longer exists');
$this->mediaJob->setStatusFailed($errorStr);
Log::info($errorStr);
throw new \Exception($errorStr);
} }
// convert HEIC files to JPG // convert HEIC files to JPG
$fileExtension = File::extension($data['file']); $fileExtension = File::extension($data['file']);
if ($fileExtension === 'heic') { 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 // Get the path without the file name
$uploadedFileDirectory = dirname($data['file']); $uploadedFileDirectory = dirname($data['file']);
@@ -91,22 +75,26 @@ class MediaWorkerJob implements ShouldQueue
$jpgFileName = pathinfo($data['file'], PATHINFO_FILENAME) . '.jpg'; $jpgFileName = pathinfo($data['file'], PATHINFO_FILENAME) . '.jpg';
$jpgFilePath = $uploadedFileDirectory . '/' . $jpgFileName; $jpgFilePath = $uploadedFileDirectory . '/' . $jpgFileName;
if (file_exists($jpgFilePath) === true) { if (file_exists($jpgFilePath) === true) {
$errorStr = 'file already exists on server'; $this->throwMediaJobFailure('file already exists on server');
$this->mediaJob->setStatusFailed($errorStr);
Log::info($errorStr);
throw new \Exception($errorStr);
} }
Image::make($data['file'])->save($jpgFilePath); Image::make($data['file'])->save($jpgFilePath);
// Update the uploaded file path and file name // Update the uploaded file path and file name
unlink($data['file']); unlink($data['file']);
$filePath = $jpgFilePath;
$data['file'] = $jpgFileName; $data['file'] = $jpgFileName;
}//end if }//end if
// get storage // get storage
$storage = '';
if ($media === null) {
if (array_key_exists('storage', $data) === true) {
$storage = $data['storage']; $storage = $data['storage'];
}
} else {
$storage = $media->storage;
}
if ($storage === '') { if ($storage === '') {
if (strpos($data['mime_type'], 'image/') === 0) { if (strpos($data['mime_type'], 'image/') === 0) {
$storage = 'local'; $storage = 'local';
@@ -118,14 +106,12 @@ class MediaWorkerJob implements ShouldQueue
// Check if file already exists // Check if file already exists
if (Storage::disk($storage)->exists($data['name']) === true) { if (Storage::disk($storage)->exists($data['name']) === true) {
if (array_key_exists('replace', $data) === false || isTrue($data['replace']) === false) { if (array_key_exists('replace', $data) === false || isTrue($data['replace']) === false) {
$this->mediaJob->setStatusFailed('file already exists on server'); $this->throwMediaJobFailure('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);
} }
} }
if ($media === null) {
$newMedia = true;
$media = new Media([ $media = new Media([
'user_id' => $this->mediaJob->user_id, 'user_id' => $this->mediaJob->user_id,
'title' => $data['title'], 'title' => $data['title'],
@@ -134,30 +120,17 @@ class MediaWorkerJob implements ShouldQueue
'size' => $data['size'], 'size' => $data['size'],
'storage' => $storage 'storage' => $storage
]); ]);
}//end if
$media->setStagingFile($filePath); $media->setStagingFile($data['file']);
} else { } else {
$media = $this->mediaJob->media()->first(); if ($media === null) {
if($media === null || $media->exists() === false) { $this->throwMediaJobFailure('The media item no longer exists');
$errorStr = 'The media item no longer exists';
$this->mediaJob->setStatusFailed($errorStr);
Log::info($errorStr);
throw new \Exception($errorStr);
} }
} }//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->createStagingFile();
$media->deleteFile();
// Modifications // Modifications
if (strpos($media->mime_type, 'image/') === 0) { if (strpos($media->mime_type, 'image/') === 0) {
@@ -168,9 +141,7 @@ class MediaWorkerJob implements ShouldQueue
if (array_key_exists("rotate", $data['transform']) === true) { if (array_key_exists("rotate", $data['transform']) === true) {
$rotate = intval($data['transform']['rotate']); $rotate = intval($data['transform']['rotate']);
if ($rotate !== 0) { if ($rotate !== 0) {
if ($this->silent === false) {
$this->mediaJob->setStatusProcessing(0, 'rotating image'); $this->mediaJob->setStatusProcessing(0, 'rotating image');
}
$image = $image->rotate($rotate); $image = $image->rotate($rotate);
$modified = true; $modified = true;
} }
@@ -179,17 +150,13 @@ class MediaWorkerJob implements ShouldQueue
// FLIP-H/V // FLIP-H/V
if (array_key_exists('flip', $data['transform']) === true) { if (array_key_exists('flip', $data['transform']) === true) {
if (stripos($data['transform']['flip'], 'h') !== false) { 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'); $image = $image->flip('h');
$modified = true; $modified = true;
} }
if (stripos($data['transform']['flip'], 'v') !== false) { 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'); $image = $image->flip('v');
$modified = true; $modified = true;
} }
@@ -203,9 +170,7 @@ class MediaWorkerJob implements ShouldQueue
$x = intval(arrayDefaultValue("x", $cropData, 0)); $x = intval(arrayDefaultValue("x", $cropData, 0));
$y = intval(arrayDefaultValue("y", $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); $image = $image->crop($width, $height, $x, $y);
$modified = true; $modified = true;
}//end if }//end if
@@ -253,17 +218,13 @@ class MediaWorkerJob implements ShouldQueue
// FLIP-H/V // FLIP-H/V
if (array_key_exists('flip', $data['transform']) === true) { if (array_key_exists('flip', $data['transform']) === true) {
if (stripos($data['transform']['flip'], 'h') !== false) { if (stripos($data['transform']['flip'], 'h') !== false) {
if ($this->silent === false) { $this->mediaJob->setStatusProcessing(0, 'flipping video');
$media->status('Flipping video');
}
$filters->hflip()->synchronize(); $filters->hflip()->synchronize();
$modified = true; $modified = true;
} }
if (stripos($data['transform']['flip'], 'v') !== false) { if (stripos($data['transform']['flip'], 'v') !== false) {
if ($this->silent === false) { $this->mediaJob->setStatusProcessing(0, 'flipping video');
$media->status('Flipping video');
}
$filters->vflip()->synchronize(); $filters->vflip()->synchronize();
$modified = true; $modified = true;
} }
@@ -281,18 +242,16 @@ class MediaWorkerJob implements ShouldQueue
$cropDimension = new Dimension($width, $height); $cropDimension = new Dimension($width, $height);
if ($this->silent === false) { $this->mediaJob->setStatusProcessing(0, 'cropping video');
$media->status('Cropping video');
}
$filters->crop($cropDimension, $x, $y)->synchronize(); $filters->crop($cropDimension, $x, $y)->synchronize();
$modified = true; $modified = true;
}//end if }//end if
$tempFilePath = generateTempFilePath(pathinfo($stagingFilePath, PATHINFO_EXTENSION)); $tempFilePath = generateTempFilePath(pathinfo($stagingFilePath, PATHINFO_EXTENSION));
if (method_exists($format, 'on') === true) { if (method_exists($format, 'on') === true) {
$media = $media; $mediaJob = $this->mediaJob;
$format->on('progress', function ($video, $format, $percentage) use ($media) { $format->on('progress', function ($video, $format, $percentage) use ($mediaJob) {
$media->status("{$percentage}% transcoded"); $mediaJob->setStatusProcessing($percentage, 'transcoded');
}); });
} }
@@ -303,30 +262,47 @@ class MediaWorkerJob implements ShouldQueue
}//end if }//end if
// Move file // Move file
if (array_key_exists("move", $data['transform']) === true) { if (array_key_exists('move', $data['transform']) === true) {
if (array_key_exists("storage", $data['transform']['move']) === true) { if (array_key_exists('storage', $data['transform']['move']) === true) {
$newStorage = $data['transform']['move"]["storage']; $newStorage = $data['transform']['move']['storage'];
if ($media->storage !== $newStorage) { if ($media->storage !== $newStorage) {
if (Storage::has($newStorage) === true) { if (Storage::has($newStorage) === true) {
$media->createStagingFile();
$media->storage = $newStorage; $media->storage = $newStorage;
} else { } 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 // Finish media object
if ($media->hasStagingFile() === true) {
$this->mediaJob->setStatusProcessing(-1, 'uploading to cdn');
$media->deleteFile();
$media->saveStagingFile(true); $media->saveStagingFile(true);
}
$media->save(); $media->save();
$this->mediaJob->media_id = $media->id;
$this->mediaJob->setStatusComplete(); $this->mediaJob->setStatusComplete();
} catch (\Exception $e) { } catch (\Exception $e) {
$media->deleteStagingFile(); if ($this->mediaJob->status !== 'failed') {
$this->mediaJob->setStatusFailed('Unexpected server error occurred');
}
// if (strpos($media->status, 'Error') !== 0) { if ($media !== null) {
// $media->error('Failed to process the file'); $media->deleteStagingFile();
// } if ($newMedia === true) {
$media->delete();
}
}
Log::error($e->getMessage() . "\n" . $e->getFile() . " - " . $e->getLine() . "\n" . $e->getTraceAsString()); Log::error($e->getMessage() . "\n" . $e->getFile() . " - " . $e->getLine() . "\n" . $e->getTraceAsString());
$this->fail($e); $this->fail($e);
@@ -373,4 +349,16 @@ class MediaWorkerJob implements ShouldQueue
return new $formatClassName(); 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);
}
} }

View File

@@ -3,35 +3,24 @@
namespace App\Models; namespace App\Models;
use App\Enum\HttpResponseCodes; use App\Enum\HttpResponseCodes;
use App\Jobs\MediaJob; use App\Jobs\MediaWorkerJob;
use App\Jobs\MoveMediaJob; use App\Jobs\MoveMediaJob;
use App\Jobs\StoreUploadedFileJob;
use App\Traits\Uuids; use App\Traits\Uuids;
use Exception;
use FFMpeg\Coordinate\TimeCode; use FFMpeg\Coordinate\TimeCode;
use FFMpeg\FFMpeg; use FFMpeg\FFMpeg;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\InvalidCastException;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Exception\NotWritableException;
use ImagickException;
use Intervention\Image\Facades\Image; use Intervention\Image\Facades\Image;
use InvalidArgumentException;
use Psr\Container\NotFoundExceptionInterface;
use Psr\Container\ContainerExceptionInterface;
use SplFileInfo; use SplFileInfo;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
class Media extends Model class Media extends Model
@@ -59,7 +48,6 @@ class Media extends Model
'description', 'description',
'name', 'name',
'size', 'size',
'status',
]; ];
/** /**
@@ -367,7 +355,7 @@ class Media extends Model
public function moveToStorage(string $storage): void public function moveToStorage(string $storage): void
{ {
if ($storage !== $this->storage && Config::has("filesystems.disks.$storage") === true) { if ($storage !== $this->storage && Config::has("filesystems.disks.$storage") === true) {
$this->status = "Processing media"; // $this->status = "Processing media";
MoveMediaJob::dispatch($this, $storage)->onQueue('media'); MoveMediaJob::dispatch($this, $storage)->onQueue('media');
$this->save(); $this->save();
} }
@@ -377,35 +365,25 @@ class Media extends Model
* Transform the media through the Media Job Queue * Transform the media through the Media Job Queue
* *
* @param array $transform The transform data. * @param array $transform The transform data.
* @param boolean $silent Update the medium progress through its status field. * @return MediaJob
* @return void
*/ */
public function transform(array $transform, bool $silent = false): void public function transform(array $transform): MediaJob
{ {
foreach ($transform as $key => $value) { $mediaJob = new MediaJob([
if (is_string($value) === true) { 'media_id' => $this->media,
if (preg_match('/^rotate-(-?\d+)$/', $value, $matches) !== false) { 'user_id' => auth()->user()?->id,
unset($transform[$key]); 'data' => json_encode(['transform' => $transform]),
$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]];
}
}
}
try { try {
MediaJob::dispatch($this, $transform, $silent)->onQueue('media'); MediaWorkerJob::dispatch($mediaJob)->onQueue('media');
return $mediaJob;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->error('Failed to transform media'); $this->error('Failed to transform media');
throw $e; throw $e;
}//end try }//end try
return null;
} }
/** /**
@@ -498,8 +476,8 @@ class Media extends Model
if ( if (
static::fileNameHasSuffix($fileName) === true || static::fileNameHasSuffix($fileName) === true ||
static::fileExistsInStorage("$fileName.$extension") === true || static::fileExistsInStorage("$fileName.$extension") === true //||
Media::where('name', "$fileName.$extension")->where('status', 'not like', 'failed%')->exists() === true // Media::where('name', "$fileName.$extension")->where('status', 'not like', 'failed%')->exists() === true
) { ) {
$fileName .= '-'; $fileName .= '-';
for ($i = 1; $i < $maxTries; $i++) { for ($i = 1; $i < $maxTries; $i++) {
@@ -507,7 +485,7 @@ class Media extends Model
if ( if (
static::fileExistsInStorage("$fileNameIndex.$extension") !== true && static::fileExistsInStorage("$fileNameIndex.$extension") !== true &&
Media::where('name', "$fileNameIndex.$extension") Media::where('name', "$fileNameIndex.$extension")
->where('status', 'not like', 'Failed%') // ->where('status', 'not like', 'Failed%')
->exists() !== true ->exists() !== true
) { ) {
return "$fileNameIndex.$extension"; return "$fileNameIndex.$extension";
@@ -721,6 +699,7 @@ class Media extends Model
*/ */
public function saveStagingFile(bool $delete = true, bool $silent = false): void public function saveStagingFile(bool $delete = true, bool $silent = false): void
{ {
if ($this->stagingFilePath !== '') {
if (strlen($this->storage) > 0 && strlen($this->name) > 0) { if (strlen($this->storage) > 0 && strlen($this->name) > 0) {
if (Storage::disk($this->storage)->exists($this->name) === true) { if (Storage::disk($this->storage)->exists($this->name) === true) {
Storage::disk($this->storage)->delete($this->name); Storage::disk($this->storage)->delete($this->name);
@@ -728,25 +707,26 @@ class Media extends Model
/** @var Illuminate\Filesystem\FilesystemAdapter */ /** @var Illuminate\Filesystem\FilesystemAdapter */
$fileSystem = Storage::disk($this->storage); $fileSystem = Storage::disk($this->storage);
if ($silent === false) { // if ($silent === false) {
$this->status('Uploading to CDN'); // $this->status('Uploading to CDN');
} // }
$fileSystem->putFileAs('/', $this->stagingFilePath, $this->name); $fileSystem->putFileAs('/', $this->stagingFilePath, $this->name);
} }
if ($silent === false) { // if ($silent === false) {
$this->status('Generating Thumbnail'); // $this->status('Generating Thumbnail');
} // }
$this->generateThumbnail(); $this->generateThumbnail();
if ($silent === false) { // if ($silent === false) {
$this->status('Generating Variants'); // $this->status('Generating Variants');
} // }
$this->generateVariants(); $this->generateVariants();
if ($delete === true) { if ($delete === true) {
$this->deleteStagingFile(); $this->deleteStagingFile();
} }
}//end if
} }
/** /**
@@ -777,6 +757,16 @@ class Media extends Model
$this->stagingFilePath = $newFile; $this->stagingFilePath = $newFile;
} }
/**
* Is a staging file present
*
* @return boolean
*/
public function hasStagingFile(): bool
{
return $this->stagingFilePath !== "";
}
/** /**
* Generate a Thumbnail for this media. * Generate a Thumbnail for this media.
* *
@@ -1019,7 +1009,7 @@ class Media extends Model
*/ */
public function ok(): void public function ok(): void
{ {
$this->status = "OK"; // $this->status = "OK";
$this->save(); $this->save();
} }
@@ -1030,7 +1020,7 @@ class Media extends Model
*/ */
public function error(string $error = ""): void public function error(string $error = ""): void
{ {
$this->status = "Error" . ($error !== "" ? ": {$error}" : ""); // $this->status = "Error" . ($error !== "" ? ": {$error}" : "");
$this->save(); $this->save();
} }
@@ -1041,7 +1031,7 @@ class Media extends Model
*/ */
public function status(string $status = ""): void public function status(string $status = ""): void
{ {
$this->status = "Info: " . $status; // $this->status = "Info: " . $status;
$this->save(); $this->save();
} }
} }

View File

@@ -13,19 +13,62 @@ class MediaJob extends Model
use HasFactory; use HasFactory;
use Uuids; 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); $this->setStatus('failed', $statusText, 0);
} }
public function setStatusQueued(): void { /**
* Set MediaJob status to queued.
*
* @return void
*/
public function setStatusQueued(): void
{
$this->setStatus('queued', '', 0); $this->setStatus('queued', '', 0);
} }
public function setStatusWaiting(): void { /**
* Set MediaJob status to waiting.
*
* @return void
*/
public function setStatusWaiting(): void
{
$this->setStatus('waiting', '', 0); $this->setStatus('waiting', '', 0);
} }
public function setStatusProcessing(int $progress = 0, string $statusText = ''): void { /**
* 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 === '') { if ($statusText === '') {
$statusText = $this->status_text; $statusText = $this->status_text;
} }
@@ -33,29 +76,60 @@ class MediaJob extends Model
$this->setStatus('processing', $statusText, $progress); $this->setStatus('processing', $statusText, $progress);
} }
public function setStatusComplete(): void { /**
* Set MediaJob status to complete.
*
* @return void
*/
public function setStatusComplete(): void
{
$this->setStatus('complete'); $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 = $status;
$this->status_text = $text; $this->status_text = $text;
$this->progress = $progress; $this->progress = $progress;
$this->save(); $this->save();
} }
/**
* Process the MediaJob.
*
* @return void
*/
public function process(): void public function process(): void
{ {
$data = json_decode($this->data, true); $data = json_decode($this->data, true);
if ($data !== null) { if ($data !== null) {
if (array_key_exists('chunks', $data) === true) { if (array_key_exists('chunks', $data) === true) {
if(array_key_exists('chunk_count', $data) === false || array_key_exists('name', $data) === false) { if (array_key_exists('chunk_count', $data) === false) {
$this->setStatusInvalid(); $this->setStatusInvalid('chunk_count is missing');
return;
}
if (array_key_exists('name', $data) === false) {
$this->setStatusInvalid('name is missing');
return; return;
} }
@@ -64,7 +138,7 @@ class MediaJob extends Model
if ($numChunks >= $maxChunks) { if ($numChunks >= $maxChunks) {
// merge file and dispatch // merge file and dispatch
$percentage = 0; $percentage = 0;
$percentageStep = 100 / $maxChunks; $percentageStep = (100 / $maxChunks);
$this->setStatusProcessing($percentage, 'combining chunks'); $this->setStatusProcessing($percentage, 'combining chunks');
$newFile = generateTempFilePath(pathinfo($data['name'], PATHINFO_EXTENSION)); $newFile = generateTempFilePath(pathinfo($data['name'], PATHINFO_EXTENSION));
@@ -72,14 +146,14 @@ class MediaJob extends Model
for ($index = 1; $index <= $maxChunks; $index++) { for ($index = 1; $index <= $maxChunks; $index++) {
if (array_key_exists($index, $data['chunks']) === false) { if (array_key_exists($index, $data['chunks']) === false) {
$failed = true; $failed = `{$index} chunk is missing`;
} else { } else {
$tempFileName = $data['chunks'][$index]; $tempFileName = $data['chunks'][$index];
if ($failed === false) { if ($failed === false) {
$chunkContents = file_get_contents($tempFileName); $chunkContents = file_get_contents($tempFileName);
if ($chunkContents === false) { if ($chunkContents === false) {
$failed = true; $failed = `{$index} chunk is empty`;
} else { } else {
file_put_contents($newFile, $chunkContents, FILE_APPEND); file_put_contents($newFile, $chunkContents, FILE_APPEND);
} }
@@ -93,66 +167,28 @@ class MediaJob extends Model
unset($data['chunks']); unset($data['chunks']);
$this->data = json_encode($data); $this->data = json_encode($data);
if($failed === false) { if ($failed !== false) {
$this->setStatusInvalid(); $this->setStatusInvalid($failed);
} else { } else {
$finfo = finfo_open(FILEINFO_MIME_TYPE); $finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $newFile); $mime = finfo_file($finfo, $newFile);
finfo_close($finfo); finfo_close($finfo);
$data['file']['path'] = $newFile; $data['file'] = $newFile;
$data['file']['size'] = filesize($newFile); $data['size'] = filesize($newFile);
$data['file']['mime_type'] = $mime; $data['mime_type'] = $mime;
$this->data = json_encode($data);
$this->setStatusQueued(); $this->setStatusQueued();
MediaWorkerJob::dispatch($this); MediaWorkerJob::dispatch($this)->onQueue('media');
} }
} }//end if
} else if(array_key_exists('file', $data) || array_key_exists('transform', $data)) { } else {
$this->setStatusQueued(); $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 * Return the job owner

View File

@@ -12,10 +12,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::create('media_jobs', function (Blueprint $table) { Schema::create('media_jobs', function (Blueprint $table) {
$table->uuid()->primary(); $table->uuid('id')->primary();
$table->timestamps(); $table->timestamps();
$table->uuid('user_id'); $table->uuid('user_id')->nullable();
$table->uuid('media_id'); // Add a foreign key for the media model $table->uuid('media_id')->nullable(); // Add a foreign key for the media model
$table->string('status'); $table->string('status');
$table->string('status_text'); $table->string('status_text');
$table->text('data'); $table->text('data');

View File

@@ -4,7 +4,7 @@
<div v-if="value" class="flex flex-justify-center mb-4"> <div v-if="value" class="flex flex-justify-center mb-4">
<SMLoading v-if="!imgError && !imgLoaded" class="w-48 h-48" small /> <SMLoading v-if="!imgError && !imgLoaded" class="w-48 h-48" small />
<svg <svg
v-if="imgError" v-if="imgError && imgLoaded"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="h-48 text-gray"> class="h-48 text-gray">
@@ -13,11 +13,11 @@
fill="currentColor" /> fill="currentColor" />
</svg> </svg>
<img <img
v-if="!imgError"
class="max-w-48 max-h-48 w-full h-full" class="max-w-48 max-h-48 w-full h-full"
@load="imgLoaded = true" @load="handleImageLoaded"
@error="imgError = true" @error="handleImageError"
:src="mediaGetThumbnail(value, 'medium')" /> :style="{ display: image == '' ? 'none' : 'block' }"
:src="image" />
</div> </div>
<svg <svg
v-else v-else
@@ -38,17 +38,24 @@
</button> </button>
</div> </div>
<template v-if="slots.help"><slot name="help"></slot></template> <template v-if="slots.help"><slot name="help"></slot></template>
<input
id="file"
ref="refUploadInput"
type="file"
style="display: none"
:accept="props.accepts"
@change="handleChangeSelectFile" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, watch, ref, useSlots } from "vue"; import { inject, watch, ref, useSlots, onMounted } from "vue";
import { isEmpty, generateRandomElementId } from "../helpers/utils"; import { isEmpty, generateRandomElementId } from "../helpers/utils";
import { toTitleCase } from "../helpers/string"; import { toTitleCase } from "../helpers/string";
import { mediaGetThumbnail } from "../helpers/media"; import { mediaGetThumbnail } from "../helpers/media";
import { openDialog } from "./SMDialog"; import { openDialog } from "./SMDialog";
import SMDialogMedia from "./dialogs/SMDialogMedia.vue"; import SMDialogMedia from "./dialogs/SMDialogMedia.vue";
import SMDialogUpload from "./dialogs/SMDialogUpload.vue"; // import SMDialogUpload from "./dialogs/SMDialogUpload.vue";
import { Media } from "../helpers/api.types"; import { Media } from "../helpers/api.types";
import SMLoading from "./SMLoading.vue"; import SMLoading from "./SMLoading.vue";
@@ -168,6 +175,8 @@ const props = defineProps({
}); });
const slots = useSlots(); const slots = useSlots();
const refUploadInput = ref(null);
const image = ref("");
const form = inject(props.formId, props.form); const form = inject(props.formId, props.form);
const control = const control =
@@ -232,6 +241,16 @@ watch(
}, },
); );
watch(
() => value.value,
(newValue) => {
mediaGetThumbnail(newValue, "medium", (e) => {
image.value = e;
imgLoaded.value = true;
});
},
);
if (typeof control === "object" && control !== null) { if (typeof control === "object" && control !== null) {
watch( watch(
() => control.validation.result.valid, () => control.validation.result.valid,
@@ -269,11 +288,6 @@ const handleMediaSelect = async () => {
allowUpload: props.allowUpload, allowUpload: props.allowUpload,
accepts: props.accepts, accepts: props.accepts,
}); });
} else {
result = await openDialog(SMDialogUpload, {
accepts: props.accepts,
});
}
if (result) { if (result) {
const mediaResult = result as Media; const mediaResult = result as Media;
@@ -283,6 +297,48 @@ const handleMediaSelect = async () => {
feedbackInvalid.value = ""; feedbackInvalid.value = "";
} }
} }
} else {
if (refUploadInput.value != null) {
refUploadInput.value.click();
}
}
};
const handleChangeSelectFile = async () => {
if (refUploadInput.value != null && refUploadInput.value.files != null) {
imgLoaded.value = false;
imgError.value = false;
const fileList = Array.from(refUploadInput.value.files);
let file = fileList.length > 0 ? fileList[0] : null;
emits("update:modelValue", file);
if (control) {
control.value = file;
feedbackInvalid.value = "";
}
}
};
onMounted(() => {
window.setTimeout(() => {
mediaGetThumbnail(value.value, "medium", (e) => {
image.value = e;
});
}, 500);
});
const handleImageLoaded = () => {
imgLoaded.value = true;
imgError.value = false;
};
const handleImageError = () => {
if (image.value !== "") {
imgLoaded.value = true;
imgError.value = true;
}
}; };
</script> </script>

View File

@@ -28,6 +28,7 @@ interface ApiOptions {
signal?: AbortSignal | null; signal?: AbortSignal | null;
progress?: ApiProgressCallback; progress?: ApiProgressCallback;
callback?: ApiResultCallback; callback?: ApiResultCallback;
chunk?: string;
} }
export interface ApiResponse { export interface ApiResponse {
@@ -322,7 +323,7 @@ export const api = {
} }
apiOptions.method = "POST"; apiOptions.method = "POST";
return await this.send(options); return await this.send(apiOptions);
}, },
put: async function (options: ApiOptions | string): Promise<ApiResponse> { put: async function (options: ApiOptions | string): Promise<ApiResponse> {
@@ -335,7 +336,7 @@ export const api = {
} }
apiOptions.method = "PUT"; apiOptions.method = "PUT";
return await this.send(options); return await this.send(apiOptions);
}, },
delete: async function ( delete: async function (
@@ -350,7 +351,104 @@ export const api = {
} }
apiOptions.method = "DELETE"; apiOptions.method = "DELETE";
return await this.send(options); return await this.send(apiOptions);
},
chunk: async function (options: ApiOptions | string): Promise<ApiResponse> {
let apiOptions = {} as ApiOptions;
// setup api options
if (typeof options == "string") {
apiOptions.url = options;
} else {
apiOptions = options;
}
// set method to post by default
if (!Object.prototype.hasOwnProperty.call(apiOptions, "method")) {
apiOptions.method = "POST";
}
// check for chunk option
if (
Object.prototype.hasOwnProperty.call(apiOptions, "chunk") &&
Object.prototype.hasOwnProperty.call(apiOptions, "body") &&
apiOptions.body instanceof FormData
) {
if (apiOptions.body.has(apiOptions.chunk)) {
const file = apiOptions.body.get(apiOptions.chunk);
if (file instanceof File) {
const chunkSize = 50 * 1024 * 1024;
let chunk = 0;
let chunkCount = 1;
let job_id = -1;
if (file.size > chunkSize) {
chunkCount = Math.ceil(file.size / chunkSize);
}
let result = null;
for (chunk = 0; chunk < chunkCount; chunk++) {
const offset = chunk * chunkSize;
const fileChunk = file.slice(
offset,
offset + chunkSize,
);
const chunkFormData = new FormData();
if (job_id == -1) {
for (const [field, value] of apiOptions.body) {
chunkFormData.append(field, value);
}
chunkFormData.append("name", file.name);
chunkFormData.append("size", file.size.toString());
chunkFormData.append("mime_type", file.type);
} else {
chunkFormData.append("job_id", job_id.toString());
}
chunkFormData.set(apiOptions.chunk, fileChunk);
chunkFormData.append("chunk", (chunk + 1).toString());
chunkFormData.append(
"chunk_count",
chunkCount.toString(),
);
const chunkOptions = {
method: apiOptions.method,
url: apiOptions.url,
params: apiOptions.params || {},
body: chunkFormData,
headers: apiOptions.headers || {},
progress: (progressEvent) => {
if (
Object.prototype.hasOwnProperty.call(
apiOptions,
"progress",
)
) {
apiOptions.progress({
loaded:
chunk * chunkSize +
progressEvent.loaded,
total: file.size,
});
}
},
};
result = await this.send(chunkOptions);
job_id = result.data.media_job.id;
}
return result;
}
}
}
return await this.send(apiOptions);
}, },
}; };

View File

@@ -88,6 +88,19 @@ export interface MediaCollection {
total: number; total: number;
} }
export interface MediaJob {
id: string;
media_id: string;
user_id: string;
status: string;
status_text: string;
progress: number;
}
export interface MediaJobResponse {
media_job: MediaJob;
}
export interface Article { export interface Article {
id: string; id: string;
title: string; title: string;

View File

@@ -39,16 +39,27 @@ export const mimeMatches = (
}; };
export const mediaGetThumbnail = ( export const mediaGetThumbnail = (
media: Media, media: Media | File,
useVariant: string | null = "", useVariant: string | null = "",
forceRefresh: boolean = false, callback = null,
): string => { ): string => {
let url: string = ""; let url: string = "";
if (!media) { if (media) {
if (media instanceof File) {
if (callback != null) {
if (mimeMatches("image/*", media.type) == true) {
const reader = new FileReader();
reader.onload = function (e) {
callback(e.target.result.toString());
};
reader.readAsDataURL(media);
return ""; return "";
} }
}
} else {
if ( if (
useVariant && useVariant &&
useVariant != "" && useVariant != "" &&
@@ -66,20 +77,16 @@ export const mediaGetThumbnail = (
if (media.variants && media.variants["thumb"]) { if (media.variants && media.variants["thumb"]) {
url = media.url.replace(media.name, media.variants["thumb"]); url = media.url.replace(media.name, media.variants["thumb"]);
} }
if (url === "") {
return "/assets/fileicons/unknown.webp";
} }
if (forceRefresh == true) { if (url === "") {
// Generate a random string url = "/assets/fileicons/unknown.webp";
const randomString = Math.random().toString(36).substring(7); }
}
// Check if the URL already has query parameters if (callback != null) {
const separator = url.includes("?") ? "&" : "?"; callback(url);
return "";
// Append the random string as a query parameter
url = `${url}${separator}_random=${randomString}`;
} }
return url; return url;

View File

@@ -5,7 +5,7 @@
:title="pageHeading" :title="pageHeading"
:back-link="{ name: 'dashboard-media-list' }" :back-link="{ name: 'dashboard-media-list' }"
back-title="Back to Media" /> back-title="Back to Media" />
<SMLoading v-if="form.loading()" /> <SMLoading v-if="form.loading()">{{ progressText }}</SMLoading>
<div v-else class="max-w-4xl mx-auto px-4 mt-8"> <div v-else class="max-w-4xl mx-auto px-4 mt-8">
<SMForm <SMForm
:model-value="form" :model-value="form"
@@ -17,7 +17,6 @@
<SMSelectFile <SMSelectFile
v-if="!editMultiple" v-if="!editMultiple"
control="file" control="file"
allow-upload
upload-only upload-only
accepts="*" accepts="*"
class="mb-4" /> class="mb-4" />
@@ -84,7 +83,11 @@ import { api } from "../../helpers/api";
import { Form, FormControl } from "../../helpers/form"; import { Form, FormControl } from "../../helpers/form";
import { bytesReadable } from "../../helpers/types"; import { bytesReadable } from "../../helpers/types";
import { And, Required } from "../../helpers/validate"; 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 { openDialog } from "../../components/SMDialog";
import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue"; import DialogConfirm from "../../components/dialogs/SMDialogConfirm.vue";
import SMForm from "../../components/SMForm.vue"; import SMForm from "../../components/SMForm.vue";
@@ -96,6 +99,7 @@ import SMPageStatus from "../../components/SMPageStatus.vue";
import SMSelectFile from "../../components/SMSelectFile.vue"; import SMSelectFile from "../../components/SMSelectFile.vue";
import { userHasPermission } from "../../helpers/utils"; import { userHasPermission } from "../../helpers/utils";
import SMImageGallery from "../../components/SMImageGallery.vue"; import SMImageGallery from "../../components/SMImageGallery.vue";
import { toTitleCase } from "../../helpers/string";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -190,8 +194,10 @@ const handleLoad = async () => {
}; };
const handleSubmit = async (enableFormCallBack) => { const handleSubmit = async (enableFormCallBack) => {
try { let processing = false;
form.loading(true); form.loading(true);
try {
if (editMultiple === false) { if (editMultiple === false) {
let submitData = new FormData(); let submitData = new FormData();
@@ -210,8 +216,9 @@ const handleSubmit = async (enableFormCallBack) => {
form.controls.description.value as string, form.controls.description.value as string,
); );
let result = null;
if (route.params.id) { if (route.params.id) {
await api.put({ result = await api.put({
url: "/media/{id}", url: "/media/{id}",
params: { params: {
id: route.params.id, id: route.params.id,
@@ -226,12 +233,13 @@ const handleSubmit = async (enableFormCallBack) => {
)}%`), )}%`),
}); });
} else { } else {
await api.post({ result = await api.chunk({
url: "/media", url: "/media",
body: submitData, body: submitData,
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",
}, },
chunk: "file",
progress: (progressEvent) => progress: (progressEvent) =>
(progressText.value = `Uploading File: ${Math.floor( (progressText.value = `Uploading File: ${Math.floor(
(progressEvent.loaded / progressEvent.total) * 100, (progressEvent.loaded / progressEvent.total) * 100,
@@ -239,13 +247,79 @@ const handleSubmit = async (enableFormCallBack) => {
}); });
} }
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({ useToastStore().addToast({
title: route.params.id ? "Media Updated" : "Media Created", 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 content: route.params.id
? "The media item has been updated." ? "The media item has been updated."
: "The media item been created.", : "The media item been created.",
type: "success", type: "success",
}); });
progressText.value = "";
form.loading(false);
return;
}
})
.catch((e) => {
console.log("error", e);
});
};
processing = true;
mediaJobUpdate();
} else { } else {
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
@@ -292,14 +366,16 @@ const handleSubmit = async (enableFormCallBack) => {
} }
} }
const urlParams = new URLSearchParams(window.location.search); // const urlParams = new URLSearchParams(window.location.search);
const returnUrl = urlParams.get("return"); // const returnUrl = urlParams.get("return");
if (returnUrl) { // if (returnUrl) {
router.push(decodeURIComponent(returnUrl)); // router.push(decodeURIComponent(returnUrl));
} else { // } else {
router.push({ name: "dashboard-media-list" }); // router.push({ name: "dashboard-media-list" });
} // }
} catch (error) { } catch (error) {
processing = false;
useToastStore().addToast({ useToastStore().addToast({
title: "Server error", title: "Server error",
content: "An error occurred saving the media.", content: "An error occurred saving the media.",
@@ -308,9 +384,11 @@ const handleSubmit = async (enableFormCallBack) => {
enableFormCallBack(); enableFormCallBack();
} finally { } finally {
if (processing == false) {
progressText.value = ""; progressText.value = "";
form.loading(false); form.loading(false);
} }
}
}; };
const handleFailValidation = () => { const handleFailValidation = () => {

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Api\EventController;
use App\Http\Controllers\Api\InfoController; use App\Http\Controllers\Api\InfoController;
use App\Http\Controllers\Api\LogController; use App\Http\Controllers\Api\LogController;
use App\Http\Controllers\Api\MediaController; use App\Http\Controllers\Api\MediaController;
use App\Http\Controllers\Api\MediaJobController;
use App\Http\Controllers\Api\OCRController; use App\Http\Controllers\Api\OCRController;
use App\Http\Controllers\Api\ArticleController; use App\Http\Controllers\Api\ArticleController;
use App\Http\Controllers\Api\ShortlinkController; 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::post('/users/verifyEmail', [UserController::class, 'verifyEmail']);
Route::get('/users/{user}/events', [UserController::class, 'eventList']); Route::get('/users/{user}/events', [UserController::class, 'eventList']);
Route::get('media/job/{mediaJob}', [MediaJobController::class, 'show']);
Route::apiResource('media', MediaController::class); Route::apiResource('media', MediaController::class);
Route::get('media/{medium}/download', [MediaController::class, 'download']); Route::get('media/{medium}/download', [MediaController::class, 'download']);