diff --git a/.env.example b/.env.example index f15feaec8..1fd3b6167 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -65,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 @@ -77,6 +83,10 @@ ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false # elastic migrations ELASTIC_MIGRATIONS_TABLE=elastic_migrations +# ffmpeg +FFMPEG_BINARIES= +FFPROBE_BINARIES= + # filesystems FILESYSTEM_DISK=local @@ -90,17 +100,45 @@ 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 @@ -115,6 +153,10 @@ BCRYPT_ROUNDS=10 HORIZON_DOMAIN= HORIZON_PATH=horizon +# image +IMAGE_DISK=images +IMAGE_DISK_ROOT= + # jetstream JETSTREAM_PATH= JETSTREAM_URL=http://localhost @@ -189,8 +231,15 @@ 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 diff --git a/.gitattributes b/.gitattributes index 510d9961f..7dbbf41a4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,3 +8,4 @@ /.github export-ignore CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 556d3719c..8f1a8665f 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -12,7 +12,7 @@ jobs: 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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c6986500..c96279e67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: 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 diff --git a/README.md b/README.md index e1dd09a2b..dbe3fccff 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@

-tests -static-analysis -StyleCI +tests +static-analysis +StyleCI - +

diff --git a/app/Actions/ActionResult.php b/app/Actions/ActionResult.php new file mode 100644 index 000000000..e6ba2c367 --- /dev/null +++ b/app/Actions/ActionResult.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/app/Actions/Models/BaseAction.php b/app/Actions/Models/BaseAction.php new file mode 100644 index 000000000..f6f77e45f --- /dev/null +++ b/app/Actions/Models/BaseAction.php @@ -0,0 +1,58 @@ +getModel())); + } +} diff --git a/app/Pipes/Wiki/Anime/BackfillAnimeImage.php b/app/Actions/Models/Wiki/Anime/BackfillAnimeImageAction.php similarity index 62% rename from app/Pipes/Wiki/Anime/BackfillAnimeImage.php rename to app/Actions/Models/Wiki/Anime/BackfillAnimeImageAction.php index df0b212e0..cd8b25cca 100644 --- a/app/Pipes/Wiki/Anime/BackfillAnimeImage.php +++ b/app/Actions/Models/Wiki/Anime/BackfillAnimeImageAction.php @@ -2,25 +2,23 @@ declare(strict_types=1); -namespace App\Pipes\Wiki\Anime; +namespace App\Actions\Models\Wiki\Anime; +use App\Actions\Models\Wiki\BackfillImageAction; use App\Models\Wiki\Anime; use App\Models\Wiki\Image; -use App\Nova\Resources\BaseResource; -use App\Nova\Resources\Wiki\Anime as AnimeResource; -use App\Pipes\Wiki\BackfillImage; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Facades\Log; /** - * Class BackfillAnimeImage. + * Class BackfillAnimeImageAction. * - * @extends BackfillImage + * @extends BackfillImageAction */ -abstract class BackfillAnimeImage extends BackfillImage +abstract class BackfillAnimeImageAction extends BackfillImageAction { /** - * Create a new pipe instance. + * Create a new action instance. * * @param Anime $anime */ @@ -30,11 +28,11 @@ public function __construct(Anime $anime) } /** - * Get the model passed into the pipeline. + * Get the model the action is handling. * * @return Anime */ - public function getModel(): Anime + protected function getModel(): Anime { return $this->model; } @@ -49,16 +47,6 @@ protected function relation(): BelongsToMany return $this->getModel()->images(); } - /** - * Get the nova resource. - * - * @return class-string - */ - protected function resource(): string - { - return AnimeResource::class; - } - /** * Attach Image to Anime. * diff --git a/app/Pipes/Wiki/Anime/BackfillAnimeResource.php b/app/Actions/Models/Wiki/Anime/BackfillAnimeResourceAction.php similarity index 74% rename from app/Pipes/Wiki/Anime/BackfillAnimeResource.php rename to app/Actions/Models/Wiki/Anime/BackfillAnimeResourceAction.php index 119335e59..2b1fdc117 100644 --- a/app/Pipes/Wiki/Anime/BackfillAnimeResource.php +++ b/app/Actions/Models/Wiki/Anime/BackfillAnimeResourceAction.php @@ -2,28 +2,25 @@ declare(strict_types=1); -namespace App\Pipes\Wiki\Anime; +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\Nova\Resources\BaseResource; -use App\Nova\Resources\Wiki\Anime as AnimeNovaResource; -use App\Pipes\Wiki\BackfillResource; use App\Pivots\AnimeResource; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Facades\Log; /** - * Class BackfillAnimeResource. + * Class BackfillAnimeResourceAction. * - * @extends BackfillResource + * @extends BackfillResourceAction */ -abstract class BackfillAnimeResource extends BackfillResource +abstract class BackfillAnimeResourceAction extends BackfillResourceAction { /** - * Create a new pipe instance. + * Create a new action instance. * * @param Anime $anime */ @@ -33,11 +30,11 @@ public function __construct(Anime $anime) } /** - * Get the model passed into the pipeline. + * Get the model the action is handling. * * @return Anime */ - public function getModel(): Anime + protected function getModel(): Anime { return $this->model; } @@ -52,16 +49,6 @@ protected function relation(): BelongsToMany return $this->getModel()->resources(); } - /** - * Get the nova resource. - * - * @return class-string - */ - protected function resource(): string - { - return AnimeNovaResource::class; - } - /** * Get or Create Resource from response. * @@ -74,7 +61,7 @@ protected function getOrCreateResource(int $id, string $slug = null): ExternalRe $resource = ExternalResource::query() ->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value) ->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id) - ->whereHas(ExternalResource::RELATION_ANIME, fn (Builder $animeQuery) => $animeQuery->whereKey($this->getModel())) + ->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatAnimeResourceLink($this->getSite(), $id, $slug)) ->first(); if ($resource === null) { diff --git a/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php b/app/Actions/Models/Wiki/Anime/Image/BackfillLargeCoverImageAction.php similarity index 89% rename from app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php rename to app/Actions/Models/Wiki/Anime/Image/BackfillLargeCoverImageAction.php index e4b05e323..096fba3c8 100644 --- a/app/Pipes/Wiki/Anime/Image/BackfillLargeCoverImage.php +++ b/app/Actions/Models/Wiki/Anime/Image/BackfillLargeCoverImageAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php b/app/Actions/Models/Wiki/Anime/Image/BackfillSmallCoverImageAction.php similarity index 89% rename from app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php rename to app/Actions/Models/Wiki/Anime/Image/BackfillSmallCoverImageAction.php index 5859d947a..b883cdf37 100644 --- a/app/Pipes/Wiki/Anime/Image/BackfillSmallCoverImage.php +++ b/app/Actions/Models/Wiki/Anime/Image/BackfillSmallCoverImageAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnidbResourceAction.php similarity index 92% rename from app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php rename to app/Actions/Models/Wiki/Anime/Resource/BackfillAnidbResourceAction.php index 6e7048226..2e82f9d1f 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnidbResource.php +++ b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnidbResourceAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnilistResourceAction.php similarity index 95% rename from app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php rename to app/Actions/Models/Wiki/Anime/Resource/BackfillAnilistResourceAction.php index d4b20b6e2..11a5071a5 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnilistResource.php +++ b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnilistResourceAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnnResourceAction.php similarity index 91% rename from app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php rename to app/Actions/Models/Wiki/Anime/Resource/BackfillAnnResourceAction.php index 607677e01..6336da112 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillAnnResource.php +++ b/app/Actions/Models/Wiki/Anime/Resource/BackfillAnnResourceAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php b/app/Actions/Models/Wiki/Anime/Resource/BackfillKitsuResourceAction.php similarity index 94% rename from app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php rename to app/Actions/Models/Wiki/Anime/Resource/BackfillKitsuResourceAction.php index 93af92e5c..4237a2584 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillKitsuResource.php +++ b/app/Actions/Models/Wiki/Anime/Resource/BackfillKitsuResourceAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php b/app/Actions/Models/Wiki/Anime/Resource/BackfillMalResourceAction.php similarity index 95% rename from app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php rename to app/Actions/Models/Wiki/Anime/Resource/BackfillMalResourceAction.php index 3d273bb9d..7cacdaa6e 100644 --- a/app/Pipes/Wiki/Anime/Resource/BackfillMalResource.php +++ b/app/Actions/Models/Wiki/Anime/Resource/BackfillMalResourceAction.php @@ -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. diff --git a/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php b/app/Actions/Models/Wiki/Anime/Studio/BackfillAnimeStudiosAction.php similarity index 67% rename from app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php rename to app/Actions/Models/Wiki/Anime/Studio/BackfillAnimeStudiosAction.php index 2cbb9d28e..f12bf768a 100644 --- a/app/Pipes/Wiki/Anime/Studio/BackfillAnimeStudios.php +++ b/app/Actions/Models/Wiki/Anime/Studio/BackfillAnimeStudiosAction.php @@ -2,33 +2,29 @@ declare(strict_types=1); -namespace App\Pipes\Wiki\Anime\Studio; +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 App\Nova\Resources\BaseResource; -use App\Nova\Resources\Wiki\Anime as AnimeResource; -use App\Pipes\Wiki\BackfillStudios; 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; -use Orchestra\Parser\Xml\Facade as XmlParser; -use RuntimeException; /** - * Class BackfillAnimeStudios. + * Class BackfillAnimeStudiosAction. * - * @extends BackfillStudios + * @extends BackfillStudiosAction */ -class BackfillAnimeStudios extends BackfillStudios +class BackfillAnimeStudiosAction extends BackfillStudiosAction { /** - * Create a new pipe instance. + * Create a new action instance. * * @param Anime $anime */ @@ -38,11 +34,11 @@ public function __construct(Anime $anime) } /** - * Get the model passed into the pipeline. + * Get the model the action is handling. * * @return Anime */ - public function getModel(): Anime + protected function getModel(): Anime { return $this->model; } @@ -57,16 +53,6 @@ protected function relation(): BelongsToMany return $this->getModel()->studios(); } - /** - * Get the nova resource. - * - * @return class-string - */ - protected function resource(): string - { - return AnimeResource::class; - } - /** * Query third-party API for Anime Studios. * @@ -94,15 +80,7 @@ protected function getStudios(): array $kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU); if ($kitsuResource instanceof ExternalResource) { - $studios = $this->getKitsuAnimeStudios($kitsuResource); - if (! empty($studios)) { - return $studios; - } - } - - $annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN); - if ($annResource instanceof ExternalResource) { - return $this->getAnnAnimeStudios($annResource); + return $this->getKitsuAnimeStudios($kitsuResource); } return []; @@ -120,12 +98,7 @@ protected function getMalAnimeStudios(ExternalResource $malResource): array { $studios = []; - $malClientID = Config::get('services.mal.client'); - if ($malClientID === null) { - throw new RuntimeException('MAL_CLIENT_ID must be configured in your env file.'); - } - - $response = Http::withHeaders(['X-MAL-CLIENT-ID' => $malClientID]) + $response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get('services.mal.client')]) ->get("https://api.myanimelist.net/v2/anime/$malResource->external_id", [ 'fields' => 'studios', ]) @@ -246,11 +219,11 @@ protected function getKitsuAnimeStudios(ExternalResource $kitsuResource): array ->throw() ->json(); - $anilistStudios = Arr::get($response, 'data.findAnimeById.productions.nodes', []); + $kitsuStudios = Arr::get($response, 'data.findAnimeById.productions.nodes', []); - foreach ($anilistStudios as $anilistStudio) { - $role = Arr::get($anilistStudio, 'role'); - $name = Arr::get($anilistStudio, 'company.name'); + 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; @@ -264,50 +237,6 @@ protected function getKitsuAnimeStudios(ExternalResource $kitsuResource): array return $studios; } - /** - * Query ANN API for Anime Studios. - * - * @param ExternalResource $annResource - * @return Studio[] - * - * @throws RequestException - */ - protected function getAnnAnimeStudios(ExternalResource $annResource): array - { - $studios = []; - - $response = Http::get("https://cdn.animenewsnetwork.com/encyclopedia/api.xml?anime=$annResource->external_id") - ->throw() - ->body(); - - $xml = XmlParser::extract($response); - - $annCredits = $xml->parse([ - 'credits' => [ - 'uses' => 'anime.credit[task,company>name,company::id>id]', - ], - ]); - - $annStudios = Arr::get($annCredits, 'credits', []); - foreach ($annStudios as $annStudio) { - $task = Arr::get($annStudio, 'task'); - $name = Arr::get($annStudio, 'name'); - $id = Arr::get($annStudio, 'id'); - if ($task !== 'Animation Production' || empty($name) || empty($id)) { - Log::info("Skipping production company of task '$task' and name '$name' and id '$id'"); - continue; - } - - $studio = $this->getOrCreateStudio($name); - - $studios[] = $studio; - - $this->ensureStudioHasResource($studio, ResourceSite::ANN(), intval($id)); - } - - return $studios; - } - /** * Attach Studios. * diff --git a/app/Pipes/Wiki/BackfillImage.php b/app/Actions/Models/Wiki/BackfillImageAction.php similarity index 80% rename from app/Pipes/Wiki/BackfillImage.php rename to app/Actions/Models/Wiki/BackfillImageAction.php index 2faf97cbb..807b4f343 100644 --- a/app/Pipes/Wiki/BackfillImage.php +++ b/app/Actions/Models/Wiki/BackfillImageAction.php @@ -2,43 +2,42 @@ declare(strict_types=1); -namespace App\Pipes\Wiki; +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\Auth\User; use App\Models\Wiki\Image; -use App\Pipes\BasePipe; -use Closure; 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 BackfillImage. + * Class BackfillImageAction. * * @template TModel of \App\Models\BaseModel - * @extends BasePipe + * @extends BaseAction */ -abstract class BackfillImage extends BasePipe +abstract class BackfillImageAction extends BaseAction { /** - * Handle an incoming request. + * Handle action. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed + * @return ActionResult * * @throws RequestException */ - public function handle(User $user, Closure $next): mixed + 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 $next($user); + return new ActionResult(ActionStatus::SKIPPED()); } $image = $this->getImage(); @@ -48,13 +47,13 @@ public function handle(User $user, Closure $next): mixed } if ($this->relation()->getQuery()->where(Image::ATTRIBUTE_FACET, $this->getFacet()->value)->doesntExist()) { - $this->sendNotification( - $user, + return new ActionResult( + ActionStatus::FAILED(), "{$this->label()} '{$this->getModel()->getName()}' has no {$this->getFacet()->description} Image after backfilling. Please review." ); } - return $next($user); + return new ActionResult(ActionStatus::PASSED()); } /** @@ -73,7 +72,7 @@ protected function createImage(string $url): Image $file = File::createWithContent(basename($url), $image); - $fs = Storage::disk('images'); + $fs = Storage::disk(Config::get('image.disk')); $fsFile = $fs->putFile($this->path(), $file); diff --git a/app/Pipes/Wiki/BackfillResource.php b/app/Actions/Models/Wiki/BackfillResourceAction.php similarity index 77% rename from app/Pipes/Wiki/BackfillResource.php rename to app/Actions/Models/Wiki/BackfillResourceAction.php index b49b59af9..68cf89c16 100644 --- a/app/Pipes/Wiki/BackfillResource.php +++ b/app/Actions/Models/Wiki/BackfillResourceAction.php @@ -2,39 +2,37 @@ declare(strict_types=1); -namespace App\Pipes\Wiki; +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\Auth\User; use App\Models\Wiki\ExternalResource; -use App\Pipes\BasePipe; -use Closure; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Log; /** - * Class BackfillResource. + * Class BackfillResourceAction. * * @template TModel of \App\Models\BaseModel - * @extends BasePipe + * @extends BaseAction */ -abstract class BackfillResource extends BasePipe +abstract class BackfillResourceAction extends BaseAction { /** - * Handle an incoming request. + * Handle action. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed + * @return ActionResult * * @throws RequestException */ - public function handle(User $user, Closure $next): mixed + 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 $next($user); + return new ActionResult(ActionStatus::SKIPPED()); } $resource = $this->getResource(); @@ -44,13 +42,13 @@ public function handle(User $user, Closure $next): mixed } if ($this->relation()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $this->getSite()->value)->doesntExist()) { - $this->sendNotification( - $user, + return new ActionResult( + ActionStatus::FAILED(), "{$this->label()} '{$this->getModel()->getName()}' has no {$this->getSite()->description} Resource after backfilling. Please review." ); } - return $next($user); + return new ActionResult(ActionStatus::PASSED()); } /** diff --git a/app/Pipes/Wiki/BackfillStudios.php b/app/Actions/Models/Wiki/BackfillStudiosAction.php similarity index 80% rename from app/Pipes/Wiki/BackfillStudios.php rename to app/Actions/Models/Wiki/BackfillStudiosAction.php index 87a3f6553..96acc8ce6 100644 --- a/app/Pipes/Wiki/BackfillStudios.php +++ b/app/Actions/Models/Wiki/BackfillStudiosAction.php @@ -2,44 +2,41 @@ declare(strict_types=1); -namespace App\Pipes\Wiki; +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\Auth\User; use App\Models\Wiki\ExternalResource; use App\Models\Wiki\Studio; -use App\Pipes\BasePipe; use App\Pivots\StudioResource; -use Closure; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; /** - * Class BackfillStudios. + * Class BackfillStudiosAction. * * @template TModel of \App\Models\BaseModel - * @extends BasePipe + * @extends BaseAction */ -abstract class BackfillStudios extends BasePipe +abstract class BackfillStudiosAction extends BaseAction { /** - * Handle an incoming request. + * Handle action. * - * @param User $user - * @param Closure(User): mixed $next - * @return mixed + * @return ActionResult * * @throws RequestException */ - public function handle(User $user, Closure $next): mixed + public function handle(): ActionResult { if ($this->relation()->getQuery()->exists()) { Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Studios."); - return $next($user); + return new ActionResult(ActionStatus::SKIPPED()); } $studios = $this->getStudios(); @@ -49,10 +46,13 @@ public function handle(User $user, Closure $next): mixed } if ($this->relation()->getQuery()->doesntExist()) { - $this->sendNotification($user, "{$this->label()} '{$this->getModel()->getName()}' has no Studios after backfilling. Please review."); + return new ActionResult( + ActionStatus::FAILED(), + "{$this->label()} '{$this->getModel()->getName()}' has no Studios after backfilling. Please review." + ); } - return $next($user); + return new ActionResult(ActionStatus::PASSED()); } /** @@ -93,7 +93,7 @@ protected function ensureStudioHasResource(Studio $studio, ResourceSite $site, i $studioResource = ExternalResource::query() ->where(ExternalResource::ATTRIBUTE_SITE, $site->value) ->where(ExternalResource::ATTRIBUTE_EXTERNAL_ID, $id) - ->whereHas(ExternalResource::RELATION_STUDIOS, fn (Builder $studioQuery) => $studioQuery->whereKey($studio)) + ->where(ExternalResource::ATTRIBUTE_LINK, ResourceSite::formatStudioResourceLink($site, $id)) ->first(); if (! $studioResource instanceof ExternalResource) { diff --git a/app/Pipes/Wiki/Studio/BackfillStudioImage.php b/app/Actions/Models/Wiki/Studio/BackfillStudioImageAction.php similarity index 62% rename from app/Pipes/Wiki/Studio/BackfillStudioImage.php rename to app/Actions/Models/Wiki/Studio/BackfillStudioImageAction.php index d64c0146c..54667071e 100644 --- a/app/Pipes/Wiki/Studio/BackfillStudioImage.php +++ b/app/Actions/Models/Wiki/Studio/BackfillStudioImageAction.php @@ -2,25 +2,23 @@ declare(strict_types=1); -namespace App\Pipes\Wiki\Studio; +namespace App\Actions\Models\Wiki\Studio; +use App\Actions\Models\Wiki\BackfillImageAction; use App\Models\Wiki\Image; use App\Models\Wiki\Studio; -use App\Nova\Resources\BaseResource; -use App\Nova\Resources\Wiki\Studio as StudioResource; -use App\Pipes\Wiki\BackfillImage; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Support\Facades\Log; /** - * Class BackfillAnimeImage. + * Class BackfillStudioImageAction. * - * @extends BackfillImage + * @extends BackfillImageAction */ -abstract class BackfillStudioImage extends BackfillImage +abstract class BackfillStudioImageAction extends BackfillImageAction { /** - * Create a new pipe instance. + * Create a new action instance. * * @param Studio $studio */ @@ -30,11 +28,11 @@ public function __construct(Studio $studio) } /** - * Get the model passed into the pipeline. + * Get the model the action is handling. * * @return Studio */ - public function getModel(): Studio + protected function getModel(): Studio { return $this->model; } @@ -49,16 +47,6 @@ protected function relation(): BelongsToMany return $this->getModel()->images(); } - /** - * Get the nova resource. - * - * @return class-string - */ - protected function resource(): string - { - return StudioResource::class; - } - /** * Attach Image to Studio. * diff --git a/app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php b/app/Actions/Models/Wiki/Studio/Image/BackfillLargeCoverImageAction.php similarity index 85% rename from app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php rename to app/Actions/Models/Wiki/Studio/Image/BackfillLargeCoverImageAction.php index 45cffafbd..7fb717af5 100644 --- a/app/Pipes/Wiki/Studio/Image/BackfillLargeCoverImage.php +++ b/app/Actions/Models/Wiki/Studio/Image/BackfillLargeCoverImageAction.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace App\Pipes\Wiki\Studio\Image; +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 App\Pipes\Wiki\Studio\BackfillStudioImage; use Illuminate\Http\Client\RequestException; /** - * Class BackfillLargeCoverImage. + * Class BackfillLargeCoverImageAction. */ -class BackfillLargeCoverImage extends BackfillStudioImage +class BackfillLargeCoverImageAction extends BackfillStudioImageAction { /** * Get the facet to backfill. diff --git a/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php new file mode 100644 index 000000000..e1a0641b0 --- /dev/null +++ b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php @@ -0,0 +1,285 @@ + + */ +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 + */ + 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(); + } + } +} diff --git a/app/Concerns/Repositories/Billing/ReconcilesBalanceRepositories.php b/app/Actions/Repositories/Billing/Balance/ReconcileBalanceRepositories.php similarity index 76% rename from app/Concerns/Repositories/Billing/ReconcilesBalanceRepositories.php rename to app/Actions/Repositories/Billing/Balance/ReconcileBalanceRepositories.php index 4fa217482..ab32e6770 100644 --- a/app/Concerns/Repositories/Billing/ReconcilesBalanceRepositories.php +++ b/app/Actions/Repositories/Billing/Balance/ReconcileBalanceRepositories.php @@ -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\Support\Collection; /** - * Trait ReconcilesBalanceRepositories. + * Class ReconcileBalanceRepositories. + * + * @extends ReconcileRepositories */ -trait ReconcilesBalanceRepositories +class ReconcileBalanceRepositories extends ReconcileRepositories { - use ReconcilesRepositories; - /** * The columns used for create and delete set operations. * @@ -85,4 +86,17 @@ protected function resolveUpdatedModel(Collection $sourceModels, Model $destinat 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); + } } diff --git a/app/Actions/Repositories/Billing/Balance/ReconcileBalanceResults.php b/app/Actions/Repositories/Billing/Balance/ReconcileBalanceResults.php new file mode 100644 index 000000000..fd30022a6 --- /dev/null +++ b/app/Actions/Repositories/Billing/Balance/ReconcileBalanceResults.php @@ -0,0 +1,26 @@ + + */ +class ReconcileBalanceResults extends ReconcileResults +{ + /** + * Get the model of the reconciliation results. + * + * @return class-string + */ + protected function model(): string + { + return Balance::class; + } +} diff --git a/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionRepositories.php b/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionRepositories.php new file mode 100644 index 000000000..7d72d656a --- /dev/null +++ b/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionRepositories.php @@ -0,0 +1,93 @@ + + */ +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); + } +} diff --git a/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionResults.php b/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionResults.php new file mode 100644 index 000000000..31d5fcf7b --- /dev/null +++ b/app/Actions/Repositories/Billing/Transaction/ReconcileTransactionResults.php @@ -0,0 +1,26 @@ + + */ +class ReconcileTransactionResults extends ReconcileResults +{ + /** + * Get the model of the reconciliation results. + * + * @return class-string + */ + protected function model(): string + { + return Transaction::class; + } +} diff --git a/app/Actions/Repositories/ReconcileRepositories.php b/app/Actions/Repositories/ReconcileRepositories.php new file mode 100644 index 000000000..4d484e416 --- /dev/null +++ b/app/Actions/Repositories/ReconcileRepositories.php @@ -0,0 +1,148 @@ + $source + * @param RepositoryInterface $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 $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 $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 $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; +} diff --git a/app/Actions/Repositories/ReconcileResults.php b/app/Actions/Repositories/ReconcileResults.php new file mode 100644 index 000000000..68fbebc25 --- /dev/null +++ b/app/Actions/Repositories/ReconcileResults.php @@ -0,0 +1,115 @@ +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 + */ + 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); + } +} diff --git a/app/Actions/Repositories/Wiki/Audio/ReconcileAudioRepositories.php b/app/Actions/Repositories/Wiki/Audio/ReconcileAudioRepositories.php new file mode 100644 index 000000000..dd35c5d55 --- /dev/null +++ b/app/Actions/Repositories/Wiki/Audio/ReconcileAudioRepositories.php @@ -0,0 +1,98 @@ + + */ +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); + } +} diff --git a/app/Actions/Repositories/Wiki/Audio/ReconcileAudioResults.php b/app/Actions/Repositories/Wiki/Audio/ReconcileAudioResults.php new file mode 100644 index 000000000..3adfb6911 --- /dev/null +++ b/app/Actions/Repositories/Wiki/Audio/ReconcileAudioResults.php @@ -0,0 +1,26 @@ + + */ +class ReconcileAudioResults extends ReconcileResults +{ + /** + * Get the model of the reconciliation results. + * + * @return class-string