feat(admin): upload video action (#436)

* feat(admin): upload video action

* style: fix StyleCI findings

* tests: fix video storage tests
This commit is contained in:
paranarimasu
2022-08-15 21:58:42 -05:00
committed by GitHub
parent 3a77f95e49
commit e973f68537
43 changed files with 1594 additions and 25 deletions
+19 -8
View File
@@ -98,14 +98,23 @@ IMAGE_DISABLE_ASSERTS=
IMAGE_VISIBILITY=
IMAGE_URL=
VIDEO_ACCESS_KEY_ID=
VIDEO_SECRET_ACCESS_KEY=
VIDEO_DEFAULT_REGION=
VIDEO_ENDPOINT=
VIDEO_BUCKET=
VIDEO_STREAM_READS=
VIDEO_DISABLE_ASSERTS=
VIDEO_VISIBILITY=
VIDEO_NYC_ACCESS_KEY_ID=
VIDEO_NYC_SECRET_ACCESS_KEY=
VIDEO_NYC_DEFAULT_REGION=
VIDEO_NYC_ENDPOINT=
VIDEO_NYC_BUCKET=
VIDEO_NYC_STREAM_READS=
VIDEO_NYC_DISABLE_ASSERTS=
VIDEO_NYC_VISIBILITY=
VIDEO_FRA_ACCESS_KEY_ID=
VIDEO_FRA_SECRET_ACCESS_KEY=
VIDEO_FRA_DEFAULT_REGION=
VIDEO_FRA_ENDPOINT=
VIDEO_FRA_BUCKET=
VIDEO_FRA_STREAM_READS=
VIDEO_FRA_DISABLE_ASSERTS=
VIDEO_FRA_VISIBILITY=
AUDIO_ACCESS_KEY_ID=
AUDIO_SECRET_ACCESS_KEY=
@@ -217,6 +226,8 @@ VIDEO_PATH=/video
VIDEO_URL=
VIDEO_STREAMING_METHOD=
VIDEO_NGINX_REDIRECT=
VIDEO_ENCODER_VERSION=
VIDEO_UPLOAD_DISKS=
# web
WEB_URL=http://localhost
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Video;
use App\Actions\Models\ActionResult;
use App\Enums\Actions\ActionStatus;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class UploadResults.
*/
class UploadResults
{
/**
* Create a new action result instance.
*
* @param array<string, string|false> $uploads
*/
public function __construct(protected readonly array $uploads = [])
{
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
if (empty($this->uploads)) {
Log::error('No uploads were attempted.');
}
foreach ($this->uploads as $fs => $result) {
$result === false
? Log::error("Failed to upload to disk '$fs'")
: Log::info("Uploaded '$result' to disk '$fs'");
}
}
/**
* Transform to Action Result.
*
* @return ActionResult
*/
public function toActionResult(): ActionResult
{
if (empty($this->uploads)) {
return new ActionResult(
ActionStatus::FAILED(),
'No uploads were attempted. Please check that upload disks are configured.'
);
}
/** @var Collection $failed */
/** @var Collection $passed */
[$failed, $passed] = collect($this->uploads)->partition(fn (string|false $result, string $fs) => $result === false);
if ($failed->isNotEmpty()) {
return new ActionResult(
ActionStatus::FAILED(),
"Failed to upload to disks {$failed->keys()->join(', ', '& ')}."
);
}
return new ActionResult(
ActionStatus::PASSED(),
"Uploaded '{$passed->values()->first()}' to disks {$passed->keys()->join(', ', '& ')}."
);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Video;
use App\Actions\Models\ActionResult;
use App\Actions\Repositories\ReconcileResults;
use App\Actions\Repositories\Wiki\Video\ReconcileVideoRepositories;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Wiki\VideoRepository as VideoDestinationRepository;
use App\Repositories\Storage\Wiki\VideoRepository as VideoSourceRepository;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
/**
* Class UploadVideoAction.
*/
class UploadVideoAction
{
/**
* Create a new action instance.
*
* @param UploadedFile $file
* @param string $path
*/
public function __construct(protected readonly UploadedFile $file, protected readonly string $path)
{
}
/**
* Handle action.
*
* @return ActionResult
*/
public function handle(): ActionResult
{
$uploadResults = $this->upload();
$uploadResults->toLog();
$reconcileResults = $this->reconcileVideo();
$reconcileResults->toLog();
return $uploadResults->toActionResult();
}
/**
* Upload the video to configured disks.
*
* @return UploadResults
*/
protected function upload(): UploadResults
{
$results = [];
$uploadDisks = Config::get('video.upload_disks');
foreach ($uploadDisks as $uploadDisk) {
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($uploadDisk);
$result = $fs->putFileAs($this->path, $this->file, $this->file->getClientOriginalName());
$results[$uploadDisk] = $result;
}
return new UploadResults($results);
}
/**
* Reconcile video repository.
*
* @return ReconcileResults
*/
protected function reconcileVideo(): ReconcileResults
{
$action = new ReconcileVideoRepositories();
/** @var RepositoryInterface $sourceRepository */
$sourceRepository = App::make(VideoSourceRepository::class);
$sourceRepository->handleFilter('path', $this->path);
/** @var RepositoryInterface $destinationRepository */
$destinationRepository = App::make(VideoDestinationRepository::class);
$destinationRepository->handleFilter('path', $this->path);
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
}
@@ -8,7 +8,7 @@ use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\UpdatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\ExternalResource;
use App\Rules\Wiki\ResourceLinkMatchesSiteRule;
use App\Rules\Wiki\Resource\ResourceLinkMatchesSiteRule;
use Illuminate\Http\Request;
/**
@@ -9,7 +9,7 @@ use App\Contracts\Http\Api\Field\UpdatableField;
use App\Enums\Models\Wiki\ResourceSite;
use App\Http\Api\Field\EnumField;
use App\Models\Wiki\ExternalResource;
use App\Rules\Wiki\ResourceSiteMatchesLinkRule;
use App\Rules\Wiki\Resource\ResourceSiteMatchesLinkRule;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Http\Request;
@@ -6,7 +6,7 @@ namespace App\Nova\Actions\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Rules\Wiki\ResourceLinkMatchesSiteRule;
use App\Rules\Wiki\Resource\ResourceLinkMatchesSiteRule;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;
use Laravel\Nova\Actions\Action;
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Nova\Actions\Wiki\Video;
use App\Actions\Models\Wiki\Video\UploadVideoAction as UploadVideo;
use App\Rules\Wiki\StorageDirectoryExistsRule;
use App\Rules\Wiki\Submission\Audio\AudioChannelLayoutStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioChannelsStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioCodecStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioIndexStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioLoudnessIntegratedTargetStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioLoudnessTruePeakStreamRule;
use App\Rules\Wiki\Submission\Audio\AudioSampleRateStreamRule;
use App\Rules\Wiki\Submission\Format\BitrateRestrictionFormatRule;
use App\Rules\Wiki\Submission\Format\EncoderNameFormatRule;
use App\Rules\Wiki\Submission\Format\EncoderVersionFormatRule;
use App\Rules\Wiki\Submission\Format\ExtraneousChaptersFormatRule;
use App\Rules\Wiki\Submission\Format\ExtraneousMetadataFormatRule;
use App\Rules\Wiki\Submission\Format\FormatNameFormatRule;
use App\Rules\Wiki\Submission\Format\TotalStreamsFormatRule;
use App\Rules\Wiki\Submission\Video\VideoCodecStreamRule;
use App\Rules\Wiki\Submission\Video\VideoColorPrimariesStreamRule;
use App\Rules\Wiki\Submission\Video\VideoColorSpaceStreamRule;
use App\Rules\Wiki\Submission\Video\VideoColorTransferStreamRule;
use App\Rules\Wiki\Submission\Video\VideoIndexStreamRule;
use App\Rules\Wiki\Submission\Video\VideoPixelFormatStreamRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File as FileRule;
use Laravel\Nova\Actions\Action;
use Laravel\Nova\Fields\ActionFields;
use Laravel\Nova\Fields\File;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Http\Requests\NovaRequest;
/**
* Class UploadVideoAction.
*/
class UploadVideoAction extends Action
{
/**
* Get the displayable name of the action.
*
* @return string
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function name(): string
{
return __('nova.upload_video');
}
/**
* Perform the action on the given models.
*
* @param ActionFields $fields
* @param Collection $models
* @return array
*
* @noinspection PhpUnusedParameterInspection
*/
public function handle(ActionFields $fields, Collection $models): array
{
/** @var UploadedFile $video */
$video = $fields->get('video');
$path = $fields->get('path');
$action = new UploadVideo($video, $path);
$result = $action->handle();
if ($result->hasFailed()) {
return Action::danger($result->getMessage());
}
return Action::message($result->getMessage());
}
/**
* Get the fields available on the action.
*
* @param NovaRequest $request
* @return array
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function fields(NovaRequest $request): array
{
$fs = Storage::disk(Config::get('video.disk'));
return [
File::make(__('nova.video'), 'video')
->required()
->rules([
'required',
FileRule::types('webm')->max(200 * 1024),
new TotalStreamsFormatRule(),
new EncoderNameFormatRule(),
new EncoderVersionFormatRule(),
new FormatNameFormatRule(),
new BitrateRestrictionFormatRule(),
new ExtraneousMetadataFormatRule(),
new ExtraneousChaptersFormatRule(),
new AudioIndexStreamRule(),
new AudioCodecStreamRule(),
new AudioSampleRateStreamRule(),
new AudioChannelsStreamRule(),
new AudioChannelLayoutStreamRule(),
new AudioLoudnessTruePeakStreamRule(),
new AudioLoudnessIntegratedTargetStreamRule(),
new VideoIndexStreamRule(),
new VideoCodecStreamRule(),
new VideoPixelFormatStreamRule(),
new VideoColorSpaceStreamRule(),
new VideoColorTransferStreamRule(),
new VideoColorPrimariesStreamRule(),
])
->help(__('nova.upload_video_help')),
Text::make(__('nova.path'), 'path')
->required()
->rules(['required', 'string', 'doesnt_start_with:/', new StorageDirectoryExistsRule($fs)])
->help(__('nova.upload_video_path_help')),
];
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ use App\Pivots\AnimeResource;
use App\Pivots\ArtistResource;
use App\Pivots\BasePivot;
use App\Pivots\StudioResource;
use App\Rules\Wiki\ResourceLinkMatchesSiteRule;
use App\Rules\Wiki\Resource\ResourceLinkMatchesSiteRule;
use BenSampo\Enum\Enum;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Validation\Rule;
+12
View File
@@ -10,6 +10,7 @@ use App\Models\Auth\User;
use App\Models\Wiki\Video as VideoModel;
use App\Nova\Actions\Wiki\Video\BackfillAudioAction;
use App\Nova\Actions\Wiki\Video\ReconcileVideoAction;
use App\Nova\Actions\Wiki\Video\UploadVideoAction;
use App\Nova\Lenses\Video\VideoAudioLens;
use App\Nova\Lenses\Video\VideoResolutionLens;
use App\Nova\Lenses\Video\VideoSourceLens;
@@ -282,6 +283,17 @@ class Video extends BaseResource
return $user instanceof User && $user->can('update video');
}),
(new UploadVideoAction())
->confirmButtonText(__('nova.upload'))
->cancelButtonText(__('nova.cancel'))
->onlyOnIndex()
->standalone()
->canSee(function (Request $request) {
$user = $request->user();
return $user instanceof User && $user->can('create video');
}),
(new ReconcileVideoAction())
->confirmButtonText(__('nova.reconcile'))
->cancelButtonText(__('nova.cancel'))
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Rules\Wiki;
namespace App\Rules\Wiki\Resource;
use App\Enums\Models\Wiki\ResourceSite;
use Illuminate\Contracts\Validation\Rule;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Rules\Wiki;
namespace App\Rules\Wiki\Resource;
use App\Enums\Models\Wiki\ResourceSite;
use Illuminate\Contracts\Validation\Rule;
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class AudioChannelLayoutStreamRule.
*/
class AudioChannelLayoutStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$audio = $this->streams()
->audios()
->first();
return $audio !== null && $audio->get('channel_layout') === 'stereo';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_channel_layout');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class AudioChannelsStreamRule.
*/
class AudioChannelsStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$audio = $this->streams()
->audios()
->first();
return $audio !== null && $audio->get('channels') === 2;
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_channels');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class AudioCodecStreamRule.
*/
class AudioCodecStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$audio = $this->streams()
->audios()
->first();
return $audio !== null && $audio->get('codec_name') === 'opus';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_codec');
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use FFMpeg\FFProbe\DataMapping\Stream;
use Illuminate\Http\UploadedFile;
/**
* Class AudioIndexStreamRule.
*/
class AudioIndexStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$streams = $this->streams()->all();
return collect($streams)->contains(fn (Stream $stream) => $stream->isAudio() && $stream->get('index') === 1);
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_index');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
/**
* Class AudioLoudnessIntegratedTargetStreamRule.
*/
class AudioLoudnessIntegratedTargetStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$loudness = $this->loudness();
$target = floatval(Arr::get($loudness, 'input_i'));
return $target >= -17.0 && $target <= -14.0;
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_loudness_integrated_target');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
/**
* Class AudioLoudnessTruePeakStreamRule.
*/
class AudioLoudnessTruePeakStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$loudness = $this->loudness();
$peak = floatval(Arr::get($loudness, 'input_tp'));
return $peak <= 0.0;
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_loudness_true_peak');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Audio;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class AudioSampleRateStreamRule.
*/
class AudioSampleRateStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$audio = $this->streams()
->audios()
->first();
return $audio !== null && $audio->get('sample_rate') === '48000';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.audio_sample_rate');
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
/**
* Class BitrateRestrictionFormatRule.
*/
class BitrateRestrictionFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$format = $this->format()->all();
$video = $this->streams()
->videos()
->first();
$bitrate = intval(Arr::get($format, 'bit_rate'));
$height = $video->getDimensions()->getHeight();
// Linear approximation of egregious bitrate by resolution
return $bitrate < $height * 7100 + 475000;
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_bitrate_restriction');
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
/**
* Class EncoderNameFormatRule.
*/
class EncoderNameFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$format = $this->format()->all();
$tags = Arr::get($format, 'tags');
$tags = array_change_key_case($tags);
$encoder = Arr::get($tags, 'encoder');
return Str::startsWith($encoder, 'Lavf');
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_encoder_name');
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
/**
* Class EncoderVersionFormatRule.
*/
class EncoderVersionFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$format = $this->format()->all();
$tags = Arr::get($format, 'tags');
$tags = array_change_key_case($tags);
$encoder = Arr::get($tags, 'encoder');
return version_compare($encoder, Config::get('video.encoder_version'), '>=');
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_encoder_version');
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use Alchemy\BinaryDriver\Exception\ExecutionFailureException;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class ExtraneousChaptersRule.
*/
class ExtraneousChaptersFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*
* @throws ExecutionFailureException
*/
public function passes($attribute, $value): bool
{
return empty($this->chapters());
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_extraneous_chapters');
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
/**
* Class ExtraneousMetadataFormatRule.
*/
class ExtraneousMetadataFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$format = $this->format()->all();
$tags = Arr::get($format, 'tags');
$tags = array_change_key_case($tags);
return collect($tags)->keys()->diff(['encoder', 'duration'])->isEmpty();
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_extraneous_metadata');
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
/**
* Class FormatNameFormatRule.
*/
class FormatNameFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$format = $this->format()->all();
$formatName = Arr::get($format, 'format_name');
return $formatName === 'matroska,webm';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_format_name');
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Format;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class TotalStreamsFormatRule.
*/
class TotalStreamsFormatRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$streams = $this->streams();
return $streams->count() === 2;
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.format_total_streams');
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission;
use FFMpeg\FFProbe\DataMapping\Format;
use FFMpeg\FFProbe\DataMapping\StreamCollection;
use FFMpeg\FFProbe\Mapper;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
use ProtoneMedia\LaravelFFMpeg\FFMpeg\FFProbe;
use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;
/**
* Class SubmissionRule.
*/
abstract class SubmissionRule implements DataAwareRule, Rule, ValidatorAwareRule
{
/**
* The data under validation.
*
* @var array
*/
protected array $data = [];
/**
* Set the data under validation.
*
* @param array $data
* @return $this
*/
public function setData($data): self
{
$this->data = $data;
return $this;
}
/**
* Set the current validator.
*
* @param Validator $validator
* @return $this
*/
public function setValidator($validator): self
{
/** @var UploadedFile|null $file */
$file = Arr::get($validator->getData(), 'video');
$ffprobeData = Arr::get($validator->getData(), 'ffprobeData');
if ($ffprobeData === null && $file !== null) {
$data = array_merge($validator->getData(), ['ffprobeData' => $this->getFFprobeData($file)]);
$validator->setData($data);
}
$loudnessStats = Arr::get($validator->getData(), 'loudnessStats');
if ($loudnessStats === null && $file !== null) {
$data = array_merge($validator->getData(), ['loudnessStats' => $this->getLoudnessStats($file)]);
$validator->setData($data);
}
return $this;
}
/**
* Get the FFprobe data for the uploaded file.
*
* @param UploadedFile $file
* @return array
*/
protected function getFFprobeData(UploadedFile $file): array
{
$commands = [
$file->path(),
'-v',
'quiet',
'-print_format',
'json',
'-show_streams',
'-show_format',
'-show_chapters',
];
$output = FFProbe::create()
->getFFProbeDriver()
->command($commands);
return json_decode($output, true);
}
/**
* Get the loudness stats for the uploaded file.
*
* @param UploadedFile $file
* @return array
*/
protected function getLoudnessStats(UploadedFile $file): array
{
$filter = [
'-hide_banner',
'-nostats',
'-vn',
'-sn',
'-dn',
'-filter:a',
'loudnorm=I=-16:LRA=20:TP=-1:dual_mono=true:linear=true:print_format=json',
'-f',
'null',
];
$output = FFMpeg::open($file)
->export()
->addFilter($filter)
->getProcessOutput();
$output = Arr::join($output->all(), '');
$loudness = Str::match('/{[^}]*}/m', $output);
return json_decode($loudness, true);
}
/**
* Get submission streams.
*
* @return StreamCollection
*/
protected function streams(): StreamCollection
{
$ffprobeData = Arr::get($this->data, 'ffprobeData');
$mapper = new Mapper();
return $mapper->map('streams', $ffprobeData);
}
/**
* Get submission format.
*
* @return Format
*/
protected function format(): Format
{
$ffprobeData = Arr::get($this->data, 'ffprobeData');
$mapper = new Mapper();
return $mapper->map('format', $ffprobeData);
}
/**
* Get submission chapters.
*
* @return array
*/
protected function chapters(): array
{
return Arr::get($this->data, 'ffprobeData.chapters');
}
/**
* Get the submission loudness stats.
*
* @return array
*/
protected function loudness(): array
{
return Arr::get($this->data, 'loudnessStats');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class VideoCodecStreamRule.
*/
class VideoCodecStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$video = $this->streams()
->videos()
->first();
return $video !== null && $video->get('codec_name') === 'vp9';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_codec');
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class VideoColorPrimariesStreamRule.
*/
class VideoColorPrimariesStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$video = $this->streams()
->videos()
->first();
$colorPrimaries = ['bt709', 'smpte170m', 'bt470bg'];
return $video !== null && in_array($video->get('color_primaries'), $colorPrimaries);
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_color_primaries');
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class VideoColorSpaceStreamRule.
*/
class VideoColorSpaceStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$video = $this->streams()
->videos()
->first();
$colorSpaces = ['bt709', 'smpte170m', 'bt470bg'];
return $video !== null && in_array($video->get('color_space'), $colorSpaces);
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_color_space');
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class VideoColorTransferStreamRule.
*/
class VideoColorTransferStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$video = $this->streams()
->videos()
->first();
$colorTransfers = ['bt709', 'smpte170m', 'bt470bg'];
return $video !== null && in_array($video->get('color_transfer'), $colorTransfers);
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_color_transfer');
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use FFMpeg\FFProbe\DataMapping\Stream;
use Illuminate\Http\UploadedFile;
/**
* Class VideoIndexStreamRule.
*/
class VideoIndexStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$streams = $this->streams()->all();
return collect($streams)->contains(fn (Stream $stream) => $stream->isVideo() && $stream->get('index') === 0);
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_index');
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Rules\Wiki\Submission\Video;
use App\Rules\Wiki\Submission\SubmissionRule;
use Illuminate\Http\UploadedFile;
/**
* Class VideoPixelFormatStreamRule.
*/
class VideoPixelFormatStreamRule extends SubmissionRule
{
/**
* Determine if the validation rule passes.
*
* @param string $attribute
* @param UploadedFile $value
* @return bool
*/
public function passes($attribute, $value): bool
{
$video = $this->streams()
->videos()
->first();
return $video !== null && $video->get('pix_fmt') === 'yuv420p';
}
/**
* Get the validation error message.
*
* @return string|array
*/
public function message(): string|array
{
return __('validation.submission.video_pixel_format');
}
}
+22 -9
View File
@@ -68,16 +68,29 @@ return [
'throw' => false,
],
'videos' => [
'videos_nyc' => [
'driver' => 's3',
'key' => env('VIDEO_ACCESS_KEY_ID'),
'secret' => env('VIDEO_SECRET_ACCESS_KEY'),
'region' => env('VIDEO_DEFAULT_REGION'),
'bucket' => env('VIDEO_BUCKET'),
'endpoint' => env('VIDEO_ENDPOINT'),
'stream_reads' => env('VIDEO_STREAM_READS'),
'disable_asserts' => env('VIDEO_DISABLE_ASSERTS'),
'visibility' => env('VIDEO_VISIBILITY'),
'key' => env('VIDEO_NYC_ACCESS_KEY_ID'),
'secret' => env('VIDEO_NYC_SECRET_ACCESS_KEY'),
'region' => env('VIDEO_NYC_DEFAULT_REGION'),
'bucket' => env('VIDEO_NYC_BUCKET'),
'endpoint' => env('VIDEO_NYC_ENDPOINT'),
'stream_reads' => env('VIDEO_NYC_STREAM_READS'),
'disable_asserts' => env('VIDEO_NYC_DISABLE_ASSERTS'),
'visibility' => env('VIDEO_NYC_VISIBILITY'),
'throw' => false,
],
'videos_fra' => [
'driver' => 's3',
'key' => env('VIDEO_FRA_ACCESS_KEY_ID'),
'secret' => env('VIDEO_FRA_SECRET_ACCESS_KEY'),
'region' => env('VIDEO_FRA_DEFAULT_REGION'),
'bucket' => env('VIDEO_FRA_BUCKET'),
'endpoint' => env('VIDEO_FRA_ENDPOINT'),
'stream_reads' => env('VIDEO_FRA_STREAM_READS'),
'disable_asserts' => env('VIDEO_FRA_DISABLE_ASSERTS'),
'visibility' => env('VIDEO_FRA_VISIBILITY'),
'throw' => false,
],
+16
View File
@@ -49,4 +49,20 @@ return [
'streaming_method' => env('VIDEO_STREAMING_METHOD', 'response'),
'nginx_redirect' => env('VIDEO_NGINX_REDIRECT', '/video_redirect/'),
/*
|--------------------------------------------------------------------------
| Video Uploading
|--------------------------------------------------------------------------
|
| These values facilitate the validation and uploading of video in-system to object storage.
| For validation, we want to enforce that the latest FFmpeg version is used.
| We will use the libavformat version of the form "Lavf{major}.{minor}.{patch]".
| For uploading, we want to specify the target disks to upload the file.
|
*/
'encoder_version' => env('VIDEO_ENCODER_VERSION'),
'upload_disks' => explode(',', env('VIDEO_UPLOAD_DISKS')),
];
+4
View File
@@ -21,6 +21,10 @@ parameters:
- '#Call to an undefined method Mockery\\ExpectationInterface|Mockery\\HigherOrderMessage::once\(\).#'
- '#Strict comparison using === between class-string<Laravel\\Nova\\Resource> and null will always evaluate to false.#'
- '#Call to an undefined method ProtoneMedia\\LaravelFFMpeg\\Drivers\\PHPFFMpeg::export\(\).#'
- '#Call to an undefined method ProtoneMedia\\LaravelFFMpeg\\Drivers\\PHPFFMpeg::getProcessOutput\(\).#'
-
message: '#Unreachable statement - code above always terminates.#'
path: app/Http/Middleware/ThrottleRequestsWithService.php
-
message: '#Method App\\Rules\\Wiki\\Submission\\SubmissionRule::#'
path: app/Rules/Wiki/Submission/SubmissionRule.php
+4
View File
@@ -228,6 +228,10 @@ return [
'updated_at_end' => 'Updated At End',
'updated_at_start' => 'Updated At Start',
'updated_at' => 'Updated At',
'upload_video_help' => 'The WebM to upload. WebMs will be uploaded to each configured storage disk.',
'upload_video_path_help' => 'The directory the video will be uploaded to. Ex: 2022/Spring.',
'upload_video' => 'Upload Video',
'upload' => 'Upload',
'usage' => 'Usage',
'user' => 'User',
'users' => 'Users',
+23
View File
@@ -135,6 +135,29 @@ return [
],
'starts_with' => 'The :attribute must start with one of the following: :values.',
'string' => 'The :attribute must be a string.',
'submission' => [
'audio_channel_layout' => 'Incorrect audio layout.',
'audio_channels' => 'Incorrect audio channels.',
'audio_codec' => 'Incorrect audio codec.',
'audio_index' => 'Second stream is not audio.',
'audio_loudness_integrated_target' => 'Unexpected target loudness.',
'audio_loudness_true_peak' => 'Unexpected true peak.',
'audio_sample_rate' => 'Incorrect sample rate.',
'file_name' => 'File name is not expected pattern of \'[Title]-{OP|ED}#v#-[Tags]\'',
'format_bitrate_restriction' => 'File size restriction violated.',
'format_encoder_name' => 'Incorrect encoder.',
'format_encoder_version' => 'FFmpeg build is out of date.',
'format_extraneous_chapters' => 'Extraneous menu data.',
'format_extraneous_metadata' => 'Extraneous source file metadata.',
'format_format_name' => 'Incorrect file format.',
'format_total_streams' => 'The file must have exactly two streams.',
'video_codec' => 'Incorrect video codec.',
'video_color_primaries' => 'Unexpected color primaries.',
'video_color_space' => 'Unexpected color space.',
'video_color_transfer' => 'Unexpected color transfer.',
'video_index' => 'First stream is not video.',
'video_pixel_format' => 'Incorrect pixel format.',
],
'timezone' => 'The :attribute must be a valid timezone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Actions\Models\Wiki\Video;
use App\Actions\Models\Wiki\Video\UploadVideoAction;
use App\Enums\Actions\ActionStatus;
use App\Models\Wiki\Video;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Http\Testing\File;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* Class UploadVideoTest.
*/
class UploadVideoTest extends TestCase
{
use WithFaker;
/**
* The Upload Video Action shall fail if there are no uploads.
*
* @return void
*/
public function testDefault(): void
{
Config::set('video.upload_disks', []);
Storage::fake(Config::get('video.disk'));
$file = File::fake()->create($this->faker->word().'.webm', $this->faker->randomDigitNotNull());
$action = new UploadVideoAction($file, $this->faker->word());
$result = $action->handle();
static::assertTrue($result->hasFailed());
}
/**
* The Upload Video Action shall pass if given a valid file.
*
* @return void
*/
public function testPassed(): void
{
Storage::fake(Config::get('video.disk'));
Config::set('video.upload_disks', [Config::get('video.disk')]);
$file = File::fake()->create($this->faker->word().'.webm', $this->faker->randomDigitNotNull());
$action = new UploadVideoAction($file, $this->faker->word());
$result = $action->handle();
static::assertTrue(ActionStatus::PASSED()->is($result->getStatus()));
}
/**
* The Upload Video Action shall upload the file to the configured disk.
*
* @return void
*/
public function testUploadedToDisk(): void
{
Storage::fake(Config::get('video.disk'));
Config::set('video.upload_disks', [Config::get('video.disk')]);
$file = File::fake()->create($this->faker->word().'.webm', $this->faker->randomDigitNotNull());
$action = new UploadVideoAction($file, $this->faker->word());
$action->handle();
static::assertCount(1, Storage::disk(Config::get('video.disk'))->allFiles());
}
/**
* The Upload Video Action shall upload the file to the configured disk.
*
* @return void
*/
public function testCreatedVideo(): void
{
Storage::fake(Config::get('video.disk'));
Config::set('video.upload_disks', [Config::get('video.disk')]);
$file = File::fake()->create($this->faker->word().'.webm', $this->faker->randomDigitNotNull());
$action = new UploadVideoAction($file, $this->faker->word());
$action->handle();
static::assertDatabaseCount(Video::class, 1);
}
}
@@ -10,6 +10,8 @@ use App\Repositories\Storage\Wiki\VideoRepository;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\WithoutEvents;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface;
use Tests\TestCase;
@@ -28,6 +30,8 @@ class VideoReconcileTest extends TestCase
*/
public function testNoResults(): void
{
Storage::fake(Config::get('video.disk'));
$this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command
$this->mock(VideoRepository::class, function (MockInterface $mock) {
@@ -44,6 +48,8 @@ class VideoReconcileTest extends TestCase
*/
public function testCreated(): void
{
Storage::fake(Config::get('video.disk'));
$this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command
$createdVideoCount = $this->faker->numberBetween(2, 9);
@@ -64,6 +70,8 @@ class VideoReconcileTest extends TestCase
*/
public function testDeleted(): void
{
Storage::fake(Config::get('video.disk'));
$deletedVideoCount = $this->faker->numberBetween(2, 9);
Video::factory()->count($deletedVideoCount)->create();
@@ -82,6 +90,8 @@ class VideoReconcileTest extends TestCase
*/
public function testUpdated(): void
{
Storage::fake(Config::get('video.disk'));
$updatedVideoCount = $this->faker->numberBetween(2, 9);
$basenames = collect($this->faker->words($updatedVideoCount));
+3
View File
@@ -10,6 +10,7 @@ use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\WithoutEvents;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
@@ -116,6 +117,8 @@ class VideoTest extends TestCase
*/
public function testStreamedThroughResponse(): void
{
Storage::fake(Config::get('video.disk'));
Config::set(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED, true);
Config::set('video.streaming_method', 'response');
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Actions\Models\Wiki\Video;
use App\Actions\Models\Wiki\Video\UploadResults;
use App\Enums\Actions\ActionStatus;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
/**
* Class UploadResultsTest.
*/
class UploadResultsTest extends TestCase
{
use WithFaker;
/**
* The Action result has failed if there are no uploads.
*
* @return void
*/
public function testDefault(): void
{
$uploadResults = new UploadResults();
$result = $uploadResults->toActionResult();
static::assertTrue($result->hasFailed());
}
/**
* The Action result has failed if any uploads have returned false.
*
* @return void
*/
public function testFailed(): void
{
$uploads = [];
foreach (range(0, $this->faker->randomDigitNotNull()) as $ignored) {
$uploads[$this->faker->word()] = $this->faker->filePath();
}
foreach (range(0, $this->faker->randomDigitNotNull()) as $ignored) {
$uploads[$this->faker->word()] = false;
}
$uploadResults = new UploadResults($uploads);
$result = $uploadResults->toActionResult();
static::assertTrue($result->hasFailed());
}
/**
* The Action result has passed if all uploads have returned the file path.
*
* @return void
*/
public function testPassed(): void
{
$uploads = [];
foreach (range(0, $this->faker->randomDigitNotNull()) as $ignored) {
$uploads[$this->faker->word()] = $this->faker->filePath();
}
$uploadResults = new UploadResults($uploads);
$result = $uploadResults->toActionResult();
static::assertTrue(ActionStatus::PASSED()->is($result->getStatus()));
}
}
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Rules\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Rules\Wiki\ResourceLinkMatchesSiteRule;
use App\Rules\Wiki\Resource\ResourceLinkMatchesSiteRule;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Tests\Unit\Rules\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Rules\Wiki\ResourceSiteMatchesLinkRule;
use App\Rules\Wiki\Resource\ResourceSiteMatchesLinkRule;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;