74 Commits

Author SHA1 Message Date
paranarimasu c0464406d3 feat: add configurable throttling to video (#536) 2023-03-01 08:29:01 -06:00
paranarimasu 134a82ecc9 test: fix value boundary in billing factories & backfill anime studios needs unique value from external api (#514) 2023-02-07 23:58:45 -06:00
paranarimasu 3e71ff8ace refactor(admin): reduce number of queries for upload actions (#504)
* refactor(admin): reduce number of queries for upload actions

* style: fix phpdoc description for upload audio action

* style: fix audio attribute constants
2023-01-15 23:13:48 -06:00
paranarimasu 39250632b2 fix(api): fix anime video script allowed include path (#501) 2022-12-30 23:52:30 -06:00
Mani 3cdf824e47 feat(api): Added videoscript include path to anime (#499) 2022-12-30 21:26:23 -06:00
paranarimasu 681f07ecc9 chore: applying framework updates (#498) 2022-12-30 16:41:43 -06:00
paranarimasu 9028557f4c chore: bump dependencies for php8.2 (#500) 2022-12-30 16:40:26 -06:00
paranarimasu cea28e61f1 feat(admin): attach resource from detail/inline (#496) 2022-12-23 16:05:57 -06:00
paranarimasu fed083b5d3 feat(admin): allow video attributes to be set on upload (#495) 2022-12-23 11:53:17 -06:00
paranarimasu 2e97bb78ea chore: bump dependencies (#486) 2022-12-20 23:20:48 -06:00
paranarimasu b320882d6d feat: add static link for latest db dumps (#485) 2022-12-20 23:05:11 -06:00
paranarimasu a8801edec9 Chore 2022 11 07 (#479)
* chore: bump dependencies

* style: fix StyleCI finding
2022-12-20 22:22:26 -06:00
paranarimasu ed33d4154a chore: updating dependencies (#475) 2022-12-19 22:57:54 -06:00
paranarimasu d3a8d58cd2 #490 fix(admin): update pattern for mal studio cover image (#492) 2022-12-12 00:58:11 -06:00
paranarimasu 4cf68a96ac chore: updating dependencies & adding monitoring logs (#466) 2022-09-24 12:37:28 -05:00
paranarimasu 8b6b196c99 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
2022-09-24 00:01:37 -05:00
paranarimasu 99cf085666 feat(admin): support attaching entry when uploading video & fix(admin): support anidb change requests for artist & studio resources (#464)
* feat(admin): support attaching entry when uploading video & fix(admin): support anidb change requests for artist & studio resources

* style: fix StyleCI finding
2022-09-20 15:21:09 -05:00
paranarimasu 1d93000c6f feat(admin): migrate db dumps from github to platform (#463)
* feat(admin): migrate db dumps from github to platform

* style: fix StyleCI findings
2022-09-18 22:31:29 -05:00
paranarimasu 57cc3d508d chore: prune redundant auditing dependency (#462) 2022-09-06 14:31:37 -05:00
paranarimasu 2792961a06 chore: updating dependencies (#461) 2022-09-05 23:41:55 -05:00
paranarimasu 95134a384c fix(api): validate untrimmed and empty values with custom delimited validation rule (#460)
* fix(api): validate untrimmed and empty values with custom delimited validation rule

* style: fix StyleCI findings
2022-09-05 23:01:54 -05:00
paranarimasu 16c8d1781b fix(admin): enforce stricter normalization of external resource links (#459)
* fix(admin): enforce stricter normalization of external resource links

* fix(admin): value safety when resolving site for resource link format rule
2022-08-31 21:09:28 -05:00
paranarimasu 23afebc70e fix(api): validate null values for filter and sort parameters (#458)
* fix(api): validate null values for filter and sort parameters

* refactor(api): consistency of sort type array check
2022-08-29 15:35:52 -05:00
paranarimasu 7d5cd09768 fix(admin): model action query & fix(api): restricted type validation rules (#457) 2022-08-28 23:29:11 -05:00
paranarimasu b1e2a65b85 fix(admin): theme slug type safety for autocomplete (#456) 2022-08-27 13:16:58 -05:00
paranarimasu 99ec12c690 Merge pull request #453 from AnimeThemes/audios
Audios Release
2022-08-26 22:24:34 -05:00
paranarimasu 979773d476 chore: revert anime-planet resource seeder (#455) 2022-08-26 22:18:59 -05:00
paranarimasu ff2de264c8 fix: broken README links (#452) 2022-08-26 21:09:31 -05:00
paranarimasu 3151921c58 fix(admin): support multiple disks for audio extraction (#451)
* fix(admin): support multiple disks for audio extraction

* fix: example env should reference configured filesystem
2022-08-26 18:05:07 -05:00
paranarimasu 118dd45085 chore: updating dependencies (#449) 2022-08-26 14:04:54 -05:00
paranarimasu 5517d4061e feat(admin): add settings to override config values (#448)
* feat(admin): add settings to override config values

* style: fix StyleCI findings

* refactor: use model constants in migration
2022-08-26 12:53:55 -05:00
paranarimasu 952d3775f9 feat(admin): add move/rename action for audio/video (#447) 2022-08-25 14:22:51 -05:00
paranarimasu 43c97f1a7c Upload audio action (#446)
* feat(admin): add upload audio action

* style: fix StyleCI findings
2022-08-24 13:12:15 -05:00
paranarimasu d8354c536a feat(admin): add delete audio action (#445) 2022-08-23 16:49:54 -05:00
paranarimasu 80899b07df Delete video action (#444)
* feat(admin): remove video from storage

* style: fix StyleCI findings
2022-08-23 13:45:40 -05:00
paranarimasu d71c675b23 feat(admin): parse external id from sluggable anime resources (#443) 2022-08-20 23:03:18 -05:00
paranarimasu bb667d0b45 feat(db): seed anime planet resources (#442)
* feat(db): seed anime planet resources

* style: fix StyleCI findings
2022-08-17 23:04:37 -05:00
paranarimasu e812b94b87 fix(api): fix elastic config redux (#441) 2022-08-16 19:33:25 -05:00
paranarimasu 9a0989a3f7 fix(api): update elastic client config (#440) 2022-08-16 16:59:51 -05:00
paranarimasu 00e03a9f38 fix(api): elasticsearch injection fix (#439)
* fix(api): elasticsearch injection fix

* fix(api): use interface as suggested by docs
2022-08-16 16:50:49 -05:00
paranarimasu 146833338d chore: bump dependencies (#438) 2022-08-16 16:16:13 -05:00
paranarimasu 298c4b31d8 fix(admin): add missing config for FFProbe to resolve binary (#437) 2022-08-16 10:00:10 -05:00
paranarimasu e973f68537 feat(admin): upload video action (#436)
* feat(admin): upload video action

* style: fix StyleCI findings

* tests: fix video storage tests
2022-08-15 21:58:42 -05:00
paranarimasu 3a77f95e49 feat(admin): allow overwriting and skipping source video derivation for backfilling audio (#435)
* feat(admin): allow overwriting and skipping source video derivation for backfilling audio

* test: fix video streaming test
2022-08-13 14:47:00 -05:00
paranarimasu c18006badb test: backfill audio action & audio/video streaming (#433)
* test: backfill audio & streamed response

* style: fix StyleCI Findings
2022-08-11 18:39:43 -05:00
paranarimasu 2d17f7b9f7 test: backfill actions & drop support for backfilling studios through ANN (#432) 2022-08-10 23:16:25 -05:00
paranarimasu 5f915c86ea Local filesystem streaming (#431)
* feat: support streaming from local filesystems

* style: fix StyleCI findings
2022-08-09 18:54:43 -05:00
paranarimasu d1a4e99303 Refactor pipes to actions (#430)
* refactor: remove pipeline in favor of actions

* style: fix StyleCI findings
2022-08-06 11:05:07 -05:00
paranarimasu 191a5e890c fix(admin): update path field for storage repositories, fix(console): centralized validation through type hierarchy for repository reconciliation (#429) 2022-08-02 16:51:16 -05:00
paranarimasu 07eb52b68f feat(admin): add standalone reconcile actions (#428) 2022-08-02 11:41:23 -05:00
paranarimasu 39f665a524 feat(api): add audio include path to anime resource (#427) 2022-07-31 16:14:38 -05:00
paranarimasu 4258e0727d Seed audios (#426)
* feat: initial implementation of audio resource

* chore: bump dependencies
2022-07-30 21:14:29 -05:00
paranarimasu 06ee91f968 feat(admin): allow existing resources to be attached in lenses (#421)
* feat(admin): allow existing resources to be attached to anime in lenses

* style: fix StyleCI findings

* fix(admin): change confirm button text
2022-07-15 17:09:46 -05:00
paranarimasu 95f8229b50 fix(admin): make lens actions available inline (#420) 2022-07-14 19:00:19 -05:00
paranarimasu 3c128ff036 fix(admin): fix studio lens fields (#419) 2022-07-14 16:56:11 -05:00
paranarimasu 9975feb7ec chore: bump dependencies (#417) 2022-07-13 14:47:48 -05:00
paranarimasu ad6eca731b feat(admin): configure additional features for lens fields & adding type hierarchy (#415) 2022-07-11 22:38:31 -05:00
paranarimasu 211c164908 fix(discord): format date fields for embed (#414) 2022-07-09 23:28:25 -05:00
paranarimasu ee1f93f011 fix(admin): disable resource polling (#413) 2022-07-08 23:27:33 -05:00
paranarimasu 8818bd0fd2 fix(admin): add missing reconcile update columns & add missing query criteria for video source lens (#412) 2022-07-07 23:11:39 -05:00
paranarimasu 1611c73001 fix(admin): use canonical hostname for ANN (#410) 2022-07-06 18:43:32 -05:00
paranarimasu b05e61e4ac test: initialization of discord embed fields for update events (#407)
* test: initialization of discord embed fields for update events

* refactor: fix variable names
2022-07-05 12:00:02 -05:00
paranarimasu 57d5d9b2ef chore: bump dependencies & vite migration (#406) 2022-06-30 22:03:22 -05:00
paranarimasu 9c569bc435 feat(api): add anime image resource (#405) 2022-06-30 15:41:33 -05:00
paranarimasu ea8a644aa3 chore: bump dependencies, update stubs, fix(admin): add missing lens actions (#404) 2022-06-23 16:45:54 -05:00
paranarimasu ad07aa3703 #395 support images for studios (#403)
* feat: support images for studios

* style: fix StyleCI findings

* fix(admin): throw exception for image request

* style: fix StyleCI findings

* style: remove extraneous constant

* fix(admin): resources mapping may not exist when sending pipe notification

* style: fix StyleCI findings
2022-06-23 12:44:45 -05:00
paranarimasu 101feb031f chore: bump dependencies & some cleanup (#402) 2022-06-16 16:58:33 -05:00
paranarimasu f5942d0de5 fix(console): check if port is numeric (#401) 2022-06-16 15:06:46 -05:00
paranarimasu 4b01e3ac17 #392 fix(console): allow config options for db dumpers to support managed db requirements (#400)
* fix(console): allow config options for db dumpers

* style: fix StyleCI findings

* style: fix StyleCI findings

* fix(console): update scheduled commands and file naming
2022-06-16 13:33:59 -05:00
paranarimasu ccdf3b423a fix: don't use testing factories in app context (#399) 2022-06-14 22:01:20 -05:00
paranarimasu 6a6d28c9c7 feat(admin): configure theme slug field dependency (#398) 2022-06-14 15:05:06 -05:00
paranarimasu ebfa4f96af chore: bump dependencies, feat(admin): lens menu section & badges (#397)
* chore: bump dependencies, feat(admin): lens menu section & badges

* style: fix StyleCI findings

* style: fix StyleCI findings
2022-06-09 22:09:16 -05:00
paranarimasu 899d01fada fix(admin): add missing constraints to resource queries in backfill anime actions (#396) 2022-06-07 23:39:03 -05:00
paranarimasu 3ed878ab46 fix: allow configurable domain and prefix for all supported routes (#393)
* fix: allow configurable domain and prefix for all supported routes

* style: fix StyleCI finding
2022-06-07 09:56:46 -05:00
829 changed files with 40048 additions and 27298 deletions
+2 -2
View File
@@ -3,9 +3,9 @@ root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
+119 -32
View File
@@ -10,6 +10,15 @@ APP_URL=http://localhost
ASSET_URL=null
APP_KEY=
# audio
AUDIO_DISK_DEFAULT=audios_nyc
AUDIO_DISKS=audios_nyc
AUDIO_DISK_ROOT=
AUDIO_PATH=/audio
AUDIO_URL=
AUDIO_STREAMING_METHOD=
AUDIO_NGINX_REDIRECT=
# audit
AUDITING_ENABLED=true
@@ -18,10 +27,17 @@ BROADCAST_DRIVER=log
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_ID=
PUSHER_HOST=
PUSHER_APP_CLUSTER=mt1
PUSHER_PORT=443
PUSHER_SCHEME=https
ABLY_KEY=
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# cache
CACHE_DRIVER=file
@@ -57,12 +73,15 @@ REDIS_SCHEME=tcp
REDIS_DB=0
REDIS_CACHE_DB=1
# dump
DUMP_DISK=dumps
DUMP_DISK_ROOT=
DUMP_URL=http://localhost
DUMP_PATH=
# elastic client
ELASTIC_HOST=localhost
ELASTIC_PORT=9200
ELASTIC_SCHEME=http
ELASTIC_USER=
ELASTIC_PASS=
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
# elastic driver
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
@@ -70,16 +89,9 @@ ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
# elastic migrations
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
# enlightn
ENLIGHTN_SKIP_ENVIRONMENT_SPECIFIC=false
ENLIGHTN_USERNAME=
ENLIGHTN_API_TOKEN=
ENLIGHTN_GITHUB_REPO=
# feature flags
ALLOW_VIDEO_STREAMS=false
ALLOW_DISCORD_NOTIFICATIONS=false
ALLOW_VIEW_RECORDING=false
# ffmpeg
FFMPEG_BINARIES=
FFPROBE_BINARIES=
# filesystems
FILESYSTEM_DISK=local
@@ -94,14 +106,71 @@ IMAGE_DISABLE_ASSERTS=
IMAGE_VISIBILITY=
IMAGE_URL=
VIDEO_ACCESS_KEY_ID=
VIDEO_SECRET_ACCESS_KEY=
VIDEO_DEFAULT_REGION=
VIDEO_ENDPOINT=
VIDEO_BUCKET=
VIDEO_STREAM_READS=
VIDEO_DISABLE_ASSERTS=
VIDEO_VISIBILITY=
VIDEO_NYC_ACCESS_KEY_ID=
VIDEO_NYC_SECRET_ACCESS_KEY=
VIDEO_NYC_DEFAULT_REGION=
VIDEO_NYC_ENDPOINT=
VIDEO_NYC_BUCKET=
VIDEO_NYC_STREAM_READS=
VIDEO_NYC_DISABLE_ASSERTS=
VIDEO_NYC_VISIBILITY=
VIDEO_FRA_ACCESS_KEY_ID=
VIDEO_FRA_SECRET_ACCESS_KEY=
VIDEO_FRA_DEFAULT_REGION=
VIDEO_FRA_ENDPOINT=
VIDEO_FRA_BUCKET=
VIDEO_FRA_STREAM_READS=
VIDEO_FRA_DISABLE_ASSERTS=
VIDEO_FRA_VISIBILITY=
AUDIO_NYC_ACCESS_KEY_ID=
AUDIO_NYC_SECRET_ACCESS_KEY=
AUDIO_NYC_DEFAULT_REGION=
AUDIO_NYC_ENDPOINT=
AUDIO_NYC_BUCKET=
AUDIO_NYC_STREAM_READS=
AUDIO_NYC_DISABLE_ASSERTS=
AUDIO_NYC_VISIBILITY=
AUDIO_FRA_ACCESS_KEY_ID=
AUDIO_FRA_SECRET_ACCESS_KEY=
AUDIO_FRA_DEFAULT_REGION=
AUDIO_FRA_ENDPOINT=
AUDIO_FRA_BUCKET=
AUDIO_FRA_STREAM_READS=
AUDIO_FRA_DISABLE_ASSERTS=
AUDIO_FRA_VISIBILITY=
DUMP_ACCESS_KEY_ID=
DUMP_SECRET_ACCESS_KEY=
DUMP_DEFAULT_REGION=
DUMP_ENDPOINT=
DUMP_BUCKET=
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=
FORTIFY_URL=http://localhost
# hashing
BCRYPT_ROUNDS=10
@@ -110,8 +179,13 @@ BCRYPT_ROUNDS=10
HORIZON_DOMAIN=
HORIZON_PATH=horizon
# image
IMAGE_DISK=images
IMAGE_DISK_ROOT=
# jetstream
TEAM_CREATOR_ID=
JETSTREAM_PATH=
JETSTREAM_URL=http://localhost
# logging
LOG_CHANNEL=daily
@@ -127,7 +201,7 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}"
MAIL_SENDMAIL_PATH=/usr/sbin/sendmail
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
MAIL_LOG_CHANNEL=
# nova
@@ -176,11 +250,6 @@ SESSION_STORE=null
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=
# teams
HORIZON_TEAM_ID=
NOVA_TEAM_ID=
TELESCOPE_TEAM_ID=
# telescope
TELESCOPE_DOMAIN=null
TELESCOPE_PATH=telescope
@@ -188,9 +257,27 @@ TELESCOPE_DRIVER=database
TELESCOPE_ENABLED=true
# video
VIDEO_DISK_DEFAULT=videos_nyc
VIDEO_DISKS=videos_nyc
VIDEO_DISK_ROOT=
VIDEO_PATH=/video
VIDEO_URL=
VIDEO_STREAMING_METHOD=
VIDEO_NGINX_REDIRECT=
VIDEO_ENCODER_VERSION=
VIDEO_UPLOAD_DISKS=
SCRIPT_DISK=scripts
SCRIPT_DISK_ROOT=
SCRIPT_URL=http://localhost
SCRIPT_PATH=/videoscript
# web
WEB_URL=http://localhost
WEB_PATH=
# wiki
WIKI_FEATURED_ENTRY=
WIKI_FEATURED_VIDEO=
WIKI_DONATE_URL=#
WIKI_FAQ_URL=#
+1
View File
@@ -8,3 +8,4 @@
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+4 -4
View File
@@ -5,14 +5,14 @@ on: push
jobs:
static-analysis:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
php-version: '8.2'
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
@@ -30,7 +30,7 @@ jobs:
- name: Install NPM dependencies
run: npm install
- name: Compile assets
run: npm run production
run: npm run build
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Create Database
+4 -7
View File
@@ -5,32 +5,29 @@ on: push
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
php-version: '8.2'
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
env:
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
NOVA_PASSWORD: ${{ secrets.NOVA_PASSWORD }}
ENLIGHTN_USERNAME: ${{ secrets.ENLIGHTN_USERNAME }}
ENLIGHTN_PASSWORD: ${{ secrets.ENLIGHTN_PASSWORD }}
run: |
composer config "http-basic.nova.laravel.com" "$NOVA_USERNAME" "$NOVA_PASSWORD"
composer config "http-basic.satis.laravel-enlightn.com" "$ENLIGHTN_USERNAME" "$ENLIGHTN_PASSWORD"
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Generate key
run: php artisan key:generate
- name: Install NPM dependencies
run: npm install
- name: Compile assets
run: npm run production
run: npm run build
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Create Database
+8
View File
@@ -1,15 +1,23 @@
/node_modules
/public/hot
/public/storage
/public/build
/public/audios
/public/images
/public/videos
/public/dumps
/public/scripts
/storage/*.key
/vendor
.env
.env.backup
.env.production
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
/.fleet
.vscode
.idea
auth.json
+4 -4
View File
@@ -1,9 +1,9 @@
<p align="center">
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Tests/badge.svg?branch=wiki" alt="tests"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/static-analysis.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Static%20Analysis/badge.svg?branch=wiki" alt="static-analysis"></a>
<a href="https://github.styleci.io/repos/111264405?branch=wiki"><img src="https://github.styleci.io/repos/111264405/shield?branch=wiki" alt="StyleCI"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Tests/badge.svg?branch=main" alt="tests"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/static-analysis.yml"><img src="https://github.com/AnimeThemes/animethemes-server/workflows/Static%20Analysis/badge.svg?branch=main" alt="static-analysis"></a>
<a href="https://github.styleci.io/repos/111264405?branch=main"><img src="https://github.styleci.io/repos/111264405/shield?branch=main" alt="StyleCI"></a>
<a href="https://discordapp.com/invite/m9zbVyQ"><img src="https://img.shields.io/discord/354388306580078594.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/blob/wiki/LICENSE"><img src="https://img.shields.io/github/license/AnimeThemes/animethemes-server"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/blob/main/LICENSE"><img src="https://img.shields.io/github/license/AnimeThemes/animethemes-server"></a>
<a href="https://reddit.com/r/AnimeThemes"><img src="https://img.shields.io/reddit/subreddit-subscribers/AnimeThemes?style=social"></a>
<a href="https://twitter.com/AnimeThemesMoe"><img src="https://img.shields.io/twitter/follow/AnimeThemesMoe?style=social"></a>
</p>
+80
View File
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Enums\Actions\ActionStatus;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Class ActionResult.
*/
class ActionResult
{
/**
* Create a new action result instance.
*
* @param ActionStatus $status
* @param string|null $message
*/
public function __construct(protected readonly ActionStatus $status, protected readonly ?string $message = null)
{
}
/**
* Get the action result status.
*
* @return ActionStatus
*/
public function getStatus(): ActionStatus
{
return $this->status;
}
/**
* Get the action result message.
*
* @return string|null
*/
public function getMessage(): ?string
{
return $this->message;
}
/**
* Has the action failed?
*
* @return bool
*/
public function hasFailed(): bool
{
return ActionStatus::FAILED()->is($this->status);
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
$this->hasFailed()
? Log::error($this->getMessage())
: Log::info($this->getMessage());
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
$this->hasFailed()
? $command->error($this->getMessage())
: $command->info($this->getMessage());
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Admin\Dump;
use App\Actions\Http\DownloadAction;
use App\Constants\Config\DumpConstants;
use App\Models\Admin\Dump;
use Illuminate\Support\Facades\Config;
/**
* Class DumpDownloadAction.
*
* @extends DownloadAction<Dump>
*/
class DumpDownloadAction extends DownloadAction
{
/**
* Create a new action instance.
*
* @param Dump $dump
*/
public function __construct(Dump $dump)
{
parent::__construct($dump);
}
/**
* Get the path of the resource in storage.
*
* @return string
*/
protected function path(): string
{
return $this->model->path;
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(DumpConstants::DISK_QUALIFIED);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http;
use App\Contracts\Storage\InteractsWithDisk;
use App\Models\BaseModel;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Class DownloadAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class DownloadAction implements InteractsWithDisk
{
/**
* Create a new action instance.
*
* @param TModel $model
*/
public function __construct(protected readonly BaseModel $model)
{
}
/**
* Download the resource.
*
* @return StreamedResponse
*/
public function download(): StreamedResponse
{
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($this->disk());
return $fs->download($this->path());
}
/**
* Get the path of the resource in storage.
*
* @return string
*/
abstract protected function path(): string;
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
/**
* Class NginxStreamAction.
*/
abstract class NginxStreamAction extends StreamAction
{
/**
* Stream the resource.
*
* @return Response
*/
public function stream(): Response
{
$fs = Storage::disk($this->disk());
// Generate temporary link for the object
$temporaryURL = $fs->temporaryUrl($this->streamable->path(), now()->addMinutes(5));
// Get the url information
$url_scheme = parse_url($temporaryURL, PHP_URL_SCHEME);
$url_host = parse_url($temporaryURL, PHP_URL_HOST);
$url_path_query = parse_url($temporaryURL, PHP_URL_PATH).'?'.parse_url($temporaryURL, PHP_URL_QUERY);
// Construct the new link for the redirect
$link = "{$this->nginxRedirect()}$url_scheme/$url_host$url_path_query";
$response = new Response();
$disposition = $response->headers->makeDisposition('inline', $this->streamable->basename());
return $response->withHeaders([
'Accept-Ranges' => 'bytes',
'Content-Type' => $this->streamable->mimetype(),
'Content-Length' => $this->streamable->size(),
'Content-Disposition' => $disposition,
'X-Accel-Redirect' => $link,
]);
}
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
abstract protected function nginxRedirect(): string;
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Class ResponseStreamAction.
*/
abstract class ResponseStreamAction extends StreamAction
{
/**
* Stream the resource.
*
* @return StreamedResponse
*/
public function stream(): StreamedResponse
{
$response = new StreamedResponse();
$disposition = $response->headers->makeDisposition('inline', $this->streamable->basename());
$response->headers->replace([
'Accept-Ranges' => 'bytes',
'Content-Type' => $this->streamable->mimetype(),
'Content-Length' => $this->streamable->size(),
'Content-Disposition' => $disposition,
]);
$fs = Storage::disk($this->disk());
$response->setCallback(function () use ($fs) {
$stream = $fs->readStream($this->streamable->path());
fpassthru($stream);
fclose($stream);
});
return $response;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http;
use App\Contracts\Models\Streamable;
use App\Contracts\Storage\InteractsWithDisk;
use Symfony\Component\HttpFoundation\Response;
/**
* Class StreamAction.
*/
abstract class StreamAction implements InteractsWithDisk
{
/**
* Create a new action instance.
*
* @param Streamable $streamable
*/
public function __construct(protected readonly Streamable $streamable)
{
}
/**
* Stream the resource.
*
* @return Response
*/
abstract public function stream(): Response;
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Wiki\Audio;
use App\Actions\Http\NginxStreamAction;
use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
/**
* Class AudioNginxStreamAction.
*/
class AudioNginxStreamAction extends NginxStreamAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
}
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
protected function nginxRedirect(): string
{
return Config::get(AudioConstants::NGINX_REDIRECT_QUALIFIED);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Wiki\Audio;
use App\Actions\Http\ResponseStreamAction;
use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
/**
* Class AudioResponseStreamAction.
*/
class AudioResponseStreamAction extends ResponseStreamAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Wiki\Video\Script;
use App\Actions\Http\DownloadAction;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video\VideoScript;
use Illuminate\Support\Facades\Config;
/**
* Class ScriptDownloadAction.
*
* @extends DownloadAction<VideoScript>
*/
class ScriptDownloadAction extends DownloadAction
{
/**
* Create a new action instance.
*
* @param VideoScript $script
*/
public function __construct(VideoScript $script)
{
parent::__construct($script);
}
/**
* Get the path of the resource in storage.
*
* @return string
*/
protected function path(): string
{
return $this->model->path;
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED);
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Wiki\Video;
use App\Actions\Http\NginxStreamAction;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
/**
* Class VideoNginxStreamAction.
*/
class VideoNginxStreamAction extends NginxStreamAction
{
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
}
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
protected function nginxRedirect(): string
{
return Config::get(VideoConstants::NGINX_REDIRECT_QUALIFIED);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Wiki\Video;
use App\Actions\Http\ResponseStreamAction;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
/**
* Class VideoResponseStreamAction.
*/
class VideoResponseStreamAction extends ResponseStreamAction
{
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models;
use App\Actions\ActionResult;
use App\Models\BaseModel;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;
/**
* Class BaseAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class BaseAction
{
/**
* Create a new action instance.
*
* @param TModel $model
*/
public function __construct(protected BaseModel $model)
{
}
/**
* Handle action.
*
* @return ActionResult
*/
abstract public function handle(): ActionResult;
/**
* Get the model the action is handling.
*
* @return TModel
*/
abstract protected function getModel(): BaseModel;
/**
* Get the relation to resources.
*
* @return Relation
*/
abstract protected function relation(): Relation;
/**
* Get the human-friendly label for the underlying model.
*
* @return string
*/
protected function label(): string
{
return Str::headline(class_basename($this->getModel()));
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime;
use App\Actions\Models\Wiki\BackfillImageAction;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Image;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnimeImageAction.
*
* @extends BackfillImageAction<Anime>
*/
abstract class BackfillAnimeImageAction extends BackfillImageAction
{
/**
* Create a new action instance.
*
* @param Anime $anime
*/
public function __construct(Anime $anime)
{
parent::__construct($anime);
}
/**
* Get the model the action is handling.
*
* @return Anime
*/
protected function getModel(): Anime
{
return $this->model;
}
/**
* Get the relation to images.
*
* @return BelongsToMany
*/
protected function relation(): BelongsToMany
{
return $this->getModel()->images();
}
/**
* Attach Image to Anime.
*
* @param Image $image
* @return void
*/
protected function attachImage(Image $image): void
{
Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
$this->relation()->attach($image);
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime;
use App\Actions\Models\Wiki\BackfillResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use App\Pivots\AnimeResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnimeResourceAction.
*
* @extends BackfillResourceAction<Anime>
*/
abstract class BackfillAnimeResourceAction extends BackfillResourceAction
{
/**
* Create a new action instance.
*
* @param Anime $anime
*/
public function __construct(Anime $anime)
{
parent::__construct($anime);
}
/**
* Get the model the action is handling.
*
* @return Anime
*/
protected function getModel(): Anime
{
return $this->model;
}
/**
* Get the relation to images.
*
* @return BelongsToMany
*/
protected function relation(): BelongsToMany
{
return $this->getModel()->resources();
}
/**
* Get or Create Resource from response.
*
* @param int $id
* @param string|null $slug
* @return ExternalResource
*/
protected function getOrCreateResource(int $id, string $slug = null): ExternalResource
{
$resource = ExternalResource::query()
->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)
->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id)
->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatAnimeResourceLink($this->getSite(), $id, $slug))
->first();
if ($resource === null) {
Log::info("Creating {$this->getSite()->description} Resource '$id'");
$resource = ExternalResource::query()->create([
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id,
ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatAnimeResourceLink($this->getSite(), $id, $slug),
ExternalResource::ATTRIBUTE_SITE => $this->getSite()->value,
]);
}
return $resource;
}
/**
* Attach Resource to Anime.
*
* @param ExternalResource $resource
* @return void
*/
protected function attachResource(ExternalResource $resource): void
{
if (AnimeResource::query()
->where($this->getModel()->getKeyName(), $this->getModel()->getKey())
->where($resource->getKeyName(), $resource->getKey())
->doesntExist()
) {
Log::info("Attaching Resource '{$resource->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
$this->relation()->attach($resource);
}
}
}
@@ -2,21 +2,21 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Image;
namespace App\Actions\Models\Wiki\Anime\Image;
use App\Actions\Models\Wiki\Anime\BackfillAnimeImageAction;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use App\Pipes\Wiki\Anime\BackfillAnimeImage;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillLargeCoverImage.
* Class BackfillLargeCoverImageAction.
*/
class BackfillLargeCoverImage extends BackfillAnimeImage
class BackfillLargeCoverImageAction extends BackfillAnimeImageAction
{
/**
* Get the facet to backfill.
@@ -37,12 +37,9 @@ class BackfillLargeCoverImage extends BackfillAnimeImage
*/
protected function getImage(): ?Image
{
$anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbImage($anilistResource);
if ($anidbResource !== null) {
return $anidbResource;
}
return $this->getAnilistImage($anilistResource);
}
return null;
@@ -56,7 +53,7 @@ class BackfillLargeCoverImage extends BackfillAnimeImage
*
* @throws RequestException
*/
protected function getAnidbImage(ExternalResource $anilistResource): ?Image
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
{
$query = '
query ($id: Int) {
@@ -2,21 +2,21 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Image;
namespace App\Actions\Models\Wiki\Anime\Image;
use App\Actions\Models\Wiki\Anime\BackfillAnimeImageAction;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use App\Pipes\Wiki\Anime\BackfillAnimeImage;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillSmallCoverImage.
* Class BackfillSmallCoverImageAction.
*/
class BackfillSmallCoverImage extends BackfillAnimeImage
class BackfillSmallCoverImageAction extends BackfillAnimeImageAction
{
/**
* Get the facet to backfill.
@@ -37,12 +37,9 @@ class BackfillSmallCoverImage extends BackfillAnimeImage
*/
protected function getImage(): ?Image
{
$anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbImage($anilistResource);
if ($anidbResource !== null) {
return $anidbResource;
}
return $this->getAnilistImage($anilistResource);
}
return null;
@@ -56,7 +53,7 @@ class BackfillSmallCoverImage extends BackfillAnimeImage
*
* @throws RequestException
*/
protected function getAnidbImage(ExternalResource $anilistResource): ?Image
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
{
$query = '
query ($id: Int) {
@@ -2,19 +2,19 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Resource;
namespace App\Actions\Models\Wiki\Anime\Resource;
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Pipes\Wiki\Anime\BackfillAnimeResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillAnidbResource.
* Class BackfillAnidbResourceAction.
*/
class BackfillAnidbResource extends BackfillAnimeResource
class BackfillAnidbResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
@@ -37,7 +37,7 @@ class BackfillAnidbResource extends BackfillAnimeResource
{
// Allow fall-throughs in case AniDB Resource is not mapped to every external site.
$malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbMapping($malResource, 'myanimelist');
if ($anidbResource !== null) {
@@ -48,7 +48,7 @@ class BackfillAnidbResource extends BackfillAnimeResource
sleep(rand(1, 3));
}
$anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbMapping($anilistResource, 'anilist');
if ($anidbResource !== null) {
@@ -59,7 +59,7 @@ class BackfillAnidbResource extends BackfillAnimeResource
sleep(rand(1, 3));
}
$kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
return $this->getAnidbMapping($kitsuResource, 'kitsu');
}
@@ -2,20 +2,20 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Resource;
namespace App\Actions\Models\Wiki\Anime\Resource;
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Pipes\Wiki\Anime\BackfillAnimeResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnilistResource.
* Class BackfillAnilistResourceAction.
*/
class BackfillAnilistResource extends BackfillAnimeResource
class BackfillAnilistResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
@@ -38,7 +38,7 @@ class BackfillAnilistResource extends BackfillAnimeResource
{
// Allow fall-throughs in case Anilist Resource is not mapped to every external site.
$malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$anilistResource = $this->getMalAnilistMapping($malResource);
if ($anilistResource !== null) {
@@ -46,7 +46,7 @@ class BackfillAnilistResource extends BackfillAnimeResource
}
}
$kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
$anilistResource = $this->getKitsuAnilistMapping($kitsuResource);
if ($anilistResource !== null) {
@@ -54,7 +54,7 @@ class BackfillAnilistResource extends BackfillAnimeResource
}
}
$anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
return $this->getAnidbAnilistMapping($anidbResource);
}
@@ -2,20 +2,20 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Resource;
namespace App\Actions\Models\Wiki\Anime\Resource;
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Pipes\Wiki\Anime\BackfillAnimeResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnnResource.
* Class BackfillAnnResourceAction.
*/
class BackfillAnnResource extends BackfillAnimeResource
class BackfillAnnResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
@@ -36,7 +36,7 @@ class BackfillAnnResource extends BackfillAnimeResource
*/
protected function getResource(): ?ExternalResource
{
$kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
return $this->getKitsuAnnMapping($kitsuResource);
}
@@ -2,20 +2,20 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Resource;
namespace App\Actions\Models\Wiki\Anime\Resource;
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Pipes\Wiki\Anime\BackfillAnimeResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillKitsuResource.
* Class BackfillKitsuResourceAction.
*/
class BackfillKitsuResource extends BackfillAnimeResource
class BackfillKitsuResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
@@ -38,7 +38,7 @@ class BackfillKitsuResource extends BackfillAnimeResource
{
// Allow fall-throughs in case Kitsu Resource is not mapped to every external site.
$malResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($malResource, 'MYANIMELIST_ANIME');
if ($kitsuResource !== null) {
@@ -49,7 +49,7 @@ class BackfillKitsuResource extends BackfillAnimeResource
sleep(rand(1, 3));
}
$anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($anilistResource, 'ANILIST_ANIME');
if ($kitsuResource !== null) {
@@ -60,7 +60,7 @@ class BackfillKitsuResource extends BackfillAnimeResource
sleep(rand(1, 3));
}
$anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($anidbResource, 'ANIDB');
if ($kitsuResource !== null) {
@@ -71,7 +71,7 @@ class BackfillKitsuResource extends BackfillAnimeResource
sleep(rand(1, 3));
}
$annResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN);
$annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN);
if ($annResource instanceof ExternalResource) {
return $this->getKitsuMapping($annResource, 'ANIMENEWSNETWORK');
}
@@ -2,20 +2,20 @@
declare(strict_types=1);
namespace App\Pipes\Wiki\Anime\Resource;
namespace App\Actions\Models\Wiki\Anime\Resource;
use App\Actions\Models\Wiki\Anime\BackfillAnimeResourceAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Pipes\Wiki\Anime\BackfillAnimeResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillMalResource.
* Class BackfillMalResourceAction.
*/
class BackfillMalResource extends BackfillAnimeResource
class BackfillMalResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
@@ -36,7 +36,7 @@ class BackfillMalResource extends BackfillAnimeResource
*/
protected function getResource(): ?ExternalResource
{
$kitsuResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
$malResource = $this->getKitsuMalMapping($kitsuResource);
if ($malResource !== null) {
@@ -44,7 +44,7 @@ class BackfillMalResource extends BackfillAnimeResource
}
}
$anilistResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$malResource = $this->getAnilistMalMapping($anilistResource);
if ($malResource !== null) {
@@ -52,7 +52,7 @@ class BackfillMalResource extends BackfillAnimeResource
}
}
$anidbResource = $this->anime->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
return $this->getAnidbMalMapping($anidbResource);
}
@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\Studio;
use App\Actions\Models\Wiki\BackfillStudiosAction;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Studio;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnimeStudiosAction.
*
* @extends BackfillStudiosAction<Anime>
*/
class BackfillAnimeStudiosAction extends BackfillStudiosAction
{
/**
* Create a new action instance.
*
* @param Anime $anime
*/
public function __construct(Anime $anime)
{
parent::__construct($anime);
}
/**
* Get the model the action is handling.
*
* @return Anime
*/
protected function getModel(): Anime
{
return $this->model;
}
/**
* Get the relation to studios.
*
* @return BelongsToMany
*/
protected function relation(): BelongsToMany
{
return $this->getModel()->studios();
}
/**
* Query third-party API for Anime Studios.
*
* @return Studio[]
*
* @throws RequestException
*/
protected function getStudios(): array
{
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$studios = $this->getMalAnimeStudios($malResource);
if (! empty($studios)) {
return $studios;
}
}
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$studios = $this->getAnilistAnimeStudios($anilistResource);
if (! empty($studios)) {
return $studios;
}
}
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
return $this->getKitsuAnimeStudios($kitsuResource);
}
return [];
}
/**
* Query MAL API for Anime Studios.
*
* @param ExternalResource $malResource
* @return Studio[]
*
* @throws RequestException
*/
protected function getMalAnimeStudios(ExternalResource $malResource): array
{
$studios = [];
$response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get('services.mal.client')])
->get("https://api.myanimelist.net/v2/anime/$malResource->external_id", [
'fields' => 'studios',
])
->throw()
->json();
$malStudios = Arr::get($response, 'studios', []);
foreach ($malStudios as $malStudio) {
$name = Arr::get($malStudio, 'name');
$id = Arr::get($malStudio, 'id');
if (empty($name) || empty($id)) {
Log::info("Skipping empty studio of name '$name' and id '$id' for MAL Resource '{$malResource->getName()}'");
continue;
}
$studio = $this->getOrCreateStudio($name);
$studios[] = $studio;
$this->ensureStudioHasResource($studio, ResourceSite::MAL(), $id);
}
return $studios;
}
/**
* Query Anilist API for Anime Studios.
*
* @param ExternalResource $anilistResource
* @return Studio[]
*
* @throws RequestException
*/
protected function getAnilistAnimeStudios(ExternalResource $anilistResource): array
{
$studios = [];
$query = '
query ($id: Int) {
Media (id: $id, type: ANIME) {
studios (isMain: true) {
nodes {
id
name
}
}
}
}
';
$variables = [
'id' => $anilistResource->external_id,
];
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$anilistStudios = Arr::get($response, 'data.Media.studios.nodes', []);
foreach ($anilistStudios as $anilistStudio) {
$name = Arr::get($anilistStudio, 'name');
$id = Arr::get($anilistStudio, 'id');
if (empty($name) || empty($id)) {
Log::info("Skipping empty studio of name '$name' and id '$id' for Anilist Resource '{$anilistResource->getName()}'");
continue;
}
$studio = $this->getOrCreateStudio($name);
$studios[] = $studio;
$this->ensureStudioHasResource($studio, ResourceSite::ANILIST(), $id);
}
return $studios;
}
/**
* Query Kitsu API for Anime Studios.
*
* @param ExternalResource $kitsuResource
* @return Studio[]
*
* @throws RequestException
*/
protected function getKitsuAnimeStudios(ExternalResource $kitsuResource): array
{
$studios = [];
$query = '
query ($id: ID!) {
findAnimeById(id: $id) {
productions(first:20) {
nodes {
role
company {
name
}
}
}
}
}
';
$variables = [
'id' => $kitsuResource->external_id,
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$kitsuStudios = Arr::get($response, 'data.findAnimeById.productions.nodes', []);
foreach ($kitsuStudios as $kitsuStudio) {
$role = Arr::get($kitsuStudio, 'role');
$name = Arr::get($kitsuStudio, 'company.name');
if ($role !== 'STUDIO' || empty($name)) {
Log::info("Skipping production company of name '$name' and role '$role' for Anilist Resource '{$kitsuResource->getName()}'");
continue;
}
$studio = $this->getOrCreateStudio($name);
$studios[] = $studio;
}
return $studios;
}
/**
* Attach Studios.
*
* @param Studio[] $studios
* @return void
*/
protected function attachStudios(array $studios): void
{
Log::info("Attaching studios to {$this->label()} '{$this->getModel()->getName()}'");
$this->relation()->attach(Arr::pluck($studios, Studio::ATTRIBUTE_ID));
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BaseAction;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\Wiki\Image;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Testing\File;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Class BackfillImageAction.
*
* @template TModel of \App\Models\BaseModel
* @extends BaseAction<TModel>
*/
abstract class BackfillImageAction extends BaseAction
{
/**
* Handle action.
*
* @return ActionResult
*
* @throws RequestException
*/
public function handle(): ActionResult
{
if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->exists()) {
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Image of Facet '{$this->getFacet()->value}'.");
return new ActionResult(ActionStatus::SKIPPED());
}
$image = $this->getImage();
if ($image !== null) {
$this->attachImage($image);
}
if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->doesntExist()) {
return new ActionResult(
ActionStatus::FAILED(),
"{$this->label()} '{$this->getModel()->getName()}' has no {$this->getFacet()->description} Image after backfilling. Please review."
);
}
return new ActionResult(ActionStatus::PASSED());
}
/**
* Create Image from response.
*
* @param string $url
* @return Image
*
* @throws RequestException
*/
protected function createImage(string $url): Image
{
$imageResponse = Http::get($url)->throw();
$image = $imageResponse->body();
$file = File::createWithContent(basename($url), $image);
$fs = Storage::disk(Config::get('image.disk'));
$fsFile = $fs->putFile($this->path(), $file);
/** @var Image $image */
$image = Image::query()->create([
Image::ATTRIBUTE_FACET => $this->getFacet()->value,
Image::ATTRIBUTE_PATH => $fsFile,
]);
return $image;
}
/**
* Path to storage image in filesystem.
*
* @return string
*/
protected function path(): string
{
return Str::of(Str::kebab(class_basename($this->getModel())))
->append(DIRECTORY_SEPARATOR)
->append(Str::kebab($this->getFacet()->description))
->__toString();
}
/**
* Attach Image to model.
*
* @param Image $image
* @return void
*/
abstract protected function attachImage(Image $image): void;
/**
* Get the facet to backfill.
*
* @return ImageFacet
*/
abstract protected function getFacet(): ImageFacet;
/**
* Query third-party APIs to find Image.
*
* @return Image|null
*
* @throws RequestException
*/
abstract protected function getImage(): ?Image;
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BaseAction;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillResourceAction.
*
* @template TModel of \App\Models\BaseModel
* @extends BaseAction<TModel>
*/
abstract class BackfillResourceAction extends BaseAction
{
/**
* Handle action.
*
* @return ActionResult
*
* @throws RequestException
*/
public function handle(): ActionResult
{
if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->exists()) {
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Resource of Site '{$this->getSite()->value}'.");
return new ActionResult(ActionStatus::SKIPPED());
}
$resource = $this->getResource();
if ($resource !== null) {
$this->attachResource($resource);
}
if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->doesntExist()) {
return new ActionResult(
ActionStatus::FAILED(),
"{$this->label()} '{$this->getModel()->getName()}' has no {$this->getSite()->description} Resource after backfilling. Please review."
);
}
return new ActionResult(ActionStatus::PASSED());
}
/**
* Get or Create Resource from response.
*
* @param int $id
* @param string|null $slug
* @return ExternalResource
*/
abstract protected function getOrCreateResource(int $id, string $slug = null): ExternalResource;
/**
* Attach External Resource to model.
*
* @param ExternalResource $resource
* @return void
*/
abstract protected function attachResource(ExternalResource $resource): void;
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
abstract protected function getSite(): ResourceSite;
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
abstract protected function getResource(): ?ExternalResource;
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BaseAction;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Studio;
use App\Pivots\StudioResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Class BackfillStudiosAction.
*
* @template TModel of \App\Models\BaseModel
* @extends BaseAction<TModel>
*/
abstract class BackfillStudiosAction extends BaseAction
{
/**
* Handle action.
*
* @return ActionResult
*
* @throws RequestException
*/
public function handle(): ActionResult
{
if ($this->relation()->getQuery()->exists()) {
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Studios.");
return new ActionResult(ActionStatus::SKIPPED());
}
$studios = $this->getStudios();
if (! empty($studios)) {
$this->attachStudios($studios);
}
if ($this->relation()->getQuery()->doesntExist()) {
return new ActionResult(
ActionStatus::FAILED(),
"{$this->label()} '{$this->getModel()->getName()}' has no Studios after backfilling. Please review."
);
}
return new ActionResult(ActionStatus::PASSED());
}
/**
* Get or create Studio from name (case-insensitive).
*
* @param string $name
* @return Studio
*/
protected function getOrCreateStudio(string $name): Studio
{
$column = Studio::ATTRIBUTE_NAME;
$studio = Studio::query()
->where(DB::raw("lower($column)"), Str::lower($name))
->first();
if (! $studio instanceof Studio) {
Log::info("Creating studio '$name'");
$studio = Studio::query()->create([
Studio::ATTRIBUTE_NAME => $name,
Studio::ATTRIBUTE_SLUG => Str::slug($name, '_'),
]);
}
return $studio;
}
/**
* Ensure Studio has Resource.
*
* @param Studio $studio
* @param ResourceSite $site
* @param int $id
* @return void
*/
protected function ensureStudioHasResource(Studio $studio, ResourceSite $site, int $id): void
{
$studioResource = ExternalResource::query()
->where(ExternalResource::ATTRIBUTE_SITE, $site->value)
->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id)
->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatStudioResourceLink($site, $id))
->first();
if (! $studioResource instanceof ExternalResource) {
Log::info("Creating studio resource with site '$site->value' and id '$id'");
$studioResource = ExternalResource::query()->create([
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $id,
ExternalResource::ATTRIBUTE_LINK => ResourceSite::formatStudioResourceLink($site, $id),
ExternalResource::ATTRIBUTE_SITE => $site->value,
]);
}
if (StudioResource::query()
->where($studio->getKeyName(), $studio->getKey())
->where($studioResource->getKeyName(), $studioResource->getKey())
->doesntExist()
) {
Log::info("Attaching resource '$studioResource->link' to studio '{$studio->getName()}'");
$studioResource->studios()->attach($studio);
}
}
/**
* Attach Studios.
*
* @param Studio[] $studios
* @return void
*/
abstract protected function attachStudios(array $studios): void;
/**
* Query third-party API for Studios.
*
* @return Studio[]
*
* @throws RequestException
*/
abstract protected function getStudios(): array;
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Studio;
use App\Actions\Models\Wiki\BackfillImageAction;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillStudioImageAction.
*
* @extends BackfillImageAction<Studio>
*/
abstract class BackfillStudioImageAction extends BackfillImageAction
{
/**
* Create a new action instance.
*
* @param Studio $studio
*/
public function __construct(Studio $studio)
{
parent::__construct($studio);
}
/**
* Get the model the action is handling.
*
* @return Studio
*/
protected function getModel(): Studio
{
return $this->model;
}
/**
* Get the relation to images.
*
* @return BelongsToMany
*/
protected function relation(): BelongsToMany
{
return $this->getModel()->images();
}
/**
* Attach Image to Studio.
*
* @param Image $image
* @return void
*/
protected function attachImage(Image $image): void
{
Log::info("Attaching Image '{$image->getName()}' to {$this->label()} '{$this->getModel()->getName()}'");
$this->relation()->attach($image);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Studio\Image;
use App\Actions\Models\Wiki\Studio\BackfillStudioImageAction;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use Illuminate\Http\Client\RequestException;
/**
* Class BackfillLargeCoverImageAction.
*/
class BackfillLargeCoverImageAction extends BackfillStudioImageAction
{
/**
* Get the facet to backfill.
*
* @return ImageFacet
*/
protected function getFacet(): ImageFacet
{
return ImageFacet::COVER_LARGE();
}
/**
* Query third-party APIs to find Image.
*
* @return Image|null
*
* @throws RequestException
*/
protected function getImage(): ?Image
{
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
return $this->getMalImage($malResource);
}
return null;
}
/**
* Query MAL API for large cover image.
*
* @param ExternalResource $malResource
* @return Image|null
*
* @throws RequestException
*/
protected function getMalImage(ExternalResource $malResource): ?Image
{
return $this->createImage("https://cdn.myanimelist.net/images/company/$malResource->external_id.png");
}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Video\Audio;
use App\Actions\ActionResult;
use App\Actions\Models\BaseAction;
use App\Actions\Repositories\ReconcileResults;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositoriesAction;
use App\Constants\Config\AudioConstants;
use App\Constants\Config\VideoConstants;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Actions\ActionStatus;
use App\Enums\Actions\Models\Wiki\Video\DeriveSourceVideo;
use App\Enums\Actions\Models\Wiki\Video\OverwriteAudio;
use App\Models\Wiki\Anime;
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\Repositories\Eloquent\Wiki\AudioRepository as AudioDestinationRepository;
use App\Repositories\Storage\Wiki\AudioRepository as AudioSourceRepository;
use Exception;
use FFMpeg\Coordinate\TimeCode;
use FFMpeg\Filters\Audio\AddMetadataFilter;
use FFMpeg\Filters\Audio\AudioClipFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;
/**
* Class BackfillVideoAudioAction.
*
* @extends BaseAction<Video>
*/
class BackfillAudioAction extends BaseAction
{
/**
* Create a new action instance.
*
* @param Video $video
* @param DeriveSourceVideo $deriveSourceVideo
* @param OverwriteAudio $overwriteAudio
*/
public function __construct(
Video $video,
protected readonly DeriveSourceVideo $deriveSourceVideo = new DeriveSourceVideo(DeriveSourceVideo::YES),
protected readonly OverwriteAudio $overwriteAudio = new OverwriteAudio(OverwriteAudio::NO)
) {
parent::__construct($video);
}
/**
* Handle action.
*
* @return ActionResult
*/
public function handle(): ActionResult
{
if ($this->relation()->getQuery()->exists() && ! $this->overwriteAudio()) {
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Audio'.");
return new ActionResult(ActionStatus::SKIPPED());
}
$audio = $this->getAudio();
if ($audio !== null) {
$this->attachAudio($audio);
}
if ($this->relation()->getQuery()->doesntExist()) {
return new ActionResult(
ActionStatus::FAILED(),
"{$this->label()} '{$this->getModel()->getName()}' has no Audio after backfilling. Please review."
);
}
return new ActionResult(ActionStatus::PASSED());
}
/**
* Get the model the action is handling.
*
* @return Video
*/
protected function getModel(): Video
{
return $this->model;
}
/**
* Get the relation to audio.
*
* @return BelongsTo
*/
protected function relation(): BelongsTo
{
return $this->getModel()->audio();
}
/**
* Determine if the source video should be derived.
*
* @return bool
*/
protected function deriveSourceVideo(): bool
{
return DeriveSourceVideo::YES()->is($this->deriveSourceVideo);
}
/**
* Determine if audio should be overwritten.
*
* @return bool
*/
protected function overwriteAudio(): bool
{
return OverwriteAudio::YES()->is($this->overwriteAudio);
}
/**
* Get or Create Audio.
*
* @return Audio|null
*/
protected function getAudio(): ?Audio
{
// Allow bypassing of source video derivation
$sourceVideo = $this->deriveSourceVideo()
? $this->getSourceVideo()
: $this->getModel();
// It's possible that the video is not attached to any themes, exit early.
if ($sourceVideo === null) {
return null;
}
// First, attempt to set audio from the source video
$audio = $sourceVideo->audio;
// Anticipate audio path for FFmpeg save file
$audioPath = $audio === null
? Str::replace('webm', 'ogg', $sourceVideo->path)
: $audio->path;
// Second, attempt to set audio from path
if ($audio === null) {
$audio = Audio::query()->firstWhere(Audio::ATTRIBUTE_PATH, $audioPath);
}
// Finally, extract audio from the source video
if ($audio === null || $this->overwriteAudio()) {
Log::info("Extracting Audio from Video '{$sourceVideo->getName()}'");
$this->extractAudio($sourceVideo, $audioPath);
$results = $this->reconcileAudio($audioPath);
$results->toLog();
if ($audio === null) {
$audio = $results->getCreated()->firstWhere(fn (Audio $audio) => $audio->path === $audioPath);
}
}
return $audio;
}
/**
* Get the source video for the given video.
*
* @return Video|null
*/
protected function getSourceVideo(): ?Video
{
$source = null;
$sourceCandidates = $this->getAdjacentVideos();
foreach ($sourceCandidates as $sourceCandidate) {
if (! $source instanceof Video || $sourceCandidate->getSourcePriority() > $source->getSourcePriority()) {
$source = $sourceCandidate;
}
}
return $source;
}
/**
* Get the adjacent videos for sourcing.
*
* @return Collection<int, Video>
*/
protected function getAdjacentVideos(): Collection
{
$builder = AnimeTheme::query();
$sortRelation = $builder->getRelation(AnimeTheme::RELATION_ANIME);
$orderByNameQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_NAME]);
$orderBySeasonQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_SEASON]);
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_YEAR]);
return $builder->whereHas(AnimeTheme::RELATION_VIDEOS, fn (Builder $relationBuilder) => $relationBuilder->whereKey($this->getModel()))
->orderBy($orderByYearQuery->toBase())
->orderBy($orderBySeasonQuery->toBase())
->orderBy($orderByNameQuery->toBase())
->with([
AnimeTheme::RELATION_ANIME,
AnimeTheme::RELATION_AUDIO,
AnimeTheme::RELATION_ENTRIES => fn (Relation $relation) => $relation->getQuery()->orderBy(AnimeThemeEntry::ATTRIBUTE_VERSION),
])
->get()
->flatMap(fn (AnimeTheme $theme) => $theme->animethemeentries)
->flatMap(fn (AnimeThemeEntry $entry) => $entry->videos);
}
/**
* Extract audio stream from video and store in filesystem.
*
* @param Video $video
* @param string $audioPath
* @return void
*/
protected function extractAudio(Video $video, string $audioPath): void
{
try {
foreach (Config::get(AudioConstants::DISKS_QUALIFIED, []) as $audioDisk) {
FFMpeg::fromDisk(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED))
->open($video->path)
->addFilter(new AudioClipFilter(new TimeCode(0, 0, 0, 0)))
->addFilter(new AddMetadataFilter())
->export()
->toDisk($audioDisk)
->save($audioPath);
}
} catch (Exception $e) {
Log::error($e->getMessage());
} finally {
FFMpeg::cleanupTemporaryFiles();
}
}
/**
* Reconcile audio repositories.
*
* @param string $audioPath
* @return ReconcileResults
*/
protected function reconcileAudio(string $audioPath): ReconcileResults
{
$action = new ReconcileAudioRepositoriesAction();
/** @var RepositoryInterface $sourceRepository */
$sourceRepository = App::make(AudioSourceRepository::class);
$sourceRepository->handleFilter('path', File::dirname($audioPath));
/** @var RepositoryInterface $destinationRepository */
$destinationRepository = App::make(AudioDestinationRepository::class);
$destinationRepository->handleFilter('path', File::dirname($audioPath));
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
/**
* Attach Audio to model.
*
* @param Audio $audio
* @return void
*/
protected function attachAudio(Audio $audio): void
{
if ($this->relation()->isNot($audio)) {
Log::info("Associating Audio '{$audio->getName()}' with Video '{$this->getModel()->getName()}'");
$this->relation()->associate($audio)->save();
}
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Admin\Dump;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Admin\Dump;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileDumpRepositories.
*
* @extends ReconcileRepositoriesAction<Dump>
*/
class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Dump::ATTRIBUTE_ID,
Dump::ATTRIBUTE_PATH,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Dump $first, Dump $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 ReconcileDumpResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Admin\Dump;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Admin\Dump;
/**
* Class ReconcileDumpResults.
*
* @extends ReconcileResults<Dump>
*/
class ReconcileDumpResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Dump>
*/
protected function model(): string
{
return Dump::class;
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Balance;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Balance;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileBalanceRepositories.
*
* @extends ReconcileRepositoriesAction<Balance>
*/
class ReconcileBalanceRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Balance $first, Balance $second) => $first->date->format(AllowedDateFormat::YM) <=> $second->date->format(AllowedDateFormat::YM);
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
protected function columnsForUpdate(): array
{
return [
Balance::ATTRIBUTE_BALANCE,
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_FREQUENCY,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
Balance::ATTRIBUTE_USAGE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Balance $first, Balance $second) => [$first->date->format(AllowedDateFormat::YMD), $first->usage, $first->balance]
<=> [$second->date->format(AllowedDateFormat::YMD), $second->usage, $second->balance];
}
/**
* 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
{
$formattedDestinationDate = $destinationModel->getAttribute(Balance::ATTRIBUTE_DATE)->format(AllowedDateFormat::YM);
return $sourceModels->first(
fn (Balance $balance) => $balance->date->format(AllowedDateFormat::YM) === $formattedDestinationDate
);
}
/**
* 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 ReconcileBalanceResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Balance;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Billing\Balance;
/**
* Class ReconcileBalanceResults.
*
* @extends ReconcileResults<Balance>
*/
class ReconcileBalanceResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Balance>
*/
protected function model(): string
{
return Balance::class;
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Transaction;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Transaction;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileTransactionRepositories.
*
* @extends ReconcileRepositoriesAction<Transaction>
*/
class ReconcileTransactionRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Transaction::ATTRIBUTE_AMOUNT,
Transaction::ATTRIBUTE_DATE,
Transaction::ATTRIBUTE_EXTERNAL_ID,
Transaction::ATTRIBUTE_ID,
Transaction::ATTRIBUTE_SERVICE,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Transaction $first, Transaction $second) => [$first->external_id, $first->date->format(AllowedDateFormat::YMD), $first->amount]
<=> [$second->external_id, $second->date->format(AllowedDateFormat::YMD), $second->amount];
}
/**
* 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 ReconcileTransactionResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Transaction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Billing\Transaction;
/**
* Class ReconcileTransactionResults.
*
* @extends ReconcileResults<Transaction>
*/
class ReconcileTransactionResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Transaction>
*/
protected function model(): string
{
return Transaction::class;
}
}
@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories;
use App\Contracts\Repositories\RepositoryInterface;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileRepositories.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class ReconcileRepositoriesAction
{
/**
* Perform set reconciliation between source and destination repositories.
*
* @param RepositoryInterface<TModel> $source
* @param RepositoryInterface<TModel> $destination
* @return ReconcileResults
*/
public function reconcileRepositories(RepositoryInterface $source, RepositoryInterface $destination): ReconcileResults
{
$sourceModels = $source->get();
$destinationModels = $destination->get($this->columnsForCreateDelete());
$created = $this->createModelsFromSource($destination, $sourceModels, $destinationModels);
$deleted = $this->deleteModelsFromDestination($destination, $sourceModels, $destinationModels);
$destinationModels = $destination->get($this->columnsForUpdate());
$updated = $this->updateDestinationModels($destination, $sourceModels, $destinationModels);
return $this->getResults($created, $deleted, $updated);
}
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
abstract protected function columnsForCreateDelete(): array;
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
abstract protected function diffCallbackForCreateDelete(): Closure;
/**
* Create models that exist in source but not in destination.
*
* @param RepositoryInterface<TModel> $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return Collection
*/
protected function createModelsFromSource(
RepositoryInterface $destination,
Collection $sourceModels,
Collection $destinationModels
): Collection {
$createModels = $sourceModels->diffUsing($destinationModels, $this->diffCallbackForCreateDelete());
return $createModels->each(fn (Model $createModel) => $destination->save($createModel));
}
/**
* Delete models that exist in destination but not in source.
*
* @param RepositoryInterface<TModel> $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return Collection
*/
protected function deleteModelsFromDestination(
RepositoryInterface $destination,
Collection $sourceModels,
Collection $destinationModels
): Collection {
$deleteModels = $destinationModels->diffUsing($sourceModels, $this->diffCallbackForCreateDelete());
return $deleteModels->each(fn (Model $deleteModel) => $destination->delete($deleteModel));
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
abstract protected function columnsForUpdate(): array;
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
abstract protected function diffCallbackForUpdate(): Closure;
/**
* Get source model that has been updated for destination model.
*
* @param Collection $sourceModels
* @param Model $destinationModel
* @return Model|null
*/
abstract protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model;
/**
* Update destination models that have changed in source.
*
* @param RepositoryInterface<TModel> $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return Collection
*/
protected function updateDestinationModels(
RepositoryInterface $destination,
Collection $sourceModels,
Collection $destinationModels
): Collection {
$updatedModels = $destinationModels->diffUsing($sourceModels, $this->diffCallbackForUpdate());
return $updatedModels->each(function (Model $updatedModel) use ($sourceModels, $destination) {
$sourceModel = $this->resolveUpdatedModel($sourceModels, $updatedModel);
if ($sourceModel !== null) {
$destination->update($updatedModel, $sourceModel->toArray());
}
});
}
/**
* Get reconciliation results.
*
* @param Collection $created
* @param Collection $deleted
* @param Collection $updated
* @return ReconcileResults
*/
abstract protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults;
}
@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories;
use App\Actions\ActionResult;
use App\Enums\Actions\ActionStatus;
use App\Models\BaseModel;
use Countable;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Class ReconcileResults.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class ReconcileResults extends ActionResult
{
/**
* Create a new results instance.
*
* @param Collection $created
* @param Collection $deleted
* @param Collection $updated
*/
public function __construct(
protected readonly Collection $created = new Collection(),
protected readonly Collection $deleted = new Collection(),
protected readonly Collection $updated = new Collection()
) {
parent::__construct(ActionStatus::PASSED());
}
/**
* Get created models.
*
* @return Collection
*/
public function getCreated(): Collection
{
return $this->created;
}
/**
* Get deleted models.
*
* @return Collection
*/
public function getDeleted(): Collection
{
return $this->deleted;
}
/**
* Get updated models.
*
* @return Collection
*/
public function getUpdated(): Collection
{
return $this->updated;
}
/**
* Determines if any successful changes were made during reconciliation.
*
* @return bool
*/
public function hasChanges(): bool
{
return $this->created->isNotEmpty() || $this->deleted->isNotEmpty() || $this->updated->isNotEmpty();
}
/**
* Write reconcile results to log.
*
* @return void
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function toLog(): void
{
$this->created->each(fn (BaseModel $model) => Log::info("{$this->label()} '{$model->getName()}' created"));
$this->deleted->each(fn (BaseModel $model) => Log::info("{$this->label()} '{$model->getName()}' deleted"));
$this->updated->each(fn (BaseModel $model) => Log::info("{$this->label()} '{$model->getName()}' updated"));
Log::info($this->getMessage());
}
/**
* Write reconcile results to console output.
*
* @param Command $command
* @return void
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function toConsole(Command $command): void
{
$this->created->each(fn (BaseModel $model) => $command->info("{$this->label()} '{$model->getName()}' created"));
$this->deleted->each(fn (BaseModel $model) => $command->info("{$this->label()} '{$model->getName()}' deleted"));
$this->updated->each(fn (BaseModel $model) => $command->info("{$this->label()} '{$model->getName()}' updated"));
$command->info($this->getMessage());
}
/**
* Get the action result message.
*
* @return string|null
*
* @noinspection PhpMissingParentCallCommonInspection
*/
public function getMessage(): ?string
{
if ($this->hasChanges()) {
return "{$this->created->count()} {$this->label($this->created)} created, {$this->deleted->count()} {$this->label($this->deleted)} deleted, {$this->updated->count()} {$this->label($this->updated)} updated";
}
return "No {$this->label($this->created)} created or deleted or updated";
}
/**
* Get the model of the reconciliation results.
*
* @return class-string<TModel>
*/
abstract protected function model(): string;
/**
* Get the user-friendly label for the model class name.
*
* @param int|array|Countable $models
* @return string
*/
protected function label(int|array|Countable $models = 1): string
{
return Str::plural(Str::headline(class_basename($this->model())), $models);
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Audio;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Audio;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileAudioRepositories.
*
* @extends ReconcileRepositoriesAction<Audio>
*/
class ReconcileAudioRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Audio::ATTRIBUTE_BASENAME,
Audio::ATTRIBUTE_ID,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Audio $first, Audio $second) => $first->basename <=> $second->basename;
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
protected function columnsForUpdate(): array
{
return [
Audio::ATTRIBUTE_BASENAME,
Audio::ATTRIBUTE_FILENAME,
Audio::ATTRIBUTE_ID,
Audio::ATTRIBUTE_MIMETYPE,
Audio::ATTRIBUTE_PATH,
Audio::ATTRIBUTE_SIZE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Audio $first, Audio $second) => [$first->basename, $first->path, $first->size] <=> [$second->basename, $second->path, $second->size];
}
/**
* 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 $sourceModels->firstWhere(
Audio::ATTRIBUTE_BASENAME,
$destinationModel->getAttribute(Audio::ATTRIBUTE_BASENAME)
);
}
/**
* 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 ReconcileAudioResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Audio;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Audio;
/**
* Class ReconcileAudioResults.
*
* @extends ReconcileResults<Audio>
*/
class ReconcileAudioResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Audio>
*/
protected function model(): string
{
return Audio::class;
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Video;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Video;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileVideoRepositories.
*
* @extends ReconcileRepositoriesAction<Video>
*/
class ReconcileVideoRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Video::ATTRIBUTE_BASENAME,
Video::ATTRIBUTE_ID,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Video $first, Video $second) => $first->basename <=> $second->basename;
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
protected function columnsForUpdate(): array
{
return [
Video::ATTRIBUTE_BASENAME,
Video::ATTRIBUTE_FILENAME,
Video::ATTRIBUTE_ID,
Video::ATTRIBUTE_MIMETYPE,
Video::ATTRIBUTE_PATH,
Video::ATTRIBUTE_SIZE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Video $first, Video $second) => [$first->basename, $first->path, $first->size] <=> [$second->basename, $second->path, $second->size];
}
/**
* 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 $sourceModels->firstWhere(
Video::ATTRIBUTE_BASENAME,
$destinationModel->getAttribute(Video::ATTRIBUTE_BASENAME)
);
}
/**
* 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 ReconcileVideoResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Video;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Video;
/**
* Class ReconcileVideoResults.
*
* @extends ReconcileResults<Video>
*/
class ReconcileVideoResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Video>
*/
protected function model(): string
{
return Video::class;
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Video\Script;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Video\VideoScript;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileScriptRepositoriesAction.
*
* @extends ReconcileRepositoriesAction<VideoScript>
*/
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);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Video\Script;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Video\VideoScript;
/**
* Class ReconcileScriptResults.
*
* @extends ReconcileResults<VideoScript>
*/
class ReconcileScriptResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<VideoScript>
*/
protected function model(): string
{
return VideoScript::class;
}
}
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Admin\Dump;
use App\Actions\ActionResult;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Constants\Config\DumpConstants;
use App\Enums\Actions\ActionStatus;
use Exception;
use Illuminate\Database\Connection;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use PDO;
use RuntimeException;
use Spatie\DbDumper\Databases\MySql;
use Spatie\DbDumper\Databases\PostgreSql;
use Spatie\DbDumper\Databases\Sqlite;
use Spatie\DbDumper\DbDumper;
use Spatie\DbDumper\Exceptions\CannotSetParameter;
/**
* Class DumpAction.
*/
abstract class DumpAction
{
use ReconcilesRepositories;
/**
* Create a new action instance.
*
* @param array<string, mixed> $options
*/
public function __construct(protected readonly array $options = [])
{
}
/**
* Handle action.
*
* @return ActionResult
*/
public function handle(): ActionResult
{
$dumpFile = $this->getDumpFile();
try {
/** @var Connection $connection */
$connection = DB::connection();
$dumper = $this->getDumper($connection);
if ($dumper === null) {
return new ActionResult(
ActionStatus::FAILED(),
"Unrecognized connection '{$connection->getName()}'"
);
}
// First, Dump file to temporary location
// Writing to disks is not fully supported
$dumper->dumpToFile($dumpFile);
// Then, store dump file in intended location
/** @var FilesystemAdapter $fs */
$fs = Storage::disk(Config::get(DumpConstants::DISK_QUALIFIED));
$fs->putFileAs('', $dumpFile, File::basename($dumpFile));
// Finally, delete the temporary file
File::delete($dumpFile);
} catch (Exception $e) {
return new ActionResult(
ActionStatus::FAILED(),
$e->getMessage()
);
}
$reconcileResults = $this->reconcileRepositories();
$reconcileResults->toLog();
return new ActionResult(
ActionStatus::PASSED(),
"Database dump '$dumpFile' has been created",
);
}
/**
* Get the dumper for the database connection.
*
* @param Connection $connection
* @return DbDumper|null
*
* @throws CannotSetParameter
*/
protected function getDumper(Connection $connection): ?DbDumper
{
return match (get_class($connection)) {
SQLiteConnection::class => $this->prepareSqliteDumper($connection),
MySqlConnection::class => $this->prepareMySqlDumper($connection),
PostgresConnection::class => $this->preparePostgreSqlDumper($connection),
default => null,
};
}
/**
* Configure Sqlite database dumper.
*
* @param Connection $connection
* @return Sqlite
*
* @throws RuntimeException
* @throws CannotSetParameter
*/
protected function prepareSqliteDumper(Connection $connection): Sqlite
{
if (version_compare($connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), '3.32.0', '<')) {
throw new RuntimeException('DB connection does not support includeTables option');
}
$dumper = Sqlite::create();
$dumper->setDbName($connection->getDatabaseName());
$dumper->includeTables($this->allowedTables());
return $dumper;
}
/**
* Configure MySQL database dumper.
*
* @param Connection $connection
* @return MySql
*
* @throws CannotSetParameter
*/
protected function prepareMySqlDumper(Connection $connection): MySql
{
$dumper = MySql::create();
$dumper->setDbName($connection->getDatabaseName());
$dumper->setUserName(strval($connection->getConfig('username')));
$dumper->setPassword(strval($connection->getConfig('password')));
$hostConfig = $connection->getConfig('host');
if ($hostConfig !== null) {
$dumper->setHost(collect($hostConfig)->first());
}
$port = $connection->getConfig('port');
if (is_numeric($port)) {
$dumper->setPort(intval($port));
}
$dumper->includeTables($this->allowedTables());
if ($this->option('comments')) {
$dumper->dontSkipComments();
}
if ($this->option('skip-comments')) {
$dumper->skipComments();
}
if ($this->option('extended-insert')) {
$dumper->useExtendedInserts();
}
if ($this->option('skip-extended-insert')) {
$dumper->dontUseExtendedInserts();
}
$this->option('single-transaction')
? $dumper->useSingleTransaction()
: $dumper->dontUseSingleTransaction();
if ($this->option('lock-tables')) {
$dumper->dontSkipLockTables();
}
if ($this->option('skip-lock-tables')) {
$dumper->skipLockTables();
}
if ($this->option('skip-column-statistics')) {
$dumper->doNotUseColumnStatistics();
}
if ($this->option('quick')) {
$dumper->useQuick();
}
if ($this->option('skip-quick')) {
$dumper->dontUseQuick();
}
if ($this->hasOption('default-character-set')) {
$dumper->setDefaultCharacterSet($this->option('default-character-set'));
}
if ($this->hasOption('set-gtid-purged')) {
$dumper->setGtidPurged($this->option('set-gtid-purged'));
}
if ($this->option('no-create-info')) {
$dumper->doNotCreateTables();
}
return $dumper;
}
/**
* Configure PostgreSql database dumper.
*
* @param Connection $connection
* @return PostgreSql
*
* @throws CannotSetParameter
*/
protected function preparePostgreSqlDumper(Connection $connection): PostgreSql
{
$dumper = PostgreSql::create();
$dumper->setDbName($connection->getDatabaseName());
$dumper->setUserName(strval($connection->getConfig('username')));
$dumper->setPassword(strval($connection->getConfig('password')));
$host = $connection->getConfig('host');
if ($host !== null) {
$dumper->setHost(collect($host)->first());
}
$port = $connection->getConfig('port');
if (is_int($port)) {
$dumper->setPort($port);
}
$dumper->includeTables($this->allowedTables());
if ($this->option('inserts')) {
$dumper->useInserts();
}
if ($this->option('data-only')) {
$dumper->doNotCreateTables();
}
return $dumper;
}
/**
* Determine if the string option is set.
*
* @param string $key
* @return bool
*/
protected function hasOption(string $key): bool
{
return is_string(Arr::get($this->options, $key));
}
/**
* Get the option by key.
*
* @param string $key
* @return mixed
*/
protected function option(string $key): mixed
{
return Arr::get($this->options, $key);
}
/**
* The temporary path for the database dump.
* Note: The dumper library does not support writing to disk, so we have to write to the local filesystem first.
* Pattern: "animethemes-db-dump-{concern}-{milliseconds from epoch}.sql".
*
* @return string
*/
abstract protected function getDumpFile(): string;
/**
* The list of tables to include in the dump.
*
* @return array
*/
abstract protected function allowedTables(): array;
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Admin\Dump;
use App\Concerns\Repositories\Admin\ReconcilesDumpRepositories;
use App\Models\Document\Page;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Class DumpDocumentAction.
*/
class DumpDocumentAction extends DumpAction
{
use ReconcilesDumpRepositories;
final public const FILENAME_PREFIX = 'animethemes-db-dump-document-';
/**
* The list of tables to include in the dump.
*
* @return array
*/
protected function allowedTables(): array
{
return [
Page::TABLE,
];
}
/**
* The temporary path for the database dump.
* Note: The dumper library does not support writing to disk, so we have to write to the local filesystem first.
* Pattern: "animethemes-db-dump-document-{milliseconds from epoch}.sql".
*
* @return string
*/
protected function getDumpFile(): string
{
$filesystem = Storage::disk('local');
return Str::of($filesystem->path(''))
->append(DumpDocumentAction::FILENAME_PREFIX)
->append(intval(Date::now()->valueOf()))
->append('.sql')
->__toString();
}
}
@@ -2,20 +2,22 @@
declare(strict_types=1);
namespace App\Console\Commands\Wiki;
namespace App\Actions\Storage\Admin\Dump;
use App\Console\Commands\DatabaseDumpCommand;
use App\Concerns\Repositories\Admin\ReconcilesDumpRepositories;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Anime\AnimeSynonym;
use App\Models\Wiki\Anime\AnimeTheme;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Artist;
use App\Models\Wiki\Audio;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use App\Models\Wiki\Series;
use App\Models\Wiki\Song;
use App\Models\Wiki\Studio;
use App\Models\Wiki\Video;
use App\Models\Wiki\Video\VideoScript;
use App\Pivots\AnimeImage;
use App\Pivots\AnimeResource;
use App\Pivots\AnimeSeries;
@@ -25,26 +27,20 @@ use App\Pivots\ArtistImage;
use App\Pivots\ArtistMember;
use App\Pivots\ArtistResource;
use App\Pivots\ArtistSong;
use App\Pivots\StudioImage;
use App\Pivots\StudioResource;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Class WikiDatabaseDumpCommand.
* Class DumpWikiAction.
*/
class WikiDatabaseDumpCommand extends DatabaseDumpCommand
class DumpWikiAction extends DumpAction
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:dump-wiki {--C|create : Whether the dumper should include create table statements}';
use ReconcilesDumpRepositories;
/**
* The console command description.
*
* @var string
*/
protected $description = 'Produces sanitized database dump, targeting wiki-related tables for seeding purposes';
final public const FILENAME_PREFIX = 'animethemes-db-dump-wiki-';
/**
* The list of tables to include in the dump.
@@ -68,23 +64,34 @@ class WikiDatabaseDumpCommand extends DatabaseDumpCommand
ArtistMember::TABLE,
ArtistResource::TABLE,
ArtistSong::TABLE,
Audio::TABLE,
ExternalResource::TABLE,
Image::TABLE,
Series::TABLE,
Song::TABLE,
Studio::TABLE,
StudioImage::TABLE,
StudioResource::TABLE,
Video::TABLE,
VideoScript::TABLE,
];
}
/**
* The directory that the file should be dumped to.
* The temporary path for the database dump.
* Note: The dumper library does not support writing to disk, so we have to write to the local filesystem first.
* Pattern: "animethemes-db-dump-wiki-{milliseconds from epoch}.sql".
*
* @return string
*/
protected function getDumpFilePath(): string
protected function getDumpFile(): string
{
return 'wiki';
$filesystem = Storage::disk('local');
return Str::of($filesystem->path(''))
->append(DumpWikiAction::FILENAME_PREFIX)
->append(intval(Date::now()->valueOf()))
->append('.sql')
->__toString();
}
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Admin\Dump;
use App\Actions\Storage\Base\PruneAction;
use App\Concerns\Repositories\Admin\ReconcilesDumpRepositories;
use App\Constants\Config\DumpConstants;
use Illuminate\Support\Facades\Config;
/**
* Class PruneDumpAction.
*/
class PruneDumpAction extends PruneAction
{
use ReconcilesDumpRepositories;
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(DumpConstants::DISK_QUALIFIED);
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Contracts\Actions\Storage\StorageAction;
use App\Contracts\Actions\Storage\StorageResults;
use App\Contracts\Repositories\RepositoryInterface;
use App\Contracts\Storage\InteractsWithDisks;
use App\Models\BaseModel;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
/**
* Class DeleteAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class DeleteAction implements InteractsWithDisks, StorageAction
{
use ReconcilesRepositories;
/**
* Create a new action instance.
*
* @param TModel $model
*/
public function __construct(protected readonly BaseModel $model)
{
}
/**
* Handle action.
*
* @return StorageResults
*/
public function handle(): StorageResults
{
$results = [];
foreach ($this->disks() as $disk) {
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($disk);
$result = $fs->delete($this->path());
$results[$disk] = $result;
}
return new DeleteResults($this->model, $results);
}
/**
* Apply filters to repositories before reconciliation.
*
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @param array $data
* @return void
*/
protected function handleFilters(
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository,
array $data = []
): void {
$sourceRepository->handleFilter('path', File::dirname($this->path()));
$destinationRepository->handleFilter('path', File::dirname($this->path()));
}
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
$reconcileResults = $this->reconcileRepositories();
$reconcileResults->toLog();
}
/**
* Get the path to delete.
*
* @return string
*/
abstract protected function path(): string;
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Contracts\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use App\Models\BaseModel;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class DeleteResults.
*/
class DeleteResults implements StorageResults
{
/**
* Create a new action result instance.
*
* @param BaseModel $model
* @param array<string, bool> $deletions
*/
public function __construct(protected readonly BaseModel $model, protected readonly array $deletions = [])
{
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
if (empty($this->deletions)) {
Log::error('No deletions were attempted.');
}
foreach ($this->deletions as $fs => $result) {
$result
? Log::info("Deleted '{$this->model->getName()}' from disk '$fs'")
: Log::error("Failed to delete '{$this->model->getName()}' from disk '$fs'");
}
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
if (empty($this->deletions)) {
$command->error('No deletions were attempted.');
}
foreach ($this->deletions as $fs => $result) {
$result
? $command->info("Deleted '{$this->model->getName()}' from disk '$fs'")
: $command->error("Failed to delete '{$this->model->getName()}' from disk '$fs'");
}
}
/**
* Transform to Action Result.
*
* @return ActionResult
*/
public function toActionResult(): ActionResult
{
if (empty($this->deletions)) {
return new ActionResult(
ActionStatus::FAILED(),
'No deletions were attempted. Please check that disks are configured.'
);
}
/** @var Collection $passed */
/** @var Collection $failed */
[$passed, $failed] = collect($this->deletions)->partition(fn (bool $result, string $fs) => $result);
if ($failed->isNotEmpty()) {
return new ActionResult(
ActionStatus::FAILED(),
"Failed to delete '{$this->model->getName()}' from disks {$failed->keys()->join(', ', ' & ')}."
);
}
return new ActionResult(
ActionStatus::PASSED(),
"Deleted '{$this->model->getName()}' from disks {$passed->keys()->join(', ', ' & ')}."
);
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Contracts\Actions\Storage\StorageAction;
use App\Contracts\Actions\Storage\StorageResults;
use App\Contracts\Storage\InteractsWithDisks;
use App\Models\BaseModel;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
/**
* Class MoveAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class MoveAction implements InteractsWithDisks, StorageAction
{
/**
* Create a new action instance.
*
* @param TModel $model
*/
public function __construct(protected BaseModel $model, protected readonly string $to)
{
}
/**
* Handle action.
*
* @return StorageResults
*/
public function handle(): StorageResults
{
$results = [];
$from = $this->from();
foreach ($this->disks() as $disk) {
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($disk);
$result = $fs->move($from, $this->to);
$results[$disk] = $result;
}
return new MoveResults($this->model, $from, $this->to, $results);
}
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
$this->update();
}
/**
* Get the path to move from.
*
* @return string
*/
abstract protected function from(): string;
/**
* 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
*/
abstract protected function update(): void;
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Contracts\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use App\Models\BaseModel;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class MoveResults.
*/
class MoveResults implements StorageResults
{
/**
* Create a new action result instance.
*
* @param BaseModel $model
* @param string $from
* @param string $to
* @param array<string, bool> $moves
*/
public function __construct(
protected readonly BaseModel $model,
protected readonly string $from,
protected readonly string $to,
protected readonly array $moves = []
) {
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
if (empty($this->moves)) {
Log::error('No moves were attempted.');
}
foreach ($this->moves as $fs => $result) {
$result
? Log::info("Moved '{$this->model->getName()}' from '$this->from' to '$this->to' in disk '$fs'")
: Log::error("Failed to move '{$this->model->getName()}' from '$this->from' to '$this->to' in disk '$fs'");
}
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
if (empty($this->moves)) {
$command->error('No moves were attempted.');
}
foreach ($this->moves as $fs => $result) {
$result
? $command->info("Moved '{$this->model->getName()}' from '$this->from' to '$this->to' in disk '$fs'")
: $command->error("Failed to move '{$this->model->getName()}' from '$this->from' to '$this->to' in disk '$fs'");
}
}
/**
* Transform to Action Result.
*
* @return ActionResult
*/
public function toActionResult(): ActionResult
{
if (empty($this->moves)) {
return new ActionResult(
ActionStatus::FAILED(),
'No moves were attempted. Please check that disks are configured.'
);
}
/** @var Collection $passed */
/** @var Collection $failed */
[$passed, $failed] = collect($this->moves)->partition(fn (bool $result, string $fs) => $result);
if ($failed->isNotEmpty()) {
return new ActionResult(
ActionStatus::FAILED(),
"Failed to move '{$this->model->getName()}' from '$this->from' to '$this->to' in disks {$failed->keys()->join(', ', ' & ')}."
);
}
return new ActionResult(
ActionStatus::PASSED(),
"Moved '{$this->model->getName()}' from '$this->from' to '$this->to' in disks {$passed->keys()->join(', ', ' & ')}."
);
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Contracts\Actions\Storage\StorageAction;
use App\Contracts\Actions\Storage\StorageResults;
use App\Contracts\Storage\InteractsWithDisk;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Storage;
/**
* Class PruneAction.
*/
abstract class PruneAction implements InteractsWithDisk, StorageAction
{
use ReconcilesRepositories;
/**
* Create a new action instance.
*
* @param int $hours
*/
public function __construct(protected readonly int $hours = 72)
{
}
/**
* Handle action.
*
* @return StorageResults
*/
public function handle(): StorageResults
{
$fs = Storage::disk($this->disk());
$pruneDate = Date::now()->subHours($this->hours);
$results = [];
foreach ($fs->allFiles() as $path) {
$lastModified = Date::createFromTimestamp($fs->lastModified($path));
if ($lastModified->isBefore($pruneDate)) {
$result = $fs->delete($path);
$results[$path] = $result;
}
}
return new PruneResults($this->disk(), $results);
}
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
$reconcileResults = $this->reconcileRepositories();
$reconcileResults->toLog();
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Contracts\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class PruneResults.
*/
class PruneResults implements StorageResults
{
/**
* Create a new action result instance.
*
* @param string $fs
* @param array<string, bool> $prunings
*/
public function __construct(protected readonly string $fs, protected readonly array $prunings = [])
{
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
if (empty($this->prunings)) {
Log::error('No prunings were attempted.');
}
foreach ($this->prunings as $path => $result) {
$result === false
? Log::error("Failed to prune '$path' from disk '$this->fs'")
: Log::info("Pruned '$path' from disk '$this->fs'");
}
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
if (empty($this->prunings)) {
$command->error('No prunings were attempted.');
}
foreach ($this->prunings as $path => $result) {
$result === false
? $command->error("Failed to prune '$path' from disk '$this->fs'")
: $command->info("Pruned '$path' from disk '$this->fs'");
}
}
/**
* Transform to Action Result.
*
* @return ActionResult
*/
public function toActionResult(): ActionResult
{
if (empty($this->prunings)) {
return new ActionResult(
ActionStatus::FAILED(),
'No prunings were attempted.'
);
}
/** @var Collection $passed */
/** @var Collection $failed */
[$passed, $failed] = collect($this->prunings)->partition(fn (bool $result, string $path) => $result);
if ($failed->isNotEmpty()) {
return new ActionResult(
ActionStatus::FAILED(),
"Failed to prune {$failed->keys()->join(', ', ' & ')} from disk '$this->fs'."
);
}
return new ActionResult(
ActionStatus::PASSED(),
"Pruned {$passed->keys()->join(', ', ' & ')} from disk '$this->fs'."
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Contracts\Actions\Storage\StorageAction;
use App\Contracts\Actions\Storage\StorageResults;
use App\Contracts\Storage\InteractsWithDisks;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* Class UploadAction.
*/
abstract class UploadAction implements InteractsWithDisks, StorageAction
{
/**
* Create a new action instance.
*
* @param UploadedFile $file
* @param string $path
*/
public function __construct(protected readonly UploadedFile $file, protected readonly string $path)
{
}
/**
* Handle action.
*
* @return StorageResults
*/
public function handle(): StorageResults
{
$results = [];
foreach ($this->disks() as $disk) {
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($disk);
$result = $fs->putFileAs($this->path, $this->file, $this->file->getClientOriginalName());
$results[$disk] = $result;
}
return new UploadResults($results);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Contracts\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class UploadResults.
*/
class UploadResults implements StorageResults
{
/**
* Create a new action result instance.
*
* @param array<string, string|false> $uploads
*/
public function __construct(protected readonly array $uploads = [])
{
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
if (empty($this->uploads)) {
Log::error('No uploads were attempted.');
}
foreach ($this->uploads as $fs => $result) {
$result === false
? Log::error("Failed to upload to disk '$fs'")
: Log::info("Uploaded '$result' to disk '$fs'");
}
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
if (empty($this->uploads)) {
$command->error('No uploads were attempted.');
}
foreach ($this->uploads as $fs => $result) {
$result === false
? $command->error("Failed to upload to disk '$fs'")
: $command->info("Uploaded '$result' to disk '$fs'");
}
}
/**
* Transform to Action Result.
*
* @return ActionResult
*/
public function toActionResult(): ActionResult
{
if (empty($this->uploads)) {
return new ActionResult(
ActionStatus::FAILED(),
'No uploads were attempted. Please check that disks are configured.'
);
}
/** @var Collection $failed */
/** @var Collection $passed */
[$failed, $passed] = collect($this->uploads)->partition(fn (string|false $result, string $fs) => $result === false);
if ($failed->isNotEmpty()) {
return new ActionResult(
ActionStatus::FAILED(),
"Failed to upload to disks {$failed->keys()->join(', ', ' & ')}."
);
}
return new ActionResult(
ActionStatus::PASSED(),
"Uploaded '{$passed->values()->first()}' to disks {$passed->keys()->join(', ', ' & ')}."
);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Audio;
use App\Actions\Storage\Base\DeleteAction;
use App\Concerns\Repositories\Wiki\ReconcilesAudioRepositories;
use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
/**
* Class DeleteAudioAction.
*
* @extends DeleteAction<Audio>
*/
class DeleteAudioAction extends DeleteAction
{
use ReconcilesAudioRepositories;
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(AudioConstants::DISKS_QUALIFIED);
}
/**
* Get the path to delete.
*
* @return string
*/
protected function path(): string
{
return $this->model->path();
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Audio;
use App\Actions\Storage\Base\MoveAction;
use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
/**
* Class MoveAudioAction.
*
* @extends MoveAction<Audio>
*/
class MoveAudioAction extends MoveAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
* @param string $to
*/
public function __construct(Audio $audio, string $to)
{
parent::__construct($audio, $to);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(AudioConstants::DISKS_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([
Audio::ATTRIBUTE_BASENAME => File::basename($this->to),
Audio::ATTRIBUTE_FILENAME => File::name($this->to),
Audio::ATTRIBUTE_PATH => $this->to,
]);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Audio;
use App\Actions\Storage\Base\UploadAction;
use App\Constants\Config\AudioConstants;
use App\Contracts\Actions\Storage\StorageResults;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* Class UploadAudioAction.
*/
class UploadAudioAction extends UploadAction
{
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
if ($storageResults->toActionResult()->hasFailed()) {
return;
}
$this->getOrCreateAudio();
}
/**
* Get existing or create new audio for file upload.
*
* @return Audio
*/
protected function getOrCreateAudio(): Audio
{
$path = Str::of($this->path)
->finish(DIRECTORY_SEPARATOR)
->append($this->file->getClientOriginalName())
->__toString();
$attributes = [
Audio::ATTRIBUTE_FILENAME => File::name($this->file->getClientOriginalName()),
Audio::ATTRIBUTE_MIMETYPE => $this->file->getMimeType(),
Audio::ATTRIBUTE_PATH => $path,
Audio::ATTRIBUTE_SIZE => $this->file->getSize(),
];
return Audio::updateOrCreate(
[
Audio::ATTRIBUTE_BASENAME => $this->file->getClientOriginalName(),
],
$attributes
);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(AudioConstants::DISKS_QUALIFIED);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video;
use App\Actions\Storage\Base\DeleteAction;
use App\Concerns\Repositories\Wiki\ReconcilesVideoRepositories;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
/**
* Class DeleteVideoAction.
*
* @extends DeleteAction<Video>
*/
class DeleteVideoAction extends DeleteAction
{
use ReconcilesVideoRepositories;
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(VideoConstants::DISKS_QUALIFIED);
}
/**
* Get the path to delete.
*
* @return string
*/
protected function path(): string
{
return $this->model->path();
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video;
use App\Actions\Storage\Base\MoveAction;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
/**
* Class MoveVideoAction.
*
* @extends MoveAction<Video>
*/
class MoveVideoAction extends MoveAction
{
/**
* Create a new action instance.
*
* @param Video $video
* @param string $to
*/
public function __construct(Video $video, string $to)
{
parent::__construct($video, $to);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(VideoConstants::DISKS_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([
Video::ATTRIBUTE_BASENAME => File::basename($this->to),
Video::ATTRIBUTE_FILENAME => File::name($this->to),
Video::ATTRIBUTE_PATH => $this->to,
]);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video\Script;
use App\Actions\Storage\Base\DeleteAction;
use App\Concerns\Repositories\Wiki\Video\ReconcilesScriptRepositories;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video\VideoScript;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
/**
* Class DeleteScriptAction.
*
* @extends DeleteAction<VideoScript>
*/
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;
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video\Script;
use App\Actions\Storage\Base\MoveAction;
use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video\VideoScript;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
/**
* Class MoveVideoAction.
*
* @extends MoveAction<VideoScript>
*/
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,
]);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video\Script;
use App\Actions\Storage\Base\UploadAction;
use App\Constants\Config\VideoConstants;
use App\Contracts\Actions\Storage\StorageResults;
use App\Models\Wiki\Video;
use App\Models\Wiki\Video\VideoScript;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
/**
* Class UploadScriptAction.
*/
class UploadScriptAction extends UploadAction
{
/**
* Create a new action instance.
*
* @param UploadedFile $file
* @param string $path
* @param Video|null $video
*/
public function __construct(UploadedFile $file, string $path, protected ?Video $video = null)
{
parent::__construct($file, $path);
}
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
if ($storageResults->toActionResult()->hasFailed()) {
return;
}
$this->getOrCreateScript();
}
/**
* Get existing or create new script for file upload.
*
* @return VideoScript
*/
protected function getOrCreateScript(): VideoScript
{
$path = Str::of($this->path)
->finish(DIRECTORY_SEPARATOR)
->append($this->file->getClientOriginalName())
->__toString();
$attributes = [
VideoScript::ATTRIBUTE_PATH => $path,
];
if ($this->video !== null) {
$attributes[VideoScript::ATTRIBUTE_VIDEO] = $this->video->getKey();
}
return VideoScript::updateOrCreate(
[
VideoScript::ATTRIBUTE_PATH => $path,
],
$attributes
);
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Arr::wrap(Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED));
}
}
@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video;
use App\Actions\Storage\Base\UploadAction;
use App\Actions\Storage\Wiki\Video\Script\UploadScriptAction;
use App\Constants\Config\VideoConstants;
use App\Contracts\Actions\Storage\StorageResults;
use App\Enums\Models\Wiki\VideoOverlap;
use App\Enums\Models\Wiki\VideoSource;
use App\Models\Wiki\Anime\Theme\AnimeThemeEntry;
use App\Models\Wiki\Video;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
/**
* Class UploadVideoAction.
*/
class UploadVideoAction extends UploadAction
{
/**
* Create a new action instance.
*
* @param UploadedFile $file
* @param string $path
* @param array $attributes
* @param AnimeThemeEntry|null $entry
* @param UploadedFile|null $script
*/
public function __construct(
UploadedFile $file,
string $path,
protected array $attributes = [],
protected ?AnimeThemeEntry $entry = null,
protected readonly ?UploadedFile $script = null
) {
parent::__construct($file, $path);
}
/**
* Processes to be completed after handling action.
*
* @param StorageResults $storageResults
* @return void
*/
public function then(StorageResults $storageResults): void
{
if ($storageResults->toActionResult()->hasFailed()) {
return;
}
$video = $this->getOrCreateVideo();
$this->attachEntry($video);
$this->uploadScript($video);
}
/**
* Get existing or create new video for file upload.
*
* @return Video
*/
protected function getOrCreateVideo(): Video
{
$path = Str::of($this->path)
->finish(DIRECTORY_SEPARATOR)
->append($this->file->getClientOriginalName())
->__toString();
$attributes = [
Video::ATTRIBUTE_FILENAME => File::name($this->file->getClientOriginalName()),
Video::ATTRIBUTE_MIMETYPE => $this->file->getMimeType(),
Video::ATTRIBUTE_PATH => $path,
Video::ATTRIBUTE_SIZE => $this->file->getSize(),
];
if (Arr::has($this->attributes, Video::ATTRIBUTE_RESOLUTION)) {
$attributes[Video::ATTRIBUTE_RESOLUTION] = Arr::get($this->attributes, Video::ATTRIBUTE_RESOLUTION);
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_NC)) {
$attributes[Video::ATTRIBUTE_NC] = Arr::get($this->attributes, Video::ATTRIBUTE_NC);
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_SUBBED)) {
$attributes[Video::ATTRIBUTE_SUBBED] = Arr::get($this->attributes, Video::ATTRIBUTE_SUBBED);
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_LYRICS)) {
$attributes[Video::ATTRIBUTE_LYRICS] = Arr::get($this->attributes, Video::ATTRIBUTE_LYRICS);
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_UNCEN)) {
$attributes[Video::ATTRIBUTE_UNCEN] = Arr::get($this->attributes, Video::ATTRIBUTE_UNCEN);
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_UNCEN)) {
$overlap = VideoOverlap::unstrictCoerce(Arr::get($this->attributes, Video::ATTRIBUTE_OVERLAP));
if ($overlap !== null) {
$attributes[Video::ATTRIBUTE_OVERLAP] = $overlap;
}
}
if (Arr::has($this->attributes, Video::ATTRIBUTE_SOURCE)) {
$attributes[Video::ATTRIBUTE_SOURCE] = VideoSource::unstrictCoerce(Arr::get($this->attributes, Video::ATTRIBUTE_SOURCE));
}
return Video::updateOrCreate(
[
Video::ATTRIBUTE_BASENAME => $this->file->getClientOriginalName(),
],
$attributes
);
}
/**
* Attach entry to created video if uploaded from entry detail screen.
*
* @param Video $video
* @return void
*/
protected function attachEntry(Video $video): void
{
if ($this->entry !== null && $video->wasRecentlyCreated) {
$video->animethemeentries()->attach($this->entry);
}
}
/**
* Upload & Associate Script if video upload was successful.
*
* @param Video $video
* @return void
*/
protected function uploadScript(Video $video): void
{
if ($this->script !== null) {
$uploadScript = new UploadScriptAction($this->script, $this->path, $video);
$scriptResult = $uploadScript->handle();
$uploadScript->then($scriptResult);
}
}
/**
* The list of disk names.
*
* @return array
*/
public function disks(): array
{
return Config::get(VideoConstants::DISKS_QUALIFIED);
}
}
@@ -11,11 +11,11 @@ use App\Enums\Http\Api\Filter\UnaryLogicalOperator;
use App\Http\Api\Criteria\Filter\Criteria;
use App\Http\Api\Filter\Filter;
use App\Http\Api\Parser\FilterParser;
use App\Rules\Api\DelimitedRule;
use Illuminate\Support\Arr;
use Illuminate\Support\Fluent;
use Illuminate\Support\Str;
use Illuminate\Validation\Validator;
use Spatie\ValidationRules\Rules\Delimited;
/**
* Trait ValidatesFilters.
@@ -113,7 +113,7 @@ trait ValidatesFilters
$validator->sometimes(
$formattedParameter,
$filter->getRules(),
fn (Fluent $fluent) => is_string(Arr::get($fluent->toArray(), $formattedParameter)) && ! Str::of(Arr::get($fluent->toArray(), $formattedParameter))->contains(Criteria::VALUE_SEPARATOR)
fn (Fluent $fluent) => Arr::has($fluent->toArray(), $formattedParameter) && ! Str::of(Arr::get($fluent->toArray(), $formattedParameter))->contains(Criteria::VALUE_SEPARATOR)
);
}
}
@@ -121,7 +121,7 @@ trait ValidatesFilters
$multiValueRules = [];
foreach ($filter->getRules() as $rule) {
$multiValueRules[] = new Delimited($rule);
$multiValueRules[] = new DelimitedRule($rule);
}
$multiValueFilterFormats = $this->getFilterFormats($filter, UnaryLogicalOperator::getInstances());
@@ -131,7 +131,7 @@ trait ValidatesFilters
$validator->sometimes(
$formattedParameter,
$multiValueRules,
fn (Fluent $fluent) => is_string(Arr::get($fluent->toArray(), $formattedParameter)) && Str::of(Arr::get($fluent->toArray(), $formattedParameter))->contains(Criteria::VALUE_SEPARATOR)
fn (Fluent $fluent) => Arr::has($fluent->toArray(), $formattedParameter) && Str::of(Arr::get($fluent->toArray(), $formattedParameter))->contains(Criteria::VALUE_SEPARATOR)
);
}
}
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Concerns\Http\Requests\Api;
use App\Rules\Api\DelimitedRule;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Validation\Rule;
use Spatie\ValidationRules\Rules\Delimited;
/**
* Trait ValidatesParameters.
@@ -30,7 +30,8 @@ trait ValidatesParameters
return [
$param => [
'nullable',
'sometimes',
'required',
"array:$types",
],
];
@@ -48,7 +49,13 @@ trait ValidatesParameters
{
return [
$param => array_merge(
['bail', 'sometimes', 'required', 'string', new Delimited(Rule::in($values))],
[
'bail',
'sometimes',
'required',
'string',
new DelimitedRule(['required', Rule::in($values)->__toString()]),
],
$customRules,
),
];
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Concerns\Repositories\Admin;
use App\Actions\Repositories\Admin\Dump\ReconcileDumpRepositoriesAction;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Admin\DumpRepository as DumpDestinationRepository;
use App\Repositories\Storage\Admin\DumpRepository as DumpSourceRepository;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesDumpRepositories.
*/
trait ReconcilesDumpRepositories
{
/**
* Get source repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return App::make(DumpSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return App::make(DumpDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositoriesAction
*/
protected function action(): ReconcileRepositoriesAction
{
return new ReconcileDumpRepositoriesAction();
}
}
@@ -4,84 +4,59 @@ declare(strict_types=1);
namespace App\Concerns\Repositories\Billing;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Balance;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use App\Actions\Repositories\Billing\Balance\ReconcileBalanceRepositoriesAction;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Models\Billing\Service;
use App\Repositories\DigitalOcean\Billing\DigitalOceanBalanceRepository as DigitalOceanSourceRepository;
use App\Repositories\Eloquent\Billing\DigitalOceanBalanceRepository as DigitalOceanDestinationRepository;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesBalanceRepositories.
*/
trait ReconcilesBalanceRepositories
{
use ReconcilesRepositories;
/**
* The columns used for create and delete set operations.
* Get source repository for action.
*
* @return string[]
* @param array $data
* @return RepositoryInterface|null
*/
protected function columnsForCreateDelete(): array
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return [
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
];
$service = Service::unstrictCoerce(Arr::get($data, 'service'));
return match ($service?->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
/**
* Callback for create and delete set operation item comparison.
* Get destination repository for action.
*
* @return Closure
* @param array $data
* @return RepositoryInterface|null
*/
protected function diffCallbackForCreateDelete(): Closure
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return fn (Balance $first, Balance $second) => $first->date->format(AllowedDateFormat::YM) <=> $second->date->format(AllowedDateFormat::YM);
$service = Service::unstrictCoerce(Arr::get($data, 'service'));
return match ($service?->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
/**
* The columns used for update set operation.
* Get the reconcile action.
*
* @return string[]
* @return ReconcileRepositoriesAction
*/
protected function columnsForUpdate(): array
protected function action(): ReconcileRepositoriesAction
{
return [
Balance::ATTRIBUTE_BALANCE,
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
Balance::ATTRIBUTE_USAGE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Balance $first, Balance $second) => [$first->date->format(AllowedDateFormat::YMD), $first->usage, $first->balance]
<=> [$second->date->format(AllowedDateFormat::YMD), $second->usage, $second->balance];
}
/**
* 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
{
$formattedDestinationDate = $destinationModel->getAttribute(Balance::ATTRIBUTE_DATE)->format(AllowedDateFormat::YM);
return $sourceModels->first(
fn (Balance $balance) => $balance->date->format(AllowedDateFormat::YM) === $formattedDestinationDate
);
return new ReconcileBalanceRepositoriesAction();
}
}
@@ -4,42 +4,59 @@ declare(strict_types=1);
namespace App\Concerns\Repositories\Billing;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Transaction;
use Closure;
use App\Actions\Repositories\Billing\Transaction\ReconcileTransactionRepositoriesAction;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Models\Billing\Service;
use App\Repositories\DigitalOcean\Billing\DigitalOceanTransactionRepository as DigitalOceanSourceRepository;
use App\Repositories\Eloquent\Billing\DigitalOceanTransactionRepository as DigitalOceanDestinationRepository;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesTransactionRepositories.
*/
trait ReconcilesTransactionRepositories
{
use ReconcilesRepositories;
/**
* The columns used for create and delete set operations.
* Get source repository for action.
*
* @return string[]
* @param array $data
* @return RepositoryInterface|null
*/
protected function columnsForCreateDelete(): array
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return [
Transaction::ATTRIBUTE_AMOUNT,
Transaction::ATTRIBUTE_DATE,
Transaction::ATTRIBUTE_EXTERNAL_ID,
Transaction::ATTRIBUTE_ID,
Transaction::ATTRIBUTE_SERVICE,
];
$service = Service::unstrictCoerce(Arr::get($data, 'service'));
return match ($service?->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
/**
* Callback for create and delete set operation item comparison.
* Get destination repository for action.
*
* @return Closure
* @param array $data
* @return RepositoryInterface|null
*/
protected function diffCallbackForCreateDelete(): Closure
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return fn (Transaction $first, Transaction $second) => [$first->external_id, $first->date->format(AllowedDateFormat::YMD), $first->amount]
<=> [$second->external_id, $second->date->format(AllowedDateFormat::YMD), $second->amount];
$service = Service::unstrictCoerce(Arr::get($data, 'service'));
return match ($service?->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositoriesAction
*/
protected function action(): ReconcileRepositoriesAction
{
return new ReconcileTransactionRepositoriesAction();
}
}
@@ -4,12 +4,10 @@ declare(strict_types=1);
namespace App\Concerns\Repositories;
use App\Contracts\Repositories\Repository;
use App\Models\BaseModel;
use Closure;
use Exception;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use App\Actions\ActionResult;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Actions\ActionStatus;
/**
* Trait ReconcilesRepositories.
@@ -17,325 +15,72 @@ use Illuminate\Support\Collection;
trait ReconcilesRepositories
{
/**
* The number of models created.
* Reconcile repositories.
*
* @var int
* @param array $data
* @return ActionResult
*/
protected int $created = 0;
/**
* The number of models whose creation failed.
*
* @var int
*/
protected int $createdFailed = 0;
/**
* The number of models deleted.
*
* @var int
*/
protected int $deleted = 0;
/**
* The number of models whose deletion failed.
*
* @var int
*/
protected int $deletedFailed = 0;
/**
* The number of models updated.
*
* @var int
*/
protected int $updated = 0;
/**
* The number of models whose update failed.
*
* @var int
*/
protected int $updatedFailed = 0;
/**
* Callback for successful model creation.
*
* @param BaseModel $model
* @return void
*/
protected function handleCreated(BaseModel $model): void
protected function reconcileRepositories(array $data = []): ActionResult
{
//
}
/**
* Callback for failed model creation.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedCreation(BaseModel $model): void
{
//
}
/**
* Callback for successful model deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleDeleted(BaseModel $model): void
{
//
}
/**
* Callback for failed model deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedDeletion(BaseModel $model): void
{
//
}
/**
* Callback for successful model update.
*
* @param BaseModel $model
* @return void
*/
protected function handleUpdated(BaseModel $model): void
{
//
}
/**
* Callback for failed model update.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedUpdate(BaseModel $model): void
{
//
}
/**
* Callback for exception.
*
* @param Exception $exception
* @return void
*/
protected function handleException(Exception $exception): void
{
//
}
/**
* Callback for handling completion of reconciliation.
*
* @return void
*/
protected function postReconciliationTask(): void
{
//
}
/**
* Determines if any changes, successful or not, were made during reconciliation.
*
* @return bool
*/
protected function hasResults(): bool
{
return $this->hasChanges() || $this->hasFailures();
}
/**
* Determines if any successful changes were made during reconciliation.
*
* @return bool
*/
protected function hasChanges(): bool
{
return $this->created > 0 || $this->deleted > 0 || $this->updated > 0;
}
/**
* Determines if any unsuccessful changes were made during reconciliation.
*
* @return bool
*/
protected function hasFailures(): bool
{
return $this->createdFailed > 0 || $this->deletedFailed > 0 || $this->updatedFailed > 0;
}
/**
* Perform set reconciliation between source and destination repositories.
*
* @param Repository $source
* @param Repository $destination
* @return void
*/
public function reconcileRepositories(Repository $source, Repository $destination): void
{
try {
$sourceModels = $source->get();
$destinationModels = $destination->get($this->columnsForCreateDelete());
$this->createModelsFromSource($destination, $sourceModels, $destinationModels);
$this->deleteModelsFromDestination($destination, $sourceModels, $destinationModels);
$destinationModels = $destination->get($this->columnsForUpdate());
$this->updateDestinationModels($destination, $sourceModels, $destinationModels);
} catch (Exception $exception) {
$this->handleException($exception);
} finally {
$this->postReconciliationTask();
$sourceRepository = $this->getSourceRepository($data);
if ($sourceRepository === null) {
return new ActionResult(
ActionStatus::FAILED(),
'Could not find source repository'
);
}
$destinationRepository = $this->getDestinationRepository($data);
if ($destinationRepository === null) {
return new ActionResult(
ActionStatus::FAILED(),
'Could not find destination repository'
);
}
$this->handleFilters($sourceRepository, $destinationRepository, $data);
$action = $this->action();
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
/**
* The columns used for create and delete set operations.
* Get the reconcile action.
*
* @return string[]
* @return ReconcileRepositoriesAction
*/
protected function columnsForCreateDelete(): array
{
return ['*'];
}
abstract protected function action(): ReconcileRepositoriesAction;
/**
* Callback for create and delete set operation item comparison.
* Get source repository.
*
* @return Closure
* @param array $data
* @return RepositoryInterface|null
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn () => 0;
}
abstract protected function getSourceRepository(array $data = []): ?RepositoryInterface;
/**
* Create models that exist in source but not in destination.
* Get destination repository.
*
* @param Repository $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @param array $data
* @return RepositoryInterface|null
*/
abstract protected function getDestinationRepository(array $data = []): ?RepositoryInterface;
/**
* Apply filters to repositories before reconciliation.
*
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @param array $data
* @return void
*/
protected function createModelsFromSource(
Repository $destination,
Collection $sourceModels,
Collection $destinationModels
protected function handleFilters(
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository,
array $data = []
): void {
$createModels = $sourceModels->diffUsing($destinationModels, $this->diffCallbackForCreateDelete());
foreach ($createModels as $createModel) {
$createResult = $destination->save($createModel);
if ($createResult) {
$this->created++;
$this->handleCreated($createModel);
} else {
$this->createdFailed++;
$this->handleFailedCreation($createModel);
}
}
}
/**
* Delete models that exist in destination but not in source.
*
* @param Repository $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return void
*/
protected function deleteModelsFromDestination(
Repository $destination,
Collection $sourceModels,
Collection $destinationModels
): void {
$deleteModels = $destinationModels->diffUsing($sourceModels, $this->diffCallbackForCreateDelete());
foreach ($deleteModels as $deleteModel) {
$deleteResult = $destination->delete($deleteModel);
if ($deleteResult) {
$this->deleted++;
$this->handleDeleted($deleteModel);
} else {
$this->deletedFailed++;
$this->handleFailedDeletion($deleteModel);
}
}
}
/**
* 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;
}
/**
* Update destination models that have changed in source.
*
* @param Repository $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return void
*/
protected function updateDestinationModels(
Repository $destination,
Collection $sourceModels,
Collection $destinationModels
): void {
$updatedModels = $destinationModels->diffUsing($sourceModels, $this->diffCallbackForUpdate());
foreach ($updatedModels as $updatedModel) {
$sourceModel = $this->resolveUpdatedModel($sourceModels, $updatedModel);
if ($sourceModel !== null) {
$updateResult = $destination->update($updatedModel, $sourceModel->toArray());
if ($updateResult) {
$this->updated++;
$this->handleUpdated($updatedModel);
} else {
$this->updatedFailed++;
$this->handleFailedUpdate($updatedModel);
}
}
}
// Not supported by default
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Concerns\Repositories\Wiki;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Wiki\AudioRepository as AudioDestinationRepository;
use App\Repositories\Storage\Wiki\AudioRepository as AudioSourceRepository;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesAudioRepositories.
*/
trait ReconcilesAudioRepositories
{
/**
* Get source repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return App::make(AudioSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return App::make(AudioDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositoriesAction
*/
protected function action(): ReconcileRepositoriesAction
{
return new ReconcileAudioRepositoriesAction();
}
}
@@ -4,79 +4,47 @@ declare(strict_types=1);
namespace App\Concerns\Repositories\Wiki;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Models\Wiki\Video;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\Wiki\Video\ReconcileVideoRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Wiki\VideoRepository as VideoDestinationRepository;
use App\Repositories\Storage\Wiki\VideoRepository as VideoSourceRepository;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesVideoRepositories.
*/
trait ReconcilesVideoRepositories
{
use ReconcilesRepositories;
/**
* The columns used for create and delete set operations.
* Get source repository for action.
*
* @return string[]
* @param array $data
* @return RepositoryInterface|null
*/
protected function columnsForCreateDelete(): array
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return [
Video::ATTRIBUTE_BASENAME,
Video::ATTRIBUTE_ID,
];
return App::make(VideoSourceRepository::class);
}
/**
* Callback for create and delete set operation item comparison.
* Get destination repository for action.
*
* @return Closure
* @param array $data
* @return RepositoryInterface|null
*/
protected function diffCallbackForCreateDelete(): Closure
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return fn (Video $first, Video $second) => $first->basename <=> $second->basename;
return App::make(VideoDestinationRepository::class);
}
/**
* The columns used for update set operation.
* Get the reconcile action.
*
* @return string[]
* @return ReconcileRepositoriesAction
*/
protected function columnsForUpdate(): array
protected function action(): ReconcileRepositoriesAction
{
return [
Video::ATTRIBUTE_BASENAME,
Video::ATTRIBUTE_ID,
Video::ATTRIBUTE_PATH,
Video::ATTRIBUTE_SIZE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Video $first, Video $second) => [$first->basename, $first->path, $first->size] <=> [$second->basename, $second->path, $second->size];
}
/**
* 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 $sourceModels->firstWhere(
Video::ATTRIBUTE_BASENAME,
$destinationModel->getAttribute(Video::ATTRIBUTE_BASENAME)
);
return new ReconcileVideoRepositoriesAction();
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Concerns\Repositories\Wiki\Video;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\Wiki\Video\Script\ReconcileScriptRepositoriesAction;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Wiki\Video\ScriptRepository as ScriptDestinationRepository;
use App\Repositories\Storage\Wiki\Video\ScriptRepository as ScriptSourceRepository;
use Illuminate\Support\Facades\App;
/**
* Trait ReconcilesScriptRepositories.
*/
trait ReconcilesScriptRepositories
{
/**
* Get source repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getSourceRepository(array $data = []): ?RepositoryInterface
{
return App::make(ScriptSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @param array $data
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(array $data = []): ?RepositoryInterface
{
return App::make(ScriptDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositoriesAction
*/
protected function action(): ReconcileRepositoriesAction
{
return new ReconcileScriptRepositoriesAction();
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Isolatable;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException;
use Symfony\Component\Console\Input\InputOption;
/**
* Class BaseCommand.
*/
abstract class BaseCommand extends Command implements Isolatable
{
/**
* Execute the console command.
*
* @return int
*
* @throws ValidationException
*/
abstract public function handle(): int;
/**
* Get the validator for options.
*
* @return Validator
*/
abstract protected function validator(): Validator;
/**
* Configure the console command for isolation.
* Note: Overrides default framework behavior which disables isolation by default.
*
* @return void
*
* @noinspection PhpMissingParentCallCommonInspection
*/
protected function configureIsolation(): void
{
$this->getDefinition()->addOption(new InputOption(
'isolated',
null,
InputOption::VALUE_OPTIONAL,
'Do not run the command if another instance of the command is already running',
true
));
}
}
@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Billing\Balance;
use App\Concerns\Repositories\Billing\ReconcilesBalanceRepositories;
use App\Console\Commands\Billing\ServiceReconcileCommand;
use App\Contracts\Repositories\Repository;
use App\Enums\Models\Billing\Service;
use App\Models\BaseModel;
use App\Repositories\Eloquent\Billing\DigitalOceanBalanceRepository as DigitalOceanDestinationRepository;
use App\Repositories\Service\DigitalOcean\Billing\DigitalOceanBalanceRepository as DigitalOceanSourceRepository;
use Exception;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
/**
* Class BalanceReconcileCommand.
*/
class BalanceReconcileCommand extends ServiceReconcileCommand
{
use ReconcilesBalanceRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:balance {service}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between vendor billing API and balance database';
/**
* Print the result to console and log the results to the app log.
*
* @return void
*/
protected function postReconciliationTask(): void
{
if ($this->hasResults()) {
if ($this->hasChanges()) {
Log::info("$this->created Balances created, $this->deleted Balances deleted, $this->updated Balances updated");
$this->info("$this->created Balances created, $this->deleted Balances deleted, $this->updated Balances updated");
}
if ($this->hasFailures()) {
Log::error("Failed to create $this->createdFailed Balances, delete $this->deletedFailed Balances, update $this->updatedFailed Balances");
$this->error("Failed to create $this->createdFailed Balances, delete $this->deletedFailed Balances, update $this->updatedFailed Balances");
}
} else {
Log::info('No Balances created or deleted or updated');
$this->info('No Balances created or deleted or updated');
}
}
/**
* Handler for successful balance creation.
*
* @param BaseModel $model
* @return void
*/
protected function handleCreated(BaseModel $model): void
{
Log::info("Balance '{$model->getName()}' created");
$this->info("Balance '{$model->getName()}' created");
}
/**
* Handler for failed balance creation.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedCreation(BaseModel $model): void
{
Log::error("Balance '{$model->getName()}' was not created");
$this->error("Balance '{$model->getName()}' was not created");
}
/**
* Handler for successful balance deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleDeleted(BaseModel $model): void
{
Log::info("Balance '{$model->getName()}' deleted");
$this->info("Balance '{$model->getName()}' deleted");
}
/**
* Handler for failed balance deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedDeletion(BaseModel $model): void
{
Log::error("Balance '{$model->getName()}' was not deleted");
$this->error("Balance '{$model->getName()}' was not deleted");
}
/**
* Handler for successful balance update.
*
* @param BaseModel $model
* @return void
*/
protected function handleUpdated(BaseModel $model): void
{
Log::info("Balance '{$model->getName()}' updated");
$this->info("Balance '{$model->getName()}' updated");
}
/**
* Handler for failed balance update.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedUpdate(BaseModel $model): void
{
Log::error("Balance '{$model->getName()}' was not updated");
$this->error("Balance '{$model->getName()}' was not updated");
}
/**
* Handler for exception.
*
* @param Exception $exception
* @return void
*/
protected function handleException(Exception $exception): void
{
Log::error($exception->getMessage());
$this->error($exception->getMessage());
}
/**
* Get source repository for service.
*
* @param Service $service
* @return Repository|null
*/
protected function getSourceRepository(Service $service): ?Repository
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
/**
* Get destination repository for service.
*
* @param Service $service
* @return Repository|null
*/
protected function getDestinationRepository(Service $service): ?Repository
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
}
@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Billing;
use App\Contracts\Repositories\Repository;
use App\Enums\Models\Billing\Service;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Class ServiceReconcileCommand.
*/
abstract class ServiceReconcileCommand extends Command
{
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$key = $this->argument('service');
$service = Service::coerce(Str::upper($key));
if ($service === null) {
Log::error("Invalid Service '$key'");
$this->error("Invalid Service '$key'");
return 1;
}
$sourceRepository = $this->getSourceRepository($service);
if ($sourceRepository === null) {
Log::error("No source repository implemented for Service '$key'");
$this->error("No source repository implemented for Service '$key'");
return 1;
}
$destinationRepository = $this->getDestinationRepository($service);
if ($destinationRepository === null) {
Log::error("No destination repository implemented for Service '$key'");
$this->error("No destination repository implemented for Service '$key'");
return 1;
}
$this->reconcileRepositories($sourceRepository, $destinationRepository);
return 0;
}
/**
* Perform set reconciliation between source and destination repositories.
*
* @param Repository $source
* @param Repository $destination
* @return void
*/
abstract public function reconcileRepositories(Repository $source, Repository $destination): void;
/**
* Get source repository for service.
*
* @param Service $service
* @return Repository|null
*/
abstract protected function getSourceRepository(Service $service): ?Repository;
/**
* Get destination repository for service.
*
* @param Service $service
* @return Repository|null
*/
abstract protected function getDestinationRepository(Service $service): ?Repository;
}
@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Billing\Transaction;
use App\Concerns\Repositories\Billing\ReconcilesTransactionRepositories;
use App\Console\Commands\Billing\ServiceReconcileCommand;
use App\Contracts\Repositories\Repository;
use App\Enums\Models\Billing\Service;
use App\Models\Billing\Transaction;
use App\Repositories\Eloquent\Billing\DigitalOceanTransactionRepository as DigitalOceanDestinationRepository;
use App\Repositories\Service\DigitalOcean\Billing\DigitalOceanTransactionRepository as DigitalOceanSourceRepository;
use Exception;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
/**
* Class TransactionReconcileCommand.
*/
class TransactionReconcileCommand extends ServiceReconcileCommand
{
use ReconcilesTransactionRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:transaction {service}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between vendor billing API and transaction database';
/**
* Print the result to console and log the results to the app log.
*
* @return void
*/
protected function postReconciliationTask(): void
{
if ($this->hasResults()) {
if ($this->hasChanges()) {
Log::info("$this->created Transactions created, $this->deleted Transactions deleted, $this->updated Transactions updated");
$this->info("$this->created Transactions created, $this->deleted Transactions deleted, $this->updated Transactions updated");
}
if ($this->hasFailures()) {
Log::error("Failed to create $this->createdFailed Transactions, delete $this->deletedFailed Transactions, update $this->updatedFailed Transactions");
$this->error("Failed to create $this->createdFailed Transactions, delete $this->deletedFailed Transactions, update $this->updatedFailed Transactions");
}
} else {
Log::info('No Transactions created or deleted or updated');
$this->info('No Transactions created or deleted or updated');
}
}
/**
* Handler for successful transaction creation.
*
* @param Transaction $model
* @return void
*/
protected function handleCreated(Transaction $model): void
{
Log::info("Transaction '{$model->getName()}' created");
$this->info("Transaction '{$model->getName()}' created");
}
/**
* Handler for failed transaction creation.
*
* @param Transaction $model
* @return void
*/
protected function handleFailedCreation(Transaction $model): void
{
Log::error("Transaction '{$model->getName()}' was not created");
$this->error("Transaction '{$model->getName()}' was not created");
}
/**
* Handler for successful transaction deletion.
*
* @param Transaction $model
* @return void
*/
protected function handleDeleted(Transaction $model): void
{
Log::info("Transaction '{$model->getName()}' deleted");
$this->info("Transaction '{$model->getName()}' deleted");
}
/**
* Handler for failed transaction deletion.
*
* @param Transaction $model
* @return void
*/
protected function handleFailedDeletion(Transaction $model): void
{
Log::error("Transaction '{$model->getName()}' was not deleted");
$this->error("Transaction '{$model->getName()}' was not deleted");
}
/**
* Handler for successful transaction update.
*
* @param Transaction $model
* @return void
*/
protected function handleUpdated(Transaction $model): void
{
Log::info("Transaction '{$model->getName()}' updated");
$this->info("Transaction '{$model->getName()}' updated");
}
/**
* Handler for failed transaction update.
*
* @param Transaction $model
* @return void
*/
protected function handleFailedUpdate(Transaction $model): void
{
Log::error("Transaction '{$model->getName()}' was not updated");
$this->error("Transaction '{$model->getName()}' was not updated");
}
/**
* Handler for exception.
*
* @param Exception $exception
* @return void
*/
protected function handleException(Exception $exception): void
{
Log::error($exception->getMessage());
$this->error($exception->getMessage());
}
/**
* Get source repository for service.
*
* @param Service $service
* @return Repository|null
*/
protected function getSourceRepository(Service $service): ?Repository
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
/**
* Get destination repository for service.
*
* @param Service $service
* @return Repository|null
*/
protected function getDestinationRepository(Service $service): ?Repository
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
}
@@ -1,157 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\SQLiteConnection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PDO;
use Spatie\DbDumper\Databases\MySql;
use Spatie\DbDumper\Databases\PostgreSql;
use Spatie\DbDumper\Databases\Sqlite;
use Spatie\DbDumper\DbDumper;
/**
* Class DatabaseDumpCommand.
*/
abstract class DatabaseDumpCommand extends Command
{
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$create = $this->option('create');
try {
$connection = DB::connection();
$dumper = $this->getDumper($connection, $create);
if ($dumper === null) {
Log::error("Unrecognized connection '{$connection->getName()}'");
$this->error("Unrecognized connection '{$connection->getName()}'");
return 1;
}
if (! static::canIncludeTables($connection)) {
Log::error('DB connection does not support includeTables option');
$this->error('DB connection does not support includeTables option');
return 1;
}
$dumper->includeTables($this->allowedTables());
$dumper->setDbName($connection->getDatabaseName())
->setUserName(strval($connection->getConfig('username')))
->setPassword(strval($connection->getConfig('password')));
$host = $connection->getConfig('host');
if ($host !== null) {
$dumper->setHost(collect($host)->first());
}
$port = $connection->getConfig('port');
if (is_int($port)) {
$dumper->setPort($port);
}
$dumpFile = $this->getDumpFile($create);
$dumper->dumpToFile($dumpFile);
// Assume success if no exceptions were thrown
// The library will check if the file exists and is not empty
Log::info("Database dump '$dumpFile' has been created");
$this->info("Database dump '$dumpFile' has been created");
} catch (Exception $e) {
Log::error($e->getMessage());
$this->error($e->getMessage());
return 1;
}
return 0;
}
/**
* Get the dumper for the database connection.
*
* @param ConnectionInterface $connection
* @param bool $create
* @return DbDumper|null
*/
protected function getDumper(ConnectionInterface $connection, bool $create): ?DbDumper
{
return match (get_class($connection)) {
SQLiteConnection::class => Sqlite::create(),
MySqlConnection::class => $create ? MySql::create() : MySql::create()->doNotCreateTables(),
PostgresConnection::class => $create ? PostgreSql::create() : PostgreSql::create()->doNotCreateTables(),
default => null,
};
}
/**
* Determine if the database connection supports table inclusion.
*
* @param ConnectionInterface $connection
* @return bool
*/
public static function canIncludeTables(ConnectionInterface $connection): bool
{
// Sqlite version 3.32.0 is required when using the includeTables option
if ($connection instanceof SQLiteConnection) {
return version_compare($connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), '3.32.0', '>=');
}
return true;
}
/**
* The target path for the database dump.
* Pattern: "/path/to/project/storage/db-dumps/{path}/animethemes-db-dump-{create?}-{year}-{month}-{day}.sql".
*
* @param bool $create
* @return string
*/
public function getDumpFile(bool $create = false): string
{
$filesystem = Storage::disk('db-dumps');
$filesystem->makeDirectory($this->getDumpFilePath());
return Str::of($filesystem->path($this->getDumpFilePath()))
->append(DIRECTORY_SEPARATOR)
->append('animethemes-db-dump-')
->append($create ? 'create-' : '')
->append(Date::now()->toDateString())
->append('.sql')
->__toString();
}
/**
* The directory that the file should be dumped to.
*
* @return string
*/
abstract protected function getDumpFilePath(): string;
/**
* The list of tables to include in the dump.
*
* @return array
*/
abstract protected function allowedTables(): array;
}
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Document;
use App\Console\Commands\DatabaseDumpCommand;
use App\Models\Document\Page;
/**
* Class DocumentDatabaseDumpCommand.
*/
class DocumentDatabaseDumpCommand extends DatabaseDumpCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:dump-document {--C|create : Whether the dumper should include create table statements}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Produces sanitized database dump, targeting document-related tables for seeding purposes';
/**
* The list of tables to include in the dump.
*
* @return array
*/
protected function allowedTables(): array
{
return [
Page::TABLE,
];
}
/**
* The directory that the file should be dumped to.
*
* @return string
*/
protected function getDumpFilePath(): string
{
return 'document';
}
}
@@ -1,150 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Class PruneDatabaseDumpsCommand.
*/
class PruneDatabaseDumpsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:prune-dumps {--H|hours=72 : The number of hours to retain sanitized database dumps}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Prune stale database dumps from local storage';
/**
* The number of dumps deleted.
*
* @var int
*/
protected int $deleted = 0;
/**
* The number of dumps whose deletion failed.
*
* @var int
*/
protected int $deletedFailed = 0;
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$hours = $this->option('hours');
if (! is_numeric($hours)) {
Log::error("Invalid hours value '$hours'");
$this->error("Invalid hours value '$hours'");
return 1;
}
$this->prune(
Storage::disk('db-dumps'),
Date::now()->subHours(intval($hours))
);
$this->printResults();
return 0;
}
/**
* Prune database dumps in filesystem against date.
*
* @param Filesystem $filesystem
* @param Carbon $pruneDate
* @return void
*/
protected function prune(Filesystem $filesystem, Carbon $pruneDate): void
{
foreach ($filesystem->allFiles() as $path) {
$lastModified = Date::createFromTimestamp($filesystem->lastModified($path));
if (Str::contains($path, 'animethemes-db-dump') && $lastModified->isBefore($pruneDate)) {
$result = $filesystem->delete($path);
if ($result) {
$this->deleted++;
Log::info("Deleted database dump '$path'");
$this->info("Deleted database dump '$path'");
} else {
$this->deletedFailed++;
Log::error("Failed to delete database dump '$path'");
$this->error("Failed to delete database dump '$path'");
}
}
}
}
/**
* Print results to console and logs.
*
* @return void
*/
protected function printResults(): void
{
if ($this->hasResults()) {
if ($this->hasDeletions()) {
Log::info("$this->deleted database dumps deleted");
$this->info("$this->deleted database dumps deleted");
}
if ($this->hasFailures()) {
Log::error("Failed to delete $this->deletedFailed database dumps");
$this->error("Failed to delete $this->deletedFailed database dumps");
}
} else {
Log::info('No database dumps deleted');
$this->info('No database dumps deleted');
}
}
/**
* Determines if any deletions, successful or not, were made during pruning.
*
* @return bool
*/
protected function hasResults(): bool
{
return $this->hasDeletions() || $this->hasFailures();
}
/**
* Determines if any successful deletions were made during pruning.
*
* @return bool
*/
protected function hasDeletions(): bool
{
return $this->deleted > 0;
}
/**
* Determines if any unsuccessful deletions were attempted during pruning.
*
* @return bool
*/
protected function hasFailures(): bool
{
return $this->deletedFailed > 0;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Billing\Balance;
use App\Concerns\Repositories\Billing\ReconcilesBalanceRepositories;
use App\Console\Commands\Repositories\Billing\ServiceReconcileCommand;
/**
* Class BalanceReconcileCommand.
*/
class BalanceReconcileCommand extends ServiceReconcileCommand
{
use ReconcilesBalanceRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:balance {service}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between vendor billing API and balance database';
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Billing;
use App\Console\Commands\Repositories\ReconcileCommand;
use App\Enums\Models\Billing\Service;
use BenSampo\Enum\Rules\EnumKey;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
/**
* Class ServiceReconcileCommand.
*/
abstract class ServiceReconcileCommand extends ReconcileCommand
{
/**
* Get the validator for options.
*
* @return Validator
*/
protected function validator(): Validator
{
return ValidatorFacade::make($this->arguments(), [
'service' => ['required', new EnumKey(Service::class)],
]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Billing\Transaction;
use App\Concerns\Repositories\Billing\ReconcilesTransactionRepositories;
use App\Console\Commands\Repositories\Billing\ServiceReconcileCommand;
/**
* Class TransactionReconcileCommand.
*/
class TransactionReconcileCommand extends ServiceReconcileCommand
{
use ReconcilesTransactionRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:transaction {service}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between vendor billing API and transaction database';
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Console\Commands\BaseCommand;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Validation\ValidationException;
/**
* Class ReconcileCommand.
*/
abstract class ReconcileCommand extends BaseCommand
{
use ReconcilesRepositories;
/**
* Execute the console command.
*
* @return int
*
* @throws ValidationException
*/
public function handle(): int
{
$validator = $this->validator();
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return 1;
}
$result = $this->reconcileRepositories($validator->validated());
$result->toLog();
$result->toConsole($this);
return $result->hasFailed() ? 1 : 0;
}
/**
* Get the validator for options.
*
* @return Validator
*/
abstract protected function validator(): Validator;
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Storage\Admin;
use App\Concerns\Repositories\Admin\ReconcilesDumpRepositories;
use App\Console\Commands\Repositories\Storage\StorageReconcileCommand;
use App\Constants\Config\DumpConstants;
use Illuminate\Support\Facades\Config;
/**
* Class DumpReconcileCommand.
*/
class DumpReconcileCommand extends StorageReconcileCommand
{
use ReconcilesDumpRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:dump';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between object storage and dump database';
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(DumpConstants::DISK_QUALIFIED);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Storage;
use App\Console\Commands\Repositories\ReconcileCommand;
use App\Contracts\Repositories\RepositoryInterface;
use App\Contracts\Storage\InteractsWithDisk;
use App\Rules\Storage\StorageDirectoryExistsRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
/**
* Class StorageReconcileCommand.
*/
abstract class StorageReconcileCommand extends ReconcileCommand implements InteractsWithDisk
{
/**
* Apply filters to repositories before reconciliation.
*
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @param array $data
* @return void
*/
protected function handleFilters(
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository,
array $data = []
): void {
parent::handleFilters($sourceRepository, $destinationRepository, $data);
$path = Arr::get($data, 'path');
if ($path !== null) {
$sourceRepository->handleFilter('path', $path);
$destinationRepository->handleFilter('path', $path);
}
}
/**
* Get the validator for options.
*
* @return Validator
*/
protected function validator(): Validator
{
$fs = Storage::disk($this->disk());
return ValidatorFacade::make($this->options(), [
'path' => ['nullable', 'string', 'doesnt_start_with:/', new StorageDirectoryExistsRule($fs)],
]);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Storage\Wiki;
use App\Concerns\Repositories\Wiki\ReconcilesAudioRepositories;
use App\Console\Commands\Repositories\Storage\StorageReconcileCommand;
use App\Constants\Config\AudioConstants;
use Illuminate\Support\Facades\Config;
/**
* Class AudioReconcileCommand.
*/
class AudioReconcileCommand extends StorageReconcileCommand
{
use ReconcilesAudioRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:audio
{--path= : The directory of audios to reconcile. Ex: 2022/Spring/. If unspecified, all directories will be listed.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between object storage and audios database';
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Storage\Wiki\Video;
use App\Concerns\Repositories\Wiki\Video\ReconcilesScriptRepositories;
use App\Console\Commands\Repositories\Storage\StorageReconcileCommand;
use App\Constants\Config\VideoConstants;
use Illuminate\Support\Facades\Config;
/**
* Class ScriptReconcileCommand.
*/
class ScriptReconcileCommand extends StorageReconcileCommand
{
use ReconcilesScriptRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:script
{--path= : The directory of scripts to reconcile. Ex: 2022/Spring/. If unspecified, all directories will be listed.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between object storage and script database';
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Repositories\Storage\Wiki;
use App\Concerns\Repositories\Wiki\ReconcilesVideoRepositories;
use App\Console\Commands\Repositories\Storage\StorageReconcileCommand;
use App\Constants\Config\VideoConstants;
use Illuminate\Support\Facades\Config;
/**
* Class VideoReconcileCommand.
*/
class VideoReconcileCommand extends StorageReconcileCommand
{
use ReconcilesVideoRepositories;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'reconcile:video
{--path= : The directory of videos to reconcile. Ex: 2022/Spring/. If unspecified, all directories will be listed.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Perform set reconciliation between object storage and video database';
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Storage\Admin;
use App\Actions\Storage\Admin\Dump\DumpAction;
use App\Actions\Storage\Admin\Dump\DumpDocumentAction;
/**
* Class DocumentDumpCommand.
*/
class DocumentDumpCommand extends DumpCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:dump-document
{--comments : Write additional information in the MySQL dump such as program version, server version and host}
{--data-only : Dump only the data without the schema in PostgreSQL dump}
{--default-character-set=utf8 : Specify default character set in MySQL dump}
{--extended-insert : Use multiple-row insert syntax in MySQL dump}
{--inserts : Dump data as INSERT commands rather than COPY in PostgreSQL dump}
{--lock-tables : Lock all tables before dumping them to MySQL dump}
{--no-create-info : Turn off CREATE TABLE statements in MySQL dump}
{--quick : Retrieve rows for a table from the server one row at a time in MySQL dump}
{--set-gtid-purged=AUTO : Add SET GTID_PURGED to output in MySQL dump}
{--single-transaction : Issue a BEGIN SQL statement before dumping data from server for MySQL dump}
{--skip-column-statistics : Turn off ANALYZE table statements in the MySQL dump}
{--skip-comments : Do not write additional information in the MySQL dump}
{--skip-extended-insert : Turn off extended-insert in MySQL dump}
{--skip-lock-tables : Turn off locking tables before dumping to MySQL dump}
{--skip-quick : Do not retrieve rows for a table from the server one row at a time in MySQL dump}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Produces sanitized database dump, targeting document-related tables for seeding purposes';
/**
* Get the underlying action.
*
* @return DumpAction
*/
protected function action(): DumpAction
{
return new DumpDocumentAction($this->options());
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Storage\Admin;
use App\Actions\Storage\Admin\Dump\DumpAction;
use App\Console\Commands\BaseCommand;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Validation\Rule;
/**
* Class DatabaseDumpCommand.
*/
abstract class DumpCommand extends BaseCommand
{
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$validator = $this->validator();
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
Log::error($error);
$this->error($error);
}
return 1;
}
$action = $this->action();
$result = $action->handle();
$result->toLog();
$result->toConsole($this);
return $result->hasFailed() ? 1 : 0;
}
/**
* Get the validator for options.
*
* @return Validator
*/
protected function validator(): Validator
{
return ValidatorFacade::make($this->options(), [
'default-character-set' => ['string'],
'set-gtid-purged' => [Rule::in(['OFF', 'ON', 'AUTO'])->__toString()],
]);
}
/**
* Get the underlying action.
*
* @return DumpAction
*/
abstract protected function action(): DumpAction;
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Storage\Admin;
use App\Actions\Storage\Admin\Dump\PruneDumpAction;
use App\Actions\Storage\Base\PruneAction;
use App\Console\Commands\Storage\Base\PruneCommand;
/**
* Class DumpPruneCommand.
*/
class DumpPruneCommand extends PruneCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'prune:dump {--H|hours=72 : The number of hours to retain dumps}';
/**
* Get the underlying action.
*
* @return PruneAction
*/
protected function getAction(): PruneAction
{
$hours = $this->option('hours');
return new PruneDumpAction(intval($hours));
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Storage\Admin;
use App\Actions\Storage\Admin\Dump\DumpAction;
use App\Actions\Storage\Admin\Dump\DumpWikiAction;
/**
* Class WikiDumpCommand.
*/
class WikiDumpCommand extends DumpCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'db:dump-wiki
{--comments : Write additional information in the MySQL dump such as program version, server version and host}
{--data-only : Dump only the data without the schema in PostgreSQL dump}
{--default-character-set=utf8 : Specify default character set in MySQL dump}
{--extended-insert : Use multiple-row insert syntax in MySQL dump}
{--inserts : Dump data as INSERT commands rather than COPY in PostgreSQL dump}
{--lock-tables : Lock all tables before dumping them to MySQL dump}
{--no-create-info : Turn off CREATE TABLE statements in MySQL dump}
{--quick : Retrieve rows for a table from the server one row at a time in MySQL dump}
{--set-gtid-purged=AUTO : Add SET GTID_PURGED to output in MySQL dump}
{--single-transaction : Issue a BEGIN SQL statement before dumping data from server for MySQL dump}
{--skip-column-statistics : Turn off ANALYZE table statements in the MySQL dump}
{--skip-comments : Do not write additional information in the MySQL dump}
{--skip-extended-insert : Turn off extended-insert in MySQL dump}
{--skip-lock-tables : Turn off locking tables before dumping to MySQL dump}
{--skip-quick : Do not retrieve rows for a table from the server one row at a time in MySQL dump}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Produces sanitized database dump, targeting wiki-related tables for seeding purposes';
/**
* Get the underlying action.
*
* @return DumpAction
*/
protected function action(): DumpAction
{
return new DumpWikiAction($this->options());
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Storage\Base;
use App\Actions\Storage\Base\PruneAction;
use App\Console\Commands\Storage\StorageCommand;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
/**
* Class PruneCommand.
*/
abstract class PruneCommand extends StorageCommand
{
/**
* The console command description.
*
* @var string
*/
protected $description = 'Prune stale files from storage';
/**
* Get the underlying action.
*
* @return PruneAction
*/
abstract protected function getAction(): PruneAction;
/**
* Get the validator for options.
*
* @return Validator
*/
protected function validator(): Validator
{
return ValidatorFacade::make($this->options(), [
'hours' => ['required', 'integer'],
]);
}
}

Some files were not shown because too many files have changed in this diff Show More