49 Commits

Author SHA1 Message Date
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
566 changed files with 24780 additions and 23497 deletions
+88 -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
@@ -58,11 +74,8 @@ REDIS_DB=0
REDIS_CACHE_DB=1
# 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 +83,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 +100,51 @@ 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=
# flags
ALLOW_VIDEO_STREAMS=false
ALLOW_AUDIO_STREAMS=false
ALLOW_DISCORD_NOTIFICATIONS=false
ALLOW_VIEW_RECORDING=false
# fortify
FORTIFY_PATH=
FORTIFY_URL=http://localhost
# hashing
BCRYPT_ROUNDS=10
@@ -110,8 +153,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 +175,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 +224,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 +231,22 @@ 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=
# 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
+3 -3
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'
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
+3 -6
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'
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
+1
View File
@@ -1,6 +1,7 @@
/node_modules
/public/hot
/public/storage
/public/build
/storage/*.key
/vendor
.env
+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>
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Enums\Actions\ActionStatus;
/**
* 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);
}
}
+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/img/common/companies/$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\ReconcileAudioRepositories;
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 ReconcileAudioRepositories();
/** @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();
}
}
}
@@ -2,9 +2,10 @@
declare(strict_types=1);
namespace App\Concerns\Repositories\Billing;
namespace App\Actions\Repositories\Billing\Balance;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\ReconcileResults;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Balance;
use Closure;
@@ -12,12 +13,12 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Trait ReconcilesBalanceRepositories.
* Class ReconcileBalanceRepositories.
*
* @extends ReconcileRepositories<Balance>
*/
trait ReconcilesBalanceRepositories
class ReconcileBalanceRepositories extends ReconcileRepositories
{
use ReconcilesRepositories;
/**
* The columns used for create and delete set operations.
*
@@ -52,6 +53,7 @@ trait ReconcilesBalanceRepositories
return [
Balance::ATTRIBUTE_BALANCE,
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_FREQUENCY,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
Balance::ATTRIBUTE_USAGE,
@@ -84,4 +86,17 @@ trait ReconcilesBalanceRepositories
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\ReconcileRepositories;
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 ReconcileRepositories<Transaction>
*/
class ReconcileTransactionRepositories extends ReconcileRepositories
{
/**
* 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 ReconcileRepositories
{
/**
* 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,115 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories;
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
{
/**
* Create a new results instance.
*
* @param Collection $created
* @param Collection $deleted
* @param Collection $updated
*/
public function __construct(
protected readonly Collection $created,
protected readonly Collection $deleted,
protected readonly Collection $updated
) {
}
/**
* Get created models.
*
* @return Collection
*/
public function getCreated(): Collection
{
return $this->created;
}
/**
* Determines if any successful changes were made during reconciliation.
*
* @return bool
*/
protected function hasChanges(): bool
{
return $this->created->isNotEmpty() || $this->deleted->isNotEmpty() || $this->updated->isNotEmpty();
}
/**
* Write reconcile results to log.
*
* @return void
*/
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->summary());
}
/**
* Write reconcile results to console output.
*
* @param Command $command
* @return void
*/
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->summary());
}
/**
* Get the summary line.
*
* @return string
*/
public function summary(): 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(class_basename($this->model()), $models);
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Wiki\Audio;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Audio;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileAudioRepositories.
*
* @extends ReconcileRepositories<Audio>
*/
class ReconcileAudioRepositories extends ReconcileRepositories
{
/**
* 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;
}
}
@@ -2,21 +2,22 @@
declare(strict_types=1);
namespace App\Concerns\Repositories\Wiki;
namespace App\Actions\Repositories\Wiki\Video;
use App\Concerns\Repositories\ReconcilesRepositories;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Wiki\Video;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Trait ReconcilesVideoRepositories.
* Class ReconcileVideoRepositories.
*
* @extends ReconcileRepositories<Video>
*/
trait ReconcilesVideoRepositories
class ReconcileVideoRepositories extends ReconcileRepositories
{
use ReconcilesRepositories;
/**
* The columns used for create and delete set operations.
*
@@ -49,7 +50,9 @@ trait ReconcilesVideoRepositories
{
return [
Video::ATTRIBUTE_BASENAME,
Video::ATTRIBUTE_FILENAME,
Video::ATTRIBUTE_ID,
Video::ATTRIBUTE_MIMETYPE,
Video::ATTRIBUTE_PATH,
Video::ATTRIBUTE_SIZE,
];
@@ -79,4 +82,17 @@ trait ReconcilesVideoRepositories
$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;
}
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\ReconcileResults;
use App\Actions\Storage\StorageAction;
use App\Actions\Storage\StorageResults;
use App\Contracts\Repositories\RepositoryInterface;
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 extends StorageAction
{
/**
* 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;
}
$reconcileResults = $this->reconcileRepositories();
$reconcileResults->toLog();
return new DeleteResults($this->model, $results);
}
/**
* Reconcile storage repositories.
*
* @return ReconcileResults
*/
protected function reconcileRepositories(): ReconcileResults
{
$action = $this->action();
$sourceRepository = $this->getSourceRepository();
$destinationRepository = $this->getDestinationRepository();
$this->handleFilters($sourceRepository, $destinationRepository);
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
abstract protected function getSourceRepository(): RepositoryInterface;
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
abstract protected function getDestinationRepository(): RepositoryInterface;
/**
* Apply filters to repositories before reconciliation.
*
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @return void
*/
protected function handleFilters(
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository
): void {
$sourceRepository->handleFilter('path', File::dirname($this->path()));
$destinationRepository->handleFilter('path', File::dirname($this->path()));
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
abstract protected function action(): ReconcileRepositories;
/**
* Get the path to delete.
*
* @return string
*/
abstract protected function path(): string;
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use App\Models\BaseModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class DeleteResults.
*/
class DeleteResults extends 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'");
}
}
/**
* 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(', ', ' & ')}."
);
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\Storage\StorageAction;
use App\Actions\Storage\StorageResults;
use App\Models\BaseModel;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
/**
* Class MoveAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class MoveAction extends 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;
}
$this->update();
return new MoveResults($this->model, $from, $this->to, $results);
}
/**
* 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;
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use App\Models\BaseModel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class MoveResults.
*/
class MoveResults extends 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'");
}
}
/**
* 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(', ', ' & ')}."
);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\ReconcileResults;
use App\Actions\Storage\StorageAction;
use App\Actions\Storage\StorageResults;
use App\Contracts\Repositories\RepositoryInterface;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* Class UploadAction.
*/
abstract class UploadAction extends 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;
}
$reconcileResults = $this->reconcileRepositories();
$reconcileResults->toLog();
return new UploadResults($results);
}
/**
* Reconcile storage repositories.
*
* @return ReconcileResults
*/
protected function reconcileRepositories(): ReconcileResults
{
$action = $this->action();
$sourceRepository = $this->getSourceRepository();
$destinationRepository = $this->getDestinationRepository();
$this->handleFilters($sourceRepository, $destinationRepository);
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
abstract protected function getSourceRepository(): RepositoryInterface;
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
abstract protected function getDestinationRepository(): RepositoryInterface;
/**
* Apply filters to repositories before reconciliation.
*
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @return void
*/
protected function handleFilters(
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository
): void {
$sourceRepository->handleFilter('path', $this->path);
$destinationRepository->handleFilter('path', $this->path);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
abstract protected function action(): ReconcileRepositories;
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Base;
use App\Actions\ActionResult;
use App\Actions\Storage\StorageResults;
use App\Enums\Actions\ActionStatus;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* Class UploadResults.
*/
class UploadResults extends 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'");
}
}
/**
* 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(', ', ' & ')}."
);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage;
/**
* Class StorageAction.
*/
abstract class StorageAction
{
/**
* Handle action.
*
* @return StorageResults
*/
abstract public function handle(): StorageResults;
/**
* Get the disks to update.
*
* @return array
*/
abstract protected function disks(): array;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage;
use App\Actions\ActionResult;
/**
* Class StorageResults.
*/
abstract class StorageResults
{
/**
* Write results to log.
*
* @return void
*/
abstract public function toLog(): void;
/**
* Transform to Action Result.
*
* @return ActionResult
*/
abstract public function toActionResult(): ActionResult;
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Audio;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositories;
use App\Actions\Storage\Base\DeleteAction;
use App\Constants\Config\AudioConstants;
use App\Contracts\Repositories\RepositoryInterface;
use App\Models\Wiki\Audio;
use App\Repositories\Eloquent\Wiki\AudioRepository as AudioDestinationRepository;
use App\Repositories\Storage\Wiki\AudioRepository as AudioSourceRepository;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
/**
* Class DeleteAudioAction.
*
* @extends DeleteAction<Audio>
*/
class DeleteAudioAction extends DeleteAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* Get the disks to update.
*
* @return array
*/
protected function disks(): array
{
return Config::get(AudioConstants::DISKS_QUALIFIED);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
protected function getSourceRepository(): RepositoryInterface
{
return App::make(AudioSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
protected function getDestinationRepository(): RepositoryInterface
{
return App::make(AudioDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
protected function action(): ReconcileRepositories
{
return new ReconcileAudioRepositories();
}
/**
* 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);
}
/**
* Get the disks to update.
*
* @return array
*/
protected 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,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Audio;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositories;
use App\Actions\Storage\Base\UploadAction;
use App\Constants\Config\AudioConstants;
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;
use Illuminate\Support\Facades\Config;
/**
* Class UploadAudioAction.
*/
class UploadAudioAction extends UploadAction
{
/**
* Get the disks to update.
*
* @return array
*/
protected function disks(): array
{
return Config::get(AudioConstants::DISKS_QUALIFIED);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
protected function getSourceRepository(): RepositoryInterface
{
return App::make(AudioSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
protected function getDestinationRepository(): RepositoryInterface
{
return App::make(AudioDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
protected function action(): ReconcileRepositories
{
return new ReconcileAudioRepositories();
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Video\ReconcileVideoRepositories;
use App\Actions\Storage\Base\DeleteAction;
use App\Constants\Config\VideoConstants;
use App\Contracts\Repositories\RepositoryInterface;
use App\Models\Wiki\Video;
use App\Repositories\Eloquent\Wiki\VideoRepository as VideoDestinationRepository;
use App\Repositories\Storage\Wiki\VideoRepository as VideoSourceRepository;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
/**
* Class DeleteVideoAction.
*
* @extends DeleteAction<Video>
*/
class DeleteVideoAction extends DeleteAction
{
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* Get the disks to update.
*
* @return array
*/
protected function disks(): array
{
return Config::get(VideoConstants::DISKS_QUALIFIED);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
protected function getSourceRepository(): RepositoryInterface
{
return App::make(VideoSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
protected function getDestinationRepository(): RepositoryInterface
{
return App::make(VideoDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
protected function action(): ReconcileRepositories
{
return new ReconcileVideoRepositories();
}
/**
* 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);
}
/**
* Get the disks to update.
*
* @return array
*/
protected 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,61 @@
<?php
declare(strict_types=1);
namespace App\Actions\Storage\Wiki\Video;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Video\ReconcileVideoRepositories;
use App\Actions\Storage\Base\UploadAction;
use App\Constants\Config\VideoConstants;
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;
use Illuminate\Support\Facades\Config;
/**
* Class UploadVideoAction.
*/
class UploadVideoAction extends UploadAction
{
/**
* Get the disks to update.
*
* @return array
*/
protected function disks(): array
{
return Config::get(VideoConstants::DISKS_QUALIFIED);
}
/**
* Get source repository for action.
*
* @return RepositoryInterface
*/
protected function getSourceRepository(): RepositoryInterface
{
return App::make(VideoSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @return RepositoryInterface
*/
protected function getDestinationRepository(): RepositoryInterface
{
return App::make(VideoDestinationRepository::class);
}
/**
* Get the reconcile action.
*
* @return ReconcileRepositories
*/
protected function action(): ReconcileRepositories
{
return new ReconcileVideoRepositories();
}
}
@@ -1,45 +0,0 @@
<?php
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;
/**
* Trait ReconcilesTransactionRepositories.
*/
trait ReconcilesTransactionRepositories
{
use ReconcilesRepositories;
/**
* 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];
}
}
@@ -1,341 +0,0 @@
<?php
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;
/**
* Trait ReconcilesRepositories.
*/
trait ReconcilesRepositories
{
/**
* The number of models created.
*
* @var int
*/
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
{
//
}
/**
* 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();
}
}
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return ['*'];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn () => 0;
}
/**
* Create models that exist in source but not in destination.
*
* @param Repository $destination
* @param Collection $sourceModels
* @param Collection $destinationModels
* @return void
*/
protected function createModelsFromSource(
Repository $destination,
Collection $sourceModels,
Collection $destinationModels
): 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);
}
}
}
}
}
@@ -4,24 +4,21 @@ declare(strict_types=1);
namespace App\Console\Commands\Billing\Balance;
use App\Concerns\Repositories\Billing\ReconcilesBalanceRepositories;
use App\Actions\Repositories\Billing\Balance\ReconcileBalanceRepositories;
use App\Actions\Repositories\ReconcileRepositories;
use App\Console\Commands\Billing\ServiceReconcileCommand;
use App\Contracts\Repositories\Repository;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Models\Billing\Service;
use App\Models\BaseModel;
use App\Repositories\DigitalOcean\Billing\DigitalOceanBalanceRepository as DigitalOceanSourceRepository;
use App\Repositories\Eloquent\Billing\DigitalOceanBalanceRepository as DigitalOceanDestinationRepository;
use App\Repositories\Service\DigitalOcean\Billing\DigitalOceanBalanceRepository as DigitalOceanSourceRepository;
use Exception;
use Illuminate\Support\Arr;
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.
*
@@ -37,121 +34,25 @@ class BalanceReconcileCommand extends ServiceReconcileCommand
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.
* Get the reconciliation action.
*
* @return void
* @return ReconcileRepositories
*/
protected function postReconciliationTask(): void
protected function getAction(): ReconcileRepositories
{
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());
return new ReconcileBalanceRepositories();
}
/**
* Get source repository for service.
*
* @param Service $service
* @return Repository|null
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getSourceRepository(Service $service): ?Repository
protected function getSourceRepository(array $validated): ?RepositoryInterface
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
return match (Arr::get($validated, 'service')) {
Service::DIGITALOCEAN()->key => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
@@ -159,13 +60,13 @@ class BalanceReconcileCommand extends ServiceReconcileCommand
/**
* Get destination repository for service.
*
* @param Service $service
* @return Repository|null
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(Service $service): ?Repository
protected function getDestinationRepository(array $validated): ?RepositoryInterface
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
return match (Arr::get($validated, 'service')) {
Service::DIGITALOCEAN()->key => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
@@ -4,77 +4,42 @@ declare(strict_types=1);
namespace App\Console\Commands\Billing;
use App\Contracts\Repositories\Repository;
use App\Console\Commands\ReconcileCommand;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Models\Billing\Service;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use BenSampo\Enum\Rules\EnumKey;
use Illuminate\Contracts\Validation\Validator;
/**
* Class ServiceReconcileCommand.
*/
abstract class ServiceReconcileCommand extends Command
abstract class ServiceReconcileCommand extends ReconcileCommand
{
/**
* Execute the console command.
* Get the validator for options.
*
* @return int
* @return Validator
*/
public function handle(): int
protected function validator(): Validator
{
$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;
return \Illuminate\Support\Facades\Validator::make($this->arguments(), [
'service' => ['required', new EnumKey(Service::class)],
]);
}
/**
* Perform set reconciliation between source and destination repositories.
* Apply filters to repositories before reconciliation.
*
* @param Repository $source
* @param Repository $destination
* @param array $validated
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @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;
protected function handleFilters(
array $validated,
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository
): void {
// Not supported
}
}
@@ -4,24 +4,21 @@ declare(strict_types=1);
namespace App\Console\Commands\Billing\Transaction;
use App\Concerns\Repositories\Billing\ReconcilesTransactionRepositories;
use App\Actions\Repositories\Billing\Transaction\ReconcileTransactionRepositories;
use App\Actions\Repositories\ReconcileRepositories;
use App\Console\Commands\Billing\ServiceReconcileCommand;
use App\Contracts\Repositories\Repository;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Models\Billing\Service;
use App\Models\Billing\Transaction;
use App\Repositories\DigitalOcean\Billing\DigitalOceanTransactionRepository as DigitalOceanSourceRepository;
use App\Repositories\Eloquent\Billing\DigitalOceanTransactionRepository as DigitalOceanDestinationRepository;
use App\Repositories\Service\DigitalOcean\Billing\DigitalOceanTransactionRepository as DigitalOceanSourceRepository;
use Exception;
use Illuminate\Support\Arr;
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.
*
@@ -37,121 +34,25 @@ class TransactionReconcileCommand extends ServiceReconcileCommand
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.
* Get the reconciliation action.
*
* @return void
* @return ReconcileRepositories
*/
protected function postReconciliationTask(): void
protected function getAction(): ReconcileRepositories
{
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());
return new ReconcileTransactionRepositories();
}
/**
* Get source repository for service.
*
* @param Service $service
* @return Repository|null
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getSourceRepository(Service $service): ?Repository
protected function getSourceRepository(array $validated): ?RepositoryInterface
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanSourceRepository::class),
return match (Arr::get($validated, 'service')) {
Service::DIGITALOCEAN()->key => App::make(DigitalOceanSourceRepository::class),
default => null,
};
}
@@ -159,13 +60,13 @@ class TransactionReconcileCommand extends ServiceReconcileCommand
/**
* Get destination repository for service.
*
* @param Service $service
* @return Repository|null
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(Service $service): ?Repository
protected function getDestinationRepository(array $validated): ?RepositoryInterface
{
return match ($service->value) {
Service::DIGITALOCEAN => App::make(DigitalOceanDestinationRepository::class),
return match (Arr::get($validated, 'service')) {
Service::DIGITALOCEAN()->key => App::make(DigitalOceanDestinationRepository::class),
default => null,
};
}
+167 -45
View File
@@ -6,7 +6,7 @@ namespace App\Console\Commands;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Connection;
use Illuminate\Database\MySqlConnection;
use Illuminate\Database\PostgresConnection;
use Illuminate\Database\SQLiteConnection;
@@ -14,12 +14,16 @@ use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
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 DatabaseDumpCommand.
@@ -33,12 +37,24 @@ abstract class DatabaseDumpCommand extends Command
*/
public function handle(): int
{
$create = $this->option('create');
$validator = Validator::make($this->options(), [
'default-character-set' => ['string'],
'set-gtid-purged' => [Rule::in(['OFF', 'ON', 'AUTO'])->__toString()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return 1;
}
try {
/** @var Connection $connection */
$connection = DB::connection();
$dumper = $this->getDumper($connection, $create);
$dumper = $this->getDumper($connection);
if ($dumper === null) {
Log::error("Unrecognized connection '{$connection->getName()}'");
$this->error("Unrecognized connection '{$connection->getName()}'");
@@ -46,29 +62,7 @@ abstract class DatabaseDumpCommand extends Command
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);
$dumpFile = $this->getDumpFile();
$dumper->dumpToFile($dumpFile);
@@ -89,44 +83,173 @@ abstract class DatabaseDumpCommand extends Command
/**
* Get the dumper for the database connection.
*
* @param ConnectionInterface $connection
* @param bool $create
* @param Connection $connection
* @return DbDumper|null
*
* @throws CannotSetParameter
*/
protected function getDumper(ConnectionInterface $connection, bool $create): ?DbDumper
protected function getDumper(Connection $connection): ?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(),
SQLiteConnection::class => $this->prepareSqliteDumper($connection),
MySqlConnection::class => $this->prepareMySqlDumper($connection),
PostgresConnection::class => $this->preparePostgreSqlDumper($connection),
default => null,
};
}
/**
* Determine if the database connection supports table inclusion.
* Configure Sqlite database dumper.
*
* @param ConnectionInterface $connection
* @return bool
* @param Connection $connection
* @return Sqlite
*
* @throws RuntimeException
* @throws CannotSetParameter
*/
public static function canIncludeTables(ConnectionInterface $connection): bool
protected function prepareSqliteDumper(Connection $connection): Sqlite
{
// 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', '>=');
if (version_compare($connection->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), '3.32.0', '<')) {
throw new RuntimeException('DB connection does not support includeTables option');
}
return true;
$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;
}
/**
* The target path for the database dump.
* Pattern: "/path/to/project/storage/db-dumps/{path}/animethemes-db-dump-{create?}-{year}-{month}-{day}.sql".
* Pattern: "/path/to/project/storage/db-dumps/{path}/animethemes-db-dump-{milliseconds from epoch}.sql".
*
* @param bool $create
* @return string
*/
public function getDumpFile(bool $create = false): string
protected function getDumpFile(): string
{
$filesystem = Storage::disk('db-dumps');
@@ -135,8 +258,7 @@ abstract class DatabaseDumpCommand extends Command
return Str::of($filesystem->path($this->getDumpFilePath()))
->append(DIRECTORY_SEPARATOR)
->append('animethemes-db-dump-')
->append($create ? 'create-' : '')
->append(Date::now()->toDateString())
->append(intval(Date::now()->valueOf()))
->append('.sql')
->__toString();
}
@@ -17,7 +17,23 @@ class DocumentDatabaseDumpCommand extends DatabaseDumpCommand
*
* @var string
*/
protected $signature = 'db:dump-document {--C|create : Whether the dumper should include create table statements}';
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.
+108
View File
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Actions\Repositories\ReconcileRepositories;
use App\Contracts\Repositories\RepositoryInterface;
use Illuminate\Console\Command;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* Class ReconcileCommand.
*/
abstract class ReconcileCommand extends Command
{
/**
* 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;
}
$sourceRepository = $this->getSourceRepository($validator->validated());
if ($sourceRepository === null) {
Log::error('Could not find source repository', $validator->validated());
$this->error('Could not find source repository');
return 1;
}
$destinationRepository = $this->getDestinationRepository($validator->validated());
if ($destinationRepository === null) {
Log::error('Could not find destination repository', $validator->validated());
$this->error('Could not find destination repository');
return 1;
}
$this->handleFilters($validator->validated(), $sourceRepository, $destinationRepository);
$action = $this->getAction();
$results = $action->reconcileRepositories($sourceRepository, $destinationRepository);
$results->toLog();
$results->toConsole($this);
return 0;
}
/**
* Get the validator for options.
*
* @return Validator
*/
abstract protected function validator(): Validator;
/**
* Get source repository for action.
*
* @param array $validated
* @return RepositoryInterface|null
*/
abstract protected function getSourceRepository(array $validated): ?RepositoryInterface;
/**
* Get destination repository for action.
*
* @param array $validated
* @return RepositoryInterface|null
*/
abstract protected function getDestinationRepository(array $validated): ?RepositoryInterface;
/**
* Apply filters to repositories before reconciliation.
*
* @param array $validated
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @return void
*/
abstract protected function handleFilters(
array $validated,
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository
): void;
/**
* Get the reconciliation action.
*
* @return ReconcileRepositories
*/
abstract protected function getAction(): ReconcileRepositories;
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Wiki\Audio;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositories;
use App\Console\Commands\Wiki\StorageReconcileCommand;
use App\Constants\Config\AudioConstants;
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;
use Illuminate\Support\Facades\Config;
/**
* Class AudioReconcileCommand.
*/
class AudioReconcileCommand extends StorageReconcileCommand
{
/**
* 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';
/**
* Get the name of the disk that represents the filesystem.
*
* @return string
*/
protected function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
}
/**
* Get source repository for action.
*
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getSourceRepository(array $validated): ?RepositoryInterface
{
return App::make(AudioSourceRepository::class);
}
/**
* Get destination repository for action.
*
* @param array $validated
* @return RepositoryInterface|null
*/
protected function getDestinationRepository(array $validated): ?RepositoryInterface
{
return App::make(AudioDestinationRepository::class);
}
/**
* Get the reconciliation action.
*
* @return ReconcileRepositories
*/
protected function getAction(): ReconcileRepositories
{
return new ReconcileAudioRepositories();
}
}
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Wiki;
use App\Console\Commands\ReconcileCommand;
use App\Contracts\Repositories\RepositoryInterface;
use App\Rules\Storage\StorageDirectoryExistsRule;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
/**
* Class StorageReconcileCommand.
*/
abstract class StorageReconcileCommand extends ReconcileCommand
{
/**
* Get the name of the disk that represents the filesystem.
*
* @return string
*/
abstract protected function disk(): string;
/**
* Apply filters to repositories before reconciliation.
*
* @param array $validated
* @param RepositoryInterface $sourceRepository
* @param RepositoryInterface $destinationRepository
* @return void
*/
protected function handleFilters(
array $validated,
RepositoryInterface $sourceRepository,
RepositoryInterface $destinationRepository
): void {
$path = Arr::get($validated, '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 \Illuminate\Support\Facades\Validator::make($this->options(), [
'path' => ['nullable', 'string', 'doesnt_start_with:/', new StorageDirectoryExistsRule($fs)],
]);
}
}
@@ -4,22 +4,21 @@ declare(strict_types=1);
namespace App\Console\Commands\Wiki\Video;
use App\Concerns\Repositories\Wiki\ReconcilesVideoRepositories;
use App\Models\BaseModel;
use App\Actions\Repositories\ReconcileRepositories;
use App\Actions\Repositories\Wiki\Video\ReconcileVideoRepositories;
use App\Console\Commands\Wiki\StorageReconcileCommand;
use App\Constants\Config\VideoConstants;
use App\Contracts\Repositories\RepositoryInterface;
use App\Repositories\Eloquent\Wiki\VideoRepository as VideoDestinationRepository;
use App\Repositories\Service\DigitalOcean\VideoRepository as VideoSourceRepository;
use Exception;
use Illuminate\Console\Command;
use App\Repositories\Storage\Wiki\VideoRepository as VideoSourceRepository;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Config;
/**
* Class VideoReconcileCommand.
*/
class VideoReconcileCommand extends Command
class VideoReconcileCommand extends StorageReconcileCommand
{
use ReconcilesVideoRepositories;
/**
* The name and signature of the console command.
*
@@ -36,136 +35,44 @@ class VideoReconcileCommand extends Command
protected $description = 'Perform set reconciliation between object storage and video database';
/**
* Execute the console command.
* Get the name of the disk that represents the filesystem.
*
* @return int
* @return string
*/
public function handle(): int
protected function disk(): string
{
$sourceRepository = App::make(VideoSourceRepository::class);
$destinationRepository = App::make(VideoDestinationRepository::class);
$path = $this->option('path');
if ($path !== null) {
if (! $sourceRepository->validateFilter('path', $path) || ! $destinationRepository->validateFilter('path', $path)) {
$this->error("Invalid path '$path'");
return 1;
}
$sourceRepository->handleFilter('path', $path);
$destinationRepository->handleFilter('path', $path);
}
$this->reconcileRepositories($sourceRepository, $destinationRepository);
return 0;
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
}
/**
* Print the result to console and log the results to the app log.
* Get source repository for action.
*
* @return void
* @param array $validated
* @return RepositoryInterface|null
*/
protected function postReconciliationTask(): void
protected function getSourceRepository(array $validated): ?RepositoryInterface
{
if ($this->hasResults()) {
if ($this->hasChanges()) {
Log::info("$this->created Videos created, $this->deleted Videos deleted, $this->updated Videos updated");
$this->info("$this->created Videos created, $this->deleted Videos deleted, $this->updated Videos updated");
}
if ($this->hasFailures()) {
Log::error("Failed to create $this->createdFailed Videos, delete $this->deletedFailed Videos, update $this->updatedFailed Videos");
$this->error("Failed to create $this->createdFailed Videos, delete $this->deletedFailed Videos, update $this->updatedFailed Videos");
}
} else {
Log::info('No Videos created or deleted or updated');
$this->info('No Videos created or deleted or updated');
}
return App::make(VideoSourceRepository::class);
}
/**
* Handler for successful video creation.
* Get destination repository for action.
*
* @param BaseModel $model
* @return void
* @param array $validated
* @return RepositoryInterface|null
*/
protected function handleCreated(BaseModel $model): void
protected function getDestinationRepository(array $validated): ?RepositoryInterface
{
Log::info("Video '{$model->getName()}' created");
$this->info("Video '{$model->getName()}' created");
return App::make(VideoDestinationRepository::class);
}
/**
* Handler for failed video creation.
* Get the reconciliation action.
*
* @param BaseModel $model
* @return void
* @return ReconcileRepositories
*/
protected function handleFailedCreation(BaseModel $model): void
protected function getAction(): ReconcileRepositories
{
Log::error("Video '{$model->getName()}' was not created");
$this->error("Video '{$model->getName()}' was not created");
}
/**
* Handler for successful video deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleDeleted(BaseModel $model): void
{
Log::info("Video '{$model->getName()}' deleted");
$this->info("Video '{$model->getName()}' deleted");
}
/**
* Handler for failed video deletion.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedDeletion(BaseModel $model): void
{
Log::error("Video '{$model->getName()}' was not deleted");
$this->error("Video '{$model->getName()}' was not deleted");
}
/**
* Handler for successful video update.
*
* @param BaseModel $model
* @return void
*/
protected function handleUpdated(BaseModel $model): void
{
Log::info("Video '{$model->getName()}' updated");
$this->info("Video '{$model->getName()}' updated");
}
/**
* Handler for failed video update.
*
* @param BaseModel $model
* @return void
*/
protected function handleFailedUpdate(BaseModel $model): void
{
Log::error("Video '{$model->getName()}' was not updated");
$this->error("Video '{$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());
return new ReconcileVideoRepositories();
}
}
@@ -10,6 +10,7 @@ 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;
@@ -25,6 +26,7 @@ use App\Pivots\ArtistImage;
use App\Pivots\ArtistMember;
use App\Pivots\ArtistResource;
use App\Pivots\ArtistSong;
use App\Pivots\StudioImage;
use App\Pivots\StudioResource;
/**
@@ -37,7 +39,23 @@ class WikiDatabaseDumpCommand extends DatabaseDumpCommand
*
* @var string
*/
protected $signature = 'db:dump-wiki {--C|create : Whether the dumper should include create table statements}';
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.
@@ -68,11 +86,13 @@ 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,
];
+14 -8
View File
@@ -15,6 +15,7 @@ use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand as PruneModelsCommand;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use Illuminate\Queue\Console\PruneFailedJobsCommand;
use Illuminate\Support\Facades\Config;
use Laravel\Horizon\Console\SnapshotCommand;
use Laravel\Sanctum\Console\Commands\PruneExpired;
use Laravel\Telescope\Console\PruneCommand as PruneTelescopeEntriesCommand;
@@ -40,19 +41,22 @@ class Kernel extends ConsoleKernel
->storeOutput()
->hourly();
$schedule->command(DocumentDatabaseDumpCommand::class)
// Managed database requires --single-transaction and --set-gtid-purged=OFF
$schedule->command(DocumentDatabaseDumpCommand::class, ['--single-transaction', '--set-gtid-purged' => 'OFF'])
->withoutOverlapping()
->runInBackground()
->storeOutput()
->daily();
$schedule->command(WikiDatabaseDumpCommand::class)
// Managed database requires --single-transaction and --set-gtid-purged=OFF
$schedule->command(WikiDatabaseDumpCommand::class, ['--single-transaction', '--set-gtid-purged' => 'OFF'])
->withoutOverlapping()
->runInBackground()
->storeOutput()
->daily();
$schedule->command(WikiDatabaseDumpCommand::class, ['--create'])
// Managed database requires --single-transaction and --set-gtid-purged=OFF
$schedule->command(WikiDatabaseDumpCommand::class, ['--single-transaction', '--set-gtid-purged' => 'OFF', '--no-create-info'])
->withoutOverlapping()
->runInBackground()
->storeOutput()
@@ -82,11 +86,13 @@ class Kernel extends ConsoleKernel
->storeOutput()
->daily();
$schedule->command(PruneTelescopeEntriesCommand::class)
->withoutOverlapping()
->runInBackground()
->storeOutput()
->daily();
if (Config::bool('telescope.enabled')) {
$schedule->command(PruneTelescopeEntriesCommand::class)
->withoutOverlapping()
->runInBackground()
->storeOutput()
->daily();
}
$schedule->command(SnapshotCommand::class)
->withoutOverlapping()
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Constants\Config;
/**
* Class AudioConstants.
*/
class AudioConstants
{
final public const DEFAULT_DISK_QUALIFIED = 'audio.default_disk';
final public const DISKS_QUALIFIED = 'audio.disks';
final public const NGINX_REDIRECT_QUALIFIED = 'audio.nginx_redirect';
final public const PATH_QUALIFIED = 'audio.path';
final public const STREAMING_METHOD_QUALIFIED = 'audio.streaming_method';
final public const URL_QUALIFIED = 'audio.url';
}
+4
View File
@@ -13,6 +13,10 @@ class FlagConstants
final public const ALLOW_VIDEO_STREAMS_FLAG_QUALIFIED = 'flags.allow_video_streams';
final public const ALLOW_AUDIO_STREAMS_FLAG = 'allow_audio_streams';
final public const ALLOW_AUDIO_STREAMS_FLAG_QUALIFIED = 'flags.allow_audio_streams';
final public const ALLOW_DISCORD_NOTIFICATIONS_FLAG = 'allow_discord_notifications';
final public const ALLOW_DISCORD_NOTIFICATIONS_FLAG_QUALIFIED = 'flags.allow_discord_notifications';
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Constants\Config;
/**
* Class VideoConstants.
*/
class VideoConstants
{
final public const DEFAULT_DISK_QUALIFIED = 'video.default_disk';
final public const DISKS_QUALIFIED = 'video.disks';
final public const ENCODER_VERSION = 'encoder_version';
final public const ENCODER_VERSION_QUALIFIED = 'video.encoder_version';
final public const NGINX_REDIRECT_QUALIFIED = 'video.nginx_redirect';
final public const PATH_QUALIFIED = 'video.path';
final public const STREAMING_METHOD_QUALIFIED = 'video.streaming_method';
final public const URL_QUALIFIED = 'video.url';
}
+4
View File
@@ -9,9 +9,13 @@ namespace App\Constants\Config;
*/
class WikiConstants
{
final public const FEATURED_ENTRY_SETTING = 'featured_entry';
final public const FEATURED_ENTRY_SETTING_QUALIFIED = 'wiki.featured_entry';
final public const FEATURED_THEME_SETTING = 'featured_theme';
final public const FEATURED_VIDEO_SETTING = 'featured_video';
final public const FEATURED_VIDEO_SETTING_QUALIFIED = 'wiki.featured_video';
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Contracts\Models;
/**
* Interface Streamable.
*/
interface Streamable
{
/**
* Get the path of the streamable model in the filesystem.
*
* @return string
*/
public function path(): string;
/**
* Get the basename of the streamable model.
*
* @return string
*/
public function basename(): string;
/**
* Get the MIME type / content type of the streamable model.
*
* @return string
*/
public function mimetype(): string;
/**
* Get the content length of the streamable model.
*
* @return int
*/
public function size(): int;
}
@@ -8,9 +8,9 @@ use App\Models\Auth\User;
use Closure;
/**
* Interface Pipe.
* Interface PipeInterface.
*/
interface Pipe
interface PipeInterface
{
/**
* Handle an incoming request.
@@ -8,9 +8,11 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Interface Repository.
* Interface RepositoryInterface.
*
* @template TModel of \App\Models\BaseModel
*/
interface Repository
interface RepositoryInterface
{
/**
* Get models from the repository.
@@ -45,15 +47,6 @@ interface Repository
*/
public function update(Model $model, array $attributes): bool;
/**
* Validate repository filter.
*
* @param string $filter
* @param mixed $value
* @return bool
*/
public function validateFilter(string $filter, mixed $value = null): bool;
/**
* Filter repository models.
*
+7
View File
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Discord;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use BenSampo\Enum\Enum;
use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Str;
use JsonSerializable;
@@ -75,6 +77,11 @@ class DiscordEmbedField implements Arrayable, JsonSerializable
return $value->description;
}
// Use 'Y-m-d' format for dates
if ($value instanceof Carbon) {
return $value->format(AllowedDateFormat::YMD);
}
// Pretty print booleans
if (is_bool($value)) {
return $value ? 'true' : 'false';
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Enums\Actions;
use App\Enums\BaseEnum;
/**
* Class ActionStatus.
*
* @method static static PASSED()
* @method static static FAILED()
* @method static static SKIPPED()
*/
final class ActionStatus extends BaseEnum
{
public const PASSED = 0;
public const FAILED = 1;
public const SKIPPED = 2;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums\Actions\Models\Wiki\Video;
use App\Enums\BaseEnum;
/**
* Class DeriveSourceVideo.
*
* @method static static YES()
* @method static static NO()
*/
final class DeriveSourceVideo extends BaseEnum
{
public const YES = 0;
public const NO = 1;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums\Actions\Models\Wiki\Video;
use App\Enums\BaseEnum;
/**
* Class OverwriteAudio.
*
* @method static static YES()
* @method static static NO()
*/
final class OverwriteAudio extends BaseEnum
{
public const YES = 0;
public const NO = 1;
}
+76 -6
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Enums\Models\Wiki;
use App\Enums\BaseEnum;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
@@ -50,8 +53,8 @@ final class ResourceSite extends BaseEnum
self::TWITTER => 'twitter.com',
self::ANIDB => 'anidb.net',
self::ANILIST => 'anilist.co',
self::ANIME_PLANET => 'anime-planet.com',
self::ANN => 'animenewsnetwork.com',
self::ANIME_PLANET => 'www.anime-planet.com',
self::ANN => 'www.animenewsnetwork.com',
self::KITSU => 'kitsu.io',
self::MAL => 'myanimelist.net',
self::WIKI => 'wikipedia.org',
@@ -90,10 +93,77 @@ final class ResourceSite extends BaseEnum
ResourceSite::ANILIST,
ResourceSite::ANN,
ResourceSite::MAL => Str::match('/\d+/', $link),
ResourceSite::ANIME_PLANET => self::parseAnimePlanetIdFromLink($link),
ResourceSite::KITSU => self::parseKitsuIdFromLink($link),
default => null,
};
}
/**
* Attempt to parse Anime Planet ID from link.
*
* @param string $link
* @return string|null
*/
protected static function parseAnimePlanetIdFromLink(string $link): ?string
{
// We only want to attempt to parse the ID for an anime resource
if (Str::match('/^https:\/\/www\.anime-planet\.com\/anime\/[a-zA-Z0-9-]+$/', $link) !== $link) {
return null;
}
try {
$response = Http::get($link)
->throw()
->body();
return Str::match(
'/["\']?ENTRY_INFO["\']? *: *{.*id["\']? *: *["\']?(\d+)["\']? *,/s',
$response
);
} catch (RequestException $e) {
Log::error($e->getMessage());
}
return null;
}
/**
* Attempt to parse Kitsu ID from link.
*
* @param string $link
* @return string|null
*/
protected static function parseKitsuIdFromLink(string $link): ?string
{
try {
$query = '
query ($slug: String!) {
findAnimeBySlug(slug: $slug) {
id
}
}
';
$variables = [
'slug' => Str::afterLast($link, '/'),
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
return Arr::get($response, 'data.findAnimeBySlug.id');
} catch (RequestException $e) {
Log::error($e->getMessage());
}
return null;
}
/**
* Get the URL of the site for anime resources.
*
@@ -107,8 +177,8 @@ final class ResourceSite extends BaseEnum
return match ($site->value) {
ResourceSite::ANIDB => "https://anidb.net/anime/$id/",
ResourceSite::ANILIST => "https://anilist.co/anime/$id/",
ResourceSite::ANIME_PLANET => "https://anime-planet.com/anime/$slug",
ResourceSite::ANN => "https://animenewsnetwork.com/encyclopedia/anime.php?id=$id",
ResourceSite::ANIME_PLANET => "https://www.anime-planet.com/anime/$slug",
ResourceSite::ANN => "https://www.animenewsnetwork.com/encyclopedia/anime.php?id=$id",
ResourceSite::KITSU => "https://kitsu.io/anime/$slug",
ResourceSite::MAL => "https://myanimelist.net/anime/$id/",
default => null,
@@ -128,8 +198,8 @@ final class ResourceSite extends BaseEnum
return match ($site->value) {
ResourceSite::ANIDB => "https://anidb.net/creator/$id/",
ResourceSite::ANILIST => "https://anilist.co/studio/$id/",
ResourceSite::ANIME_PLANET => "https://anime-planet.com/anime/studios/$slug",
ResourceSite::ANN => "https://animenewsnetwork.com/encyclopedia/company.php?id=$id",
ResourceSite::ANIME_PLANET => "https://www.anime-planet.com/anime/studios/$slug",
ResourceSite::ANN => "https://www.animenewsnetwork.com/encyclopedia/company.php?id=$id",
ResourceSite::MAL => "https://myanimelist.net/anime/producer/$id/",
default => null,
};
+19
View File
@@ -17,4 +17,23 @@ final class VideoSource extends BaseEnum
public const DVD = 3;
public const VHS = 4;
public const LD = 5;
/**
* Score sources to help prioritize videos.
*
* @param int|null $value
* @return int
*/
public static function getPriority(?int $value): int
{
return match ($value) {
self::BD => 60,
self::DVD => 50,
self::LD => 40,
self::VHS => 30,
self::WEB => 20,
self::RAW => 10,
default => 0,
};
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Events\Admin\Setting;
use App\Constants\Config\ServiceConstants;
use App\Contracts\Events\DiscordMessageEvent;
use App\Enums\Discord\EmbedColor;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Config;
use NotificationChannels\Discord\DiscordMessage;
/**
* Class SettingCreated.
*/
class SettingCreated extends SettingEvent implements DiscordMessageEvent
{
use Dispatchable;
use SerializesModels;
/**
* Get Discord message payload.
*
* @return DiscordMessage
*/
public function getDiscordMessage(): DiscordMessage
{
$setting = $this->getSetting();
return DiscordMessage::create('', [
'description' => "Setting '**{$setting->getName()}**' has been created.",
'color' => EmbedColor::GREEN,
]);
}
/**
* Get Discord channel the message will be sent to.
*
* @return string
*/
public function getDiscordChannel(): string
{
return Config::get(ServiceConstants::ADMIN_DISCORD_CHANNEL_QUALIFIED);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Events\Admin\Setting;
use App\Constants\Config\ServiceConstants;
use App\Contracts\Events\DiscordMessageEvent;
use App\Enums\Discord\EmbedColor;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Support\Facades\Config;
use NotificationChannels\Discord\DiscordMessage;
/**
* Class SettingDeleted.
*/
class SettingDeleted extends SettingEvent implements DiscordMessageEvent
{
use Dispatchable;
/**
* Get Discord message payload.
*
* @return DiscordMessage
*/
public function getDiscordMessage(): DiscordMessage
{
$setting = $this->getSetting();
return DiscordMessage::create('', [
'description' => "Setting '**{$setting->getName()}**' has been deleted.",
'color' => EmbedColor::RED,
]);
}
/**
* Get Discord channel the message will be sent to.
*
* @return string
*/
public function getDiscordChannel(): string
{
return Config::get(ServiceConstants::ADMIN_DISCORD_CHANNEL_QUALIFIED);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Events\Admin\Setting;
use App\Models\Admin\Setting;
/**
* Class SettingEvent.
*/
abstract class SettingEvent
{
/**
* Create a new event instance.
*
* @param Setting $setting
* @return void
*/
public function __construct(protected Setting $setting)
{
}
/**
* Get the setting that has fired this event.
*
* @return Setting
*/
public function getSetting(): Setting
{
return $this->setting;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Events\Admin\Setting;
use App\Concerns\Discord\HasAttributeUpdateEmbedFields;
use App\Constants\Config\ServiceConstants;
use App\Contracts\Events\DiscordMessageEvent;
use App\Enums\Discord\EmbedColor;
use App\Models\Admin\Setting;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Support\Facades\Config;
use NotificationChannels\Discord\DiscordMessage;
/**
* Class SettingUpdated.
*/
class SettingUpdated extends SettingEvent implements DiscordMessageEvent
{
use Dispatchable;
use HasAttributeUpdateEmbedFields;
/**
* Create a new event instance.
*
* @param Setting $setting
* @return void
*/
public function __construct(Setting $setting)
{
parent::__construct($setting);
$this->initializeEmbedFields($setting);
}
/**
* Get Discord message payload.
*
* @return DiscordMessage
*/
public function getDiscordMessage(): DiscordMessage
{
$setting = $this->getSetting();
return DiscordMessage::create('', [
'description' => "Setting '**{$setting->getName()}**' has been updated.",
'fields' => $this->getEmbedFields(),
'color' => EmbedColor::YELLOW,
]);
}
/**
* Get Discord channel the message will be sent to.
*
* @return string
*/
public function getDiscordChannel(): string
{
return Config::get(ServiceConstants::ADMIN_DISCORD_CHANNEL_QUALIFIED);
}
}
@@ -22,6 +22,7 @@ class TransactionUpdated extends AdminUpdatedEvent
public function __construct(Transaction $transaction)
{
parent::__construct($transaction);
$this->initializeEmbedFields($transaction);
}
/**
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Events\Pivot\StudioImage;
use App\Events\Base\Pivot\PivotCreatedEvent;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
/**
* Class StudioImageCreated.
*
* @extends PivotCreatedEvent<Studio, Image>
*/
class StudioImageCreated extends PivotCreatedEvent
{
/**
* Create a new event instance.
*
* @param StudioImage $studioImage
*/
public function __construct(StudioImage $studioImage)
{
parent::__construct($studioImage->studio, $studioImage->image);
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
$foreign = $this->getForeign();
$related = $this->getRelated();
return "Image '**{$foreign->getName()}**' has been attached to Studio '**{$related->getName()}**'.";
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Events\Pivot\StudioImage;
use App\Events\Base\Pivot\PivotDeletedEvent;
use App\Models\Wiki\Image;
use App\Models\Wiki\Studio;
use App\Pivots\StudioImage;
/**
* Class StudioImageDeleted.
*
* @extends PivotDeletedEvent<Studio, Image>
*/
class StudioImageDeleted extends PivotDeletedEvent
{
/**
* Create a new event instance.
*
* @param StudioImage $studioImage
*/
public function __construct(StudioImage $studioImage)
{
parent::__construct($studioImage->studio, $studioImage->image);
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
$foreign = $this->getForeign();
$related = $this->getRelated();
return "Image '**{$foreign->getName()}**' has been detached from Studio '**{$related->getName()}**'.";
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Events\Wiki\Anime\Theme;
use App\Events\BaseEvent;
use App\Models\Wiki\Anime\AnimeTheme;
/**
* Class ThemeCreating.
*
* @extends BaseEvent<AnimeTheme>
*/
class ThemeCreating extends BaseEvent
{
/**
* Create a new event instance.
*
* @param AnimeTheme $theme
*/
public function __construct(AnimeTheme $theme)
{
parent::__construct($theme);
}
/**
* Get the model that has fired this event.
*
* @return AnimeTheme
*/
public function getModel(): AnimeTheme
{
return $this->model;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Events\Wiki\Audio;
use App\Events\Base\Wiki\WikiCreatedEvent;
use App\Models\Wiki\Audio;
/**
* Class AudioCreated.
*
* @extends WikiCreatedEvent<Audio>
*/
class AudioCreated extends WikiCreatedEvent
{
/**
* Create a new event instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* Get the model that has fired this event.
*
* @return Audio
*/
public function getModel(): Audio
{
return $this->model;
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
return "Audio '**{$this->getModel()->getName()}**' has been created.";
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Events\Wiki\Audio;
use App\Events\Base\Wiki\WikiDeletedEvent;
use App\Models\Wiki\Audio;
use App\Nova\Resources\Wiki\Audio as AudioResource;
/**
* Class AudioDeleted.
*
* @extends WikiDeletedEvent<Audio>
*/
class AudioDeleted extends WikiDeletedEvent
{
/**
* Create a new event instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* Get the model that has fired this event.
*
* @return Audio
*/
public function getModel(): Audio
{
return $this->model;
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
return "Audio '**{$this->getModel()->getName()}**' has been deleted.";
}
/**
* Get the message for the nova notification.
*
* @return string
*/
protected function getNotificationMessage(): string
{
return "Audio '{$this->getModel()->getName()}' has been deleted. It will be automatically pruned in one week. Please review.";
}
/**
* Get the URL for the nova notification.
*
* @return string
*/
protected function getNotificationUrl(): string
{
$uriKey = AudioResource::uriKey();
return "/resources/$uriKey/{$this->getModel()->getKey()}";
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Events\Wiki\Audio;
use App\Events\Base\Wiki\WikiRestoredEvent;
use App\Models\Wiki\Audio;
/**
* Class AudioRestored.
*
* @extends WikiRestoredEvent<Audio>
*/
class AudioRestored extends WikiRestoredEvent
{
/**
* Create a new event instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* Get the model that has fired this event.
*
* @return Audio
*/
public function getModel(): Audio
{
return $this->model;
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
return "Audio '**{$this->getModel()->getName()}**' has been restored.";
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Events\Wiki\Audio;
use App\Events\Base\Wiki\WikiUpdatedEvent;
use App\Models\Wiki\Audio;
/**
* Class AudioUpdated.
*
* @extends WikiUpdatedEvent<Audio>
*/
class AudioUpdated extends WikiUpdatedEvent
{
/**
* Create a new event instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
$this->initializeEmbedFields($audio);
}
/**
* Get the model that has fired this event.
*
* @return Audio
*/
public function getModel(): Audio
{
return $this->model;
}
/**
* Get the description for the Discord message payload.
*
* @return string
*/
protected function getDiscordMessageDescription(): string
{
return "Audio '**{$this->getModel()->getName()}**' has been updated.";
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Config\Flags;
use App\Constants\Config\FlagConstants;
use App\Http\Api\Field\Field;
/**
* Class FlagsAllowAudioStreamsField.
*/
class FlagsAllowAudioStreamsField extends Field
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(FlagConstants::ALLOW_AUDIO_STREAMS_FLAG);
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Pivot\AnimeImage;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\SelectableField;
use App\Http\Api\Criteria\Field\Criteria;
use App\Http\Api\Field\Field;
use App\Models\Wiki\Anime;
use App\Pivots\AnimeImage;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Class AnimeImageAnimeIdField.
*/
class AnimeImageAnimeIdField extends Field implements CreatableField, SelectableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(AnimeImage::ATTRIBUTE_ANIME);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'integer',
Rule::exists(Anime::TABLE, Anime::ATTRIBUTE_ID),
];
}
/**
* Determine if the field should be included in the select clause of our query.
*
* @param Criteria|null $criteria
* @return bool
*/
public function shouldSelect(?Criteria $criteria): bool
{
// Needed to match anime relation.
return true;
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Pivot\AnimeImage;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\SelectableField;
use App\Http\Api\Criteria\Field\Criteria;
use App\Http\Api\Field\Field;
use App\Models\Wiki\Image;
use App\Pivots\AnimeImage;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Class AnimeImageImageIdField.
*/
class AnimeImageImageIdField extends Field implements CreatableField, SelectableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(AnimeImage::ATTRIBUTE_IMAGE);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'integer',
Rule::exists(Image::TABLE, Image::ATTRIBUTE_ID),
];
}
/**
* Determine if the field should be included in the select clause of our query.
*
* @param Criteria|null $criteria
* @return bool
*/
public function shouldSelect(?Criteria $criteria): bool
{
// Needed to match image relation.
return true;
}
}
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Anime\Theme;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\UpdatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\Anime\AnimeTheme;
@@ -12,7 +13,7 @@ use Illuminate\Http\Request;
/**
* Class ThemeGroupField.
*/
class ThemeSlugField extends StringField implements UpdatableField
class ThemeSlugField extends StringField implements CreatableField, UpdatableField
{
/**
* Create a new field instance.
@@ -22,6 +23,22 @@ class ThemeSlugField extends StringField implements UpdatableField
parent::__construct(AnimeTheme::ATTRIBUTE_SLUG);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'string',
'max:192',
'alpha_dash',
];
}
/**
* Set the update validation rules for the field.
*
@@ -33,6 +50,7 @@ class ThemeSlugField extends StringField implements UpdatableField
return [
'sometimes',
'required',
'string',
'max:192',
'alpha_dash',
];
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Http\Api\Criteria\Field\Criteria;
use App\Http\Api\Field\StringField;
use App\Http\Resources\Wiki\Resource\AudioResource;
use App\Models\Wiki\Audio;
use Illuminate\Http\Request;
/**
* Class AudioBasenameField.
*/
class AudioBasenameField extends StringField implements CreatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Audio::ATTRIBUTE_BASENAME);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'string',
'max:192',
];
}
/**
* Determine if the field should be included in the select clause of our query.
*
* @param Criteria|null $criteria
* @return bool
*/
public function shouldSelect(?Criteria $criteria): bool
{
// The link field is dependent on this field to build the route.
return parent::shouldSelect($criteria) || $criteria->isAllowedField(AudioResource::ATTRIBUTE_LINK);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\Audio;
use Illuminate\Http\Request;
/**
* Class AudioFilenameField.
*/
class AudioFilenameField extends StringField implements CreatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Audio::ATTRIBUTE_FILENAME);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'string',
'max:192',
];
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Http\Api\Field\Field;
use App\Http\Resources\Wiki\Resource\AudioResource;
/**
* Class AudioLinkField.
*/
class AudioLinkField extends Field
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(AudioResource::ATTRIBUTE_LINK);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\Audio;
use Illuminate\Http\Request;
/**
* Class AudioMimeTypeField.
*/
class AudioMimeTypeField extends StringField implements CreatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Audio::ATTRIBUTE_MIMETYPE);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'string',
'max:192',
];
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\Audio;
use Illuminate\Http\Request;
/**
* Class AudioPathField.
*/
class AudioPathField extends StringField implements CreatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Audio::ATTRIBUTE_PATH);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'string',
'max:192',
];
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Audio;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Http\Api\Field\IntField;
use App\Models\Wiki\Audio;
use Illuminate\Http\Request;
/**
* Class AudioSizeField.
*/
class AudioSizeField extends IntField implements CreatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Audio::ATTRIBUTE_SIZE);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'required',
'integer',
'min:0',
];
}
}
@@ -8,7 +8,7 @@ use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\UpdatableField;
use App\Http\Api\Field\StringField;
use App\Models\Wiki\ExternalResource;
use App\Rules\Wiki\ResourceLinkMatchesSiteRule;
use App\Rules\Wiki\Resource\ResourceLinkMatchesSiteRule;
use Illuminate\Http\Request;
/**
@@ -9,7 +9,7 @@ use App\Contracts\Http\Api\Field\UpdatableField;
use App\Enums\Models\Wiki\ResourceSite;
use App\Http\Api\Field\EnumField;
use App\Models\Wiki\ExternalResource;
use App\Rules\Wiki\ResourceSiteMatchesLinkRule;
use App\Rules\Wiki\Resource\ResourceSiteMatchesLinkRule;
use BenSampo\Enum\Rules\EnumValue;
use Illuminate\Http\Request;
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Field\Wiki\Video;
use App\Contracts\Http\Api\Field\CreatableField;
use App\Contracts\Http\Api\Field\SelectableField;
use App\Contracts\Http\Api\Field\UpdatableField;
use App\Http\Api\Criteria\Field\Criteria;
use App\Http\Api\Field\Field;
use App\Models\Wiki\Audio;
use App\Models\Wiki\Video;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
/**
* Class VideoAudioIdField.
*/
class VideoAudioIdField extends Field implements CreatableField, SelectableField, UpdatableField
{
/**
* Create a new field instance.
*/
public function __construct()
{
parent::__construct(Video::ATTRIBUTE_AUDIO);
}
/**
* Set the creation validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getCreationRules(Request $request): array
{
return [
'sometimes',
'required',
'integer',
Rule::exists(Audio::TABLE, Audio::ATTRIBUTE_ID),
];
}
/**
* Determine if the field should be included in the select clause of our query.
*
* @param Criteria|null $criteria
* @return bool
*/
public function shouldSelect(?Criteria $criteria): bool
{
// Needed to match audio relation.
return true;
}
/**
* Set the update validation rules for the field.
*
* @param Request $request
* @return array
*/
public function getUpdateRules(Request $request): array
{
return [
'sometimes',
'required',
'integer',
Rule::exists(Audio::TABLE, Audio::ATTRIBUTE_ID),
];
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Api\Query\Pivot\AnimeImage;
use App\Http\Api\Query\Base\EloquentReadQuery;
use App\Http\Api\Schema\EloquentSchema;
use App\Http\Api\Schema\Pivot\AnimeImageSchema;
use App\Http\Resources\BaseCollection;
use App\Http\Resources\BaseResource;
use App\Http\Resources\Pivot\Collection\AnimeImageCollection;
use App\Http\Resources\Pivot\Resource\AnimeImageResource;
use App\Pivots\AnimeImage;
use Illuminate\Database\Eloquent\Builder;
/**
* Class AnimeImageReadQuery.
*/
class AnimeImageReadQuery extends EloquentReadQuery
{
/**
* Get the resource schema.
*
* @return EloquentSchema
*/
public function schema(): EloquentSchema
{
return new AnimeImageSchema();
}
/**
* Get the query builder of the resource.
*
* @return Builder
*/
public function builder(): Builder
{
return AnimeImage::query();
}
/**
* Get the json resource.
*
* @param mixed $resource
* @return BaseResource
*/
public function resource(mixed $resource): BaseResource
{
return new AnimeImageResource($resource, $this);
}
/**
* Get the resource collection.
*
* @param mixed $resource
* @return BaseCollection
*/
public function collection(mixed $resource): BaseCollection
{
return new AnimeImageCollection($resource, $this);
}
}

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