From 8b6b196c99a3b424d92a60d05adf286eb3fbb222 Mon Sep 17 00:00:00 2001 From: paranarimasu <33796518+paranarimasu@users.noreply.github.com> Date: Sat, 24 Sep 2022 00:01:37 -0500 Subject: [PATCH] feat(admin): migrate db dumps from github to platform (#465) * feat: migrate encoding scripts from github to platform * style: fix StyleCI finding * fix: don't concatenate updated videos when attempting to attach entry on video upload * fix(api): fix field dependencies & feat(admin): upload script from video detail screen --- .env.example | 15 + app/Actions/Repositories/ReconcileResults.php | 2 +- .../ReconcileScriptRepositoriesAction.php | 88 ++ .../Video/Script/ReconcileScriptResults.php | 26 + .../Wiki/Video/Script/DeleteScriptAction.php | 52 ++ .../Wiki/Video/Script/MoveScriptAction.php | 64 ++ .../Wiki/Video/Script/UploadScriptAction.php | 87 ++ .../Storage/Wiki/Video/UploadVideoAction.php | 57 +- .../Video/ReconcilesScriptRepositories.php | 50 ++ .../Wiki/Video/ScriptReconcileCommand.php | 43 + .../Commands/Storage/StorageCommand.php | 2 + app/Constants/Config/FlagConstants.php | 4 + app/Constants/Config/VideoConstants.php | 6 + .../Wiki/Video/Script/VideoScriptCreated.php | 46 ++ .../Wiki/Video/Script/VideoScriptDeleted.php | 69 ++ .../Wiki/Video/Script/VideoScriptRestored.php | 46 ++ .../Wiki/Video/Script/VideoScriptUpdated.php | 47 ++ .../Api/Field/Admin/Dump/DumpLinkField.php | 22 + .../Api/Field/Admin/Dump/DumpPathField.php | 14 - .../FlagsAllowScriptDownloadingField.php | 22 + .../Wiki/Video/Script/ScriptLinkField.php | 22 + .../Wiki/Video/Script/ScriptPathField.php | 56 ++ .../Wiki/Video/Script/ScriptVideoIdField.php | 73 ++ .../Wiki/Video/Script/ScriptReadQuery.php | 63 ++ .../Wiki/Video/Script/ScriptWriteQuery.php | 50 ++ app/Http/Api/Schema/Admin/DumpSchema.php | 2 + app/Http/Api/Schema/Config/FlagsSchema.php | 2 + .../Api/Schema/Wiki/Video/ScriptSchema.php | 72 ++ app/Http/Api/Schema/Wiki/VideoSchema.php | 2 + .../Api/Wiki/Video/ScriptController.php | 124 +++ .../Wiki/Video/Script/ScriptController.php | 33 + .../Video/Script/ScriptDestroyRequest.php | 37 + .../Video/Script/ScriptForceDeleteRequest.php | 37 + .../Wiki/Video/Script/ScriptIndexRequest.php | 37 + .../Video/Script/ScriptRestoreRequest.php | 37 + .../Wiki/Video/Script/ScriptShowRequest.php | 37 + .../Wiki/Video/Script/ScriptStoreRequest.php | 37 + .../Wiki/Video/Script/ScriptUpdateRequest.php | 37 + .../Config/Resource/FlagsResource.php | 4 + .../Resources/Wiki/Resource/VideoResource.php | 2 + .../Video/Collection/ScriptCollection.php | 36 + .../Wiki/Video/Resource/ScriptResource.php | 83 ++ app/Models/Wiki/Video.php | 14 + app/Models/Wiki/Video/VideoScript.php | 95 +++ .../Video/Script/ReconcileScriptAction.php | 40 + app/Nova/Actions/Storage/Base/MoveAction.php | 3 +- .../Wiki/Video/Script/DeleteScriptAction.php | 43 + .../Wiki/Video/Script/MoveScriptAction.php | 83 ++ .../Wiki/Video/Script/UploadScriptAction.php | 96 +++ .../Storage/Wiki/Video/UploadVideoAction.php | 20 +- app/Nova/Lenses/Video/VideoScriptLens.php | 144 ++++ app/Nova/Resources/Wiki/Video.php | 23 +- app/Nova/Resources/Wiki/Video/Script.php | 210 +++++ app/Policies/Wiki/Video/VideoScriptPolicy.php | 93 +++ app/Providers/AppServiceProvider.php | 7 + app/Providers/RouteServiceProvider.php | 9 + .../Eloquent/Wiki/Video/ScriptRepository.php | 42 + .../Storage/Wiki/Video/ScriptRepository.php | 53 ++ config/filesystems.php | 22 + config/flags.php | 13 + config/setting.php | 1 + config/video.php | 44 + .../Wiki/Video/VideoScriptFactory.php | 39 + ...09_21_020421_create_video_script_table.php | 43 + database/seeders/PermissionSeeder.php | 9 +- database/seeders/SettingSeeder.php | 10 + public/dumps/.gitignore | 2 - resources/lang/en/nova.php | 22 +- routes/script.php | 17 + .../ReconcileScriptRepositoriesTest.php | 107 +++ .../Wiki/Video/Script/DeleteScriptTest.php | 114 +++ .../Wiki/Video/Script/MoveScriptTest.php | 131 +++ .../Wiki/Video/Script/UploadScriptTest.php | 125 +++ .../Storage/Wiki/Video/UploadVideoTest.php | 24 + .../Wiki/Video/ScriptReconcileTest.php | 92 +++ .../Feature/Events/Wiki/Video/ScriptTest.php | 124 +++ .../Http/Api/Admin/Dump/DumpStoreTest.php | 2 +- .../Wiki/Video/Script/ScriptDestroyTest.php | 52 ++ .../Video/Script/ScriptForceDeleteTest.php | 52 ++ .../Api/Wiki/Video/Script/ScriptIndexTest.php | 754 ++++++++++++++++++ .../Wiki/Video/Script/ScriptRestoreTest.php | 56 ++ .../Api/Wiki/Video/Script/ScriptShowTest.php | 436 ++++++++++ .../Api/Wiki/Video/Script/ScriptStoreTest.php | 70 ++ .../Wiki/Video/Script/ScriptUpdateTest.php | 55 ++ .../Http/Api/Wiki/Video/VideoIndexTest.php | 2 + .../Http/Api/Wiki/Video/VideoShowTest.php | 2 + .../Http/Wiki/{ => Audio}/AudioTest.php | 2 +- .../Http/Wiki/Video/Script/ScriptTest.php | 82 ++ .../Http/Wiki/{ => Video}/VideoTest.php | 2 +- tests/Feature/Jobs/Wiki/Video/ScriptTest.php | 87 ++ tests/Unit/Models/Wiki/Video/ScriptTest.php | 43 + tests/Unit/Models/Wiki/VideoTest.php | 17 + 92 files changed, 5237 insertions(+), 36 deletions(-) create mode 100644 app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesAction.php create mode 100644 app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptResults.php create mode 100644 app/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php create mode 100644 app/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php create mode 100644 app/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php create mode 100644 app/Concerns/Repositories/Wiki/Video/ReconcilesScriptRepositories.php create mode 100644 app/Console/Commands/Repositories/Storage/Wiki/Video/ScriptReconcileCommand.php create mode 100644 app/Events/Wiki/Video/Script/VideoScriptCreated.php create mode 100644 app/Events/Wiki/Video/Script/VideoScriptDeleted.php create mode 100644 app/Events/Wiki/Video/Script/VideoScriptRestored.php create mode 100644 app/Events/Wiki/Video/Script/VideoScriptUpdated.php create mode 100644 app/Http/Api/Field/Admin/Dump/DumpLinkField.php create mode 100644 app/Http/Api/Field/Config/Flags/FlagsAllowScriptDownloadingField.php create mode 100644 app/Http/Api/Field/Wiki/Video/Script/ScriptLinkField.php create mode 100644 app/Http/Api/Field/Wiki/Video/Script/ScriptPathField.php create mode 100644 app/Http/Api/Field/Wiki/Video/Script/ScriptVideoIdField.php create mode 100644 app/Http/Api/Query/Wiki/Video/Script/ScriptReadQuery.php create mode 100644 app/Http/Api/Query/Wiki/Video/Script/ScriptWriteQuery.php create mode 100644 app/Http/Api/Schema/Wiki/Video/ScriptSchema.php create mode 100644 app/Http/Controllers/Api/Wiki/Video/ScriptController.php create mode 100644 app/Http/Controllers/Wiki/Video/Script/ScriptController.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptDestroyRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptForceDeleteRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptIndexRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptRestoreRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptShowRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptStoreRequest.php create mode 100644 app/Http/Requests/Api/Wiki/Video/Script/ScriptUpdateRequest.php create mode 100644 app/Http/Resources/Wiki/Video/Collection/ScriptCollection.php create mode 100644 app/Http/Resources/Wiki/Video/Resource/ScriptResource.php create mode 100644 app/Models/Wiki/Video/VideoScript.php create mode 100644 app/Nova/Actions/Repositories/Storage/Wiki/Video/Script/ReconcileScriptAction.php create mode 100644 app/Nova/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php create mode 100644 app/Nova/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php create mode 100644 app/Nova/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php create mode 100644 app/Nova/Lenses/Video/VideoScriptLens.php create mode 100644 app/Nova/Resources/Wiki/Video/Script.php create mode 100644 app/Policies/Wiki/Video/VideoScriptPolicy.php create mode 100644 app/Repositories/Eloquent/Wiki/Video/ScriptRepository.php create mode 100644 app/Repositories/Storage/Wiki/Video/ScriptRepository.php create mode 100644 database/factories/Wiki/Video/VideoScriptFactory.php create mode 100644 database/migrations/2022_09_21_020421_create_video_script_table.php delete mode 100644 public/dumps/.gitignore create mode 100644 routes/script.php create mode 100644 tests/Feature/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesTest.php create mode 100644 tests/Feature/Actions/Storage/Wiki/Video/Script/DeleteScriptTest.php create mode 100644 tests/Feature/Actions/Storage/Wiki/Video/Script/MoveScriptTest.php create mode 100644 tests/Feature/Actions/Storage/Wiki/Video/Script/UploadScriptTest.php create mode 100644 tests/Feature/Console/Commands/Repositories/Storage/Wiki/Video/ScriptReconcileTest.php create mode 100644 tests/Feature/Events/Wiki/Video/ScriptTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptDestroyTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptForceDeleteTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptIndexTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptRestoreTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptShowTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptStoreTest.php create mode 100644 tests/Feature/Http/Api/Wiki/Video/Script/ScriptUpdateTest.php rename tests/Feature/Http/Wiki/{ => Audio}/AudioTest.php (98%) create mode 100644 tests/Feature/Http/Wiki/Video/Script/ScriptTest.php rename tests/Feature/Http/Wiki/{ => Video}/VideoTest.php (98%) create mode 100644 tests/Feature/Jobs/Wiki/Video/ScriptTest.php create mode 100644 tests/Unit/Models/Wiki/Video/ScriptTest.php diff --git a/.env.example b/.env.example index 864939ecc..caadb7702 100644 --- a/.env.example +++ b/.env.example @@ -151,12 +151,22 @@ DUMP_STREAM_READS= DUMP_DISABLE_ASSERTS= DUMP_VISIBILITY= +SCRIPT_ACCESS_KEY_ID= +SCRIPT_SECRET_ACCESS_KEY= +SCRIPT_DEFAULT_REGION= +SCRIPT_ENDPOINT= +SCRIPT_BUCKET= +SCRIPT_STREAM_READS= +SCRIPT_DISABLE_ASSERTS= +SCRIPT_VISIBILITY= + # flags ALLOW_VIDEO_STREAMS=false ALLOW_AUDIO_STREAMS=false ALLOW_DISCORD_NOTIFICATIONS=false ALLOW_VIEW_RECORDING=false ALLOW_DUMP_DOWNLOADING=false +ALLOW_SCRIPT_DOWNLOADING=false # fortify FORTIFY_PATH= @@ -257,6 +267,11 @@ VIDEO_NGINX_REDIRECT= VIDEO_ENCODER_VERSION= VIDEO_UPLOAD_DISKS= +SCRIPT_DISK=scripts +SCRIPT_DISK_ROOT= +SCRIPT_URL=http://localhost +SCRIPT_PATH= + # web WEB_URL=http://localhost WEB_PATH= diff --git a/app/Actions/Repositories/ReconcileResults.php b/app/Actions/Repositories/ReconcileResults.php index 1dff98913..e69239d3a 100644 --- a/app/Actions/Repositories/ReconcileResults.php +++ b/app/Actions/Repositories/ReconcileResults.php @@ -139,6 +139,6 @@ abstract class ReconcileResults extends ActionResult */ protected function label(int|array|Countable $models = 1): string { - return Str::plural(class_basename($this->model()), $models); + return Str::plural(Str::headline(class_basename($this->model())), $models); } } diff --git a/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesAction.php b/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesAction.php new file mode 100644 index 000000000..a44353ef4 --- /dev/null +++ b/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesAction.php @@ -0,0 +1,88 @@ + + */ +class ReconcileScriptRepositoriesAction extends ReconcileRepositoriesAction +{ + /** + * The columns used for create and delete set operations. + * + * @return string[] + */ + protected function columnsForCreateDelete(): array + { + return [ + VideoScript::ATTRIBUTE_ID, + VideoScript::ATTRIBUTE_PATH, + ]; + } + + /** + * Callback for create and delete set operation item comparison. + * + * @return Closure + */ + protected function diffCallbackForCreateDelete(): Closure + { + return fn (VideoScript $first, VideoScript $second) => [$first->path] <=> [$second->path]; + } + + /** + * The columns used for update set operation. + * + * @return string[] + */ + protected function columnsForUpdate(): array + { + return ['*']; + } + + /** + * Callback for update set operation item comparison. + * + * @return Closure + */ + protected function diffCallbackForUpdate(): Closure + { + return fn () => 0; + } + + /** + * Get source model that has been updated for destination model. + * + * @param Collection $sourceModels + * @param Model $destinationModel + * @return Model|null + */ + protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model + { + return null; + } + + /** + * Get reconciliation results. + * + * @param Collection $created + * @param Collection $deleted + * @param Collection $updated + * @return ReconcileResults + */ + protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults + { + return new ReconcileScriptResults($created, $deleted, $updated); + } +} diff --git a/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptResults.php b/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptResults.php new file mode 100644 index 000000000..4b6abe5be --- /dev/null +++ b/app/Actions/Repositories/Wiki/Video/Script/ReconcileScriptResults.php @@ -0,0 +1,26 @@ + + */ +class ReconcileScriptResults extends ReconcileResults +{ + /** + * Get the model of the reconciliation results. + * + * @return class-string + */ + protected function model(): string + { + return VideoScript::class; + } +} diff --git a/app/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php b/app/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php new file mode 100644 index 000000000..e692ccfc2 --- /dev/null +++ b/app/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php @@ -0,0 +1,52 @@ + + */ +class DeleteScriptAction extends DeleteAction +{ + use ReconcilesScriptRepositories; + + /** + * Create a new action instance. + * + * @param VideoScript $script + */ + public function __construct(VideoScript $script) + { + parent::__construct($script); + } + + /** + * The list of disk names. + * + * @return array + */ + public function disks(): array + { + return Arr::wrap(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + } + + /** + * Get the path to delete. + * + * @return string + */ + protected function path(): string + { + return $this->model->path; + } +} diff --git a/app/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php b/app/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php new file mode 100644 index 000000000..f2f34981b --- /dev/null +++ b/app/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php @@ -0,0 +1,64 @@ + + */ +class MoveScriptAction extends MoveAction +{ + /** + * Create a new action instance. + * + * @param VideoScript $script + * @param string $to + */ + public function __construct(VideoScript $script, string $to) + { + parent::__construct($script, $to); + } + + /** + * The list of disk names. + * + * @return array + */ + public function disks(): array + { + return Arr::wrap(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + } + + /** + * Get the path to move from. + * + * @return string + */ + protected function from(): string + { + return $this->model->path; + } + + /** + * Update underlying model. + * We want to apply these updates through Eloquent to preserve relations when renaming. + * Otherwise, reconciliation would destroy the old model and create a new model for the new name. + * + * @return void + */ + protected function update(): void + { + $this->model->update([ + VideoScript::ATTRIBUTE_PATH => $this->to, + ]); + } +} diff --git a/app/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php b/app/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php new file mode 100644 index 000000000..4f453df92 --- /dev/null +++ b/app/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php @@ -0,0 +1,87 @@ +reconcileRepositories(); + + $reconcileResults->toLog(); + + // The script was successfully uploaded and reconciled into the database, so we can attempt further actions + if ($reconcileResults instanceof ReconcileResults) { + $this->attachVideo($reconcileResults); + } + } + + /** + * Attach video if uploaded from Upload Video Action. + * + * @param ReconcileResults $reconcileResults + * @return void + */ + protected function attachVideo(ReconcileResults $reconcileResults): void + { + $path = Str::of($this->path) + ->finish('/') + ->append($this->file->getClientOriginalName()) + ->__toString(); + + $script = $reconcileResults->getCreated()->firstWhere(VideoScript::ATTRIBUTE_PATH, $path); + + if ($script instanceof VideoScript && $this->video !== null) { + $script->video()->associate($this->video)->save(); + } + } + + /** + * The list of disk names. + * + * @return array + */ + public function disks(): array + { + return Arr::wrap(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + } +} diff --git a/app/Actions/Storage/Wiki/Video/UploadVideoAction.php b/app/Actions/Storage/Wiki/Video/UploadVideoAction.php index 40dc39eb4..377558bd0 100644 --- a/app/Actions/Storage/Wiki/Video/UploadVideoAction.php +++ b/app/Actions/Storage/Wiki/Video/UploadVideoAction.php @@ -6,6 +6,7 @@ namespace App\Actions\Storage\Wiki\Video; use App\Actions\Repositories\ReconcileResults; use App\Actions\Storage\Base\UploadAction; +use App\Actions\Storage\Wiki\Video\Script\UploadScriptAction; use App\Concerns\Repositories\Wiki\ReconcilesVideoRepositories; use App\Constants\Config\VideoConstants; use App\Contracts\Actions\Storage\StorageResults; @@ -27,9 +28,14 @@ class UploadVideoAction extends UploadAction * @param UploadedFile $file * @param string $path * @param AnimeThemeEntry|null $entry + * @param UploadedFile|null $script */ - public function __construct(UploadedFile $file, string $path, protected ?AnimeThemeEntry $entry = null) - { + public function __construct( + UploadedFile $file, + string $path, + protected ?AnimeThemeEntry $entry = null, + protected readonly ?UploadedFile $script = null + ) { parent::__construct($file, $path); } @@ -47,11 +53,48 @@ class UploadVideoAction extends UploadAction $reconcileResults->toLog(); - if ($reconcileResults instanceof ReconcileResults && $this->entry !== null) { - $video = $reconcileResults->getCreated()->firstWhere(Video::ATTRIBUTE_BASENAME, $this->file->getClientOriginalName()); - if ($video instanceof Video) { - $video->animethemeentries()->attach($this->entry); - } + // The video was successfully uploaded and reconciled into the database, so we can attempt further actions + if ($reconcileResults instanceof ReconcileResults) { + $this->attachEntry($reconcileResults); + $this->uploadScript($reconcileResults); + } + } + + /** + * Attach entry to created video if uploaded from entry detail screen. + * + * @param ReconcileResults $reconcileResults + * @return void + */ + protected function attachEntry(ReconcileResults $reconcileResults): void + { + $video = $reconcileResults->getCreated()->firstWhere(Video::ATTRIBUTE_BASENAME, $this->file->getClientOriginalName()); + + if ($video instanceof Video && $this->entry !== null) { + $video->animethemeentries()->attach($this->entry); + } + } + + /** + * Upload & Associate Script if video upload was successful. + * + * @param ReconcileResults $reconcileResults + * @return void + */ + protected function uploadScript(ReconcileResults $reconcileResults): void + { + $video = $reconcileResults->getCreated()->firstWhere(Video::ATTRIBUTE_BASENAME, $this->file->getClientOriginalName()); + + if ($video === null) { + $video = $reconcileResults->getUpdated()->firstWhere(Video::ATTRIBUTE_BASENAME, $this->file->getClientOriginalName()); + } + + if ($video instanceof Video && $this->script !== null) { + $uploadScript = new UploadScriptAction($this->script, $this->path, $video); + + $scriptResult = $uploadScript->handle(); + + $uploadScript->then($scriptResult); } } diff --git a/app/Concerns/Repositories/Wiki/Video/ReconcilesScriptRepositories.php b/app/Concerns/Repositories/Wiki/Video/ReconcilesScriptRepositories.php new file mode 100644 index 000000000..10dac8fc3 --- /dev/null +++ b/app/Concerns/Repositories/Wiki/Video/ReconcilesScriptRepositories.php @@ -0,0 +1,50 @@ +toLog(); $storageResults->toConsole($this); + $action->then($storageResults); + $result = $storageResults->toActionResult(); return $result->hasFailed() ? 1 : 0; diff --git a/app/Constants/Config/FlagConstants.php b/app/Constants/Config/FlagConstants.php index 6e0dfb784..b9eb12fa4 100644 --- a/app/Constants/Config/FlagConstants.php +++ b/app/Constants/Config/FlagConstants.php @@ -28,4 +28,8 @@ class FlagConstants final public const ALLOW_DUMP_DOWNLOADING_FLAG = 'allow_dump_downloading'; final public const ALLOW_DUMP_DOWNLOADING_FLAG_QUALIFIED = 'flags.allow_dump_downloading'; + + final public const ALLOW_SCRIPT_DOWNLOADING_FLAG = 'allow_script_downloading'; + + final public const ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED = 'flags.allow_script_downloading'; } diff --git a/app/Constants/Config/VideoConstants.php b/app/Constants/Config/VideoConstants.php index 27de4093d..396fe1485 100644 --- a/app/Constants/Config/VideoConstants.php +++ b/app/Constants/Config/VideoConstants.php @@ -24,4 +24,10 @@ class VideoConstants final public const STREAMING_METHOD_QUALIFIED = 'video.streaming_method'; final public const URL_QUALIFIED = 'video.url'; + + final public const SCRIPT_DISK_QUALIFIED = 'video.script.disk'; + + final public const SCRIPT_PATH_QUALIFIED = 'video.script.path'; + + final public const SCRIPT_URL_QUALIFIED = 'video.script.url'; } diff --git a/app/Events/Wiki/Video/Script/VideoScriptCreated.php b/app/Events/Wiki/Video/Script/VideoScriptCreated.php new file mode 100644 index 000000000..8950d2810 --- /dev/null +++ b/app/Events/Wiki/Video/Script/VideoScriptCreated.php @@ -0,0 +1,46 @@ + + */ +class VideoScriptCreated extends WikiCreatedEvent +{ + /** + * Create a new event instance. + * + * @param VideoScript $script + */ + public function __construct(VideoScript $script) + { + parent::__construct($script); + } + + /** + * Get the model that has fired this event. + * + * @return VideoScript + */ + public function getModel(): VideoScript + { + return $this->model; + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + return "Script '**{$this->getModel()->getName()}**' has been created."; + } +} diff --git a/app/Events/Wiki/Video/Script/VideoScriptDeleted.php b/app/Events/Wiki/Video/Script/VideoScriptDeleted.php new file mode 100644 index 000000000..717ffde59 --- /dev/null +++ b/app/Events/Wiki/Video/Script/VideoScriptDeleted.php @@ -0,0 +1,69 @@ + + */ +class VideoScriptDeleted extends WikiDeletedEvent +{ + /** + * Create a new event instance. + * + * @param VideoScript $script + */ + public function __construct(VideoScript $script) + { + parent::__construct($script); + } + + /** + * Get the model that has fired this event. + * + * @return VideoScript + */ + public function getModel(): VideoScript + { + return $this->model; + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + return "Script '**{$this->getModel()->getName()}**' has been deleted."; + } + + /** + * Get the message for the nova notification. + * + * @return string + */ + protected function getNotificationMessage(): string + { + return "Script '**{$this->getModel()->getName()}**' has been deleted. It will be automatically pruned in one week. Please review."; + } + + /** + * Get the URL for the nova notification. + * + * @return string + */ + protected function getNotificationUrl(): string + { + $uriKey = Script::uriKey(); + + return "/resources/$uriKey/{$this->getModel()->getKey()}"; + } +} diff --git a/app/Events/Wiki/Video/Script/VideoScriptRestored.php b/app/Events/Wiki/Video/Script/VideoScriptRestored.php new file mode 100644 index 000000000..64d0c53f8 --- /dev/null +++ b/app/Events/Wiki/Video/Script/VideoScriptRestored.php @@ -0,0 +1,46 @@ + + */ +class VideoScriptRestored extends WikiRestoredEvent +{ + /** + * Create a new event instance. + * + * @param VideoScript $script + */ + public function __construct(VideoScript $script) + { + parent::__construct($script); + } + + /** + * Get the model that has fired this event. + * + * @return VideoScript + */ + public function getModel(): VideoScript + { + return $this->model; + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + return "Script '**{$this->getModel()->getName()}**' has been restored."; + } +} diff --git a/app/Events/Wiki/Video/Script/VideoScriptUpdated.php b/app/Events/Wiki/Video/Script/VideoScriptUpdated.php new file mode 100644 index 000000000..55ee18a5f --- /dev/null +++ b/app/Events/Wiki/Video/Script/VideoScriptUpdated.php @@ -0,0 +1,47 @@ + + */ +class VideoScriptUpdated extends WikiUpdatedEvent +{ + /** + * Create a new event instance. + * + * @param VideoScript $script + */ + public function __construct(VideoScript $script) + { + parent::__construct($script); + $this->initializeEmbedFields($script); + } + + /** + * Get the model that has fired this event. + * + * @return VideoScript + */ + public function getModel(): VideoScript + { + return $this->model; + } + + /** + * Get the description for the Discord message payload. + * + * @return string + */ + protected function getDiscordMessageDescription(): string + { + return "Script '**{$this->getModel()->getName()}**' has been updated."; + } +} diff --git a/app/Http/Api/Field/Admin/Dump/DumpLinkField.php b/app/Http/Api/Field/Admin/Dump/DumpLinkField.php new file mode 100644 index 000000000..81534f7ba --- /dev/null +++ b/app/Http/Api/Field/Admin/Dump/DumpLinkField.php @@ -0,0 +1,22 @@ +isAllowedField(DumpResource::ATTRIBUTE_LINK); - } - /** * Set the creation validation rules for the field. * diff --git a/app/Http/Api/Field/Config/Flags/FlagsAllowScriptDownloadingField.php b/app/Http/Api/Field/Config/Flags/FlagsAllowScriptDownloadingField.php new file mode 100644 index 000000000..de0fb7b63 --- /dev/null +++ b/app/Http/Api/Field/Config/Flags/FlagsAllowScriptDownloadingField.php @@ -0,0 +1,22 @@ +getQuery(); + + return $query->index()->toResponse($request); + } + + /** + * Store a newly created resource. + * + * @param ScriptStoreRequest $request + * @return JsonResponse + */ + #[Route(fullUri: 'videoscript', name: 'videoscript.store', middleware: 'auth:sanctum')] + public function store(ScriptStoreRequest $request): JsonResponse + { + $resource = $request->getQuery()->store(); + + return $resource->toResponse($request); + } + + /** + * Display the specified resource. + * + * @param ScriptShowRequest $request + * @param VideoScript $script + * @return JsonResponse + */ + #[Route(fullUri: 'videoscript/{videoscript}', name: 'videoscript.show')] + public function show(ScriptShowRequest $request, VideoScript $script): JsonResponse + { + $resource = $request->getQuery()->show($script); + + return $resource->toResponse($request); + } + + /** + * Update the specified resource. + * + * @param ScriptUpdateRequest $request + * @param VideoScript $script + * @return JsonResponse + */ + #[Route(fullUri: 'videoscript/{videoscript}', name: 'videoscript.update', middleware: 'auth:sanctum')] + public function update(ScriptUpdateRequest $request, VideoScript $script): JsonResponse + { + $resource = $request->getQuery()->update($script); + + return $resource->toResponse($request); + } + + /** + * Remove the specified resource. + * + * @param ScriptDestroyRequest $request + * @param VideoScript $script + * @return JsonResponse + */ + #[Route(fullUri: 'videoscript/{videoscript}', name: 'videoscript.destroy', middleware: 'auth:sanctum')] + public function destroy(ScriptDestroyRequest $request, VideoScript $script): JsonResponse + { + $resource = $request->getQuery()->destroy($script); + + return $resource->toResponse($request); + } + + /** + * Restore the specified resource. + * + * @param ScriptRestoreRequest $request + * @param VideoScript $script + * @return JsonResponse + */ + #[Route(method: 'patch', fullUri: 'restore/videoscript/{videoscript}', name: 'videoscript.restore', middleware: 'auth:sanctum')] + public function restore(ScriptRestoreRequest $request, VideoScript $script): JsonResponse + { + $resource = $request->getQuery()->restore($script); + + return $resource->toResponse($request); + } + + /** + * Hard-delete the specified resource. + * + * @param ScriptForceDeleteRequest $request + * @param VideoScript $script + * @return JsonResponse + */ + #[Route(method: 'delete', fullUri: 'forceDelete/videoscript/{videoscript}', name: 'videoscript.forceDelete', middleware: 'auth:sanctum')] + public function forceDelete(ScriptForceDeleteRequest $request, VideoScript $script): JsonResponse + { + return $request->getQuery()->forceDelete($script); + } +} diff --git a/app/Http/Controllers/Wiki/Video/Script/ScriptController.php b/app/Http/Controllers/Wiki/Video/Script/ScriptController.php new file mode 100644 index 000000000..2f14aca15 --- /dev/null +++ b/app/Http/Controllers/Wiki/Video/Script/ScriptController.php @@ -0,0 +1,33 @@ +download($script->path); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptDestroyRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptDestroyRequest.php new file mode 100644 index 000000000..0f770e327 --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptDestroyRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptForceDeleteRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptForceDeleteRequest.php new file mode 100644 index 000000000..dc1c5d9eb --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptForceDeleteRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptIndexRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptIndexRequest.php new file mode 100644 index 000000000..b6093c779 --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptIndexRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptRestoreRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptRestoreRequest.php new file mode 100644 index 000000000..9ea904f38 --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptRestoreRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptShowRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptShowRequest.php new file mode 100644 index 000000000..931d128e8 --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptShowRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptStoreRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptStoreRequest.php new file mode 100644 index 000000000..09a5cb7ef --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptStoreRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Requests/Api/Wiki/Video/Script/ScriptUpdateRequest.php b/app/Http/Requests/Api/Wiki/Video/Script/ScriptUpdateRequest.php new file mode 100644 index 000000000..f5ca3fbe7 --- /dev/null +++ b/app/Http/Requests/Api/Wiki/Video/Script/ScriptUpdateRequest.php @@ -0,0 +1,37 @@ +validated()); + } +} diff --git a/app/Http/Resources/Config/Resource/FlagsResource.php b/app/Http/Resources/Config/Resource/FlagsResource.php index bf06f8e6e..73ed79e82 100644 --- a/app/Http/Resources/Config/Resource/FlagsResource.php +++ b/app/Http/Resources/Config/Resource/FlagsResource.php @@ -66,6 +66,10 @@ class FlagsResource extends BaseResource $result[FlagConstants::ALLOW_DUMP_DOWNLOADING_FLAG] = Config::bool(FlagConstants::ALLOW_DUMP_DOWNLOADING_FLAG_QUALIFIED); } + if ($this->isAllowedField(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG)) { + $result[FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG] = Config::bool(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED); + } + return $result; } } diff --git a/app/Http/Resources/Wiki/Resource/VideoResource.php b/app/Http/Resources/Wiki/Resource/VideoResource.php index 7e90b4fdc..83438231f 100644 --- a/app/Http/Resources/Wiki/Resource/VideoResource.php +++ b/app/Http/Resources/Wiki/Resource/VideoResource.php @@ -7,6 +7,7 @@ namespace App\Http\Resources\Wiki\Resource; use App\Http\Api\Query\ReadQuery; use App\Http\Resources\BaseResource; use App\Http\Resources\Wiki\Anime\Theme\Collection\EntryCollection; +use App\Http\Resources\Wiki\Video\Resource\ScriptResource; use App\Models\BaseModel; use App\Models\Wiki\Video; use Illuminate\Http\Request; @@ -126,6 +127,7 @@ class VideoResource extends BaseResource $result[Video::RELATION_ANIMETHEMEENTRIES] = new EntryCollection($this->whenLoaded(Video::RELATION_ANIMETHEMEENTRIES), $this->query); $result[Video::RELATION_AUDIO] = new AudioResource($this->whenLoaded(Video::RELATION_AUDIO), $this->query); + $result[Video::RELATION_SCRIPT] = new ScriptResource($this->whenLoaded(Video::RELATION_SCRIPT), $this->query); return $result; } diff --git a/app/Http/Resources/Wiki/Video/Collection/ScriptCollection.php b/app/Http/Resources/Wiki/Video/Collection/ScriptCollection.php new file mode 100644 index 000000000..570ec24b9 --- /dev/null +++ b/app/Http/Resources/Wiki/Video/Collection/ScriptCollection.php @@ -0,0 +1,36 @@ +collection->map(fn (VideoScript $script) => new ScriptResource($script, $this->query))->all(); + } +} diff --git a/app/Http/Resources/Wiki/Video/Resource/ScriptResource.php b/app/Http/Resources/Wiki/Video/Resource/ScriptResource.php new file mode 100644 index 000000000..d2cb96c51 --- /dev/null +++ b/app/Http/Resources/Wiki/Video/Resource/ScriptResource.php @@ -0,0 +1,83 @@ +isAllowedField(BaseResource::ATTRIBUTE_ID)) { + $result[BaseResource::ATTRIBUTE_ID] = $this->getKey(); + } + + if ($this->isAllowedField(VideoScript::ATTRIBUTE_PATH)) { + $result[VideoScript::ATTRIBUTE_PATH] = $this->path; + } + + if ($this->isAllowedField(BaseModel::ATTRIBUTE_CREATED_AT)) { + $result[BaseModel::ATTRIBUTE_CREATED_AT] = $this->created_at; + } + + if ($this->isAllowedField(BaseModel::ATTRIBUTE_UPDATED_AT)) { + $result[BaseModel::ATTRIBUTE_UPDATED_AT] = $this->updated_at; + } + + if ($this->isAllowedField(BaseModel::ATTRIBUTE_DELETED_AT)) { + $result[BaseModel::ATTRIBUTE_DELETED_AT] = $this->deleted_at; + } + + if ($this->isAllowedField(ScriptResource::ATTRIBUTE_LINK)) { + $result[ScriptResource::ATTRIBUTE_LINK] = route('videoscript.show', $this); + } + + $result[VideoScript::RELATION_VIDEO] = new VideoResource($this->whenLoaded(VideoScript::RELATION_VIDEO), $this->query); + + return $result; + } +} diff --git a/app/Models/Wiki/Video.php b/app/Models/Wiki/Video.php index 2d6262430..39427cfe5 100644 --- a/app/Models/Wiki/Video.php +++ b/app/Models/Wiki/Video.php @@ -14,6 +14,7 @@ use App\Events\Wiki\Video\VideoRestored; use App\Events\Wiki\Video\VideoUpdated; use App\Models\BaseModel; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; +use App\Models\Wiki\Video\VideoScript; use App\Pivots\AnimeThemeEntryVideo; use BenSampo\Enum\Enum; use CyrildeWit\EloquentViewable\Contracts\Viewable; @@ -23,6 +24,7 @@ use Elastic\ScoutDriverPlus\Searchable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; use Laravel\Nova\Actions\Actionable; @@ -40,6 +42,7 @@ use Laravel\Nova\Actions\Actionable; * @property Enum $overlap * @property string $path * @property int|null $resolution + * @property VideoScript|null $script * @property int $size * @property Enum|null $source * @property bool $subbed @@ -78,6 +81,7 @@ class Video extends BaseModel implements Streamable, Viewable final public const RELATION_ANIMETHEME = 'animethemeentries.animetheme'; final public const RELATION_ANIMETHEMEENTRIES = 'animethemeentries'; final public const RELATION_AUDIO = 'audio'; + final public const RELATION_SCRIPT = 'videoscript'; final public const RELATION_SONG = 'animethemeentries.animetheme.song'; /** @@ -323,4 +327,14 @@ class Video extends BaseModel implements Streamable, Viewable { return $this->belongsTo(Audio::class, Video::ATTRIBUTE_AUDIO); } + + /** + * Get the script that the video owns. + * + * @return HasOne + */ + public function videoscript(): HasOne + { + return $this->hasOne(VideoScript::class, VideoScript::ATTRIBUTE_VIDEO); + } } diff --git a/app/Models/Wiki/Video/VideoScript.php b/app/Models/Wiki/Video/VideoScript.php new file mode 100644 index 000000000..653d0bbd9 --- /dev/null +++ b/app/Models/Wiki/Video/VideoScript.php @@ -0,0 +1,95 @@ + VideoScriptCreated::class, + 'deleted' => VideoScriptDeleted::class, + 'restored' => VideoScriptRestored::class, + 'updated' => VideoScriptUpdated::class, + ]; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = VideoScript::TABLE; + + /** + * The primary key associated with the table. + * + * @var string + */ + protected $primaryKey = VideoScript::ATTRIBUTE_ID; + + /** + * Get name. + * + * @return string + */ + public function getName(): string + { + return $this->path; + } + + /** + * Get the video that owns the script. + * + * @return BelongsTo + */ + public function video(): BelongsTo + { + return $this->belongsTo(Video::class, VideoScript::ATTRIBUTE_VIDEO); + } +} diff --git a/app/Nova/Actions/Repositories/Storage/Wiki/Video/Script/ReconcileScriptAction.php b/app/Nova/Actions/Repositories/Storage/Wiki/Video/Script/ReconcileScriptAction.php new file mode 100644 index 000000000..6d3d40487 --- /dev/null +++ b/app/Nova/Actions/Repositories/Storage/Wiki/Video/Script/ReconcileScriptAction.php @@ -0,0 +1,40 @@ + __('nova.resources.label.video_scripts')]); + } + + /** + * The name of the disk. + * + * @return string + */ + public function disk(): string + { + return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED); + } +} diff --git a/app/Nova/Actions/Storage/Base/MoveAction.php b/app/Nova/Actions/Storage/Base/MoveAction.php index 0c2754c2e..97bd1bd2f 100644 --- a/app/Nova/Actions/Storage/Base/MoveAction.php +++ b/app/Nova/Actions/Storage/Base/MoveAction.php @@ -9,7 +9,6 @@ use App\Contracts\Storage\InteractsWithDisk; use App\Nova\Actions\Storage\StorageAction; use App\Rules\Storage\StorageFileDirectoryExistsRule; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; use Laravel\Nova\Fields\ActionFields; use Laravel\Nova\Fields\Text; @@ -32,7 +31,7 @@ abstract class MoveAction extends StorageAction implements InteractsWithDisk { $defaultPath = $this->defaultPath($request); - $fs = Storage::disk(Config::get($this->disk())); + $fs = Storage::disk($this->disk()); return [ Text::make(__('nova.actions.storage.move.fields.path.name'), 'path') diff --git a/app/Nova/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php b/app/Nova/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php new file mode 100644 index 000000000..3b4b6952e --- /dev/null +++ b/app/Nova/Actions/Storage/Wiki/Video/Script/DeleteScriptAction.php @@ -0,0 +1,43 @@ + $models + * @return DeleteScript + */ + protected function action(ActionFields $fields, Collection $models): DeleteScript + { + $script = $models->first(); + + return new DeleteScript($script); + } +} diff --git a/app/Nova/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php b/app/Nova/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php new file mode 100644 index 000000000..0623a5263 --- /dev/null +++ b/app/Nova/Actions/Storage/Wiki/Video/Script/MoveScriptAction.php @@ -0,0 +1,83 @@ + $models + * @return MoveScript + */ + protected function action(ActionFields $fields, Collection $models): MoveScript + { + $path = $fields->get('path'); + + $script = $models->first(); + + return new MoveScript($script, $path); + } + + /** + * The name of the disk. + * + * @return string + */ + public function disk(): string + { + return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED); + } + + /** + * Resolve the default value for the path field. + * + * @param NovaRequest $request + * @return string|null + */ + protected function defaultPath(NovaRequest $request): ?string + { + $script = $request->findModelQuery()->first(); + + return $script instanceof VideoScript + ? $script->path + : null; + } + + /** + * The file extension that the path must end with. + * + * @return string + */ + protected function allowedFileExtension(): string + { + return '.txt'; + } +} diff --git a/app/Nova/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php b/app/Nova/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php new file mode 100644 index 000000000..c6c4def58 --- /dev/null +++ b/app/Nova/Actions/Storage/Wiki/Video/Script/UploadScriptAction.php @@ -0,0 +1,96 @@ +findModelQuery()->first(); + + return array_merge( + parent::fields($request), + [ + Hidden::make(__('nova.resources.singularLabel.video'), Video::ATTRIBUTE_ID) + ->default(fn () => $model instanceof Video ? $model->getKey() : null), + ], + ); + } + + /** + * Get the underlying storage action. + * + * @param ActionFields $fields + * @param Collection $models + * @return UploadScript + */ + protected function action(ActionFields $fields, Collection $models): UploadScript + { + /** @var UploadedFile $file */ + $file = $fields->get('file'); + $path = $fields->get('path'); + + /** @var Video|null $video */ + $video = Video::query()->find($fields->get(Video::ATTRIBUTE_ID)); + + return new UploadScript($file, $path, $video); + } + + /** + * The name of the disk. + * + * @return string + */ + public function disk(): string + { + return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED); + } + + /** + * Get the file validation rules. + * + * @return array + */ + protected function fileRules(): array + { + return [ + 'required', + FileRule::types('txt')->max(2 * 1024), + ]; + } +} diff --git a/app/Nova/Actions/Storage/Wiki/Video/UploadVideoAction.php b/app/Nova/Actions/Storage/Wiki/Video/UploadVideoAction.php index 1b2d8f022..24f9dc43b 100644 --- a/app/Nova/Actions/Storage/Wiki/Video/UploadVideoAction.php +++ b/app/Nova/Actions/Storage/Wiki/Video/UploadVideoAction.php @@ -33,6 +33,8 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Validation\Rules\File as FileRule; use Laravel\Nova\Fields\ActionFields; +use Laravel\Nova\Fields\File; +use Laravel\Nova\Fields\Heading; use Laravel\Nova\Fields\Hidden; use Laravel\Nova\Http\Requests\NovaRequest; @@ -64,10 +66,20 @@ class UploadVideoAction extends UploadAction $parent = $request->findParentModel(); return array_merge( + [ + Heading::make(__('nova.resources.singularLabel.video')), + ], parent::fields($request), [ Hidden::make(__('nova.resources.singularLabel.anime_theme_entry'), AnimeThemeEntry::ATTRIBUTE_ID) ->default(fn () => $parent instanceof AnimeThemeEntry ? $parent->getKey() : null), + + Heading::make(__('nova.resources.singularLabel.video_script')), + + File::make(__('nova.resources.singularLabel.video_script'), 'script') + ->nullable() + ->rules(['nullable', FileRule::types('txt')->max(2 * 1024)]) + ->help(__('nova.actions.storage.upload.fields.file.help')), ], ); } @@ -81,14 +93,18 @@ class UploadVideoAction extends UploadAction */ protected function action(ActionFields $fields, Collection $models): UploadVideo { + $path = $fields->get('path'); + /** @var UploadedFile $file */ $file = $fields->get('file'); - $path = $fields->get('path'); /** @var AnimeThemeEntry|null $entry */ $entry = AnimeThemeEntry::query()->find($fields->get(AnimeThemeEntry::ATTRIBUTE_ID)); - return new UploadVideo($file, $path, $entry); + /** @var UploadedFile|null $script */ + $script = $fields->get('script'); + + return new UploadVideo($file, $path, $entry, $script); } /** diff --git a/app/Nova/Lenses/Video/VideoScriptLens.php b/app/Nova/Lenses/Video/VideoScriptLens.php new file mode 100644 index 000000000..283503230 --- /dev/null +++ b/app/Nova/Lenses/Video/VideoScriptLens.php @@ -0,0 +1,144 @@ +whereDoesntHave(Video::RELATION_SCRIPT) + ->where(Video::ATTRIBUTE_PATH, ComparisonOperator::NOTLIKE, 'misc%'); + } + + /** + * Get the fields available to the lens. + * + * @param NovaRequest $request + * @return array + * + * @throws Exception + */ + public function fields(NovaRequest $request): array + { + return [ + ID::make(__('nova.fields.base.id'), Video::ATTRIBUTE_ID) + ->sortable() + ->showOnPreview(), + + Text::make(__('nova.fields.video.filename.name'), Video::ATTRIBUTE_FILENAME) + ->sortable() + ->copyable() + ->showOnPreview() + ->filterable(), + + Number::make(__('nova.fields.video.resolution.name'), Video::ATTRIBUTE_RESOLUTION) + ->sortable() + ->showOnPreview() + ->filterable(), + + Boolean::make(__('nova.fields.video.nc.name'), Video::ATTRIBUTE_NC) + ->sortable() + ->showOnPreview() + ->filterable(), + + Boolean::make(__('nova.fields.video.subbed.name'), Video::ATTRIBUTE_SUBBED) + ->sortable() + ->showOnPreview() + ->filterable(), + + Boolean::make(__('nova.fields.video.lyrics.name'), Video::ATTRIBUTE_LYRICS) + ->sortable() + ->showOnPreview() + ->filterable(), + + Boolean::make(__('nova.fields.video.uncen.name'), Video::ATTRIBUTE_UNCEN) + ->sortable() + ->showOnPreview() + ->filterable(), + + Select::make(__('nova.fields.video.overlap.name'), Video::ATTRIBUTE_OVERLAP) + ->options(VideoOverlap::asSelectArray()) + ->displayUsing(fn (?Enum $enum) => $enum?->description) + ->onlyOnPreview(), + + Select::make(__('nova.fields.video.source.name'), Video::ATTRIBUTE_SOURCE) + ->options(VideoSource::asSelectArray()) + ->displayUsing(fn (?Enum $enum) => $enum?->description) + ->onlyOnPreview(), + + DateTime::make(__('nova.fields.base.created_at'), BaseModel::ATTRIBUTE_CREATED_AT) + ->onlyOnPreview(), + + DateTime::make(__('nova.fields.base.updated_at'), BaseModel::ATTRIBUTE_UPDATED_AT) + ->onlyOnPreview(), + + DateTime::make(__('nova.fields.base.deleted_at'), BaseModel::ATTRIBUTE_DELETED_AT) + ->onlyOnPreview(), + ]; + } + + /** + * Get the actions available on the lens. + * + * @param NovaRequest $request + * @return array + * + * @noinspection PhpMissingParentCallCommonInspection + */ + public function actions(NovaRequest $request): array + { + return []; + } + + /** + * Get the URI key for the lens. + * + * @return string + * + * @noinspection PhpMissingParentCallCommonInspection + */ + public function uriKey(): string + { + return 'video-script-lens'; + } +} diff --git a/app/Nova/Resources/Wiki/Video.php b/app/Nova/Resources/Wiki/Video.php index 0f84d2c99..d84368e2f 100644 --- a/app/Nova/Resources/Wiki/Video.php +++ b/app/Nova/Resources/Wiki/Video.php @@ -12,15 +12,18 @@ use App\Nova\Actions\Models\Wiki\Video\BackfillAudioAction; use App\Nova\Actions\Repositories\Storage\Wiki\Video\ReconcileVideoAction; use App\Nova\Actions\Storage\Wiki\Video\DeleteVideoAction; use App\Nova\Actions\Storage\Wiki\Video\MoveVideoAction; +use App\Nova\Actions\Storage\Wiki\Video\Script\UploadScriptAction; use App\Nova\Actions\Storage\Wiki\Video\UploadVideoAction; use App\Nova\Lenses\Video\VideoAudioLens; use App\Nova\Lenses\Video\VideoResolutionLens; +use App\Nova\Lenses\Video\VideoScriptLens; use App\Nova\Lenses\Video\VideoSourceLens; use App\Nova\Lenses\Video\VideoUnlinkedLens; use App\Nova\Metrics\Video\NewVideos; use App\Nova\Metrics\Video\VideosPerDay; use App\Nova\Resources\BaseResource; use App\Nova\Resources\Wiki\Anime\Theme\Entry; +use App\Nova\Resources\Wiki\Video\Script; use App\Pivots\BasePivot; use BenSampo\Enum\Enum; use BenSampo\Enum\Rules\EnumValue; @@ -31,6 +34,7 @@ use Laravel\Nova\Fields\BelongsTo; use Laravel\Nova\Fields\BelongsToMany; use Laravel\Nova\Fields\Boolean; use Laravel\Nova\Fields\DateTime; +use Laravel\Nova\Fields\HasOne; use Laravel\Nova\Fields\ID; use Laravel\Nova\Fields\Number; use Laravel\Nova\Fields\Select; @@ -205,6 +209,12 @@ class Video extends BaseResource ->hideWhenCreating(), ]), + HasOne::make(__('nova.resources.singularLabel.video_script'), VideoModel::RELATION_SCRIPT, Script::class) + ->hideFromIndex() + ->sortable() + ->nullable() + ->showOnPreview(), + Panel::make(__('nova.fields.base.file_properties'), $this->fileProperties()) ->collapsable(), @@ -327,6 +337,16 @@ class Video extends BaseResource return $user instanceof User && $user->can('create video'); }), + + (new UploadScriptAction()) + ->confirmButtonText(__('nova.actions.storage.upload.confirmButtonText')) + ->cancelButtonText(__('nova.actions.base.cancelButtonText')) + ->onlyOnDetail() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('create video script'); + }), ] ); } @@ -363,7 +383,8 @@ class Video extends BaseResource new VideoResolutionLens(), new VideoSourceLens(), new VideoUnlinkedLens(), - ] + new VideoScriptLens(), + ], ); } } diff --git a/app/Nova/Resources/Wiki/Video/Script.php b/app/Nova/Resources/Wiki/Video/Script.php new file mode 100644 index 000000000..0a73fbf74 --- /dev/null +++ b/app/Nova/Resources/Wiki/Video/Script.php @@ -0,0 +1,210 @@ +with(VideoScript::RELATION_VIDEO); + } + + /** + * Get the fields displayed by the resource. + * + * @param NovaRequest $request + * @return array + * + * @throws Exception + */ + public function fields(NovaRequest $request): array + { + return [ + BelongsTo::make(__('nova.resources.singularLabel.video'), VideoScript::RELATION_VIDEO, Video::class) + ->sortable() + ->filterable() + ->searchable() + ->withSubtitles() + ->nullable() + ->showOnPreview(), + + ID::make(__('nova.fields.base.id'), VideoScript::ATTRIBUTE_ID) + ->sortable() + ->showOnPreview(), + + Text::make(__('nova.fields.video_script.path'), VideoScript::ATTRIBUTE_PATH) + ->copyable() + ->showOnPreview() + ->filterable(), + + Panel::make(__('nova.fields.base.timestamps'), $this->timestamps()), + ]; + } + + /** + * Get the actions available for the resource. + * + * @param NovaRequest $request + * @return array + */ + public function actions(NovaRequest $request): array + { + return array_merge( + parent::actions($request), + [ + (new UploadScriptAction()) + ->confirmButtonText(__('nova.actions.storage.upload.confirmButtonText')) + ->cancelButtonText(__('nova.actions.base.cancelButtonText')) + ->onlyOnIndex() + ->standalone() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('create video script'); + }), + + (new MoveScriptAction()) + ->confirmButtonText(__('nova.actions.storage.move.confirmButtonText')) + ->cancelButtonText(__('nova.actions.base.cancelButtonText')) + ->exceptOnIndex() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('create video script'); + }), + + (new DeleteScriptAction()) + ->confirmText(__('nova.actions.video_script.delete.confirmText')) + ->confirmButtonText(__('nova.actions.storage.delete.confirmButtonText')) + ->cancelButtonText(__('nova.actions.base.cancelButtonText')) + ->exceptOnIndex() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('delete video script'); + }), + + (new ReconcileScriptAction()) + ->confirmButtonText(__('nova.actions.repositories.confirmButtonText')) + ->cancelButtonText(__('nova.actions.base.cancelButtonText')) + ->onlyOnIndex() + ->standalone() + ->canSee(function (Request $request) { + $user = $request->user(); + + return $user instanceof User && $user->can('create video script'); + }), + ], + ); + } +} diff --git a/app/Policies/Wiki/Video/VideoScriptPolicy.php b/app/Policies/Wiki/Video/VideoScriptPolicy.php new file mode 100644 index 000000000..33d302f01 --- /dev/null +++ b/app/Policies/Wiki/Video/VideoScriptPolicy.php @@ -0,0 +1,93 @@ +can('view video script'); + } + + /** + * Determine whether the user can view the model. + * + * @param User $user + * @return bool + */ + public function view(User $user): bool + { + return $user->can('view video script'); + } + + /** + * Determine whether the user can create models. + * + * @param User $user + * @return bool + */ + public function create(User $user): bool + { + return $user->can('create video script'); + } + + /** + * Determine whether the user can update the model. + * + * @param User $user + * @return bool + */ + public function update(User $user): bool + { + return $user->can('update video script'); + } + + /** + * Determine whether the user can delete the model. + * + * @param User $user + * @return bool + */ + public function delete(User $user): bool + { + return $user->can('delete video script'); + } + + /** + * Determine whether the user can restore the model. + * + * @param User $user + * @return bool + */ + public function restore(User $user): bool + { + return $user->can('restore video script'); + } + + /** + * Determine whether the user can permanently delete the model. + * + * @param User $user + * @return bool + */ + public function forceDelete(User $user): bool + { + return $user->can('force delete video script'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fd4915faa..a55aa9bf1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Providers; use App\Constants\Config\AudioConstants; +use App\Constants\Config\DumpConstants; use App\Constants\Config\FlagConstants; use App\Constants\Config\VideoConstants; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; @@ -48,12 +49,17 @@ class AppServiceProvider extends ServiceProvider 'Streaming Method' => fn () => Config::get(AudioConstants::STREAMING_METHOD_QUALIFIED), ]); + AboutCommand::add('Dumps', [ + 'Disk' => fn () => Config::get(DumpConstants::DISK_QUALIFIED), + ]); + AboutCommand::add('Flags', [ 'Allow Audio Streams' => fn () => Config::bool(FlagConstants::ALLOW_AUDIO_STREAMS_FLAG_QUALIFIED) ? 'true' : 'false', 'Allow Discord Notifications' => fn () => Config::bool(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED) ? 'true' : 'false', 'Allow Video Streams' => fn () => Config::bool(FlagConstants::ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED) ? 'true' : 'false', 'Allow View Recording' => fn () => Config::bool(FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED) ? 'true' : 'false', 'Allow Dump Downloading' => fn () => Config::bool(FlagConstants::ALLOW_DUMP_DOWNLOADING_FLAG_QUALIFIED) ? 'true' : 'false', + 'Allow Script Downloading' => fn () => Config::bool(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED) ? 'true' : 'false', ]); AboutCommand::add('Images', [ @@ -65,6 +71,7 @@ class AppServiceProvider extends ServiceProvider 'Disks' => fn () => implode(',', Config::get(VideoConstants::DISKS_QUALIFIED)), 'Encoder Version' => fn () => Config::get(VideoConstants::ENCODER_VERSION_QUALIFIED), 'Nginx Redirect' => fn () => Config::get(VideoConstants::NGINX_REDIRECT_QUALIFIED), + 'Script Disk' => fn () => Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED), 'Streaming Method' => fn () => Config::get(VideoConstants::STREAMING_METHOD_QUALIFIED), ]); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 8587b4946..bced65d9a 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -11,6 +11,7 @@ use App\Models\Auth\User; use App\Models\Wiki\Anime\AnimeSynonym; use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; +use App\Models\Wiki\Video\VideoScript; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Http\Request; @@ -51,6 +52,9 @@ class RouteServiceProvider extends ServiceProvider // Anime Theme Resources Route::model('animethemeentry', AnimeThemeEntry::class); + // Video Resources + Route::model('videoscript', VideoScript::class); + $this->routes(function () { Route::middleware('web') ->domain(Config::get('web.url')) @@ -72,6 +76,11 @@ class RouteServiceProvider extends ServiceProvider ->prefix(Config::get(DumpConstants::PATH_QUALIFIED)) ->group(base_path('routes/dump.php')); + Route::middleware('web') + ->domain(Config::get(VideoConstants::SCRIPT_URL_QUALIFIED)) + ->prefix(Config::get(VideoConstants::SCRIPT_PATH_QUALIFIED)) + ->group(base_path('routes/script.php')); + Route::middleware('api') ->domain(Config::get('api.url')) ->prefix(Config::get('api.path')) diff --git a/app/Repositories/Eloquent/Wiki/Video/ScriptRepository.php b/app/Repositories/Eloquent/Wiki/Video/ScriptRepository.php new file mode 100644 index 000000000..d6510fb0e --- /dev/null +++ b/app/Repositories/Eloquent/Wiki/Video/ScriptRepository.php @@ -0,0 +1,42 @@ + + */ +class ScriptRepository extends EloquentRepository +{ + /** + * Get the underlying query builder. + * + * @return Builder + */ + protected function builder(): Builder + { + return VideoScript::query(); + } + + /** + * Filter repository models. + * + * @param string $filter + * @param mixed $value + * @return void + */ + public function handleFilter(string $filter, mixed $value = null): void + { + if ($filter === 'path') { + $this->query->where(VideoScript::ATTRIBUTE_PATH, ComparisonOperator::LIKE, "$value%"); + } + } +} diff --git a/app/Repositories/Storage/Wiki/Video/ScriptRepository.php b/app/Repositories/Storage/Wiki/Video/ScriptRepository.php new file mode 100644 index 000000000..4cfdd8a8f --- /dev/null +++ b/app/Repositories/Storage/Wiki/Video/ScriptRepository.php @@ -0,0 +1,53 @@ + + */ +class ScriptRepository extends StorageRepository +{ + /** + * The name of the disk. + * + * @return string + */ + public function disk(): string + { + return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED); + } + + /** + * Return the callback to filter filesystem contents. + * + * @return Closure(StorageAttributes): bool + */ + protected function filterCallback(): Closure + { + return fn (StorageAttributes $file) => $file->isFile() && File::extension($file->path()) === 'txt'; + } + + /** + * Map filesystem files to model. + * + * @return Closure(StorageAttributes): VideoScript + */ + protected function mapCallback(): Closure + { + return fn (StorageAttributes $file) => new VideoScript([ + VideoScript::ATTRIBUTE_PATH => $file->path(), + ]); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 149e2ef29..481e32b31 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -156,6 +156,27 @@ return [ 'visibility' => 'public', 'throw' => false, ], + + 'scripts' => [ + 'driver' => 's3', + 'key' => env('SCRIPT_ACCESS_KEY_ID'), + 'secret' => env('SCRIPT_SECRET_ACCESS_KEY'), + 'region' => env('SCRIPT_DEFAULT_REGION'), + 'bucket' => env('SCRIPT_BUCKET'), + 'endpoint' => env('SCRIPT_ENDPOINT'), + 'stream_reads' => env('SCRIPT_STREAM_READS'), + 'disable_asserts' => env('SCRIPT_DISABLE_ASSERTS'), + 'visibility' => env('SCRIPT_VISIBILITY'), + 'throw' => false, + ], + + 'scripts_local' => [ + 'driver' => 'local', + 'root' => public_path('scripts'), + 'url' => env('APP_URL').'/scripts', + 'visibility' => 'public', + 'throw' => false, + ], ], /* @@ -175,6 +196,7 @@ return [ public_path('images') => env('IMAGE_DISK_ROOT', storage_path('app/images')), public_path('videos') => env('VIDEO_DISK_ROOT', storage_path('app/videos')), public_path('dumps') => env('DUMP_DISK_ROOT', storage_path('app/dumps')), + public_path('scripts') => env('SCRIPT_DISK_ROOT', storage_path('app/scripts')), ], ]; diff --git a/config/flags.php b/config/flags.php index 767c5e088..7b8ba1296 100644 --- a/config/flags.php +++ b/config/flags.php @@ -69,4 +69,17 @@ return [ */ 'allow_dump_downloading' => (bool) env('ALLOW_DUMP_DOWNLOADING', false), + + /* + |-------------------------------------------------------------------------- + | Allow Script Downloading + |-------------------------------------------------------------------------- + | + | When script downloads are allowed, requests to the videoscript.show route will + | download scripts. If disabled, requests to the videoscript.show route will + | raise a 403 Forbidden response. + | + */ + + 'allow_script_downloading' => (bool) env('ALLOW_SCRIPT_DOWNLOADING', false), ]; diff --git a/config/setting.php b/config/setting.php index f792774a4..5094d57d6 100644 --- a/config/setting.php +++ b/config/setting.php @@ -92,6 +92,7 @@ return [ FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED => FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG, FlagConstants::ALLOW_VIEW_RECORDING_FLAG_QUALIFIED => FlagConstants::ALLOW_VIEW_RECORDING_FLAG, FlagConstants::ALLOW_DUMP_DOWNLOADING_FLAG_QUALIFIED => FlagConstants::ALLOW_DUMP_DOWNLOADING_FLAG, + FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED => FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG, VideoConstants::ENCODER_VERSION_QUALIFIED => VideoConstants::ENCODER_VERSION, WikiConstants::FEATURED_ENTRY_SETTING_QUALIFIED => WikiConstants::FEATURED_ENTRY_SETTING, WikiConstants::FEATURED_VIDEO_SETTING_QUALIFIED => WikiConstants::FEATURED_VIDEO_SETTING, diff --git a/config/video.php b/config/video.php index 756c55203..f63086f3e 100644 --- a/config/video.php +++ b/config/video.php @@ -64,4 +64,48 @@ return [ */ 'encoder_version' => env('VIDEO_ENCODER_VERSION'), + + /* + |-------------------------------------------------------------------------- + | Video Scripts + |-------------------------------------------------------------------------- + | + | Scripts represent the encoding script used to produce a video. + | Generally, a script file will be a list of FFmpeg commands + | to be executed in a target runtime environment. + | + */ + + 'script' => [ + + /* + |-------------------------------------------------------------------------- + | Script Disk + |-------------------------------------------------------------------------- + | + | The filesystem disk where video scripts are hosted. By default, it is assumed + | that video scripts are hosted in an S3-like bucket. + | + */ + + 'disk' => env('SCRIPT_DISK', 'scripts'), + + /* + |-------------------------------------------------------------------------- + | Script Domain + |-------------------------------------------------------------------------- + | + | These values represent the base URL that video scripts are downloaded from. + | It is most likely that only one of these values should be set. + | If scripts are downloaded from a subdomain, set SCRIPT_URL and leave SCRIPT_PATH null. + | Ex: script.animethemes.test + | If scripts are NOT downloaded from a subdomain, set SCRIPT_PATH and leave SCRIPT_URL null. + | Ex: animethemes.test/script + | + */ + + 'url' => env('SCRIPT_URL'), + + 'path' => env('SCRIPT_PATH'), + ], ]; diff --git a/database/factories/Wiki/Video/VideoScriptFactory.php b/database/factories/Wiki/Video/VideoScriptFactory.php new file mode 100644 index 000000000..536a0833b --- /dev/null +++ b/database/factories/Wiki/Video/VideoScriptFactory.php @@ -0,0 +1,39 @@ + + */ +class VideoScriptFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + protected $model = VideoScript::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + VideoScript::ATTRIBUTE_PATH => Str::random(), + ]; + } +} diff --git a/database/migrations/2022_09_21_020421_create_video_script_table.php b/database/migrations/2022_09_21_020421_create_video_script_table.php new file mode 100644 index 000000000..15c6f6814 --- /dev/null +++ b/database/migrations/2022_09_21_020421_create_video_script_table.php @@ -0,0 +1,43 @@ +id(VideoScript::ATTRIBUTE_ID); + $table->timestamps(6); + $table->softDeletes(BaseModel::ATTRIBUTE_DELETED_AT, 6); + $table->string(VideoScript::ATTRIBUTE_PATH); + + $table->unsignedBigInteger(VideoScript::ATTRIBUTE_VIDEO)->nullable(); + $table->foreign(VideoScript::ATTRIBUTE_VIDEO)->references(Video::ATTRIBUTE_ID)->on(Video::TABLE)->nullOnDelete(); + }); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down(): void + { + Schema::dropIfExists(VideoScript::TABLE); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index cf6f98ad7..fc0472e03 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -54,6 +54,7 @@ class PermissionSeeder extends Seeder $this->configureWikiResourcePermissions($admin, $wikiEditor, $wikiViewer, 'song'); $this->configureWikiResourcePermissions($admin, $wikiEditor, $wikiViewer, 'studio'); $this->configureWikiResourcePermissions($admin, $wikiEditor, $wikiViewer, 'video'); + $this->configureWikiResourcePermissions($admin, $wikiEditor, $wikiViewer, 'video script'); // Special Permissions $this->configureSpecialPermissions($admin, $wikiEditor, $wikiViewer); @@ -108,17 +109,19 @@ class PermissionSeeder extends Seeder $create = Permission::findOrCreate("create $wikiResource"); $adminPermissions[] = $create; - if ($wikiResource !== 'video' && $wikiResource !== 'audio') { + if ($wikiResource !== 'video' && $wikiResource !== 'audio' && $wikiResource !== 'video script') { $editorPermissions[] = $create; } $update = Permission::findOrCreate("update $wikiResource"); $adminPermissions[] = $update; - $editorPermissions[] = $update; + if ($wikiResource !== 'video script') { + $editorPermissions[] = $update; + } $delete = Permission::findOrCreate("delete $wikiResource"); $adminPermissions[] = $delete; - if ($wikiResource !== 'video' && $wikiResource !== 'audio') { + if ($wikiResource !== 'video' && $wikiResource !== 'audio' && $wikiResource !== 'video script') { $editorPermissions[] = $delete; } diff --git a/database/seeders/SettingSeeder.php b/database/seeders/SettingSeeder.php index ed359f961..d32b97f0e 100644 --- a/database/seeders/SettingSeeder.php +++ b/database/seeders/SettingSeeder.php @@ -73,6 +73,16 @@ class SettingSeeder extends Seeder ] ); + Setting::query()->firstOrCreate( + [ + Setting::ATTRIBUTE_KEY => FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG, + ], + [ + Setting::ATTRIBUTE_KEY => FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG, + Setting::ATTRIBUTE_VALUE => 'false', + ] + ); + Setting::query()->firstOrCreate( [ Setting::ATTRIBUTE_KEY => VideoConstants::ENCODER_VERSION, diff --git a/public/dumps/.gitignore b/public/dumps/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/public/dumps/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/resources/lang/en/nova.php b/resources/lang/en/nova.php index af5f62bc8..8abd501b8 100644 --- a/resources/lang/en/nova.php +++ b/resources/lang/en/nova.php @@ -224,7 +224,7 @@ return [ 'confirmButtonText' => 'Move', 'fields' => [ 'path' => [ - 'help' => 'The new location of the Audio. Ex: 2009/Summer/Bakemonogatari-OP1.webm.', + 'help' => 'The new location of the file. Ex: 2009/Summer/Bakemonogatari-OP1.webm.', 'name' => 'Path', ], ], @@ -284,6 +284,18 @@ return [ 'name' => 'Revoke Role', ], ], + 'video_script' => [ + 'delete' => [ + 'confirmText' => 'Remove Video Script from configured storage disks and from the database?', + 'name' => 'Remove Video Script', + ], + 'move' => [ + 'name' => 'Move Video Script', + ], + 'upload' => [ + 'name' => 'Upload Video Script', + ], + ], 'video' => [ 'backfill' => [ 'confirmButtonText' => 'Backfill', @@ -586,6 +598,9 @@ return [ 'email' => 'Email', 'name' => 'Name', ], + 'video_script' => [ + 'path' => 'Path', + ], 'video' => [ 'basename' => [ 'name' => 'Basename', @@ -693,6 +708,9 @@ return [ 'resolution' => [ 'name' => 'Video with Unset Resolution', ], + 'script' => [ + 'name' => 'Video Without Script', + ], 'source' => [ 'name' => 'Video with Unknown Source Type', ], @@ -759,6 +777,7 @@ return [ 'studios' => 'Studios', 'transactions' => 'Transactions', 'users' => 'Users', + 'video_scripts' => 'Video Scripts', 'videos' => 'Videos', ], 'singularLabel' => [ @@ -783,6 +802,7 @@ return [ 'studio' => 'Studio', 'transaction' => 'Transaction', 'user' => 'User', + 'video_script' => 'Video Script', 'video' => 'Video', ], ], diff --git a/routes/script.php b/routes/script.php new file mode 100644 index 000000000..951f72994 --- /dev/null +++ b/routes/script.php @@ -0,0 +1,17 @@ +append(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED) + ->append(',Script Downloading Disabled') + ->__toString(); + +Route::get('/{videoscript}', [ScriptController::class, 'show']) + ->name('videoscript.show') + ->middleware([$isScriptDownloadingAllowed, 'without_trashed:videoscript']); diff --git a/tests/Feature/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesTest.php b/tests/Feature/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesTest.php new file mode 100644 index 000000000..51b0c3ac4 --- /dev/null +++ b/tests/Feature/Actions/Repositories/Wiki/Video/Script/ReconcileScriptRepositoriesTest.php @@ -0,0 +1,107 @@ +mock(ScriptSourceRepository::class, function (MockInterface $mock) { + $mock->shouldReceive('get')->once()->andReturn(Collection::make()); + }); + + $action = new ReconcileScriptRepositoriesAction(); + + $source = App::make(ScriptSourceRepository::class); + $destination = App::make(ScriptDestinationRepository::class); + + $result = $action->reconcileRepositories($source, $destination); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + static::assertFalse($result->hasChanges()); + static::assertDatabaseCount(VideoScript::class, 0); + } + + /** + * If video scripts are created, the Reconcile Script Repository Action shall return created video scripts. + * + * @return void + */ + public function testCreated(): void + { + $createdScriptCount = $this->faker->numberBetween(2, 9); + + $scripts = VideoScript::factory()->count($createdScriptCount)->make(); + + $this->mock(ScriptSourceRepository::class, function (MockInterface $mock) use ($scripts) { + $mock->shouldReceive('get')->once()->andReturn($scripts); + }); + + $action = new ReconcileScriptRepositoriesAction(); + + $source = App::make(ScriptSourceRepository::class); + $destination = App::make(ScriptDestinationRepository::class); + + $result = $action->reconcileRepositories($source, $destination); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + static::assertTrue($result->hasChanges()); + static::assertCount($createdScriptCount, $result->getCreated()); + static::assertDatabaseCount(VideoScript::class, $createdScriptCount); + } + + /** + * If video scripts are deleted, the Reconcile Script Repository Action shall return deleted video scripts. + * + * @return void + */ + public function testDeleted(): void + { + $deletedScriptCount = $this->faker->numberBetween(2, 9); + + $scripts = VideoScript::factory()->count($deletedScriptCount)->create(); + + $this->mock(ScriptSourceRepository::class, function (MockInterface $mock) { + $mock->shouldReceive('get')->once()->andReturn(Collection::make()); + }); + + $action = new ReconcileScriptRepositoriesAction(); + + $source = App::make(ScriptSourceRepository::class); + $destination = App::make(ScriptDestinationRepository::class); + + $result = $action->reconcileRepositories($source, $destination); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + static::assertTrue($result->hasChanges()); + static::assertCount($deletedScriptCount, $result->getDeleted()); + + static::assertDatabaseCount(VideoScript::class, $deletedScriptCount); + foreach ($scripts as $script) { + static::assertSoftDeleted($script); + } + } +} diff --git a/tests/Feature/Actions/Storage/Wiki/Video/Script/DeleteScriptTest.php b/tests/Feature/Actions/Storage/Wiki/Video/Script/DeleteScriptTest.php new file mode 100644 index 000000000..63cdafcdf --- /dev/null +++ b/tests/Feature/Actions/Storage/Wiki/Video/Script/DeleteScriptTest.php @@ -0,0 +1,114 @@ +createOne(); + + $action = new DeleteScriptAction($script); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue($result->hasFailed()); + } + + /** + * The Delete Script Action shall pass if there are deletions. + * + * @return void + */ + public function testPassed(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $file->path(), + ]); + + $action = new DeleteScriptAction($script); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + } + + /** + * The Delete Script Action shall delete the file from the configured disks. + * + * @return void + */ + public function testDeletedFromDisk(): void + { + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $file->path(), + ]); + + $action = new DeleteScriptAction($script); + + $action->handle(); + + static::assertEmpty($fs->allFiles()); + } + + /** + * The Delete Video Action shall delete the script. + * + * @return void + */ + public function testVideoDeleted(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $file->path(), + ]); + + $action = new DeleteScriptAction($script); + + $result = $action->handle(); + + $action->then($result); + + static::assertSoftDeleted($script); + } +} diff --git a/tests/Feature/Actions/Storage/Wiki/Video/Script/MoveScriptTest.php b/tests/Feature/Actions/Storage/Wiki/Video/Script/MoveScriptTest.php new file mode 100644 index 000000000..9b0164a3a --- /dev/null +++ b/tests/Feature/Actions/Storage/Wiki/Video/Script/MoveScriptTest.php @@ -0,0 +1,131 @@ +createOne(); + + $action = new MoveScriptAction($script, $this->faker->word()); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue($result->hasFailed()); + } + + /** + * The Move Script Action shall pass if there are moves. + * + * @return void + */ + public function testPassed(): void + { + /** @var FilesystemAdapter $fs */ + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $directory = $this->faker->word(); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $fs->putFileAs($directory, $file, $file->getClientOriginalName()), + ]); + + $action = new MoveScriptAction($script, Str::replace($directory, $this->faker->word(), $script->path)); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + } + + /** + * The Move Script Action shall move the file in the configured disks. + * + * @return void + */ + public function testMovedInDisk(): void + { + /** @var FilesystemAdapter $fs */ + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $directory = $this->faker->word(); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $fs->putFileAs($directory, $file, $file->getClientOriginalName()), + ]); + + $from = $script->path; + $to = Str::replace($directory, $this->faker->word(), $script->path); + + $action = new MoveScriptAction($script, $to); + + $action->handle(); + + $fs->assertMissing($from); + $fs->assertExists($to); + } + + /** + * The Move Script Action shall move the script. + * + * @return void + */ + public function testScriptUpdated(): void + { + /** @var FilesystemAdapter $fs */ + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $directory = $this->faker->word(); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $fs->putFileAs($directory, $file, $file->getClientOriginalName()), + ]); + + $to = Str::replace($directory, $this->faker->word(), $script->path); + + $action = new MoveScriptAction($script, $to); + + $result = $action->handle(); + + $action->then($result); + + static::assertDatabaseHas(VideoScript::class, [VideoScript::ATTRIBUTE_PATH => $to]); + } +} diff --git a/tests/Feature/Actions/Storage/Wiki/Video/Script/UploadScriptTest.php b/tests/Feature/Actions/Storage/Wiki/Video/Script/UploadScriptTest.php new file mode 100644 index 000000000..5157865a8 --- /dev/null +++ b/tests/Feature/Actions/Storage/Wiki/Video/Script/UploadScriptTest.php @@ -0,0 +1,125 @@ +create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $action = new UploadScriptAction($file, $this->faker->word()); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue($result->hasFailed()); + } + + /** + * The Upload Script Action shall pass if given a valid file. + * + * @return void + */ + public function testPassed(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $action = new UploadScriptAction($file, $this->faker->word()); + + $storageResults = $action->handle(); + + $result = $storageResults->toActionResult(); + + static::assertTrue(ActionStatus::PASSED()->is($result->getStatus())); + } + + /** + * The Upload Script Action shall upload the file to the configured disk. + * + * @return void + */ + public function testUploadedToDisk(): void + { + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $action = new UploadScriptAction($file, $this->faker->word()); + + $action->handle(); + + static::assertCount(1, $fs->allFiles()); + } + + /** + * The Upload Video Action shall upload the file to the configured disk. + * + * @return void + */ + public function testCreatedVideo(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $action = new UploadScriptAction($file, $this->faker->word()); + + $result = $action->handle(); + + $action->then($result); + + static::assertDatabaseCount(VideoScript::class, 1); + } + + /** + * The Upload Script Action shall attach the provided video. + * + * @return void + */ + public function testAttachesVideo(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $video = Video::factory()->createOne(); + + $action = new UploadScriptAction($file, $this->faker->word(), $video); + + $result = $action->handle(); + + $action->then($result); + + static::assertTrue($video->videoscript()->exists()); + } +} diff --git a/tests/Feature/Actions/Storage/Wiki/Video/UploadVideoTest.php b/tests/Feature/Actions/Storage/Wiki/Video/UploadVideoTest.php index a97a2c090..d376044f5 100644 --- a/tests/Feature/Actions/Storage/Wiki/Video/UploadVideoTest.php +++ b/tests/Feature/Actions/Storage/Wiki/Video/UploadVideoTest.php @@ -11,6 +11,7 @@ use App\Models\Wiki\Anime; use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; use App\Models\Wiki\Video; +use App\Models\Wiki\Video\VideoScript; use App\Pivots\AnimeThemeEntryVideo; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\Testing\File; @@ -131,4 +132,27 @@ class UploadVideoTest extends TestCase static::assertDatabaseCount(AnimeThemeEntryVideo::class, 1); } + + /** + * The Upload Video Action shall attach the provided script. + * + * @return void + */ + public function testAssociatesScript(): void + { + Storage::fake(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)); + Config::set(VideoConstants::DISKS_QUALIFIED, [Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED)]); + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $file = File::fake()->create($this->faker->word().'.webm', $this->faker->randomDigitNotNull()); + $script = File::fake()->create($this->faker->word().'.txt', $this->faker->randomDigitNotNull()); + + $action = new UploadVideoAction($file, $this->faker->word(), null, $script); + + $result = $action->handle(); + + $action->then($result); + + static::assertDatabaseHas(VideoScript::class, [VideoScript::ATTRIBUTE_VIDEO => 1]); + } } diff --git a/tests/Feature/Console/Commands/Repositories/Storage/Wiki/Video/ScriptReconcileTest.php b/tests/Feature/Console/Commands/Repositories/Storage/Wiki/Video/ScriptReconcileTest.php new file mode 100644 index 000000000..4d26a0856 --- /dev/null +++ b/tests/Feature/Console/Commands/Repositories/Storage/Wiki/Video/ScriptReconcileTest.php @@ -0,0 +1,92 @@ +baseRefreshDatabase(); // Cannot lazily refresh database within pending command + + $this->mock(ScriptRepository::class, function (MockInterface $mock) { + $mock->shouldReceive('get')->once()->andReturn(Collection::make()); + }); + + $this->artisan(ScriptReconcileCommand::class) + ->assertSuccessful() + ->expectsOutput('No Video Scripts created or deleted or updated'); + } + + /** + * If scripts are created, the Reconcile Script Command shall output '{Created Count} Video Scripts created, 0 Video Scripts deleted, 0 Video Scripts updated'. + * + * @return void + */ + public function testCreated(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $this->baseRefreshDatabase(); // Cannot lazily refresh database within pending command + + $createdScriptCount = $this->faker->numberBetween(2, 9); + + $scripts = VideoScript::factory()->count($createdScriptCount)->make(); + + $this->mock(ScriptRepository::class, function (MockInterface $mock) use ($scripts) { + $mock->shouldReceive('get')->once()->andReturn($scripts); + }); + + $this->artisan(ScriptReconcileCommand::class) + ->assertSuccessful() + ->expectsOutput("$createdScriptCount Video Scripts created, 0 Video Scripts deleted, 0 Video Scripts updated"); + } + + /** + * If scripts are deleted, the Reconcile Script Command shall output '0 Video Scripts created, {Deleted Count} Video Scripts deleted, 0 Video Scripts updated'. + * + * @return void + */ + public function testDeleted(): void + { + Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + + $deletedScriptCount = $this->faker->numberBetween(2, 9); + + VideoScript::factory()->count($deletedScriptCount)->create(); + + $this->mock(ScriptRepository::class, function (MockInterface $mock) { + $mock->shouldReceive('get')->once()->andReturn(Collection::make()); + }); + + $this->artisan(ScriptReconcileCommand::class) + ->assertSuccessful() + ->expectsOutput("0 Video Scripts created, $deletedScriptCount Video Scripts deleted, 0 Video Scripts updated"); + } +} diff --git a/tests/Feature/Events/Wiki/Video/ScriptTest.php b/tests/Feature/Events/Wiki/Video/ScriptTest.php new file mode 100644 index 000000000..ea0d0b29d --- /dev/null +++ b/tests/Feature/Events/Wiki/Video/ScriptTest.php @@ -0,0 +1,124 @@ +createOne(); + + Event::assertDispatched(VideoScriptCreated::class); + } + + /** + * When a Script is deleted, a VideoScriptDeleted event shall be dispatched. + * + * @return void + */ + public function testVideoScriptDeletedEventDispatched(): void + { + Event::fake(); + + $script = VideoScript::factory()->createOne(); + + $script->delete(); + + Event::assertDispatched(VideoScriptDeleted::class); + } + + /** + * When a Script is restored, a VideoScriptRestored event shall be dispatched. + * + * @return void + */ + public function testVideoScriptRestoredEventDispatched(): void + { + Event::fake(); + + $script = VideoScript::factory()->createOne(); + + $script->restore(); + + Event::assertDispatched(VideoScriptRestored::class); + } + + /** + * When a Script is restored, a VideoScriptUpdated event shall not be dispatched. + * Note: This is a customization that overrides default framework behavior. + * An updated event is fired on restore. + * + * @return void + */ + public function testVideoScriptRestoresQuietly(): void + { + Event::fake(); + + $script = VideoScript::factory()->createOne(); + + $script->restore(); + + Event::assertNotDispatched(VideoScriptUpdated::class); + } + + /** + * When a Script is updated, a VideoScriptUpdated event shall be dispatched. + * + * @return void + */ + public function testVideoScriptUpdatedEventDispatched(): void + { + Event::fake(); + + $script = VideoScript::factory()->createOne(); + $changes = VideoScript::factory()->makeOne(); + + $script->fill($changes->getAttributes()); + $script->save(); + + Event::assertDispatched(VideoScriptUpdated::class); + } + + /** + * The VideoScriptUpdated event shall contain embed fields. + * + * @return void + */ + public function testVideoScriptUpdatedEventEmbedFields(): void + { + Event::fake(); + + $script = VideoScript::factory()->createOne(); + $changes = VideoScript::factory()->makeOne(); + + $script->fill($changes->getAttributes()); + $script->save(); + + Event::assertDispatched(VideoScriptUpdated::class, function (VideoScriptUpdated $event) { + $message = $event->getDiscordMessage(); + + return ! empty(Arr::get($message->embed, 'fields')); + }); + } +} diff --git a/tests/Feature/Http/Api/Admin/Dump/DumpStoreTest.php b/tests/Feature/Http/Api/Admin/Dump/DumpStoreTest.php index 726dd6654..2a1e61f2c 100644 --- a/tests/Feature/Http/Api/Admin/Dump/DumpStoreTest.php +++ b/tests/Feature/Http/Api/Admin/Dump/DumpStoreTest.php @@ -32,7 +32,7 @@ class DumpStoreTest extends TestCase } /** - * The Dump Store Endpoint shall require the content field. + * The Dump Store Endpoint shall require the path field. * * @return void */ diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptDestroyTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptDestroyTest.php new file mode 100644 index 000000000..339c9dd88 --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptDestroyTest.php @@ -0,0 +1,52 @@ +createOne(); + + $response = $this->delete(route('api.videoscript.destroy', ['videoscript' => $script])); + + $response->assertUnauthorized(); + } + + /** + * The Script Destroy Endpoint shall delete the script. + * + * @return void + */ + public function testDeleted(): void + { + $script = VideoScript::factory()->createOne(); + + $user = User::factory()->withPermission('delete video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->delete(route('api.videoscript.destroy', ['videoscript' => $script])); + + $response->assertOk(); + static::assertSoftDeleted($script); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptForceDeleteTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptForceDeleteTest.php new file mode 100644 index 000000000..e25200095 --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptForceDeleteTest.php @@ -0,0 +1,52 @@ +createOne(); + + $response = $this->delete(route('api.videoscript.forceDelete', ['videoscript' => $script])); + + $response->assertUnauthorized(); + } + + /** + * The Script Force Destroy Endpoint shall force delete the script. + * + * @return void + */ + public function testDeleted(): void + { + $script = VideoScript::factory()->createOne(); + + $user = User::factory()->withPermission('force delete video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->delete(route('api.videoscript.forceDelete', ['videoscript' => $script])); + + $response->assertOk(); + static::assertModelMissing($script); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptIndexTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptIndexTest.php new file mode 100644 index 000000000..6fa220d0d --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptIndexTest.php @@ -0,0 +1,754 @@ +count($this->faker->randomDigitNotNull()) + ->create(); + + $response = $this->get(route('api.videoscript.index')); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery())) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall be paginated. + * + * @return void + */ + public function testPaginated(): void + { + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->create(); + + $response = $this->get(route('api.videoscript.index')); + + $response->assertJsonStructure([ + ScriptCollection::$wrap, + 'links', + 'meta', + ]); + } + + /** + * The Script Index Endpoint shall allow inclusion of related resources. + * + * @return void + */ + public function testAllowedIncludePaths(): void + { + $schema = new ScriptSchema(); + + $allowedIncludes = collect($schema->allowedIncludes()); + + $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count())); + + $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path()); + + $parameters = [ + IncludeParser::param() => $includedPaths->join(','), + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with($includedPaths->all())->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall implement sparse fieldsets. + * + * @return void + */ + public function testSparseFieldsets(): void + { + $schema = new ScriptSchema(); + + $fields = collect($schema->fields()); + + $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count())); + + $parameters = [ + FieldParser::param() => [ + ScriptResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','), + ], + ]; + + $scripts = VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->create(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Synonym Index Endpoint shall support sorting resources. + * + * @return void + */ + public function testSorts(): void + { + $schema = new ScriptSchema(); + + $sort = collect($schema->fields()) + ->filter(fn (Field $field) => $field instanceof SortableField) + ->map(fn (SortableField $field) => $field->getSort()) + ->random(); + + $parameters = [ + SortParser::param() => $sort->format(Direction::getRandomInstance()), + ]; + + $query = new ScriptReadQuery($parameters); + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->create(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + $query->collection($query->index()) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by created_at. + * + * @return void + */ + public function testCreatedAtFilter(): void + { + $createdFilter = $this->faker->date(); + $excludedDate = $this->faker->date(); + + $parameters = [ + FilterParser::param() => [ + BaseModel::ATTRIBUTE_CREATED_AT => $createdFilter, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + Carbon::withTestNow($createdFilter, function () { + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + }); + + Carbon::withTestNow($excludedDate, function () { + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + }); + + $script = VideoScript::query()->where(BaseModel::ATTRIBUTE_CREATED_AT, $createdFilter)->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by updated_at. + * + * @return void + */ + public function testUpdatedAtFilter(): void + { + $updatedFilter = $this->faker->date(); + $excludedDate = $this->faker->date(); + + $parameters = [ + FilterParser::param() => [ + BaseModel::ATTRIBUTE_UPDATED_AT => $updatedFilter, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + Carbon::withTestNow($updatedFilter, function () { + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + }); + + Carbon::withTestNow($excludedDate, function () { + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + }); + + $script = VideoScript::query()->where(BaseModel::ATTRIBUTE_UPDATED_AT, $updatedFilter)->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by trashed. + * + * @return void + */ + public function testWithoutTrashedFilter(): void + { + $parameters = [ + FilterParser::param() => [ + TrashedCriteria::PARAM_VALUE => TrashedStatus::WITHOUT, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + + $deleteVideo = VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + $deleteVideo->each(function (VideoScript $script) { + $script->delete(); + }); + + $script = VideoScript::withoutTrashed()->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by trashed. + * + * @return void + */ + public function testWithTrashedFilter(): void + { + $parameters = [ + FilterParser::param() => [ + TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + + $deleteVideo = VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + $deleteVideo->each(function (VideoScript $script) { + $script->delete(); + }); + + $script = VideoScript::withTrashed()->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by trashed. + * + * @return void + */ + public function testOnlyTrashedFilter(): void + { + $parameters = [ + FilterParser::param() => [ + TrashedCriteria::PARAM_VALUE => TrashedStatus::ONLY, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + + $deleteVideo = VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + $deleteVideo->each(function (VideoScript $script) { + $script->delete(); + }); + + $script = VideoScript::onlyTrashed()->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support filtering by deleted_at. + * + * @return void + */ + public function testDeletedAtFilter(): void + { + $deletedFilter = $this->faker->date(); + $excludedDate = $this->faker->date(); + + $parameters = [ + FilterParser::param() => [ + BaseModel::ATTRIBUTE_DELETED_AT => $deletedFilter, + TrashedCriteria::PARAM_VALUE => TrashedStatus::WITH, + ], + PagingParser::param() => [ + OffsetCriteria::SIZE_PARAM => Criteria::MAX_RESULTS, + ], + ]; + + Carbon::withTestNow($deletedFilter, function () { + $scripts = VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + $scripts->each(function (VideoScript $script) { + $script->delete(); + }); + }); + + Carbon::withTestNow($excludedDate, function () { + $scripts = VideoScript::factory()->count($this->faker->randomDigitNotNull())->create(); + $scripts->each(function (VideoScript $script) { + $script->delete(); + }); + }); + + $script = VideoScript::withTrashed()->where(BaseModel::ATTRIBUTE_DELETED_AT, $deletedFilter)->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by lyrics. + * + * @return void + */ + public function testVideosByLyrics(): void + { + $lyricsFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_LYRICS => $lyricsFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($lyricsFilter) { + $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by nc. + * + * @return void + */ + public function testVideosByNc(): void + { + $ncFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_NC => $ncFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($ncFilter) { + $query->where(Video::ATTRIBUTE_NC, $ncFilter); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by overlap. + * + * @return void + */ + public function testVideosByOverlap(): void + { + $overlapFilter = VideoOverlap::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_OVERLAP => $overlapFilter->description, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($overlapFilter) { + $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by resolution. + * + * @return void + */ + public function testVideosByResolution(): void + { + $resolutionFilter = $this->faker->randomNumber(); + $excludedResolution = $resolutionFilter + 1; + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_RESOLUTION => $resolutionFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for( + Video::factory()->state([ + Video::ATTRIBUTE_RESOLUTION => $this->faker->boolean() ? $resolutionFilter : $excludedResolution, + ]) + ) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($resolutionFilter) { + $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by source. + * + * @return void + */ + public function testVideosBySource(): void + { + $sourceFilter = VideoSource::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_SOURCE => $sourceFilter->description, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($sourceFilter) { + $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by subbed. + * + * @return void + */ + public function testVideosBySubbed(): void + { + $subbedFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_SUBBED => $subbedFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($subbedFilter) { + $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Index Endpoint shall support constrained eager loading of videos by uncen. + * + * @return void + */ + public function testVideosByUncen(): void + { + $uncenFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_UNCEN => $uncenFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + VideoScript::factory() + ->count($this->faker->randomDigitNotNull()) + ->for(Video::factory()) + ->create(); + + $scripts = VideoScript::with([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($uncenFilter) { + $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter); + }, + ]) + ->get(); + + $response = $this->get(route('api.videoscript.index', $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptCollection($scripts, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptRestoreTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptRestoreTest.php new file mode 100644 index 000000000..f3be08298 --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptRestoreTest.php @@ -0,0 +1,56 @@ +createOne(); + + $script->delete(); + + $response = $this->patch(route('api.videoscript.restore', ['videoscript' => $script])); + + $response->assertUnauthorized(); + } + + /** + * The Script Restore Endpoint shall restore the script. + * + * @return void + */ + public function testRestored(): void + { + $script = VideoScript::factory()->createOne(); + + $script->delete(); + + $user = User::factory()->withPermission('restore video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->patch(route('api.videoscript.restore', ['videoscript' => $script])); + + $response->assertOk(); + static::assertNotSoftDeleted($script); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptShowTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptShowTest.php new file mode 100644 index 000000000..f2dc70c07 --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptShowTest.php @@ -0,0 +1,436 @@ +create(); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script])); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery())) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall return a Script Resource for soft deleted scripts. + * + * @return void + */ + public function testSoftDelete(): void + { + $script = VideoScript::factory()->createOne(); + + $script->delete(); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script])); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery())) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall allow inclusion of related resources. + * + * @return void + */ + public function testAllowedIncludePaths(): void + { + $schema = new ScriptSchema(); + + $allowedIncludes = collect($schema->allowedIncludes()); + + $selectedIncludes = $allowedIncludes->random($this->faker->numberBetween(1, $allowedIncludes->count())); + + $includedPaths = $selectedIncludes->map(fn (AllowedInclude $include) => $include->path()); + + $parameters = [ + IncludeParser::param() => $includedPaths->join(','), + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->createOne(); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall implement sparse fieldsets. + * + * @return void + */ + public function testSparseFieldsets(): void + { + $schema = new ScriptSchema(); + + $fields = collect($schema->fields()); + + $includedFields = $fields->random($this->faker->numberBetween(1, $fields->count())); + + $parameters = [ + FieldParser::param() => [ + ScriptResource::$wrap => $includedFields->map(fn (Field $field) => $field->getKey())->join(','), + ], + ]; + + $script = VideoScript::factory()->create(); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by lyrics. + * + * @return void + */ + public function testVideosByLyrics(): void + { + $lyricsFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_LYRICS => $lyricsFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($lyricsFilter) { + $query->where(Video::ATTRIBUTE_LYRICS, $lyricsFilter); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by nc. + * + * @return void + */ + public function testVideosByNc(): void + { + $ncFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_NC => $ncFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($ncFilter) { + $query->where(Video::ATTRIBUTE_NC, $ncFilter); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by overlap. + * + * @return void + */ + public function testVideosByOverlap(): void + { + $overlapFilter = VideoOverlap::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_OVERLAP => $overlapFilter->description, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($overlapFilter) { + $query->where(Video::ATTRIBUTE_OVERLAP, $overlapFilter->value); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by resolution. + * + * @return void + */ + public function testVideosByResolution(): void + { + $resolutionFilter = $this->faker->randomNumber(); + $excludedResolution = $resolutionFilter + 1; + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_RESOLUTION => $resolutionFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for( + Video::factory()->state([ + Video::ATTRIBUTE_RESOLUTION => $this->faker->boolean() ? $resolutionFilter : $excludedResolution, + ]) + ) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($resolutionFilter) { + $query->where(Video::ATTRIBUTE_RESOLUTION, $resolutionFilter); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by source. + * + * @return void + */ + public function testVideosBySource(): void + { + $sourceFilter = VideoSource::getRandomInstance(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_SOURCE => $sourceFilter->description, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($sourceFilter) { + $query->where(Video::ATTRIBUTE_SOURCE, $sourceFilter->value); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by subbed. + * + * @return void + */ + public function testVideosBySubbed(): void + { + $subbedFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_SUBBED => $subbedFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($subbedFilter) { + $query->where(Video::ATTRIBUTE_SUBBED, $subbedFilter); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } + + /** + * The Script Show Endpoint shall support constrained eager loading of videos by uncen. + * + * @return void + */ + public function testVideosByUncen(): void + { + $uncenFilter = $this->faker->boolean(); + + $parameters = [ + FilterParser::param() => [ + Video::ATTRIBUTE_UNCEN => $uncenFilter, + ], + IncludeParser::param() => VideoScript::RELATION_VIDEO, + ]; + + $script = VideoScript::factory() + ->for(Video::factory()) + ->create(); + + $script->unsetRelations()->load([ + VideoScript::RELATION_VIDEO => function (BelongsTo $query) use ($uncenFilter) { + $query->where(Video::ATTRIBUTE_UNCEN, $uncenFilter); + }, + ]); + + $response = $this->get(route('api.videoscript.show', ['videoscript' => $script] + $parameters)); + + $response->assertJson( + json_decode( + json_encode( + (new ScriptResource($script, new ScriptReadQuery($parameters))) + ->response() + ->getData() + ), + true + ) + ); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptStoreTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptStoreTest.php new file mode 100644 index 000000000..d1776cdf5 --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptStoreTest.php @@ -0,0 +1,70 @@ +makeOne(); + + $response = $this->post(route('api.videoscript.store', $script->toArray())); + + $response->assertUnauthorized(); + } + + /** + * The Script Store Endpoint shall require the path field. + * + * @return void + */ + public function testRequiredFields(): void + { + $user = User::factory()->withPermission('create video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.videoscript.store')); + + $response->assertJsonValidationErrors([ + VideoScript::ATTRIBUTE_PATH, + ]); + } + + /** + * The Script Store Endpoint shall create a script. + * + * @return void + */ + public function testCreate(): void + { + $parameters = VideoScript::factory()->raw(); + + $user = User::factory()->withPermission('create video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->post(route('api.videoscript.store', $parameters)); + + $response->assertCreated(); + static::assertDatabaseCount(VideoScript::TABLE, 1); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/Script/ScriptUpdateTest.php b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptUpdateTest.php new file mode 100644 index 000000000..4ced15a2c --- /dev/null +++ b/tests/Feature/Http/Api/Wiki/Video/Script/ScriptUpdateTest.php @@ -0,0 +1,55 @@ +createOne(); + + $parameters = VideoScript::factory()->raw(); + + $response = $this->put(route('api.videoscript.update', ['videoscript' => $script] + $parameters)); + + $response->assertUnauthorized(); + } + + /** + * The Script Update Endpoint shall update a script. + * + * @return void + */ + public function testUpdate(): void + { + $script = VideoScript::factory()->createOne(); + + $parameters = VideoScript::factory()->raw(); + + $user = User::factory()->withPermission('update video script')->createOne(); + + Sanctum::actingAs($user); + + $response = $this->put(route('api.videoscript.update', ['videoscript' => $script] + $parameters)); + + $response->assertOk(); + } +} diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php index f51e60117..cdd0cac52 100644 --- a/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php +++ b/tests/Feature/Http/Api/Wiki/Video/VideoIndexTest.php @@ -31,6 +31,7 @@ use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; use App\Models\Wiki\Audio; use App\Models\Wiki\Video; +use App\Models\Wiki\Video\VideoScript; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -114,6 +115,7 @@ class VideoIndexTest extends TestCase Video::factory() ->count($this->faker->randomDigitNotNull()) ->for(Audio::factory()) + ->has(VideoScript::factory(), 'videoscript') ->has( AnimeThemeEntry::factory() ->count($this->faker->randomDigitNotNull()) diff --git a/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php b/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php index 9dda1a202..d613f2a6b 100644 --- a/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php +++ b/tests/Feature/Http/Api/Wiki/Video/VideoShowTest.php @@ -19,6 +19,7 @@ use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; use App\Models\Wiki\Audio; use App\Models\Wiki\Video; +use App\Models\Wiki\Video\VideoScript; use Illuminate\Database\Eloquent\Factories\Sequence; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -103,6 +104,7 @@ class VideoShowTest extends TestCase $video = Video::factory() ->for(Audio::factory()) + ->has(VideoScript::factory(), 'videoscript') ->has( AnimeThemeEntry::factory() ->count($this->faker->randomDigitNotNull()) diff --git a/tests/Feature/Http/Wiki/AudioTest.php b/tests/Feature/Http/Wiki/Audio/AudioTest.php similarity index 98% rename from tests/Feature/Http/Wiki/AudioTest.php rename to tests/Feature/Http/Wiki/Audio/AudioTest.php index 3bd3a116a..099528018 100644 --- a/tests/Feature/Http/Wiki/AudioTest.php +++ b/tests/Feature/Http/Wiki/Audio/AudioTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature\Http\Wiki; +namespace Tests\Feature\Http\Wiki\Audio; use App\Constants\Config\FlagConstants; use App\Models\Wiki\Audio; diff --git a/tests/Feature/Http/Wiki/Video/Script/ScriptTest.php b/tests/Feature/Http/Wiki/Video/Script/ScriptTest.php new file mode 100644 index 000000000..239805f3f --- /dev/null +++ b/tests/Feature/Http/Wiki/Video/Script/ScriptTest.php @@ -0,0 +1,82 @@ +createOne(); + + $response = $this->get(route('videoscript.show', ['videoscript' => $script])); + + $response->assertForbidden(); + } + + /** + * If the script is soft-deleted, the user shall receive a forbidden exception. + * + * @return void + */ + public function testSoftDeleteScriptDownloadingForbidden(): void + { + Config::set(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED, false); + + $script = VideoScript::factory()->createOne(); + + $script->delete(); + + $response = $this->get(route('videoscript.show', ['videoscript' => $script])); + + $response->assertForbidden(); + } + + /** + * If script downloading is enabled, the script is downloaded from storage through the response. + * + * @return void + */ + public function testDownloadedThroughResponse(): void + { + Config::set(FlagConstants::ALLOW_SCRIPT_DOWNLOADING_FLAG_QUALIFIED, true); + + $fs = Storage::fake(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED)); + $file = File::fake()->create($this->faker->word().'.txt'); + $fsFile = $fs->putFile('', $file); + + $script = VideoScript::factory()->createOne([ + VideoScript::ATTRIBUTE_PATH => $fsFile, + ]); + + $response = $this->get(route('videoscript.show', ['videoscript' => $script])); + + static::assertInstanceOf(StreamedResponse::class, $response->baseResponse); + } +} diff --git a/tests/Feature/Http/Wiki/VideoTest.php b/tests/Feature/Http/Wiki/Video/VideoTest.php similarity index 98% rename from tests/Feature/Http/Wiki/VideoTest.php rename to tests/Feature/Http/Wiki/Video/VideoTest.php index 99e84aba0..3c9671a27 100644 --- a/tests/Feature/Http/Wiki/VideoTest.php +++ b/tests/Feature/Http/Wiki/Video/VideoTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Feature\Http\Wiki; +namespace Tests\Feature\Http\Wiki\Video; use App\Constants\Config\FlagConstants; use App\Constants\Config\VideoConstants; diff --git a/tests/Feature/Jobs/Wiki/Video/ScriptTest.php b/tests/Feature/Jobs/Wiki/Video/ScriptTest.php new file mode 100644 index 000000000..f7278167f --- /dev/null +++ b/tests/Feature/Jobs/Wiki/Video/ScriptTest.php @@ -0,0 +1,87 @@ +createOne(); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } + + /** + * When a script is deleted, a SendDiscordNotification job shall be dispatched. + * + * @return void + */ + public function testVideoDeletedSendsDiscordNotification(): void + { + $script = VideoScript::factory()->createOne(); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Bus::fake(SendDiscordNotificationJob::class); + + $script->delete(); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } + + /** + * When a script is restored, a SendDiscordNotification job shall be dispatched. + * + * @return void + */ + public function testVideoRestoredSendsDiscordNotification(): void + { + $script = VideoScript::factory()->createOne(); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Bus::fake(SendDiscordNotificationJob::class); + + $script->restore(); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } + + /** + * When a script is updated, a SendDiscordNotification job shall be dispatched. + * + * @return void + */ + public function testVideoUpdatedSendsDiscordNotification(): void + { + $script = VideoScript::factory()->createOne(); + + Config::set(FlagConstants::ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED, true); + Bus::fake(SendDiscordNotificationJob::class); + + $changes = VideoScript::factory()->makeOne(); + + $script->fill($changes->getAttributes()); + $script->save(); + + Bus::assertDispatched(SendDiscordNotificationJob::class); + } +} diff --git a/tests/Unit/Models/Wiki/Video/ScriptTest.php b/tests/Unit/Models/Wiki/Video/ScriptTest.php new file mode 100644 index 000000000..540967dc0 --- /dev/null +++ b/tests/Unit/Models/Wiki/Video/ScriptTest.php @@ -0,0 +1,43 @@ +createOne(); + + static::assertIsString($script->getName()); + } + + /** + * Scripts shall belong to a Video. + * + * @return void + */ + public function testVideo(): void + { + $script = VideoScript::factory() + ->for(Video::factory()) + ->createOne(); + + static::assertInstanceOf(BelongsTo::class, $script->video()); + static::assertInstanceOf(Video::class, $script->video()->first()); + } +} diff --git a/tests/Unit/Models/Wiki/VideoTest.php b/tests/Unit/Models/Wiki/VideoTest.php index 02442e884..73061f4b9 100644 --- a/tests/Unit/Models/Wiki/VideoTest.php +++ b/tests/Unit/Models/Wiki/VideoTest.php @@ -11,10 +11,12 @@ use App\Models\Wiki\Anime\AnimeTheme; use App\Models\Wiki\Anime\Theme\AnimeThemeEntry; use App\Models\Wiki\Audio; use App\Models\Wiki\Video; +use App\Models\Wiki\Video\VideoScript; use App\Pivots\AnimeThemeEntryVideo; use CyrildeWit\EloquentViewable\View; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Foundation\Testing\WithFaker; use Tests\TestCase; @@ -309,6 +311,21 @@ class VideoTest extends TestCase static::assertInstanceOf(Audio::class, $video->audio()->first()); } + /** + * Video shall have a one-to-one relationship with the type Script. + * + * @return void + */ + public function testScript(): void + { + $video = Video::factory() + ->has(VideoScript::factory(), 'videoscript') + ->createOne(); + + static::assertInstanceOf(HasOne::class, $video->videoscript()); + static::assertInstanceOf(VideoScript::class, $video->videoscript()->first()); + } + /** * Provider for source priority testing. *