diff --git a/.env.example b/.env.example index ef6bf3a49..ab8129d03 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/Actions/Models/Wiki/Video/UploadResults.php b/app/Actions/Models/Wiki/Video/UploadResults.php new file mode 100644 index 000000000..7de0ae9c5 --- /dev/null +++ b/app/Actions/Models/Wiki/Video/UploadResults.php @@ -0,0 +1,73 @@ + $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(', ', '& ')}." + ); + } +} diff --git a/app/Actions/Models/Wiki/Video/UploadVideoAction.php b/app/Actions/Models/Wiki/Video/UploadVideoAction.php new file mode 100644 index 000000000..92a4e252f --- /dev/null +++ b/app/Actions/Models/Wiki/Video/UploadVideoAction.php @@ -0,0 +1,93 @@ +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); + } +} diff --git a/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceLinkField.php b/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceLinkField.php index 4baeb6b4e..02b78ef49 100644 --- a/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceLinkField.php +++ b/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceLinkField.php @@ -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; /** diff --git a/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceSiteField.php b/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceSiteField.php index a764021d7..ae87e5fbb 100644 --- a/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceSiteField.php +++ b/app/Http/Api/Field/Wiki/ExternalResource/ExternalResourceSiteField.php @@ -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; diff --git a/app/Nova/Actions/Wiki/AttachResourceAction.php b/app/Nova/Actions/Wiki/AttachResourceAction.php index 415510a03..c4bed4b93 100644 --- a/app/Nova/Actions/Wiki/AttachResourceAction.php +++ b/app/Nova/Actions/Wiki/AttachResourceAction.php @@ -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; diff --git a/app/Nova/Actions/Wiki/Video/UploadVideoAction.php b/app/Nova/Actions/Wiki/Video/UploadVideoAction.php new file mode 100644 index 000000000..e0c0e85f9 --- /dev/null +++ b/app/Nova/Actions/Wiki/Video/UploadVideoAction.php @@ -0,0 +1,130 @@ +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')), + ]; + } +} diff --git a/app/Nova/Resources/Wiki/ExternalResource.php b/app/Nova/Resources/Wiki/ExternalResource.php index a1a348633..6105a7ad8 100644 --- a/app/Nova/Resources/Wiki/ExternalResource.php +++ b/app/Nova/Resources/Wiki/ExternalResource.php @@ -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; diff --git a/app/Nova/Resources/Wiki/Video.php b/app/Nova/Resources/Wiki/Video.php index cc788b05c..06de2525b 100644 --- a/app/Nova/Resources/Wiki/Video.php +++ b/app/Nova/Resources/Wiki/Video.php @@ -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')) diff --git a/app/Rules/Wiki/ResourceLinkMatchesSiteRule.php b/app/Rules/Wiki/Resource/ResourceLinkMatchesSiteRule.php similarity index 96% rename from app/Rules/Wiki/ResourceLinkMatchesSiteRule.php rename to app/Rules/Wiki/Resource/ResourceLinkMatchesSiteRule.php index 086d1054c..d4e5eead1 100644 --- a/app/Rules/Wiki/ResourceLinkMatchesSiteRule.php +++ b/app/Rules/Wiki/Resource/ResourceLinkMatchesSiteRule.php @@ -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; diff --git a/app/Rules/Wiki/ResourceSiteMatchesLinkRule.php b/app/Rules/Wiki/Resource/ResourceSiteMatchesLinkRule.php similarity index 96% rename from app/Rules/Wiki/ResourceSiteMatchesLinkRule.php rename to app/Rules/Wiki/Resource/ResourceSiteMatchesLinkRule.php index bdf508b17..93af8a119 100644 --- a/app/Rules/Wiki/ResourceSiteMatchesLinkRule.php +++ b/app/Rules/Wiki/Resource/ResourceSiteMatchesLinkRule.php @@ -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; diff --git a/app/Rules/Wiki/Submission/Audio/AudioChannelLayoutStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioChannelLayoutStreamRule.php new file mode 100644 index 000000000..ef4b2db34 --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioChannelLayoutStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioChannelsStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioChannelsStreamRule.php new file mode 100644 index 000000000..1984bc94d --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioChannelsStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioCodecStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioCodecStreamRule.php new file mode 100644 index 000000000..0a4ec0d8a --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioCodecStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioIndexStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioIndexStreamRule.php new file mode 100644 index 000000000..3382b0a43 --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioIndexStreamRule.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioLoudnessIntegratedTargetStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioLoudnessIntegratedTargetStreamRule.php new file mode 100644 index 000000000..937d005e0 --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioLoudnessIntegratedTargetStreamRule.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioLoudnessTruePeakStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioLoudnessTruePeakStreamRule.php new file mode 100644 index 000000000..783b95a3d --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioLoudnessTruePeakStreamRule.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Audio/AudioSampleRateStreamRule.php b/app/Rules/Wiki/Submission/Audio/AudioSampleRateStreamRule.php new file mode 100644 index 000000000..6a15aedcd --- /dev/null +++ b/app/Rules/Wiki/Submission/Audio/AudioSampleRateStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/BitrateRestrictionFormatRule.php b/app/Rules/Wiki/Submission/Format/BitrateRestrictionFormatRule.php new file mode 100644 index 000000000..4177757e6 --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/BitrateRestrictionFormatRule.php @@ -0,0 +1,47 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/EncoderNameFormatRule.php b/app/Rules/Wiki/Submission/Format/EncoderNameFormatRule.php new file mode 100644 index 000000000..9b03c446b --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/EncoderNameFormatRule.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/EncoderVersionFormatRule.php b/app/Rules/Wiki/Submission/Format/EncoderVersionFormatRule.php new file mode 100644 index 000000000..20e5ca69b --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/EncoderVersionFormatRule.php @@ -0,0 +1,46 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/ExtraneousChaptersFormatRule.php b/app/Rules/Wiki/Submission/Format/ExtraneousChaptersFormatRule.php new file mode 100644 index 000000000..4b4381c86 --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/ExtraneousChaptersFormatRule.php @@ -0,0 +1,39 @@ +chapters()); + } + + /** + * Get the validation error message. + * + * @return string|array + */ + public function message(): string|array + { + return __('validation.submission.format_extraneous_chapters'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/ExtraneousMetadataFormatRule.php b/app/Rules/Wiki/Submission/Format/ExtraneousMetadataFormatRule.php new file mode 100644 index 000000000..40f78b81e --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/ExtraneousMetadataFormatRule.php @@ -0,0 +1,43 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/FormatNameFormatRule.php b/app/Rules/Wiki/Submission/Format/FormatNameFormatRule.php new file mode 100644 index 000000000..66967cda0 --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/FormatNameFormatRule.php @@ -0,0 +1,41 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Format/TotalStreamsFormatRule.php b/app/Rules/Wiki/Submission/Format/TotalStreamsFormatRule.php new file mode 100644 index 000000000..2479e4b6c --- /dev/null +++ b/app/Rules/Wiki/Submission/Format/TotalStreamsFormatRule.php @@ -0,0 +1,38 @@ +streams(); + + return $streams->count() === 2; + } + + /** + * Get the validation error message. + * + * @return string|array + */ + public function message(): string|array + { + return __('validation.submission.format_total_streams'); + } +} diff --git a/app/Rules/Wiki/Submission/SubmissionRule.php b/app/Rules/Wiki/Submission/SubmissionRule.php new file mode 100644 index 000000000..6ea8946e3 --- /dev/null +++ b/app/Rules/Wiki/Submission/SubmissionRule.php @@ -0,0 +1,177 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoCodecStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoCodecStreamRule.php new file mode 100644 index 000000000..0e90ccd9d --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoCodecStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoColorPrimariesStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoColorPrimariesStreamRule.php new file mode 100644 index 000000000..5f0eb8c83 --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoColorPrimariesStreamRule.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoColorSpaceStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoColorSpaceStreamRule.php new file mode 100644 index 000000000..def598064 --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoColorSpaceStreamRule.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoColorTransferStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoColorTransferStreamRule.php new file mode 100644 index 000000000..a65e7a0bf --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoColorTransferStreamRule.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoIndexStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoIndexStreamRule.php new file mode 100644 index 000000000..b95ac6a11 --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoIndexStreamRule.php @@ -0,0 +1,39 @@ +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'); + } +} diff --git a/app/Rules/Wiki/Submission/Video/VideoPixelFormatStreamRule.php b/app/Rules/Wiki/Submission/Video/VideoPixelFormatStreamRule.php new file mode 100644 index 000000000..073c8456b --- /dev/null +++ b/app/Rules/Wiki/Submission/Video/VideoPixelFormatStreamRule.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 68b8057a0..5b04c4f01 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -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, ], diff --git a/config/video.php b/config/video.php index 32e47427c..a8606b956 100644 --- a/config/video.php +++ b/config/video.php @@ -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')), ]; diff --git a/phpstan.neon b/phpstan.neon index a7dcff60f..24d50dd6e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -21,6 +21,10 @@ parameters: - '#Call to an undefined method Mockery\\ExpectationInterface|Mockery\\HigherOrderMessage::once\(\).#' - '#Strict comparison using === between class-string 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 diff --git a/resources/lang/en/nova.php b/resources/lang/en/nova.php index d246e0003..e09d85d5c 100644 --- a/resources/lang/en/nova.php +++ b/resources/lang/en/nova.php @@ -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', diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index b83edd6d4..d2f4ca253 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -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.', diff --git a/tests/Feature/Actions/Models/Wiki/Video/UploadVideoTest.php b/tests/Feature/Actions/Models/Wiki/Video/UploadVideoTest.php new file mode 100644 index 000000000..d80de3971 --- /dev/null +++ b/tests/Feature/Actions/Models/Wiki/Video/UploadVideoTest.php @@ -0,0 +1,98 @@ +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); + } +} diff --git a/tests/Feature/Console/Commands/Wiki/Video/VideoReconcileTest.php b/tests/Feature/Console/Commands/Wiki/Video/VideoReconcileTest.php index 504be10c1..c266a05d6 100644 --- a/tests/Feature/Console/Commands/Wiki/Video/VideoReconcileTest.php +++ b/tests/Feature/Console/Commands/Wiki/Video/VideoReconcileTest.php @@ -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)); diff --git a/tests/Feature/Http/Wiki/VideoTest.php b/tests/Feature/Http/Wiki/VideoTest.php index 59756360f..24615a2d2 100644 --- a/tests/Feature/Http/Wiki/VideoTest.php +++ b/tests/Feature/Http/Wiki/VideoTest.php @@ -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'); diff --git a/tests/Unit/Actions/Models/Wiki/Video/UploadResultsTest.php b/tests/Unit/Actions/Models/Wiki/Video/UploadResultsTest.php new file mode 100644 index 000000000..60d3fa94a --- /dev/null +++ b/tests/Unit/Actions/Models/Wiki/Video/UploadResultsTest.php @@ -0,0 +1,76 @@ +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())); + } +} diff --git a/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php b/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php index c52a3431c..75491a402 100644 --- a/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php +++ b/tests/Unit/Rules/Wiki/ResourceLinkMatchesSiteTest.php @@ -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; diff --git a/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php b/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php index 4bc79a7da..27bff5a0a 100644 --- a/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php +++ b/tests/Unit/Rules/Wiki/ResourceSiteMatchesLinkTest.php @@ -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;