14 Commits

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

* style: fix phpdoc description for upload audio action

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

* style: fix StyleCI finding
2022-12-20 22:22:26 -06:00
paranarimasu ed33d4154a chore: updating dependencies (#475) 2022-12-19 22:57:54 -06:00
paranarimasu d3a8d58cd2 #490 fix(admin): update pattern for mal studio cover image (#492) 2022-12-12 00:58:11 -06:00
3124 changed files with 104152 additions and 122641 deletions
+63 -89
View File
@@ -1,28 +1,27 @@
# api
API_PATH=
API_URL=http://api.animethemes.test
API_PATH=/api
API_URL=
# app
APP_NAME=AnimeThemes
APP_ENV=local
APP_DEBUG=true
DEBUGBAR_ENABLED=true
DEBUGBAR_EDITOR=
AUDITING_ENABLED=true
APP_URL=http://animethemes.test
APP_URL=http://localhost
ASSET_URL=null
APP_KEY=
APP_LOCAL_IPS=
# audio
AUDIO_DISK_DEFAULT=audios_local
AUDIO_DISKS=audios_local
AUDIO_DISK_DEFAULT=audios_nyc
AUDIO_DISKS=audios_nyc
AUDIO_DISK_ROOT=
AUDIO_PATH=/audio
AUDIO_URL=
AUDIO_STREAMING_METHOD=response
AUDIO_STREAMING_METHOD=
AUDIO_NGINX_REDIRECT=
# audit
AUDITING_ENABLED=true
# broadcasting
BROADCAST_DRIVER=log
PUSHER_APP_KEY=
@@ -52,26 +51,18 @@ DYNAMODB_ENDPOINT=
CACHE_PREFIX=animethemes
# database
DB_CONNECTION=mysql_prod
DB_CONNECTION=mysql
DATABASE_URL=
DB_HOST_READ=127.0.0.1
DB_HOST_WRITE=127.0.0.1
DB_STICKY=false
DB_PORT=3306
DB_DATABASE=animethemes
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
DB_SOCKET=
MYSQL_ATTR_SSL_CA=null
DATABASE_BETA_URL=
DB_BETA_HOST=
DB_BETA_PORT=3306
DB_BETA_DATABASE=animethemes-beta
DB_BETA_USERNAME=root
DB_BETA_PASSWORD=
DB_BETA_SOCKET=
REDIS_CLIENT=predis
REDIS_CLUSTER=redis
REDIS_URL=null
@@ -83,22 +74,25 @@ REDIS_DB=0
REDIS_CACHE_DB=1
# dump
DUMP_DISK=dumps_local
DUMP_DISK=dumps
DUMP_DISK_ROOT=
DUMP_URL="${APP_URL}"
DUMP_PATH=/dump
DUMP_URL=http://localhost
DUMP_PATH=
# external profile
USER_MAX_PROFILES=5
# elastic client
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
# elastic driver
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
# elastic migrations
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
# ffmpeg
FFMPEG_BINARIES=
FFPROBE_BINARIES=
# filament
FILAMENT_URL=http://admin.animethemes.test
FILAMENT_PATH=
# filesystems
FILESYSTEM_DISK=local
@@ -166,33 +160,33 @@ SCRIPT_STREAM_READS=
SCRIPT_DISABLE_ASSERTS=
SCRIPT_VISIBILITY=
# flags
ALLOW_VIDEO_STREAMS=false
ALLOW_AUDIO_STREAMS=false
ALLOW_DISCORD_NOTIFICATIONS=false
ALLOW_VIEW_RECORDING=false
ALLOW_DUMP_DOWNLOADING=false
ALLOW_SCRIPT_DOWNLOADING=false
# fortify
FORTIFY_HOME="${APP_URL}"
FORTIFY_PATH=
FORTIFY_URL="${APP_URL}"
# graphql
GRAPHIQL_URL="${GRAPHQL_URL}/graphiql"
GRAPHIQL_GRAPHQL_ENDPOINT="${GRAPHQL_URL}"
GRAPHQL_URL=http://graphql.animethemes.test
GRAPHQL_PATH=/graphql
GRAPHIQL_ENABLED=true
# hashids
HASHIDS_SALT_MAIN=
HASHIDS_SALT_PLAYLISTS=
FORTIFY_URL=http://localhost
# hashing
BCRYPT_ROUNDS=10
# horizon
HORIZON_URL=
HORIZON_DOMAIN=
HORIZON_PATH=horizon
# image
IMAGE_DISK=images_local
IMAGE_DISK=images
IMAGE_DISK_ROOT=
# jetstream
JETSTREAM_PATH=
JETSTREAM_URL=http://localhost
# logging
LOG_CHANNEL=daily
LOG_DEPRECATIONS_CHANNEL=null
@@ -210,40 +204,26 @@ MAIL_FROM_NAME="${APP_NAME}"
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
MAIL_LOG_CHANNEL=
# pennant
PENNANT_STORE=database
# playlist
PLAYLIST_MAX_TRACKS=1000
USER_MAX_PLAYLISTS=1000
# nova
NOVA_DOMAIN_NAME=null
NOVA_GUARD=null
NOVA_LICENSE_KEY=
NOVA_PASSWORDS=null
NOVA_PATH=/admin
# queue
QUEUE_CONNECTION=sync
REDIS_QUEUE=default
QUEUE_FAILED_DRIVER=database-uuids
# sanctum
SANCTUM_STATEFUL_DOMAINS=
# scout
SCOUT_DRIVER=null
SCOUT_PREFIX=
SCOUT_QUEUE=false
SCOUT_IDENTIFY=false
ALGOLIA_APP_ID=
ALGOLIA_SECRET=
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://localhost:9200
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=
# secure headers
ENABLE_CLEAR_SITE_DATA=false
ENABLE_HSTS=false
@@ -257,20 +237,12 @@ MAILGUN_SECRET=
MAILGUN_ENDPOINT=api.mailgun.net
POSTMARK_TOKEN=
DISCORD_BOT_API_TOKEN=
DISCORD_BOT_API_URL=
DISCORD_BOT_API_KEY=
DB_UPDATES_DISCORD_CHANNEL=
ADMIN_DISCORD_CHANNEL=
OPENAI_BEARER_TOKEN=null
MAL_CLIENT_ID=null
MAL_CLIENT_SECRET=null
MAL_REDIRECT_URI=null
ANILIST_CLIENT_ID=null
ANILIST_CLIENT_SECRET=null
ANILIST_REDIRECT_URI=null
DO_BEARER_TOKEN=null
# session
SESSION_COOKIE=animethemes_session
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_CONNECTION=null
@@ -278,32 +250,34 @@ SESSION_STORE=null
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=
# submission
USER_MAX_SUBMISSIONS=50
# validation
MODERATION_SERVICE=none
# telescope
TELESCOPE_DOMAIN=null
TELESCOPE_PATH=telescope
TELESCOPE_DRIVER=database
TELESCOPE_ENABLED=true
# video
VIDEO_DISK_DEFAULT=videos_local
VIDEO_DISKS=videos_local
VIDEO_DISK_DEFAULT=videos_nyc
VIDEO_DISKS=videos_nyc
VIDEO_DISK_ROOT=
VIDEO_PATH=/video
VIDEO_URL="${APP_URL}"
VIDEO_RATE_LIMITER=90
VIDEO_STREAMING_METHOD=response
VIDEO_URL=
VIDEO_STREAMING_METHOD=
VIDEO_NGINX_REDIRECT=
VIDEO_ENCODER_VERSION=
VIDEO_UPLOAD_DISKS=
SCRIPT_DISK=scripts_local
SCRIPT_DISK=scripts
SCRIPT_DISK_ROOT=
SCRIPT_URL="${APP_URL}"
SCRIPT_URL=http://localhost
SCRIPT_PATH=/videoscript
# web
WEB_URL="${APP_URL}:3000"
WEB_URL=http://localhost
WEB_PATH=
# wiki
WIKI_LOGIN="h${APP_URL}:3000/login"
WIKI_RESET_PASSWORD="${APP_URL}:3000/reset-password"
WIKI_EXTERNAL_PROFILE="${APP_URL}:3000/external"
WIKI_FEATURED_ENTRY=
WIKI_FEATURED_VIDEO=
WIKI_DONATE_URL=#
WIKI_FAQ_URL=#
-307
View File
@@ -1,307 +0,0 @@
# api
API_PATH=
API_URL=http://api.animethemes.test
# app
APP_NAME=AnimeThemes
APP_ENV=local
APP_DEBUG=true
DEBUGBAR_ENABLED=true
DEBUGBAR_EDITOR=
AUDITING_ENABLED=true
APP_URL=http://animethemes.test
ASSET_URL=null
APP_KEY=
APP_LOCAL_IPS=
# audio
AUDIO_DISK_DEFAULT=audios_local
AUDIO_DISKS=audios_local
AUDIO_DISK_ROOT=
AUDIO_PATH=/audio
AUDIO_URL=
AUDIO_STREAMING_METHOD=response
AUDIO_NGINX_REDIRECT=
# broadcasting
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=
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
MEMCACHED_PERSISTENT_ID=
MEMCACHED_USERNAME=
MEMCACHED_PASSWORD=
MEMCACHED_HOST=127.0.0.1
MEMCACHED_PORT=11211
DYNAMODB_CACHE_TABLE=cache
DYNAMODB_ENDPOINT=
CACHE_PREFIX=animethemes
# database
DB_CONNECTION=mysql_prod
DATABASE_URL=
DB_HOST=mysql
DB_HOST_READ=mysql
DB_HOST_WRITE=mysql
DB_STICKY=false
DB_PORT=3306
DB_DATABASE=animethemes
DB_USERNAME=sail
DB_PASSWORD=password
DB_SOCKET=
MYSQL_ATTR_SSL_CA=null
DATABASE_BETA_URL=
DB_BETA_HOST=
DB_BETA_PORT=3306
DB_BETA_DATABASE=animethemes-beta
DB_BETA_USERNAME=root
DB_BETA_PASSWORD=
DB_BETA_SOCKET=
REDIS_CLIENT=predis
REDIS_CLUSTER=redis
REDIS_URL=null
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_SCHEME=tcp
REDIS_DB=0
REDIS_CACHE_DB=1
# dump
DUMP_DISK=dumps
DUMP_DISK_ROOT=
DUMP_URL="${APP_URL}"
DUMP_PATH=/dump
# ffmpeg
FFMPEG_BINARIES=
FFPROBE_BINARIES=
# filament
FILAMENT_URL=http://admin.animethemes.test
FILAMENT_PATH=
# filesystems
FILESYSTEM_DISK=local
IMAGE_ACCESS_KEY_ID=
IMAGE_SECRET_ACCESS_KEY=
IMAGE_DEFAULT_REGION=
IMAGE_ENDPOINT=
IMAGE_BUCKET=
IMAGE_STREAM_READS=
IMAGE_DISABLE_ASSERTS=
IMAGE_VISIBILITY=
IMAGE_URL=
VIDEO_NYC_ACCESS_KEY_ID=
VIDEO_NYC_SECRET_ACCESS_KEY=
VIDEO_NYC_DEFAULT_REGION=
VIDEO_NYC_ENDPOINT=
VIDEO_NYC_BUCKET=
VIDEO_NYC_STREAM_READS=
VIDEO_NYC_DISABLE_ASSERTS=
VIDEO_NYC_VISIBILITY=
VIDEO_FRA_ACCESS_KEY_ID=
VIDEO_FRA_SECRET_ACCESS_KEY=
VIDEO_FRA_DEFAULT_REGION=
VIDEO_FRA_ENDPOINT=
VIDEO_FRA_BUCKET=
VIDEO_FRA_STREAM_READS=
VIDEO_FRA_DISABLE_ASSERTS=
VIDEO_FRA_VISIBILITY=
AUDIO_NYC_ACCESS_KEY_ID=
AUDIO_NYC_SECRET_ACCESS_KEY=
AUDIO_NYC_DEFAULT_REGION=
AUDIO_NYC_ENDPOINT=
AUDIO_NYC_BUCKET=
AUDIO_NYC_STREAM_READS=
AUDIO_NYC_DISABLE_ASSERTS=
AUDIO_NYC_VISIBILITY=
AUDIO_FRA_ACCESS_KEY_ID=
AUDIO_FRA_SECRET_ACCESS_KEY=
AUDIO_FRA_DEFAULT_REGION=
AUDIO_FRA_ENDPOINT=
AUDIO_FRA_BUCKET=
AUDIO_FRA_STREAM_READS=
AUDIO_FRA_DISABLE_ASSERTS=
AUDIO_FRA_VISIBILITY=
DUMP_ACCESS_KEY_ID=
DUMP_SECRET_ACCESS_KEY=
DUMP_DEFAULT_REGION=
DUMP_ENDPOINT=
DUMP_BUCKET=
DUMP_STREAM_READS=
DUMP_DISABLE_ASSERTS=
DUMP_VISIBILITY=
SCRIPT_ACCESS_KEY_ID=
SCRIPT_SECRET_ACCESS_KEY=
SCRIPT_DEFAULT_REGION=
SCRIPT_ENDPOINT=
SCRIPT_BUCKET=
SCRIPT_STREAM_READS=
SCRIPT_DISABLE_ASSERTS=
SCRIPT_VISIBILITY=
# fortify
FORTIFY_HOME="${APP_URL}":3000
FORTIFY_PATH=
FORTIFY_URL="${APP_URL}"
# graphql
GRAPHIQL_URL="${GRAPHQL_URL}/graphiql"
GRAPHIQL_GRAPHQL_ENDPOINT="${GRAPHQL_URL}"
GRAPHQL_URL=http://graphql.animethemes.test
GRAPHQL_PATH=/graphql
GRAPHIQL_ENABLED=true
# hashids
HASHIDS_SALT_MAIN=
HASHIDS_SALT_PLAYLISTS=
# hashing
BCRYPT_ROUNDS=10
# horizon
HORIZON_URL=
HORIZON_PATH=horizon
# image
IMAGE_DISK=images_local
IMAGE_DISK_ROOT=
# logging
LOG_CHANNEL=daily
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# mail
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_ENCRYPTION=tls
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS=admin@animethemes.moe
MAIL_FROM_NAME=AnimeThemes
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"
MAIL_LOG_CHANNEL=
# pennant
PENNANT_STORE=database
# playlist
PLAYLIST_MAX_TRACKS=1000
USER_MAX_PLAYLISTS=1000
# queue
QUEUE_CONNECTION=sync
REDIS_QUEUE=default
QUEUE_FAILED_DRIVER=database-uuids
# sanctum
SANCTUM_STATEFUL_DOMAINS=*
# scout
SCOUT_DRIVER=typesense
SCOUT_PREFIX=
SCOUT_QUEUE=false
SCOUT_IDENTIFY=false
ALGOLIA_APP_ID=
ALGOLIA_SECRET=
ELASTIC_CONNECTION=default
ELASTIC_HOST=http://elasticsearch:9200
ELASTIC_SCOUT_DRIVER_REFRESH_DOCUMENTS=false
ELASTIC_MIGRATIONS_TABLE=elastic_migrations
TYPESENSE_HOST=typesense
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=http
TYPESENSE_API_KEY=
# secure headers
ENABLE_CLEAR_SITE_DATA=false
ENABLE_HSTS=false
ENABLE_EXPECT_CT=false
ENABLE_PERMISSIONS_POLICY=false
ENABLE_CSP=false
# services
MAILGUN_DOMAIN=
MAILGUN_SECRET=
MAILGUN_ENDPOINT=api.mailgun.net
POSTMARK_TOKEN=
DISCORD_BOT_API_TOKEN=
DISCORD_BOT_API_URL=
DISCORD_BOT_API_KEY=
DB_UPDATES_DISCORD_CHANNEL=
ADMIN_DISCORD_CHANNEL=
OPENAI_BEARER_TOKEN=null
MAL_CLIENT_ID=null
MAL_CLIENT_SECRET=null
MAL_REDIRECT_URI=null
ANILIST_CLIENT_ID=null
ANILIST_CLIENT_SECRET=null
ANILIST_REDIRECT_URI=null
# session
SESSION_COOKIE=animethemes_session
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_CONNECTION=null
SESSION_STORE=null
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=
# submission
USER_MAX_SUBMISSIONS=50
# validation
MODERATION_SERVICE=none
# video
VIDEO_DISK_DEFAULT=videos_local
VIDEO_DISKS=videos_local
VIDEO_DISK_ROOT=
VIDEO_PATH=/video
VIDEO_URL="${APP_URL}"
VIDEO_RATE_LIMITER=90
VIDEO_STREAMING_METHOD=response
VIDEO_NGINX_REDIRECT=
SCRIPT_DISK=scripts_local
SCRIPT_DISK_ROOT=
SCRIPT_URL="${APP_URL}"
SCRIPT_PATH=/videoscript
# web
WEB_URL="${APP_URL}:3000"
WEB_PATH=
# wiki
WIKI_LOGIN="${APP_URL}:3000/login"
WIKI_RESET_PASSWORD="${APP_URL}:3000/reset-password"
WIKI_EXTERNAL_PROFILE="${APP_URL}:3000/external"
+1 -1
View File
@@ -1,4 +1,4 @@
* text=auto eol=lf
* text=auto
*.blade.php diff=html
*.css diff=css
+53 -49
View File
@@ -1,65 +1,66 @@
- [Assignment](#assignment)
- [Development Environment](#development-environment)
- [Feature Branch Workflow](#feature-branch-workflow)
- [Cleanup Before Pull Request](#cleanup-before-pull-request)
- [Pull Request](#pull-request)
## Assignment
Unless otherwise specified, any issue that does not have an assignee can be claimed by any volunteer.
Leave a comment in the issue indicating interest so that the assignee can be designated by a member. This is a requirement in Github to allow assignment of contributors outside of the organization by members.
Please leave a comment in the issue indicating interest so that you can be designated as the assignee by a member. This is a requirement in Github to allow asssignment of contributors outside of the organization by members.
If further discussion is needed before work can be started, please make use of the `#api` channel on the discord.
If further discussion is needed before work can be started, please make use of the programming channels on the discord.
## Development Environment
Follow the [Installation Guide](/AnimeThemes/animethemes-server/wiki/Installation) to ensure a functional local instance of AnimeThemes forked from this repository.
Ensure that you have a functional local instance of AnimeThemes forked from this repository by following the [Installation Guide](https://github.com/AnimeThemes/animethemes-server/wiki/Installation).
Enable the forked repository in [StyleCI](https://styleci.io/) so that code styling guidelines are followed. At the time of writing, analysis is run on push and merge.
Enable your repository in [StyleCI](https://styleci.io/) so that code styling guidelines are followed. At the time of writing, analysis is run on push and merge.
```sh
# Set this repository as upstream to the forked repository.
git remote add upstream git@github.com:AnimeThemes/animethemes-server.git
Set this repository as upstream to your fork.
# Pull upstream changes so that the local main branch is even with the upstream main branch.
git pull upstream main
`git remote add upstream https://github.com/AnimeThemes/animethemes-server.git`
# Push upstream changes to the forked remote main branch if needed.
git push origin main
```
Checkout release branch.
`git checkout release-branch`
Pull upstream changes so that your local branch is even with upstream.
`git pull upstream release-branch`
Push upstream changes to your remote if needed.
`git push`
## Feature Branch Workflow
```sh
# Once the release branch is even with upstream, create a new feature branch from the release branch. The new feature branch name should be descriptive.
git checkout -b new-feature-branch
Once your release branch is even with upstream, create feature branch from release branch.
# Make changes in the new feature branch. Once completed, run tests.
php artisan config:clear && php artisan test --parallel
`git branch new-feature-branch`
# Execute static code analysis.
composer test:types
Switch to feature branch.
# Execute pint.
composer lint
```
`git checkout new-feature-branch`
Evaluate code changes against [conventions & standards](/AnimeThemes/animethemes-server/wiki/Coding-Conventions-&-Standards).
Make changes in feature branch. Once completed, run tests.
Stage changes. Commit changes. Please include issue ID's and use [Semantic Commit Messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716).
`php artisan config:clear && php artisan test --parallel`
Execute static code analysis.
`./vendor/bin/phpstan analyse --memory-limit=-1`
Stage changes. Commit changes. Please include ticket ID's and use [Semantic Commit Messages](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716).
## Cleanup Before Pull Request
```sh
# Fetch the upstream release branch and merge with the fork repo's release branch.
git fetch upstream main
git checkout main
git merge upstream/main
Fetch upstream master and merge with your repo's master branch
```
git fetch upstream release-branch
git checkout release-branch
git merge upstream/release-branch
```
# If there were any new commits, rebase the new feature branch.
If there were any new commits, rebase your development branch
```
git checkout new-feature-branch
git rebase main
git rebase release-branch
```
## Pull Request
@@ -68,24 +69,27 @@ Push changes to remote.
`git push --set-upstream origin new-feature-branch`
Create Pull Request from the new feature branch to the upstream release branch.
Confirm StyleCI passing status on push.
Create Pull Request from your new feature branch to upstream release branch.
## Pruning
```sh
# Once the Pull Request is merged, check out the release branch and delete the feature branch locally.
git checkout main
git branch -D new-feature-branch
Once the Pull Request is merged, delete the feature branch locally.
# Delete the remote feature branch.
git push origin -D new-feature-branch
`git branch --delete new-feature-branch`
# Pull upstream changes so that the fork release branch is even with upstream.
git pull upstream main
Delete the remote feature branch.
# Push upstream changes to the fork's remote if needed.
git push origin main
```
`git push origin --delete new-feature-branch`
Pull upstream changes so that your local branch is even with upstream.
`git pull upstream release-branch`
Push upstream changes to your remote if needed.
`git push`
## References
+1 -1
View File
@@ -3,7 +3,7 @@
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: AnimeThemes
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a Ko-fi username
ko_fi: animethemes
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
-27
View File
@@ -1,27 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
-4
View File
@@ -1,4 +0,0 @@
contact_links:
- name: API question
url: https://discord.gg/m9zbVyQ
about: 'For API questions, go to the #api channel on the Discord server'
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement, php
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
-25
View File
@@ -1,25 +0,0 @@
name: GraphQL Schema
on: push
jobs:
graphql:
runs-on: ubuntu-22.04
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo
- uses: actions/checkout@v3
- name: Copy .env
run: php -r "file_exists('.env') || copy('.env.example', '.env');"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Validate GraphQL Schema via Lighthouse
run: |
php artisan lighthouse:validate-schema
+12 -1
View File
@@ -10,16 +10,27 @@ jobs:
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
php-version: '8.2'
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
- 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 build
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Create Database
+9 -8
View File
@@ -10,29 +10,30 @@ jobs:
steps:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
php-version: '8.2'
tools: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, phpunit
- 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 }}
run: |
composer config "http-basic.nova.laravel.com" "$NOVA_USERNAME" "$NOVA_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 build
- name: Directory Permissions
run: chmod -R 777 storage bootstrap/cache
- name: Create Database
run: |
mkdir -p database
touch database/database.sqlite
- name: Install static FFmpeg
run: |
curl -L -o ffmpeg.tar.xz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
tar -xf ffmpeg.tar.xz
sudo mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/
sudo mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/
ffmpeg -encoders | grep av1 || true
- name: Execute tests (Unit and Feature tests) via PHPUnit
env:
DB_CONNECTION: sqlite
-3
View File
@@ -1,4 +1,3 @@
/.phpunit.cache
/node_modules
/public/hot
/public/storage
@@ -24,7 +23,5 @@ yarn-error.log
auth.json
*.sqlite
*.sqlite3
*.sql
public/css/app.css
public/js/app.js
test.php
+1 -1
View File
@@ -1,4 +1,4 @@
version: 8.5
version: 8.1
preset: laravel
finder:
not-name:
+8 -151
View File
@@ -1,12 +1,11 @@
<p align="center">
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml"><img src="https://github.com/AnimeThemes/animethemes-server/actions/workflows/test.yml/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/actions/workflows/static-analysis.yml/badge.svg?branch=main" alt="static-analysis"></a>
<a href="https://github.com/AnimeThemes/animethemes-server/actions/workflows/graphql.yml"><img src="https://github.com/AnimeThemes/animethemes-server/actions/workflows/graphql.yml/badge.svg?branch=main" alt="graphql"></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/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://x.com/AnimeThemesMoe"><img src="https://img.shields.io/twitter/follow/AnimeThemesMoe?style=social"></a>
<a href="https://twitter.com/AnimeThemesMoe"><img src="https://img.shields.io/twitter/follow/AnimeThemesMoe?style=social"></a>
</p>
[**AnimeThemes**](https://animethemes.moe/) is a simple and consistent repository of anime opening and ending themes. We provide direct links to high quality WebMs of your favorite OPs and EDs for your listening and discussion needs.
@@ -15,156 +14,14 @@ This is the repository for the server application that is responsible for AnimeT
This project is powered by [**Laravel**](https://laravel.com/), a PHP framework for web artisans.
# Installation
## Installation
- [Prerequisites](#prerequisites)
- [Setup](#setup)
- [Running](#running)
- [Extra Configuration](#extra-configuration)
- [Feature Flags](#feature-flags)
- [Users](#users)
- [Search](#search)
- [Local Storage](#local-storage)
- [Contributing](#contributing)
- [Resources](#resources)
Please review the [**Installation Guide**](https://github.com/AnimeThemes/animethemes-server/wiki/Installation) in the wiki for detailed instructions.
## Prerequisites
* [Docker](https://www.docker.com/)
Docker will setup PHP, MySQL and Typesense for you. If you are on Windows, use the [WSL](https://learn.microsoft.com/windows/wsl/install) terminal.
## Setup
```bash
# Clone the repository
git clone git@github.com:AnimeThemes/animethemes-server.git
cd animethemes-server
cp .env.example-sail .env
# Install Composer dependencies using a throwaway container
docker run --rm \
-u "$(id -u):$(id -g)" \
-v "$(pwd):/var/www/html" \
-w /var/www/html \
laravelsail/php82-composer:latest \
composer install --ignore-platform-reqs
# Start sail
./vendor/bin/sail up -d
# Generate an application key
./vendor/bin/sail artisan key:generate
# Migrate the database, create fake data and run seeders
./vendor/bin/sail artisan db:sync --drop
```
You can optionally configure a shell alias following the [official Sail guide](https://laravel.com/docs/13.x/sail#configuring-a-shell-alias).
The following instruction assume you have configured a shell alias. If not you need to replace `sail` with `./vendor/bin/sail`.
Open the following file and paste the contents there:
For Windows/WSL: `C:\Windows\System32\drivers\etc\hosts`
For Linux: `/etc/hosts`
```
127.0.0.1 admin.animethemes.test
127.0.0.1 animethemes.test
127.0.0.1 api.animethemes.test
127.0.0.1 graphql.animethemes.test
```
Restart the container:
```bash
sail restart
sail artisan optimize
```
### Running
If all went well, AnimeThemes should be live at `http://animethemes.test`.
## Extra Configuration
### Feature Flags
Features that require external services are disabled by default. Here we will review the configuration options for enabling additional features.
For example, if we want to enable video streams, we need to set the `App\Features\AllowVideoStreams` value on DB to `true`. We recommend setting up a local archive for the `videos_local` disk.
### Users
```sh
# Open the terminal of tinker
sail artisan tinker
# Create the user
$user = User::factory()->create(['name' => 'User Name', 'email' => 'example@example.com', 'password' => 'password', 'email_verified_at' => now()]);
# It is useful to create a user with the Admin role with permissions to all actions
# Assign the Admin role to the user
$user->assignRole('Admin');
```
### Search
Import models into our indices using:
```sh
sail artisan scout:import-all
```
### Local Storage
We are not required to set up s3 buckets in order to interact with media. We have the option to configure local filesystems that we can stream audio/video from and download scripts from.
Configure local filesystem disks in `.env`
```sh
AUDIO_DISK_DEFAULT=audios_local
AUDIO_DISKS=audios_local
...
DUMP_DISK=dumps_local
...
IMAGE_DISK=images_local
...
VIDEO_DISK_DEFAULT=videos_local
VIDEO_DISKS=videos_local
...
SCRIPT_DISK=scripts_local
```
By default, app storage directories will be used to store media. External directories can be specified as the root if media is stored elsewhere.
Remark: It is recommended to include a `.gitignore` at the root directory of the filesystem so that media files are not indexed by git.
```sh
AUDIO_DISK_ROOT="E:\\animethemes-audios\\"
...
DUMP_DISK_ROOT="E:\\animethemes-db-dumps\\"
...
IMAGE_DISK_ROOT="E:\\animethemes-images\\"
...
SCRIPT_DISK_ROOT="E:\\animethemes-scripts\\"
...
VIDEO_DISK_ROOT="E:\\animethemes-videos\\"
```
Create symbolic links to target storage directories.
```php
sail artisan storage:link
```
# Contributing
## Contributing
Please review the [**Contributing Guide**](https://github.com/AnimeThemes/animethemes-server/wiki/Contributing) in the wiki for detailed instructions.
# Resources
## Resources
Please make use of the #api channel in the [**Discord Server**](https://discordapp.com/invite/m9zbVyQ) for questions pertaining to the AnimeThemes database or API.
Please make use of the #development channel in the [**Discord Server**](https://discordapp.com/invite/m9zbVyQ) for questions pertaining to the AnimeThemes database or API.
+39 -2
View File
@@ -8,25 +8,56 @@ use App\Enums\Actions\ActionStatus;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* Class ActionResult.
*/
class ActionResult
{
public function __construct(protected readonly ActionStatus $status, protected readonly ?string $message = null) {}
/**
* 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 $this->status === ActionStatus::FAILED;
return ActionStatus::FAILED()->is($this->status);
}
/**
* Write results to log.
*
* @return void
*/
public function toLog(): void
{
$this->hasFailed()
@@ -34,6 +65,12 @@ class ActionResult
: Log::info($this->getMessage());
}
/**
* Write results to console output.
*
* @param Command $command
* @return void
*/
public function toConsole(Command $command): void
{
$this->hasFailed()
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Discord;
use App\Actions\ActionResult;
use App\Enums\Actions\ActionStatus;
use App\Enums\Actions\Models\Wiki\Video\DiscordNotificationType;
use App\Models\Wiki\Video;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
class DiscordVideoNotificationAction
{
/**
* Get the HTTP client for Discord API.
*/
public static function getHttp(): PendingRequest
{
return Http::withHeaders(['x-api-key' => Config::get('services.discord.api_key')])
->baseUrl(Config::get('services.discord.api_url'));
}
/**
* @param Collection<int, Video> $videos
* @param array<string, mixed> $fields
*/
public function handle(Collection $videos, array $fields): ActionResult
{
$type = Arr::get($fields, DiscordNotificationType::getFieldKey());
static::getHttp()
->post('/notification', [
'type' => $type->value,
'videos' => $videos->map(fn (Video $video): array => ['videoId' => $video->getKey()])->toArray(),
])
->throw();
return new ActionResult(ActionStatus::PASSED);
}
}
+16 -15
View File
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\Auth\User;
use App\Rules\ModerationRule;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -13,33 +13,34 @@ use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
/**
* Class CreateNewUser.
*/
class CreateNewUser implements CreatesNewUsers
{
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
* @param array $input
* @return Model
*
* @throws ValidationException
*/
public function create(array $input): User
public function create(array $input): Model
{
Validator::make($input, [
User::ATTRIBUTE_NAME => ['required', 'string', 'max:35', 'alpha_dash', Rule::unique(User::class), new ModerationRule()],
User::ATTRIBUTE_EMAIL => ['required', 'string', 'email', 'max:255', 'indisposable', Rule::unique(User::class)],
User::ATTRIBUTE_PASSWORD => Password::required(),
'terms' => ['required'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::TABLE)],
'password' => Password::required(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['required'] : '',
])->validate();
$user = new User([
User::ATTRIBUTE_NAME => Arr::get($input, User::ATTRIBUTE_NAME),
User::ATTRIBUTE_EMAIL => Arr::get($input, User::ATTRIBUTE_EMAIL),
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
return User::query()->create([
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
]);
$user->save();
return $user;
}
}
+9 -4
View File
@@ -12,23 +12,28 @@ use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
/**
* Class ResetUserPassword.
*/
class ResetUserPassword implements ResetsUserPasswords
{
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
* @param mixed $user
* @param array $input
* @return void
*
* @throws ValidationException
*/
public function reset(User $user, array $input): void
public function reset($user, array $input): void
{
Validator::make($input, [
User::ATTRIBUTE_PASSWORD => Password::required(),
'password' => Password::required(),
])->validate();
$user->forceFill([
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
])->save();
}
}
+10 -5
View File
@@ -13,21 +13,26 @@ use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator as IlluminateValidator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
/**
* Class UpdateUserPassword.
*/
class UpdateUserPassword implements UpdatesUserPasswords
{
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
* @param mixed $user
* @param array $input
* @return void
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
public function update($user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string'],
User::ATTRIBUTE_PASSWORD => Password::required(),
])->after(function (IlluminateValidator $validator) use ($user, $input): void {
'password' => Password::required(),
])->after(function (IlluminateValidator $validator) use ($user, $input) {
if (! isset($input['current_password']) || ! Hash::check($input['current_password'], $user->password)) {
$validator->errors()
->add('current_password', __('The provided password does not match your current password.'));
@@ -35,7 +40,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
})->validateWithBag('updatePassword');
$user->forceFill([
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, User::ATTRIBUTE_PASSWORD)),
User::ATTRIBUTE_PASSWORD => Hash::make(Arr::get($input, 'password')),
])->save();
}
}
@@ -5,39 +5,64 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\Auth\User;
use App\Rules\ModerationRule;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
/**
* Class UpdateUserProfileInformation.
*/
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
* @param mixed $user
* @param array $input
* @return void
*
* @throws ValidationException
*/
public function update(User $user, array $input): void
public function update(mixed $user, array $input): void
{
/** @var array{name: string, email: string} $validated */
$validated = Validator::make($input, [
User::ATTRIBUTE_NAME => ['required_without:'.User::ATTRIBUTE_EMAIL, 'string', 'max:35', 'alpha_dash', Rule::unique(User::class)->ignore($user->id), new ModerationRule()],
User::ATTRIBUTE_EMAIL => ['required_without:'.User::ATTRIBUTE_NAME, 'string', 'email', 'max:255', 'indisposable', Rule::unique(User::class)->ignore($user->id)],
])->validate();
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique(User::TABLE)->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
])->validateWithBag('updateProfileInformation');
$email = Arr::get($validated, User::ATTRIBUTE_EMAIL);
if (Arr::has($validated, User::ATTRIBUTE_EMAIL) && $email !== $user->email) {
$validated += [User::ATTRIBUTE_EMAIL_VERIFIED_AT => null];
if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']);
}
$user->update($validated);
$user->sendEmailVerificationNotification();
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->update($validated);
$user->forceFill([
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param mixed $user
* @param array $input
* @return void
*/
protected function updateVerifiedUser(mixed $user, array $input): void
{
$user->forceFill([
User::ATTRIBUTE_NAME => Arr::get($input, 'name'),
User::ATTRIBUTE_EMAIL => Arr::get($input, 'email'),
User::ATTRIBUTE_EMAIL_VERIFIED_AT => null,
])->save();
$user->sendEmailVerificationNotification();
}
}
@@ -10,18 +10,37 @@ use App\Models\Admin\Dump;
use Illuminate\Support\Facades\Config;
/**
* Class DumpDownloadAction.
*
* @extends DownloadAction<Dump>
*/
class DumpDownloadAction extends DownloadAction
{
/**
* Create a new action instance.
*
* @param Dump $dump
*/
public function __construct(Dump $dump)
{
parent::__construct($dump);
}
/**
* Get the path of the resource in storage.
*
* @return string
*/
protected function path(): string
{
return $this->model->path;
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(DumpConstants::DISK_QUALIFIED);
-47
View File
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use App\Contracts\Models\Nameable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*/
class DestroyAction
{
/**
* @param TModel $model
* @return TModel
*/
public function destroy(Model $model): Model
{
$model->delete();
// Scout will load relations to refresh related search indices.
$model->unsetRelations();
return $model;
}
/**
* Force delete model that doesn't apply soft deletes.
*
* @param TModel&Nameable $model
*/
public function forceDelete(Model&Nameable $model): string
{
$message = Str::of(Str::headline(class_basename($model)))
->append(' \'')
->append($model->getName())
->append('\' was deleted.')
->__toString();
$model->delete();
return $message;
}
}
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use App\Models\BaseModel;
use Illuminate\Support\Str;
class ForceDeleteAction
{
public function forceDelete(BaseModel $model): string
{
$message = Str::of(Str::headline(class_basename($model)))
->append(' \'')
->append($model->getName())
->append('\' was deleted.')
->__toString();
$model->forceDelete();
return $message;
}
}
-78
View File
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use App\Concerns\Actions\Http\Api\ConstrainsEagerLoads;
use App\Enums\Http\Api\Paging\PaginationStrategy;
use App\Http\Api\Criteria\Paging\Criteria;
use App\Http\Api\Filter\HasFilter;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\EloquentSchema;
use App\Http\Api\Schema\Schema;
use App\Http\Api\Scope\ScopeParser;
use App\Scout\Search;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Config;
use RuntimeException;
class IndexAction
{
use ConstrainsEagerLoads;
/**
* Show paginated listing of models.
*/
public function index(Builder $builder, Query $query, Schema $schema): Collection|Paginator
{
$builder->with($this->constrainEagerLoads($query, $schema));
$this->select($builder, $query, $schema);
$this->withAggregates($builder, $query, $schema);
$scope = ScopeParser::parse($schema->type());
$this->filter($builder, $query, $schema, $scope);
// special case: only apply HasFilter to top-level models
if (filled($schema->allowedIncludes())) {
$hasFilter = new HasFilter($schema->allowedIncludes());
foreach ($query->getFilterCriteria() as $criteria) {
if ($criteria->shouldFilter($hasFilter, $scope)) {
$criteria->filter($builder, $hasFilter, $query, $schema);
}
}
}
$this->sort($builder, $query, $schema, $scope);
// paginate
$paginationCriteria = $query->getPagingCriteria(PaginationStrategy::OFFSET);
return $paginationCriteria instanceof Criteria
? $paginationCriteria->paginate($builder)
: $builder->get();
}
public function search(
Query $query,
Schema $schema,
PaginationStrategy $paginationStrategy = PaginationStrategy::OFFSET
): Collection|Paginator {
if ($query->hasSearchCriteria() && $schema instanceof EloquentSchema) {
$search = Search::getSearch($schema->model(), $query->getSearchCriteria());
if ($search->shouldSearch()) {
return $search->searchViaJSONAPI($query, $schema, $paginationStrategy);
}
}
// Let developer know why search can't be performed
$driver = Config::get('scout.driver');
$term = $query->getSearchCriteria()?->getTerm();
throw new RuntimeException("Can't search for term '$term' with driver '$driver' and type '{$schema->type()}'");
}
}
@@ -1,56 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api\List\Playlist\Track;
use App\Actions\Http\Api\DestroyAction;
use App\Actions\Models\List\Playlist\RemoveTrackAction;
use App\Concerns\Actions\List\LocksPlaylist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class DestroyTrackAction
{
use LocksPlaylist;
/**
* @throws Exception
*/
public function destroy(Playlist $playlist, PlaylistTrack $track): string
{
return $this->withPlaylistLock($playlist, function () use ($playlist, $track) {
try {
DB::beginTransaction();
$removeAction = new RemoveTrackAction();
$removeAction->remove($playlist, $track);
$message = Str::of(Str::headline(class_basename($track)))
->append(' \'')
->append($track->getName())
->append('\' was deleted.')
->__toString();
$destroyAction = new DestroyAction();
$destroyAction->forceDelete($track);
DB::commit();
return $message;
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
});
}
}
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api\List\Playlist\Track;
use App\Actions\Http\Api\StoreAction;
use App\Actions\Models\List\Playlist\InsertTrackAction;
use App\Actions\Models\List\Playlist\InsertTrackAfterAction;
use App\Actions\Models\List\Playlist\InsertTrackBeforeAction;
use App\Concerns\Actions\List\LocksPlaylist;
use App\Contracts\Models\HasHashids;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class StoreTrackAction
{
use LocksPlaylist;
/**
* @throws Exception
*/
public function store(Playlist $playlist, Builder $builder, array $parameters): PlaylistTrack
{
return $this->withPlaylistLock($playlist, function () use ($playlist, $builder, $parameters) {
$trackParameters = $parameters;
$previousHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_PREVIOUS);
$nextHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_NEXT);
$trackParameters += [PlaylistTrack::ATTRIBUTE_PLAYLIST => $playlist->getKey()];
try {
DB::beginTransaction();
/** @var StoreAction<PlaylistTrack> $storeAction */
$storeAction = new StoreAction();
$track = $storeAction->store($builder, $trackParameters);
if (filled($nextHashid) && blank($previousHashid)) {
/** @var PlaylistTrack $next */
$next = PlaylistTrack::query()
->with(PlaylistTrack::RELATION_PREVIOUS)
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
->where(HasHashids::ATTRIBUTE_HASHID, $nextHashid)
->firstOrFail();
$insertAction = new InsertTrackBeforeAction();
$insertAction->insertBefore($playlist, $track, $next);
}
if (filled($previousHashid) && blank($nextHashid)) {
/** @var PlaylistTrack $previous */
$previous = PlaylistTrack::query()
->with(PlaylistTrack::RELATION_NEXT)
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
->where(HasHashids::ATTRIBUTE_HASHID, $previousHashid)
->firstOrFail();
$insertAction = new InsertTrackAfterAction();
$insertAction->insertAfter($playlist, $track, $previous);
}
if (blank($nextHashid) && blank($previousHashid)) {
$insertAction = new InsertTrackAction();
$insertAction->insert($playlist, $track);
}
DB::commit();
return $storeAction->cleanup($track);
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
});
}
}
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api\List\Playlist\Track;
use App\Actions\Http\Api\UpdateAction;
use App\Actions\Models\List\Playlist\InsertTrackAfterAction;
use App\Actions\Models\List\Playlist\InsertTrackBeforeAction;
use App\Actions\Models\List\Playlist\RemoveTrackAction;
use App\Concerns\Actions\List\LocksPlaylist;
use App\Contracts\Models\HasHashids;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class UpdateTrackAction
{
use LocksPlaylist;
/**
* @throws Exception
*/
public function update(Playlist $playlist, PlaylistTrack $track, array $parameters): PlaylistTrack
{
return $this->withPlaylistLock($playlist, function () use ($playlist, $track, $parameters) {
$trackParameters = $parameters;
$previousHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_PREVIOUS);
$nextHashid = Arr::pull($trackParameters, PlaylistTrack::RELATION_NEXT);
try {
DB::beginTransaction();
/** @var UpdateAction<PlaylistTrack> $updateAction */
$updateAction = new UpdateAction();
$updateAction->update($track, $trackParameters);
if (filled($nextHashid) || filled($previousHashid)) {
$removeAction = new RemoveTrackAction();
$removeAction->remove($playlist, $track);
}
if (filled($nextHashid) && blank($previousHashid)) {
$next = PlaylistTrack::query()
->with(PlaylistTrack::RELATION_PREVIOUS)
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
->where(HasHashids::ATTRIBUTE_HASHID, $nextHashid)
->firstOrFail();
$insertAction = new InsertTrackBeforeAction();
$insertAction->insertBefore($playlist, $track, $next);
}
if (filled($previousHashid) && blank($nextHashid)) {
$previous = PlaylistTrack::query()
->with(PlaylistTrack::RELATION_NEXT)
->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
->where(HasHashids::ATTRIBUTE_HASHID, $previousHashid)
->firstOrFail();
$insertAction = new InsertTrackAfterAction();
$insertAction->insertAfter($playlist, $track, $previous);
}
DB::commit();
return $updateAction->cleanup($track);
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
});
}
}
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use App\Contracts\Models\SoftDeletable;
use Illuminate\Database\Eloquent\Model;
class RestoreAction
{
public function restore(Model&SoftDeletable $model): Model&SoftDeletable
{
$model->restore();
return $this->cleanup($model);
}
/**
* Perform model cleanup for presentation.
*/
public function cleanup(Model&SoftDeletable $model): Model&SoftDeletable
{
// Scout will load relations to refresh related search indices.
$model->unsetRelations();
return $model;
}
}
-26
View File
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use App\Concerns\Actions\Http\Api\ConstrainsEagerLoads;
use App\Http\Api\Query\Query;
use App\Http\Api\Schema\Schema;
use Illuminate\Database\Eloquent\Model;
class ShowAction
{
use ConstrainsEagerLoads;
public function show(Model $model, Query $query, Schema $schema): Model
{
// eager load relations with constraints
$model->load($this->constrainEagerLoads($query, $schema));
// Load aggregate relation values
$this->loadAggregates($model, $query, $schema);
return $model;
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*/
class StoreAction
{
/**
* @param Builder<TModel> $builder
* @return TModel
*/
public function store(Builder $builder, array $parameters): Model
{
$model = $builder->create($parameters);
return $this->cleanup($model);
}
/**
* Perform model cleanup for presentation.
*
* @param TModel $model
* @return TModel
*/
public function cleanup(Model $model): Model
{
// Scout will load relations to refresh related search indices.
$model->unsetRelations();
// Columns with default values may be unset if not provided in the query string.
$model->refresh();
return $model;
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api;
use Illuminate\Database\Eloquent\Model;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
*/
class UpdateAction
{
/**
* @param TModel $model
* @return TModel
*/
public function update(Model $model, array $parameters): Model
{
$model->update($parameters);
return $this->cleanup($model);
}
/**
* Perform model cleanup for presentation.
*
* @param TModel $model
* @return TModel
*/
public function cleanup(Model $model): Model
{
// Scout will load relations to refresh related search indices.
$model->unsetRelations();
return $model;
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Http\Api\Wiki\Image;
use App\Actions\Http\Api\StoreAction;
use App\Http\Api\Field\Wiki\Image\ImageFileField;
use App\Models\Wiki\Image;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
/**
* @extends StoreAction<Image>
*/
class StoreImageAction extends StoreAction
{
/**
* @param Builder<Image> $builder
*/
public function store(Builder $builder, array $parameters): Image
{
$imageParameters = Arr::except($parameters, ImageFileField::ATTRIBUTE_FILE);
$file = Arr::get($parameters, ImageFileField::ATTRIBUTE_FILE);
if ($file instanceof UploadedFile) {
/** @var FilesystemAdapter $fs */
$fs = Storage::disk(Config::get('image.disk'));
$fsFile = $fs->putFile('', $file);
Arr::set($imageParameters, Image::ATTRIBUTE_PATH, $fsFile);
}
return parent::store($builder, $imageParameters);
}
}
+14 -1
View File
@@ -11,15 +11,26 @@ use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Class DownloadAction.
*
* @template TModel of \App\Models\BaseModel
*/
abstract class DownloadAction implements InteractsWithDisk
{
/**
* Create a new action instance.
*
* @param TModel $model
*/
public function __construct(protected readonly BaseModel $model) {}
public function __construct(protected readonly BaseModel $model)
{
}
/**
* Download the resource.
*
* @return StreamedResponse
*/
public function download(): StreamedResponse
{
/** @var FilesystemAdapter $fs */
@@ -30,6 +41,8 @@ abstract class DownloadAction implements InteractsWithDisk
/**
* Get the path of the resource in storage.
*
* @return string
*/
abstract protected function path(): string;
}
+14 -4
View File
@@ -4,17 +4,24 @@ declare(strict_types=1);
namespace App\Actions\Http;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
/**
* Class NginxStreamAction.
*/
abstract class NginxStreamAction extends StreamAction
{
public function stream(string $disposition = 'inline'): Response
/**
* Stream the resource.
*
* @return Response
*/
public function stream(): Response
{
/** @var FilesystemAdapter $fs */
$fs = Storage::disk($this->disk());
// Generate temporary link for the object
$temporaryURL = $fs->temporaryUrl($this->streamable->path(), now()->addMinutes(5));
// Get the url information
@@ -22,11 +29,12 @@ abstract class NginxStreamAction extends StreamAction
$url_host = parse_url($temporaryURL, PHP_URL_HOST);
$url_path_query = parse_url($temporaryURL, PHP_URL_PATH).'?'.parse_url($temporaryURL, PHP_URL_QUERY);
// Construct the new link for the redirect
$link = "{$this->nginxRedirect()}$url_scheme/$url_host$url_path_query";
$response = new Response();
$disposition = $response->headers->makeDisposition($disposition, $this->streamable->basename());
$disposition = $response->headers->makeDisposition('inline', $this->streamable->basename());
return $response->withHeaders([
'Accept-Ranges' => 'bytes',
@@ -39,6 +47,8 @@ abstract class NginxStreamAction extends StreamAction
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
abstract protected function nginxRedirect(): string;
}
+11 -3
View File
@@ -7,13 +7,21 @@ namespace App\Actions\Http;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Class ResponseStreamAction.
*/
abstract class ResponseStreamAction extends StreamAction
{
public function stream(string $disposition = 'inline'): StreamedResponse
/**
* Stream the resource.
*
* @return StreamedResponse
*/
public function stream(): StreamedResponse
{
$response = new StreamedResponse();
$disposition = $response->headers->makeDisposition($disposition, $this->streamable->basename());
$disposition = $response->headers->makeDisposition('inline', $this->streamable->basename());
$response->headers->replace([
'Accept-Ranges' => 'bytes',
@@ -24,7 +32,7 @@ abstract class ResponseStreamAction extends StreamAction
$fs = Storage::disk($this->disk());
$response->setCallback(function () use ($fs): void {
$response->setCallback(function () use ($fs) {
$stream = $fs->readStream($this->streamable->path());
fpassthru($stream);
fclose($stream);
+17 -2
View File
@@ -8,9 +8,24 @@ use App\Contracts\Models\Streamable;
use App\Contracts\Storage\InteractsWithDisk;
use Symfony\Component\HttpFoundation\Response;
/**
* Class StreamAction.
*/
abstract class StreamAction implements InteractsWithDisk
{
public function __construct(protected readonly Streamable $streamable) {}
/**
* Create a new action instance.
*
* @param Streamable $streamable
*/
public function __construct(protected readonly Streamable $streamable)
{
}
abstract public function stream(string $disposition = 'inline'): Response;
/**
* Stream the resource.
*
* @return Response
*/
abstract public function stream(): Response;
}
@@ -9,13 +9,26 @@ use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
/**
* Class AudioNginxStreamAction.
*/
class AudioNginxStreamAction extends NginxStreamAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
@@ -23,6 +36,8 @@ class AudioNginxStreamAction extends NginxStreamAction
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
protected function nginxRedirect(): string
{
@@ -9,13 +9,26 @@ use App\Constants\Config\AudioConstants;
use App\Models\Wiki\Audio;
use Illuminate\Support\Facades\Config;
/**
* Class AudioResponseStreamAction.
*/
class AudioResponseStreamAction extends ResponseStreamAction
{
/**
* Create a new action instance.
*
* @param Audio $audio
*/
public function __construct(Audio $audio)
{
parent::__construct($audio);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(AudioConstants::DEFAULT_DISK_QUALIFIED);
@@ -10,18 +10,37 @@ use App\Models\Wiki\Video\VideoScript;
use Illuminate\Support\Facades\Config;
/**
* Class ScriptDownloadAction.
*
* @extends DownloadAction<VideoScript>
*/
class ScriptDownloadAction extends DownloadAction
{
/**
* Create a new action instance.
*
* @param VideoScript $script
*/
public function __construct(VideoScript $script)
{
parent::__construct($script);
}
/**
* Get the path of the resource in storage.
*
* @return string
*/
protected function path(): string
{
return $this->model->path;
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::SCRIPT_DISK_QUALIFIED);
@@ -9,13 +9,26 @@ use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
/**
* Class VideoNginxStreamAction.
*/
class VideoNginxStreamAction extends NginxStreamAction
{
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
@@ -23,6 +36,8 @@ class VideoNginxStreamAction extends NginxStreamAction
/**
* Get the location of the nginx internal redirect.
*
* @return string
*/
protected function nginxRedirect(): string
{
@@ -9,13 +9,26 @@ use App\Constants\Config\VideoConstants;
use App\Models\Wiki\Video;
use Illuminate\Support\Facades\Config;
/**
* Class VideoResponseStreamAction.
*/
class VideoResponseStreamAction extends ResponseStreamAction
{
/**
* Create a new action instance.
*
* @param Video $video
*/
public function __construct(Video $video)
{
parent::__construct($video);
}
/**
* The name of the disk.
*
* @return string
*/
public function disk(): string
{
return Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED);
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesUsers;
/**
* Class DeleteUser.
*/
class DeleteUser implements DeletesUsers
{
/**
* Delete the given user.
*
* @param mixed $user
* @return void
*/
public function delete($user): void
{
DB::transaction(function () use ($user) {
$user->tokens->each->delete();
$user->delete();
});
}
}
@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Aggregate;
use App\Actions\ActionResult;
use App\Enums\Actions\ActionStatus;
use Exception;
use Illuminate\Support\Facades\DB;
class SyncLikeAggregatesAction
{
/**
* @throws Exception
*/
public function handle(): ActionResult
{
try {
DB::statement('
INSERT INTO like_aggregates (likeable_id, likeable_type, value)
SELECT likeable_id, likeable_type, COUNT(*) as value
FROM likes
GROUP BY likeable_type, likeable_id
ON DUPLICATE KEY UPDATE value = VALUES(value);
');
} catch (Exception $e) {
return new ActionResult(
ActionStatus::FAILED,
$e->getMessage()
);
}
return new ActionResult(
ActionStatus::PASSED,
'Like aggregates synced successfully',
);
}
}
@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models;
use App\Contracts\Models\HasHashids;
use App\Models\BaseModel;
use Vinkla\Hashids\Facades\Hashids;
class AssignHashidsAction
{
public function assign(HasHashids&BaseModel $model, ?string $connection = null): void
{
$hashids = Hashids::connection($connection);
$model->setAttribute(HasHashids::ATTRIBUTE_HASHID, $hashids->encode($model->hashids()));
$model->save();
}
}
-43
View File
@@ -1,43 +0,0 @@
<?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;
/**
* @template TModel of \App\Models\BaseModel
*/
abstract class BackfillAction
{
/**
* @param TModel $model
*/
public function __construct(protected BaseModel $model) {}
abstract public function handle(): ActionResult;
/**
* Get the model the action is handling.
*
* @return TModel
*/
abstract protected function getModel(): BaseModel;
/**
* Get the relation to resources.
*/
abstract protected function relation(): Relation;
/**
* Get the human-friendly label for the underlying model.
*/
protected function label(): string
{
return Str::headline(class_basename($this->getModel()));
}
}
-118
View File
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models;
use App\Actions\ActionResult;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Actions\Storage\Wiki\Image\UploadImageAction;
use App\Concerns\Models\CanCreateExternalResource;
use App\Contracts\Models\HasImages;
use App\Contracts\Models\HasResources;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\BaseModel;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Support\Uri;
abstract class BackfillWikiAction
{
use CanCreateExternalResource;
final public const RESOURCES = 'resources';
final public const IMAGES = 'images';
public function __construct(protected BaseModel $model, protected array $toBackfill) {}
abstract public function handle(): ActionResult;
/**
* Get the external API actions available for the backfill action.
*
* @return ExternalApiAction[]
*/
abstract protected function getExternalApiActions(): array;
/**
* Create the resources given the response.
*/
protected function forResources(ExternalApiAction $api): void
{
$toBackfill = $this->toBackfill[self::RESOURCES];
foreach ($api->getResources() as $site => $url) {
$site = ResourceSite::from($site);
if (! in_array($site, $toBackfill)) {
Log::info("Resource {$site->localize()} should not be backfilled for {$this->label()} {$this->getModel()->getName()}");
continue;
}
if ($this->getModel()->resources()->getQuery()->where(ExternalResource::ATTRIBUTE_SITE, $site->value)->exists()) {
Log::info("Resource {$site->localize()} already exists for {$this->label()} {$this->getModel()->getName()}");
continue;
}
$this->createResource(Uri::of($url), $site, $this->getModel());
$this->backfilled($site, self::RESOURCES);
}
}
/**
* Create the images given the response.
*/
protected function forImages(ExternalApiAction $api): void
{
$toBackfill = $this->toBackfill[self::IMAGES];
foreach ($api->getImages() as $facet => $url) {
$facet = ImageFacet::from($facet);
if (! in_array($facet, $toBackfill)) {
Log::info("Skipping {$facet->localize()} for {$this->label()} {$this->getModel()->getName()}");
continue;
}
if ($this->getModel()->images()->getQuery()->where(Image::ATTRIBUTE_FACET, $facet->value)->exists()) {
Log::info("Image {$facet->localize()} already exists for {$this->label()} {$this->getModel()->getName()}");
continue;
}
UploadImageAction::createImageFromUrl($url, $facet, $this->getModel());
$this->backfilled($facet, self::IMAGES);
}
}
/**
* Remove element already backfilled.
*/
protected function backfilled(mixed $enum, string $scope): void
{
$index = array_search($enum, $this->toBackfill[$scope]);
if ($index !== false) {
unset($this->toBackfill[$scope][$index]);
}
}
/**
* Get the model for the action.
*
* @return BaseModel&HasResources&HasImages
*/
abstract protected function getModel(): BaseModel;
/**
* Get the human-friendly label for the underlying model.
*/
protected function label(): string
{
return Str::headline(class_basename($this->getModel()));
}
}
+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()));
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Document;
use App\Models\Document\Page;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class UpdatePageRelations
{
public function handle(Page $page): void
{
try {
DB::beginTransaction();
$page->previous?->next()?->associate($page)?->saveQuietly();
$page->next?->previous()?->associate($page)?->saveQuietly();
$oldPreviousId = $page->getOriginal(Page::ATTRIBUTE_PREVIOUS);
if ($oldPreviousId && $oldPreviousId !== $page->previous?->getKey()) {
Page::query()->find($oldPreviousId)->next()->disassociate()->saveQuietly();
}
$oldNextId = $page->getOriginal(Page::ATTRIBUTE_NEXT);
if ($oldNextId && $oldNextId !== $page->next?->getKey()) {
Page::query()->find($oldNextId)->previous()->disassociate()->saveQuietly();
}
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Entry;
use App\Models\List\External\ExternalToken;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Crypt;
/**
* This action will create the entries through the authentication method.
*/
abstract class BaseExternalEntryClaimedAction
{
/**
* The JSON response of the external API.
*
* @var array<string, mixed>|null
*/
protected ?array $data = null;
/**
* The id of the external user.
*/
protected ?int $userId = null;
public function __construct(protected ExternalToken $token) {}
public function getUserId(): ?int
{
return $this->userId;
}
protected function getToken(): string
{
return Crypt::decrypt($this->token->access_token);
}
abstract public function getUsername(): ?string;
/**
* Get the entries of the response.
*
* @return array<int, array<string, mixed>>
*/
abstract public function getEntries(): array;
/**
* @throws RequestException
*/
abstract protected function makeRequest(): void;
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Entry;
use App\Models\List\ExternalProfile;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
/**
* This action will create the entries through the username method.
*/
abstract class BaseExternalEntryUnclaimedAction
{
/**
* The JSON response of the external API.
*
* @var array<string, mixed>|null
*/
protected ?array $data = null;
public function __construct(protected ExternalProfile|array $profile) {}
public function getUsername(): string
{
if ($this->profile instanceof ExternalProfile) {
return $this->profile->name;
}
return Arr::get($this->profile, ExternalProfile::ATTRIBUTE_NAME);
}
abstract public function getUserId(): ?int;
/**
* Get the entries of the response.
*
* @return array<int, array<string, mixed>>
*/
abstract public function getEntries(): array;
/**
* @throws RequestException
*/
abstract protected function makeRequest(): void;
}
@@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Entry\Claimed;
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
use App\Enums\Models\List\ExternalEntryStatus;
use App\Models\List\External\ExternalEntry;
use App\Models\Wiki\ExternalResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AnilistExternalEntryClaimedAction extends BaseExternalEntryClaimedAction
{
/**
* Get the entries of the response.
*
* @return array<int, array<string, mixed>>
*/
public function getEntries(): array
{
$entries = [];
if ($this->data === null) {
$this->makeRequest();
}
if ($data = $this->data) {
$lists = Arr::where(Arr::get($data, 'MediaListCollection.lists'), fn ($value): bool => $value['isCustomList'] === false);
foreach ($lists as $list) {
foreach (Arr::get($list, 'entries') as $entry) {
$entryId = intval(Arr::get($entry, 'media.id'));
$entries[] = [
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId,
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'),
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getAnilistMapping(Arr::get($entry, 'status'))->value,
ExternalEntry::ATTRIBUTE_IS_FAVORITE => Arr::get($entry, 'media.isFavourite'),
];
}
}
}
return $entries;
}
public function getUsername(): ?string
{
if ($this->data === null) {
$this->makeRequest();
}
return Arr::get($this->data, 'Viewer.name');
}
public function getUserId(): ?int
{
if ($this->userId !== null) {
return $this->userId;
}
[, $payload] = explode('.', $this->getToken());
$decodedArray = json_decode(base64_decode($payload), true);
$this->userId = intval(Arr::get($decodedArray, 'sub'));
return $this->userId;
}
/**
* @throws RequestException
*/
protected function makeRequest(): void
{
try {
$query = '
query($userId: Int) {
Viewer {
name
}
MediaListCollection(userId: $userId, type: ANIME) {
lists {
name
status
isCustomList
entries {
status
score
media {
id
isFavourite
}
}
}
}
}
';
$variables = [
'userId' => $this->getUserId(),
];
$this->data = Http::withToken($this->getToken())
->post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json('data');
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
}
}
}
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Entry\Claimed;
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
use App\Enums\Models\List\ExternalEntryStatus;
use App\Enums\Models\List\ExternalProfileSite;
use App\Models\List\External\ExternalEntry;
use App\Models\Wiki\ExternalResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
class MalExternalEntryClaimedAction extends BaseExternalEntryClaimedAction
{
/**
* The JSON response of the user endpoint in external API.
*
* @var array<string, mixed>|null
*/
protected ?array $userData = null;
/**
* Get the entries of the response.
*
* @return array<int, array<string, mixed>>
*/
public function getEntries(): array
{
$entries = [];
if ($this->data === null) {
$this->makeRequest();
}
if ($data = $this->data) {
foreach ($data as $info) {
$animeInfo = Arr::get($info, 'node');
$listStatus = Arr::get($info, 'list_status');
$status = Arr::get($listStatus, 'is_rewatching')
? 'rewatching'
: Arr::get($listStatus, 'status');
$entries[] = [
ExternalResource::ATTRIBUTE_EXTERNAL_ID => Arr::get($animeInfo, 'id'),
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($listStatus, 'score'),
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getMalMapping($status)->value,
ExternalEntry::ATTRIBUTE_IS_FAVORITE => false,
];
}
}
return $entries;
}
public function getUsername(): ?string
{
if ($this->userData === null) {
$this->makeUserRequest();
}
return Arr::get($this->userData, 'name');
}
public function getUserId(): ?int
{
if ($this->data === null) {
$this->makeUserRequest();
}
return Arr::get($this->userData, 'id');
}
/**
* @throws RequestException
*/
protected function makeUserRequest(): void
{
try {
$this->userData = Http::withToken($this->getToken())
->get('https://api.myanimelist.net/v2/users/@me')
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
}
}
/**
* @throws RequestException
*/
protected function makeRequest(): void
{
$next = null;
try {
do {
$response = Http::withToken($this->getToken())
->get($next ?? 'https://api.myanimelist.net/v2/users/@me/animelist?fields=list_status&limit=1000')
->throw()
->json();
$this->data = array_merge($this->data ?? [], Arr::get($response, 'data'));
$next = Arr::get($response, 'paging.next');
// TODO: test
RateLimiter::hit(ExternalProfileSite::MAL->name);
} while (filled($next));
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
}
}
}
@@ -1,109 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Entry\Unclaimed;
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
use App\Enums\Models\List\ExternalEntryStatus;
use App\Models\List\External\ExternalEntry;
use App\Models\Wiki\ExternalResource;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AnilistExternalEntryUnclaimedAction extends BaseExternalEntryUnclaimedAction
{
/**
* Get the entries of the response.
*
* @return array<int, array<string, mixed>>
*/
public function getEntries(): array
{
$entries = [];
if ($this->data === null) {
$this->makeRequest();
}
$favorites = Arr::map(Arr::get($this->data, 'User.favourites.anime.nodes'), fn ($value) => $value['id']);
$lists = Arr::where(Arr::get($this->data, 'MediaListCollection.lists'), fn ($value): bool => $value['isCustomList'] === false);
foreach ($lists as $list) {
foreach (Arr::get($list, 'entries') as $entry) {
$entryId = intval(Arr::get($entry, 'media.id'));
$entries[] = [
ExternalResource::ATTRIBUTE_EXTERNAL_ID => $entryId,
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, 'score'),
ExternalEntry::ATTRIBUTE_STATUS => ExternalEntryStatus::getAnilistMapping(Arr::get($entry, 'status'))->value,
ExternalEntry::ATTRIBUTE_IS_FAVORITE => in_array($entryId, $favorites),
];
}
}
return $entries;
}
public function getUserId(): ?int
{
if ($this->data === null) {
$this->makeRequest();
}
return Arr::get($this->data, 'User.id');
}
/**
* @throws RequestException
*/
protected function makeRequest(): void
{
try {
$query = '
query($userName: String) {
User(name: $userName) {
id
favourites {
anime {
nodes {
id
}
}
}
}
MediaListCollection(userName: $userName, type: ANIME) {
lists {
name
status
isCustomList
entries {
status
score
media {
id
}
}
}
}
}
';
$variables = [
'userName' => $this->getUsername(),
];
$this->data = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json('data');
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
}
}
}
@@ -1,105 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External;
use App\Actions\Http\Api\StoreAction;
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
use App\Actions\Models\List\External\Entry\Claimed\AnilistExternalEntryClaimedAction;
use App\Actions\Models\List\External\Entry\Claimed\MalExternalEntryClaimedAction;
use App\Enums\Models\List\ExternalProfileSite;
use App\Enums\Models\List\ExternalProfileVisibility;
use App\Models\List\External\ExternalToken;
use App\Models\List\ExternalProfile;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class StoreExternalProfileClaimedAction
{
protected Collection $resources;
/**
* Get the first record or store external profile given determined external token.
*
*
* @throws Exception
*/
public function firstOrCreate(ExternalToken $token, array $parameters): ExternalProfile
{
try {
$site = ExternalProfileSite::fromLocalizedName(Arr::get($parameters, ExternalProfile::ATTRIBUTE_SITE));
$action = static::getActionClass($site, $token);
$userId = $action->getUserId();
return $this->firstForUserIdOrCreate($userId, $site, $action, $parameters);
} catch (Exception $e) {
Log::error($e->getMessage());
throw $e;
}
}
/**
* Get the first record or create the profile for a userId and site.
*/
protected function firstForUserIdOrCreate(int $userId, ExternalProfileSite $site, BaseExternalEntryClaimedAction $action, array $parameters): ExternalProfile
{
$claimedProfile = ExternalProfile::query()
->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId)
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
->whereHas(ExternalProfile::RELATION_USER)
->first();
if ($claimedProfile instanceof ExternalProfile) {
return $claimedProfile;
}
$unclaimedProfile = ExternalProfile::query()
->where(ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID, $userId)
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
->whereDoesntHave(ExternalProfile::RELATION_USER)
->first();
if ($unclaimedProfile instanceof ExternalProfile) {
return tap(
$unclaimedProfile,
fn ($unclaimedProfile) => $unclaimedProfile->update([
ExternalProfile::ATTRIBUTE_USER => Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER),
ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(),
ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value,
])
);
}
/** @var StoreAction<ExternalProfile> $storeAction */
$storeAction = new StoreAction();
return $storeAction->store(ExternalProfile::query(), [
ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $userId,
ExternalProfile::ATTRIBUTE_USER => Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER),
ExternalProfile::ATTRIBUTE_NAME => $action->getUsername(),
ExternalProfile::ATTRIBUTE_SITE => $site->value,
ExternalProfile::ATTRIBUTE_VISIBILITY => ExternalProfileVisibility::PRIVATE->value,
]);
}
/**
* Get the mapping for the entries token class.
*
* @throws RuntimeException
*/
public static function getActionClass(ExternalProfileSite $site, ExternalToken $token): BaseExternalEntryClaimedAction
{
return match ($site) {
ExternalProfileSite::ANILIST => new AnilistExternalEntryClaimedAction($token),
ExternalProfileSite::MAL => new MalExternalEntryClaimedAction($token),
default => throw new RuntimeException("External entry token action not configured for site {$site->localize()}"),
};
}
}
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External;
use App\Actions\Http\Api\StoreAction;
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
use App\Actions\Models\List\External\Entry\Unclaimed\AnilistExternalEntryUnclaimedAction;
use App\Enums\Models\List\ExternalProfileSite;
use App\Enums\Models\List\ExternalProfileVisibility;
use App\Models\List\ExternalProfile;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class StoreExternalProfileUnclaimedAction
{
/**
* Get the first record or store an external profile and its entries given determined username.
*
* @throws Exception
*/
public function firstOrCreate(Builder $builder, array $profileParameters): ExternalProfile
{
$name = Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_NAME);
$site = ExternalProfileSite::fromLocalizedName(Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_SITE));
$visibility = ExternalProfileVisibility::fromLocalizedName(Arr::get($profileParameters, ExternalProfile::ATTRIBUTE_VISIBILITY));
try {
DB::beginTransaction();
$findProfile = ExternalProfile::query()
->where(ExternalProfile::ATTRIBUTE_NAME, $name)
->where(ExternalProfile::ATTRIBUTE_SITE, $site->value)
->first();
if ($findProfile instanceof ExternalProfile) {
throw_if($findProfile->isClaimed(), AuthorizationException::class, "The external profile '{$findProfile->getName()}' is already claimed.");
DB::rollBack();
return $findProfile;
}
$action = static::getActionClass($site, $profileParameters);
/** @var StoreAction<ExternalProfile> $storeAction */
$storeAction = new StoreAction();
$profile = $storeAction->store($builder, [
ExternalProfile::ATTRIBUTE_EXTERNAL_USER_ID => $action->getUserId(),
ExternalProfile::ATTRIBUTE_NAME => $name,
ExternalProfile::ATTRIBUTE_SITE => $site->value,
ExternalProfile::ATTRIBUTE_VISIBILITY => $visibility->value,
]);
DB::commit();
return $profile;
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
/**
* Get the mapping for the entries class.
*
*
* @throws RuntimeException
*/
public static function getActionClass(ExternalProfileSite $site, ExternalProfile|array $profile): BaseExternalEntryUnclaimedAction
{
return match ($site) {
ExternalProfileSite::ANILIST => new AnilistExternalEntryUnclaimedAction($profile),
default => throw new RuntimeException("External entry action not configured for site {$site->localize()}"),
};
}
}
@@ -1,125 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External;
use App\Actions\Models\List\External\Entry\BaseExternalEntryClaimedAction;
use App\Actions\Models\List\External\Entry\BaseExternalEntryUnclaimedAction;
use App\Enums\Models\List\ExternalProfileSite;
use App\Events\List\ExternalProfile\ExternalProfileSynced;
use App\Models\List\External\ExternalEntry;
use App\Models\List\ExternalProfile;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SyncExternalProfileAction
{
protected Collection $resources;
/**
* @throws Exception
*/
public function handle(ExternalProfile $profile): ExternalProfile
{
try {
DB::beginTransaction();
// A claimed profile will be synced using its external token.
$action = $profile->isClaimed()
? $this->getClaimedActionClass($profile)
: $this->getUnclaimedActionClass($profile);
$entries = $action->getEntries();
$this->cacheResources($profile->site);
$externalEntries = [];
foreach ($entries as $entry) {
$externalId = Arr::get($entry, 'external_id');
foreach ($this->getAnimesByExternalId($externalId) as $anime) {
$externalEntries[] = [
ExternalEntry::ATTRIBUTE_SCORE => Arr::get($entry, ExternalEntry::ATTRIBUTE_SCORE),
ExternalEntry::ATTRIBUTE_IS_FAVORITE => Arr::get($entry, ExternalEntry::ATTRIBUTE_IS_FAVORITE),
ExternalEntry::ATTRIBUTE_STATUS => Arr::get($entry, ExternalEntry::ATTRIBUTE_STATUS),
ExternalEntry::ATTRIBUTE_ANIME => $anime,
ExternalEntry::ATTRIBUTE_PROFILE => $profile->getKey(),
];
}
}
// Insert the external entries or update its status filtering by anime id.
ExternalEntry::query()
->upsert(
$externalEntries,
[ExternalEntry::ATTRIBUTE_ANIME, ExternalEntry::ATTRIBUTE_PROFILE],
ExternalEntry::fieldsForUpdate(),
);
// An external entry that is not in the external list anymore should be removed.
ExternalEntry::query()
->where(ExternalEntry::ATTRIBUTE_PROFILE, $profile->getKey())
->whereNotIn(ExternalEntry::ATTRIBUTE_ANIME, Arr::map($externalEntries, fn ($value) => $value[ExternalEntry::ATTRIBUTE_ANIME]))
->delete();
$profile->update([ExternalProfile::ATTRIBUTE_SYNCED_AT => now()]);
DB::commit();
ExternalProfileSynced::dispatch($profile);
return $profile;
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
/**
* Get the mapping for the entries token class.
*/
protected function getClaimedActionClass(ExternalProfile $profile): BaseExternalEntryClaimedAction
{
return StoreExternalProfileClaimedAction::getActionClass($profile->site, $profile->externaltoken);
}
/**
* Get the mapping for the entries class.
*/
protected function getUnclaimedActionClass(ExternalProfile $profile): BaseExternalEntryUnclaimedAction
{
return StoreExternalProfileUnclaimedAction::getActionClass($profile->site, $profile);
}
/**
* Cache the resources for performance proposals.
*/
protected function cacheResources(ExternalProfileSite $profileSite): void
{
// External resources are only added by mods so it doesn't change too often.
$this->resources = Cache::flexible("resources_{$profileSite->name}", [60, 300], fn () => ExternalResource::query()
->where(ExternalResource::ATTRIBUTE_SITE, $profileSite->getResourceSite()->value)
->with([ExternalResource::RELATION_ANIME => fn ($query) => $query->select([Anime::TABLE.'.'.Anime::ATTRIBUTE_ID])])
->whereHas(ExternalResource::RELATION_ANIME)
->get()
->mapWithKeys(fn (ExternalResource $resource): array => [$resource->external_id => $resource->anime->map(fn (Anime $anime) => $anime->getKey())]));
}
/**
* @return Collection<int, int>
*/
protected function getAnimesByExternalId(int $externalId): Collection
{
return $this->resources->get($externalId) ?? collect();
}
}
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Token;
use App\Models\List\External\ExternalToken;
use Illuminate\Http\Client\RequestException;
abstract class BaseExternalTokenAction
{
/**
* Use the authorization code to get the tokens and store them.
*
* @throws RequestException
*/
abstract public function store(array $parameters): ExternalToken;
}
@@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Token\Site;
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
use App\Constants\Config\ServiceConstants;
use App\Models\List\External\ExternalToken;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AnilistExternalTokenAction extends BaseExternalTokenAction
{
/**
* Use the authorization code to get the tokens and store them.
*
* @param array{code: string} $parameters
*/
public function store(array $parameters): ExternalToken
{
$code = Arr::string($parameters, 'code');
try {
$response = Http::asForm()
->acceptJson()
->post('https://anilist.co/api/v2/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => Config::get(ServiceConstants::ANILIST_CLIENT_ID),
'client_secret' => Config::get(ServiceConstants::ANILIST_CLIENT_SECRET),
'redirect_uri' => Config::get(ServiceConstants::ANILIST_REDIRECT_URI),
'code' => $code,
])
->throw()
->json();
$token = Arr::string($response, 'access_token');
return ExternalToken::query()->create([
ExternalToken::ATTRIBUTE_ACCESS_TOKEN => Crypt::encrypt($token),
]);
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
}
}
}
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\External\Token\Site;
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
use App\Constants\Config\ServiceConstants;
use App\Models\List\External\ExternalToken;
use Exception;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MalExternalTokenAction extends BaseExternalTokenAction
{
/**
* Use the authorization code to get the tokens and store them.
*
* @param array{code: string, state: string} $parameters
*
* @throws Exception
*/
public function store(array $parameters): ExternalToken
{
$code = Arr::string($parameters, 'code');
$state = Arr::string($parameters, 'state');
$codeVerifier = Cache::get("mal-external-token-request-{$state}");
try {
$response = Http::asForm()
->acceptJson()
->post('https://myanimelist.net/v1/oauth2/token', [
'grant_type' => 'authorization_code',
'client_id' => Config::string(ServiceConstants::MAL_CLIENT_ID),
'client_secret' => Config::string(ServiceConstants::MAL_CLIENT_SECRET),
'redirect_uri' => Config::string(ServiceConstants::MAL_REDIRECT_URI),
'code' => $code,
'code_verifier' => $codeVerifier,
])
->throw()
->json();
$token = Arr::string($response, 'access_token');
$refreshToken = Arr::string($response, 'refresh_token');
return ExternalToken::query()->create([
ExternalToken::ATTRIBUTE_ACCESS_TOKEN => Crypt::encrypt($token),
ExternalToken::ATTRIBUTE_REFRESH_TOKEN => Crypt::encrypt($refreshToken),
]);
} catch (RequestException $e) {
Log::error($e->getMessage());
throw $e;
} finally {
Cache::forget("mal-external-token-request-{$state}");
}
}
}
@@ -1,74 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List;
use App\Actions\Models\List\External\StoreExternalProfileClaimedAction;
use App\Actions\Models\List\External\Token\BaseExternalTokenAction;
use App\Actions\Models\List\External\Token\Site\AnilistExternalTokenAction;
use App\Actions\Models\List\External\Token\Site\MalExternalTokenAction;
use App\Enums\Models\List\ExternalProfileSite;
use App\Models\List\External\ExternalToken;
use App\Models\List\ExternalProfile;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ExternalTokenCallbackAction
{
/**
* We should store the token and the profile.
*
* @throws Exception
*/
public function store(array $parameters): ExternalProfile
{
try {
DB::beginTransaction();
$site = ExternalProfileSite::fromLocalizedName(Arr::get($parameters, ExternalProfile::ATTRIBUTE_SITE));
$externalToken = ExternalToken::query()
->whereRelation(ExternalToken::RELATION_PROFILE, ExternalProfile::ATTRIBUTE_USER, Arr::integer($parameters, ExternalProfile::ATTRIBUTE_USER))
->whereRelation(ExternalToken::RELATION_PROFILE, ExternalProfile::ATTRIBUTE_SITE, $site->value)
->first();
if (! $externalToken instanceof ExternalToken) {
$externalToken = $this->getActionClass($site)->store($parameters);
}
$profileAction = new StoreExternalProfileClaimedAction();
$profile = $profileAction->firstOrCreate($externalToken, $parameters);
$profile->externaltoken()->save($externalToken);
DB::commit();
return $profile;
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
/**
* Get the mapping for the token class.
*
* @throws RuntimeException
*/
protected function getActionClass(ExternalProfileSite $site): BaseExternalTokenAction
{
return match ($site) {
ExternalProfileSite::ANILIST => new AnilistExternalTokenAction(),
ExternalProfileSite::MAL => new MalExternalTokenAction(),
default => throw new RuntimeException("External token action not configured for site {$site->localize()}"),
};
}
}
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\Playlist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class FixPlaylistAction
{
public function handle(Playlist $playlist, mixed $context = null): int
{
// Fetch all tracks in the playlist and index them by track_id
$this->sendMessage("Fetching tracks for playlist ID: {$playlist->getKey()}...", $context, 'info');
/** @var Collection<int, PlaylistTrack> $tracks */
$tracks = PlaylistTrack::query()->where(PlaylistTrack::ATTRIBUTE_PLAYLIST, $playlist->getKey())
->orderBy(PlaylistTrack::ATTRIBUTE_ID)
->get()
->keyBy(PlaylistTrack::ATTRIBUTE_ID);
// Find the playlist and get the first_id
$this->sendMessage('Fetching playlist details...', $context, 'info');
$first_id = $playlist->first_id;
// Initialize arrays for ordered tracks and visited tracks
$orderedTracks = [];
$visitedTracks = [];
// Start at the first_id and follow the next_id chain
$this->sendMessage('Reconstructing the playlist order...', $context, 'info');
$current_id = $first_id;
while ($current_id !== null) {
if (isset($visitedTracks[$current_id])) {
// If we encounter a previously visited track, we've detected a cycle
$this->sendMessage("Cycle detected at track ID: $current_id, stopping...", $context, 'warn');
break;
}
// Mark the current track as visited and add to ordered tracks
$visitedTracks[$current_id] = true;
$orderedTracks[] = $current_id;
// Get the next track in sequence
$currentTrack = $tracks[$current_id];
$current_id = $currentTrack->next_id;
}
// Check if we have traversed all tracks; if not, some tracks are broken
if (count($orderedTracks) !== count($tracks)) {
$this->sendMessage('Detected broken tracks, attempting to add them to the end of the list...', $context, 'warn');
// Find all tracks that were not visited (potentially broken)
$remainingTracks = $tracks->reject(fn ($track): bool => isset($visitedTracks[$track->track_id]));
// Add remaining tracks to the end, preserving the existing order as much as possible
foreach ($remainingTracks as $remainingTrack) {
$orderedTracks[] = $remainingTrack->track_id;
}
}
// Update the previous_id and next_id for all tracks in the correct order
$this->sendMessage('Updating track links (previous_id and next_id)...', $context, 'info');
DB::transaction(function () use ($orderedTracks, $tracks, $playlist, $context): void {
$previous_id = null;
foreach ($orderedTracks as $index => $track_id) {
$track = $tracks[$track_id];
// Get the next track in the sequence or set null if it's the last one
$next_id = $orderedTracks[$index + 1] ?? null;
// Update the previous_id and next_id for the track
$track->previous_id = $previous_id;
$track->next_id = $next_id;
$track->save();
// Move the previous_id pointer forward
$previous_id = $track_id;
}
// Update the playlist's first_id and last_id
$playlist->first_id = $orderedTracks[0];
$playlist->last_id = $orderedTracks[count($orderedTracks) - 1];
$playlist->save();
$this->sendMessage('Playlist first_id and last_id updated successfully.', $context, 'info');
});
$this->sendMessage('Playlist fixed successfully.', $context, 'info');
return 1;
}
/**
* Send the message given the context.
*/
protected function sendMessage(string $message, mixed $context, string $type): void
{
if (is_null($context)) {
return;
}
$translatedType = match (true) {
$context === 'log' => match ($type) {
'warn' => 'warning',
default => $type,
},
default => $type,
};
match (true) {
$context instanceof Command => $context->$translatedType($message),
$context === 'log' => Log::$translatedType($message),
default => null,
};
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\Playlist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InsertTrackAction
{
/**
* Append track to playlist.
*
* @throws Exception
*/
public function insert(Playlist $playlist, PlaylistTrack $track): void
{
try {
DB::beginTransaction();
if ($playlist->first()->doesntExist()) {
$playlist->first()->associate($track)->save();
}
$last = $playlist->last;
$playlist->last()->associate($track)->save();
$last?->next()?->associate($track)?->save();
$track->previous()->associate($last);
$track->next()->disassociate()->save();
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\Playlist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InsertTrackAfterAction
{
/**
* Insert track after previous track.
*
* @throws Exception
*/
public function insertAfter(Playlist $playlist, PlaylistTrack $track, PlaylistTrack $previous): void
{
try {
DB::beginTransaction();
if ($playlist->last()->is($previous)) {
$playlist->last()->associate($track)->save();
}
$next = $previous->next;
$next?->previous()?->associate($track)?->save();
$track->next()->associate($next);
$previous->next()->associate($track)->save();
$track->previous()->associate($previous)->save();
$track->moveAfter($previous);
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\Playlist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class InsertTrackBeforeAction
{
/**
* Insert track before next track.
*
* @throws Exception
*/
public function insertBefore(Playlist $playlist, PlaylistTrack $track, PlaylistTrack $next): void
{
try {
DB::beginTransaction();
if ($playlist->first()->is($next)) {
$playlist->first()->associate($track)->save();
}
$previous = $next->previous;
$previous?->next()?->associate($track)?->save();
$track->previous()->associate($previous);
$next->previous()->associate($track)->save();
$track->next()->associate($next)->save();
$track->moveBefore($next);
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
}
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\List\Playlist;
use App\Models\List\Playlist;
use App\Models\List\Playlist\PlaylistTrack;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RemoveTrackAction
{
/**
* @throws Exception
*/
public function remove(Playlist $playlist, PlaylistTrack $track): void
{
try {
DB::beginTransaction();
$previous = $track->previous;
$next = $track->next;
if ($playlist->first()->is($track)) {
$playlist->first()->associate($next);
}
if ($playlist->last()->is($track)) {
$playlist->last()->associate($previous);
}
$playlist->save();
$previous?->next()?->associate($next)?->save();
$next?->previous()?->associate($previous)?->save();
$track->previous()->disassociate();
$track->next()->disassociate()->save();
PlaylistTrack::query()
->whereBelongsTo($playlist)
->where(PlaylistTrack::ATTRIBUTE_POSITION, '>', $track->position)
->decrement(PlaylistTrack::ATTRIBUTE_POSITION);
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
}
@@ -1,75 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime;
use App\Actions\ActionResult;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
class AnimeDateAction
{
/**
* @param Collection<int, Anime> $anime
*/
public function handle(Collection $anime): ActionResult
{
$anilistIds = $anime
->map(fn (Anime $anime) => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id)
->filter()
->unique()
->values()
->all();
$query = <<<'GRAPHQL'
query AnimeDates($ids: [Int]) {
Page(perPage: 20) {
media(id_in: $ids, type: ANIME) {
id
startDate {
year
month
day
}
endDate {
year
month
day
}
}
}
}
GRAPHQL;
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => [
'ids' => $anilistIds,
],
])
->throw();
if (! $response->ok() || $response->json('errors')) {
return new ActionResult(ActionStatus::FAILED, $response->json('errors.0.message'));
}
foreach ($response->json('data.Page.media') as $media) {
$animeToUpdate = $anime->first(
fn (Anime $anime): bool => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id === Arr::integer($media, 'id')
);
$animeToUpdate?->update([
'start_date' => Arr::get($media, 'startDate'),
'end_date' => Arr::get($media, 'endDate'),
]);
}
return new ActionResult(ActionStatus::PASSED);
}
}
@@ -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);
}
}
}
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AniZipAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
{
public function getSite(): ResourceSite
{
return ResourceSite::ANILIST;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Anime> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value);
if ($resource instanceof ExternalResource) {
$id = $resource->external_id;
try {
$this->response = Http::get("https://api.ani.zip/v1/mappings?anilist_id=$id")
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
}
}
return $this;
}
/**
* Get the mapped resources.
*
* @return string[]
*/
public function getResources(): array
{
$resources = [];
if ($links = $this->response) {
foreach ($this->getResourcesMapping() as $site => $key) {
$id = Arr::get($links, $key);
if ($id !== null) {
$resources[$site] = ResourceSite::from($site)
->formatResourceLink(
Anime::class,
is_numeric($id) ? (int) $id : null,
is_string($id) ? $id : null
);
}
}
}
return $resources;
}
/**
* Get the available sites to backfill.
*/
public function getResourcesMapping(): array
{
return [
ResourceSite::ANIME_PLANET->value => 'animeplanet_id',
ResourceSite::ANIDB->value => 'anidb_id',
ResourceSite::ANILIST->value => 'anilist_id',
ResourceSite::KITSU->value => 'kitsu_id',
ResourceSite::LIVECHART->value => 'livechart_id',
ResourceSite::MAL->value => 'mal_id',
];
}
}
@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillImages;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Enums\Models\Wiki\SynonymType;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AnilistAnimeExternalApiAction extends ExternalApiAction implements BackfillImages, BackfillResources, BackfillSynonyms
{
public function getSite(): ResourceSite
{
return ResourceSite::ANILIST;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Anime> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value);
if ($resource instanceof ExternalResource) {
$query = '
query ($id: Int) {
Media (id: $id, type: ANIME) {
id
title {
romaji
english
native
}
coverImage {
extraLarge
medium
}
externalLinks {
url
site
language
}
}
}
';
$variables = [
'id' => $resource->external_id,
];
try {
$this->response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
}
}
return $this;
}
/**
* Get the mapped resources.
*
* @return string[]
*/
public function getResources(): array
{
$resources = [];
if ($response = $this->response) {
foreach (Arr::get($response, 'data.Media.externalLinks') as $link) {
$url = Arr::get($link, 'url');
$siteAnilist = Arr::get($link, 'site');
$language = Arr::get($link, 'language');
foreach ($this->getResourcesMapping() as $site => $key) {
if ($siteAnilist === $key) {
if (in_array($siteAnilist, ['Official Site', 'Twitter']) && ! in_array($language, ['Japanese', null])) {
continue;
}
$resources[$site] = $url;
}
}
}
}
return $resources;
}
/**
* Get the available sites to backfill.
*/
public function getResourcesMapping(): array
{
return [
ResourceSite::X->value => 'Twitter',
ResourceSite::OFFICIAL_SITE->value => 'Official Site',
ResourceSite::NETFLIX->value => 'Netflix',
ResourceSite::CRUNCHYROLL->value => 'Crunchyroll',
ResourceSite::HIDIVE->value => 'HIDIVE',
ResourceSite::AMAZON_PRIME_VIDEO->value => 'Amazon Prime Video',
ResourceSite::HULU->value => 'Hulu',
ResourceSite::DISNEY_PLUS->value => 'Disney Plus',
];
}
/**
* Get the mapping for the images.
*
* @return string[]
*/
public function getImagesMapping(): array
{
return [
ImageFacet::SMALL_COVER->value => 'data.Media.coverImage.medium',
ImageFacet::LARGE_COVER->value => 'data.Media.coverImage.extraLarge',
];
}
/**
* Get the mapping for the synonyms.
*
* @return string[]
*/
public function getSynonymsMapping(): array
{
return [
SynonymType::ENGLISH->value => 'data.Media.title.english',
SynonymType::NATIVE->value => 'data.Media.title.native',
SynonymType::OTHER->value => 'data.Media.title.romaji',
];
}
}
@@ -1,89 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class JikanAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
{
public function getSite(): ResourceSite
{
return ResourceSite::MAL;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Anime> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
if ($resource instanceof ExternalResource) {
$id = $resource->external_id;
try {
$this->response = Http::get("https://api.jikan.moe/v4/anime/$id/external")
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
}
}
return $this;
}
/**
* Get the mapped resources.
*
* @return string[]
*/
public function getResources(): array
{
$resources = [];
$reversed = array_flip($this->getResourcesMapping());
if ($response = $this->response) {
$links = Arr::get($response, 'data');
foreach ($links as $link) {
$siteMal = Arr::get($link, 'name');
$url = Arr::get($link, 'url');
foreach ($this->getResourcesMapping() as $site => $key) {
if ($siteMal === $key) {
$resourceSite = ResourceSite::from(Arr::get($reversed, $siteMal));
$resources[$site] = $resourceSite->formatResourceLink(Anime::class, intval(ResourceSite::parseIdFromLink($url)));
}
}
}
}
return $resources;
}
/**
* Get the available sites to backfill.
*/
public function getResourcesMapping(): array
{
return [
ResourceSite::ANIDB->value => 'AniDB',
ResourceSite::ANN->value => 'ANN',
];
}
}
@@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LivechartAnimeExternalApiAction extends ExternalApiAction implements BackfillResources
{
public function getSite(): ResourceSite
{
return ResourceSite::LIVECHART;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Anime> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::LIVECHART->value);
if ($resource instanceof ExternalResource) {
$id = $resource->external_id;
try {
$this->response = Http::get("https://www.livechart.me/api/v1/anime/$id")
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
}
}
return $this;
}
/**
* Get the mapping for the resources.
*
* @return string[]
*/
public function getResourcesMapping(): array
{
return [
ResourceSite::ANIDB->value => 'anidb_url',
ResourceSite::ANILIST->value => 'anilist_url',
ResourceSite::ANIME_PLANET->value => 'anime_planet_url',
ResourceSite::ANN->value => 'ann_url',
ResourceSite::KITSU->value => 'kitsu_url',
ResourceSite::MAL->value => 'mal_url',
ResourceSite::OFFICIAL_SITE->value => 'website_url',
ResourceSite::X->value => 'twitter_url',
];
}
}
@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Constants\Config\ServiceConstants;
use App\Contracts\Actions\Models\Wiki\BackfillStudios;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
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 MalAnimeExternalApiAction extends ExternalApiAction implements BackfillStudios
{
public function getSite(): ResourceSite
{
return ResourceSite::MAL;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Anime> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
if ($resource instanceof ExternalResource) {
try {
$this->response = Http::withHeaders(['X-MAL-CLIENT-ID' => Config::get(ServiceConstants::MAL_CLIENT_ID)])
->get("https://api.myanimelist.net/v2/anime/$resource->external_id", [
'fields' => 'studios',
])
->throw()
->json();
} catch (RequestException $e) {
Log::error($e->getMessage());
}
}
return $this;
}
/**
* Get the mapped studios.
*/
public function getStudios(): array
{
return Arr::get($this->response, 'studios', []);
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillLargeCoverImageAction.
*/
class BackfillLargeCoverImageAction extends BackfillAnimeImageAction
{
/**
* 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
{
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
return $this->getAnilistImage($anilistResource);
}
return null;
}
/**
* Query Anilist API for large cover image.
*
* @param ExternalResource $anilistResource
* @return Image|null
*
* @throws RequestException
*/
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
{
$query = '
query ($id: Int) {
Media (id: $id, type: ANIME) {
coverImage {
extraLarge
}
}
}
';
$variables = [
'id' => $anilistResource->external_id,
];
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$anilistCoverLarge = Arr::get($response, 'data.Media.coverImage.extraLarge');
if ($anilistCoverLarge !== null) {
return $this->createImage($anilistCoverLarge);
}
return null;
}
}
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillSmallCoverImageAction.
*/
class BackfillSmallCoverImageAction extends BackfillAnimeImageAction
{
/**
* Get the facet to backfill.
*
* @return ImageFacet
*/
protected function getFacet(): ImageFacet
{
return ImageFacet::COVER_SMALL();
}
/**
* Query third-party APIs to find Image.
*
* @return Image|null
*
* @throws RequestException
*/
protected function getImage(): ?Image
{
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
return $this->getAnilistImage($anilistResource);
}
return null;
}
/**
* Query Anilist API for large cover image.
*
* @param ExternalResource $anilistResource
* @return Image|null
*
* @throws RequestException
*/
protected function getAnilistImage(ExternalResource $anilistResource): ?Image
{
$query = '
query ($id: Int) {
Media (id: $id, type: ANIME) {
coverImage {
medium
}
}
}
';
$variables = [
'id' => $anilistResource->external_id,
];
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$anilistCoverSmall = Arr::get($response, 'data.Media.coverImage.medium');
if ($anilistCoverSmall !== null) {
return $this->createImage($anilistCoverSmall);
}
return null;
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/**
* Class BackfillAnidbResourceAction.
*/
class BackfillAnidbResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
protected function getSite(): ResourceSite
{
return ResourceSite::ANIDB();
}
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getResource(): ?ExternalResource
{
// Allow fall-throughs in case AniDB Resource is not mapped to every external site.
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbMapping($malResource, 'myanimelist');
if ($anidbResource !== null) {
return $anidbResource;
}
// failed mapping, sleep before re-attempting
sleep(rand(1, 3));
}
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$anidbResource = $this->getAnidbMapping($anilistResource, 'anilist');
if ($anidbResource !== null) {
return $anidbResource;
}
// failed mapping, sleep before re-attempting
sleep(rand(1, 3));
}
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
return $this->getAnidbMapping($kitsuResource, 'kitsu');
}
return null;
}
/**
* Query Yuna API for AniDB mapping.
*
* @param ExternalResource $resource
* @param string $source
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getAnidbMapping(ExternalResource $resource, string $source): ?ExternalResource
{
$response = Http::get('https://relations.yuna.moe/api/ids', [
'source' => $source,
'id' => $resource->external_id,
])
->throw()
->json();
$anidbId = Arr::get($response, 'anidb');
// Only proceed if we have a match
if ($anidbId !== null) {
return $this->getOrCreateResource($anidbId);
}
return null;
}
}
@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnilistResourceAction.
*/
class BackfillAnilistResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
protected function getSite(): ResourceSite
{
return ResourceSite::ANILIST();
}
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getResource(): ?ExternalResource
{
// Allow fall-throughs in case Anilist Resource is not mapped to every external site.
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$anilistResource = $this->getMalAnilistMapping($malResource);
if ($anilistResource !== null) {
return $anilistResource;
}
}
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
$anilistResource = $this->getKitsuAnilistMapping($kitsuResource);
if ($anilistResource !== null) {
return $anilistResource;
}
}
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
return $this->getAnidbAnilistMapping($anidbResource);
}
return null;
}
/**
* Query Anilist API for MAL mapping.
*
* @param ExternalResource $malResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getMalAnilistMapping(ExternalResource $malResource): ?ExternalResource
{
$query = '
query ($id: Int) {
Media (idMal: $id, type: ANIME) {
id
}
}
';
$variables = [
'id' => $malResource->external_id,
];
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$anilistId = Arr::get($response, 'data.Media.id');
if ($anilistId === null) {
Log::info("Skipping null Anilist mapping for MAL id '$malResource->external_id'");
return null;
}
return $this->getOrCreateResource($anilistId);
}
/**
* Query Kitsu API for Anilist mapping.
*
* @param ExternalResource $kitsuResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getKitsuAnilistMapping(ExternalResource $kitsuResource): ?ExternalResource
{
$query = '
query ($id: ID!) {
findAnimeById (id: $id) {
mappings(first:20) {
nodes {
externalId
externalSite
}
}
}
}
';
$variables = [
'id' => $kitsuResource->external_id,
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
foreach ($kitsuMappings as $kitsuMapping) {
$externalId = Arr::get($kitsuMapping, 'externalId');
$externalSite = Arr::get($kitsuMapping, 'externalSite');
if ($externalSite !== 'ANILIST_ANIME' || empty($externalId)) {
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
continue;
}
return $this->getOrCreateResource(intval($externalId));
}
return null;
}
/**
* Query Yuna API for Anilist mapping.
*
* @param ExternalResource $anidbResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getAnidbAnilistMapping(ExternalResource $anidbResource): ?ExternalResource
{
$response = Http::get('https://relations.yuna.moe/api/ids', [
'source' => 'anidb',
'id' => $anidbResource->external_id,
])
->throw()
->json();
$anilistId = Arr::get($response, 'anilist');
if ($anilistId !== null) {
return $this->getOrCreateResource($anilistId);
}
return null;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillAnnResourceAction.
*/
class BackfillAnnResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
protected function getSite(): ResourceSite
{
return ResourceSite::ANN();
}
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getResource(): ?ExternalResource
{
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
return $this->getKitsuAnnMapping($kitsuResource);
}
return null;
}
/**
* Query Kitsu API for ANN mapping.
*
* @param ExternalResource $kitsuResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getKitsuAnnMapping(ExternalResource $kitsuResource): ?ExternalResource
{
$query = '
query ($id: ID!) {
findAnimeById (id: $id) {
mappings(first:20) {
nodes {
externalId
externalSite
}
}
}
}
';
$variables = [
'id' => $kitsuResource->external_id,
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
foreach ($kitsuMappings as $kitsuMapping) {
$externalId = Arr::get($kitsuMapping, 'externalId');
$externalSite = Arr::get($kitsuMapping, 'externalSite');
if ($externalSite !== 'ANIMENEWSNETWORK' || empty($externalId)) {
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
continue;
}
return $this->getOrCreateResource(intval($externalId));
}
return null;
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillKitsuResourceAction.
*/
class BackfillKitsuResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
protected function getSite(): ResourceSite
{
return ResourceSite::KITSU();
}
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getResource(): ?ExternalResource
{
// Allow fall-throughs in case Kitsu Resource is not mapped to every external site.
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($malResource, 'MYANIMELIST_ANIME');
if ($kitsuResource !== null) {
return $kitsuResource;
}
// failed mapping, sleep before re-attempting
sleep(rand(1, 3));
}
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($anilistResource, 'ANILIST_ANIME');
if ($kitsuResource !== null) {
return $kitsuResource;
}
// failed mapping, sleep before re-attempting
sleep(rand(1, 3));
}
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
$kitsuResource = $this->getKitsuMapping($anidbResource, 'ANIDB');
if ($kitsuResource !== null) {
return $kitsuResource;
}
// failed mapping, sleep before re-attempting
sleep(rand(1, 3));
}
$annResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANN);
if ($annResource instanceof ExternalResource) {
return $this->getKitsuMapping($annResource, 'ANIMENEWSNETWORK');
}
return null;
}
/**
* Query Kitsu API for MAL mapping.
*
* @param ExternalResource $resource
* @param string $externalSite
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getKitsuMapping(ExternalResource $resource, string $externalSite): ?ExternalResource
{
$query = '
query ($externalId: ID!, $externalSite: MappingExternalSiteEnum!) {
lookupMapping(externalId: $externalId, externalSite: $externalSite) {
... on Anime {
id
slug
}
}
}
';
$variables = [
'externalId' => $resource->external_id,
'externalSite' => $externalSite,
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$kitsuMapping = Arr::get($response, 'data.lookupMapping');
if ($kitsuMapping !== null) {
$id = Arr::get($kitsuMapping, 'id');
$slug = Arr::get($kitsuMapping, 'slug');
if (empty($id) || empty($slug)) {
Log::info("Skipping mapping of id '$id' and slug '$slug'");
return null;
}
return $this->getOrCreateResource(intval($id), $slug);
}
return null;
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
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 Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Class BackfillMalResourceAction.
*/
class BackfillMalResourceAction extends BackfillAnimeResourceAction
{
/**
* Get the site to backfill.
*
* @return ResourceSite
*/
protected function getSite(): ResourceSite
{
return ResourceSite::MAL();
}
/**
* Query third-party APIs to find Resource mapping.
*
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getResource(): ?ExternalResource
{
$kitsuResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::KITSU);
if ($kitsuResource instanceof ExternalResource) {
$malResource = $this->getKitsuMalMapping($kitsuResource);
if ($malResource !== null) {
return $malResource;
}
}
$anilistResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST);
if ($anilistResource instanceof ExternalResource) {
$malResource = $this->getAnilistMalMapping($anilistResource);
if ($malResource !== null) {
return $malResource;
}
}
$anidbResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANIDB);
if ($anidbResource instanceof ExternalResource) {
return $this->getAnidbMalMapping($anidbResource);
}
return null;
}
/**
* Query Kitsu API for MAL mapping.
*
* @param ExternalResource $kitsuResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getKitsuMalMapping(ExternalResource $kitsuResource): ?ExternalResource
{
$query = '
query ($id: ID!) {
findAnimeById (id: $id) {
mappings(first:20) {
nodes {
externalId
externalSite
}
}
}
}
';
$variables = [
'id' => $kitsuResource->external_id,
];
$response = Http::post('https://kitsu.io/api/graphql', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$kitsuMappings = Arr::get($response, 'data.findAnimeById.mappings.nodes', []);
foreach ($kitsuMappings as $kitsuMapping) {
$externalId = Arr::get($kitsuMapping, 'externalId');
$externalSite = Arr::get($kitsuMapping, 'externalSite');
if ($externalSite !== 'MYANIMELIST_ANIME' || empty($externalId)) {
Log::info("Skipping mapping of externalId '$externalId' and externalSite '$externalSite'");
continue;
}
return $this->getOrCreateResource(intval($externalId));
}
return null;
}
/**
* Query Anilist API for MAL mapping.
*
* @param ExternalResource $anilistResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getAnilistMalMapping(ExternalResource $anilistResource): ?ExternalResource
{
$query = '
query ($id: Int) {
Media (id: $id, type: ANIME) {
idMal
}
}
';
$variables = [
'id' => $anilistResource->external_id,
];
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => $variables,
])
->throw()
->json();
$malId = Arr::get($response, 'data.Media.idMal');
if ($malId === null) {
Log::info("Skipping null MAL mapping for Anilist id '$anilistResource->external_id'");
return null;
}
return $this->getOrCreateResource($malId);
}
/**
* Query Yuna API for Mal mapping.
*
* @param ExternalResource $anidbResource
* @return ExternalResource|null
*
* @throws RequestException
*/
protected function getAnidbMalMapping(ExternalResource $anidbResource): ?ExternalResource
{
$response = Http::get('https://relations.yuna.moe/api/ids', [
'source' => 'anidb',
'id' => $anidbResource->external_id,
])
->throw()
->json();
$malId = Arr::get($response, 'myanimelist');
if ($malId !== null) {
return $this->getOrCreateResource($malId);
}
return null;
}
}
@@ -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));
}
}
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Storage\Wiki\Image\UploadImageAction;
use App\Contracts\Models\HasImages;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\BaseModel;
use Illuminate\Support\Arr;
class AttachImageAction
{
/**
* @param ImageFacet[] $facets
*/
public function handle(BaseModel&HasImages $model, array $fields, array $facets): ActionResult
{
foreach ($facets as $facet) {
$image = Arr::get($fields, $facet->name);
if (blank($image)) {
continue;
}
UploadImageAction::createImageFromFile($image, $facet, $model);
}
return new ActionResult(ActionStatus::PASSED);
}
}
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Concerns\Models\CanCreateExternalResource;
use App\Contracts\Models\HasResources;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\BaseModel;
use Illuminate\Support\Arr;
class AttachResourceAction
{
use CanCreateExternalResource;
/**
* @param ResourceSite[] $sites
*/
public function handle(BaseModel&HasResources $model, array $fields, array $sites): ActionResult
{
foreach ($sites as $resourceSite) {
$url = Arr::get($fields, $resourceSite->name);
if (blank($url)) {
continue;
}
$this->createResource($url, $resourceSite, $model);
}
return new ActionResult(ActionStatus::PASSED);
}
}
@@ -1,170 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BackfillWikiAction;
use App\Actions\Models\Wiki\Anime\ExternalApi\AnilistAnimeExternalApiAction;
use App\Actions\Models\Wiki\Anime\ExternalApi\AniZipAnimeExternalApiAction;
use App\Actions\Models\Wiki\Anime\ExternalApi\JikanAnimeExternalApiAction;
use App\Actions\Models\Wiki\Anime\ExternalApi\LivechartAnimeExternalApiAction;
use App\Actions\Models\Wiki\Anime\ExternalApi\MalAnimeExternalApiAction;
use App\Concerns\Models\CanCreateStudio;
use App\Concerns\Models\HasLabel;
use App\Contracts\Actions\Models\Wiki\BackfillImages;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Contracts\Actions\Models\Wiki\BackfillStudios;
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\SynonymType;
use App\Models\Wiki\Anime;
use App\Models\Wiki\Synonym;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class BackfillAnimeAction extends BackfillWikiAction
{
use CanCreateStudio;
use HasLabel;
final public const string STUDIOS = 'studios';
final public const string SYNONYMS = 'synonyms';
public function __construct(protected Anime $anime, protected array $toBackfill)
{
parent::__construct($anime, $toBackfill);
}
public function handle(): ActionResult
{
try {
foreach ($this->getExternalApiActions() as $api) {
DB::beginTransaction();
if (
count($this->toBackfill[self::RESOURCES]) === 0
&& count($this->toBackfill[self::IMAGES]) === 0
&& ! $this->toBackfill[self::STUDIOS]
&& ! $this->toBackfill[self::SYNONYMS]
) {
// Don't make other requests if everything is backfilled
Log::info("Backfill action finished for Anime {$this->getModel()->getName()}");
DB::rollBack();
break;
}
$response = $api->handle($this->getModel()->resources());
if ($response instanceof BackfillResources) {
$this->forResources($response);
}
if ($response instanceof BackfillImages) {
$this->forImages($response);
}
if ($response instanceof BackfillStudios) {
$this->forStudios($response);
}
if ($response instanceof BackfillSynonyms) {
$this->forSynonyms($response);
}
DB::commit();
}
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
return new ActionResult(ActionStatus::PASSED);
}
/**
* Get the external API actions available for the backfill action.
*
* @return ExternalApiAction[]
*/
protected function getExternalApiActions(): array
{
return [
new AniZipAnimeExternalApiAction(),
new LivechartAnimeExternalApiAction(),
new AnilistAnimeExternalApiAction(),
new JikanAnimeExternalApiAction(),
new MalAnimeExternalApiAction(),
];
}
/**
* Create the studios given the response.
*/
protected function forStudios(ExternalApiAction&BackfillStudios $response): void
{
$studios = $response->getStudios();
if (! $this->toBackfill[self::STUDIOS]) {
return;
}
foreach ($studios as $studio) {
$id = Arr::get($studio, 'id');
$name = Arr::get($studio, 'name');
if (blank($name) || blank($id)) {
Log::info("Skipping empty studio of name '$name' and id '$id''");
continue;
}
$studio = $this->getOrCreateStudio($name);
Log::info("Attaching Studio of name '$name' to Anime {$this->getModel()->getName()}");
$this->getModel()->studios()->attach($studio);
$this->ensureStudioHasResource($studio, $response->getSite(), $id);
}
if ($this->getModel()->studios()->exists()) {
$this->toBackfill[self::STUDIOS] = false;
}
}
/**
* Create the synonyms given the response.
*/
protected function forSynonyms(ExternalApiAction&BackfillSynonyms $api): void
{
if (! $this->toBackfill[self::SYNONYMS]) {
return;
}
$synonyms = $api->getSynonyms()
->reject(
fn (string $text, int $type): bool => $type === SynonymType::OTHER->value && Str::is($text, $this->getModel()->getName(), true)
)
->all();
foreach ($synonyms as $type => $text) {
$this->getModel()->synonyms()->create([
Synonym::ATTRIBUTE_TEXT => $text,
Synonym::ATTRIBUTE_TYPE => $type,
]);
$this->toBackfill[self::SYNONYMS] = false;
}
}
protected function getModel(): Anime
{
return $this->anime;
}
}
@@ -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;
}
@@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BackfillAction;
use App\Concerns\Models\CanCreateExternalResource;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Song;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Uri;
/**
* @extends BackfillAction<Song>
*/
class BackfillSongAction extends BackfillAction
{
use CanCreateExternalResource;
public function __construct(
Song $song,
protected readonly string $lnkto,
) {
parent::__construct($song);
}
/**
* @throws Exception
*/
public function handle(): ActionResult
{
try {
DB::beginTransaction();
// Request to lnk.to site
$response = Http::get($this->lnkto)
->throw();
$pattern = '/<a[^>]*class="[^"]*music-service-list__link[^"]*js-redirect[^"]*"[^>]*href="([^"]+)"[^>]*data-label="([^"]*)"[^>]*>/i';
preg_match_all($pattern, (string) $response->body(), $matches);
foreach ($matches[1] as $key => $link) {
$label = $matches[2][$key];
$resourceSite = $this->getMappingFromExternalSite($label);
if (! $resourceSite instanceof ResourceSite) {
Log::info("Skipping {$label} for Song {$this->getModel()->getName()}");
continue;
}
$this->createResource(Uri::of($link), $resourceSite, $this->getModel());
}
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
return new ActionResult(ActionStatus::PASSED);
}
/**
* Get the resource site according the external label.
*/
protected function getMappingFromExternalSite(string $label): ?ResourceSite
{
return match ($label) {
'applemusic' => ResourceSite::APPLE_MUSIC,
'spotify' => ResourceSite::SPOTIFY,
'youtubemusic' => ResourceSite::YOUTUBE_MUSIC,
'amazonmp3' => ResourceSite::AMAZON_MUSIC,
default => null,
};
}
/**
* Get the model the action is handling.
*/
protected function getModel(): Song
{
return $this->model;
}
/**
* Get the relation to resources.
*/
protected function relation(): BelongsToMany
{
return $this->getModel()->resources();
}
}
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Actions\ActionResult;
use App\Actions\Models\BackfillWikiAction;
use App\Actions\Models\Wiki\Studio\ExternalApi\MalStudioExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillImages;
use App\Enums\Actions\ActionStatus;
use App\Models\Wiki\Studio;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class BackfillStudioAction extends BackfillWikiAction
{
public function __construct(protected Studio $studio, protected array $toBackfill)
{
parent::__construct($studio, $toBackfill);
}
public function handle(): ActionResult
{
foreach ($this->getExternalApiActions() as $api) {
try {
DB::beginTransaction();
if (count($this->toBackfill[self::IMAGES]) === 0) {
// Don't make other requests if everything is backfilled
Log::info("Backfill action finished for Studio {$this->getModel()->getName()}");
DB::rollBack();
return new ActionResult(ActionStatus::SKIPPED);
}
$response = $api->handle($this->getModel()->resources());
if ($api instanceof BackfillImages) {
$this->forImages($response);
}
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
}
return new ActionResult(ActionStatus::PASSED);
}
/**
* Get the external API actions available for the backfill action.
*
* @return ExternalApiAction[]
*/
protected function getExternalApiActions(): array
{
return [
new MalStudioExternalApiAction(),
];
}
protected function getModel(): Studio
{
return $this->studio;
}
}
@@ -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;
}
@@ -1,94 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki;
use App\Contracts\Actions\Models\Wiki\BackfillImages;
use App\Contracts\Actions\Models\Wiki\BackfillResources;
use App\Contracts\Actions\Models\Wiki\BackfillSynonyms;
use App\Enums\Models\Wiki\ResourceSite;
use App\Enums\Models\Wiki\SynonymType;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
abstract class ExternalApiAction
{
public ?array $response = null;
abstract public function getSite(): ResourceSite;
/**
* Set the response after the request.
*/
abstract public function handle(BelongsToMany $resources): static;
/**
* Get the mapped resources.
*
* @return string[]
*/
public function getResources(): array
{
$resources = [];
if ($this instanceof BackfillResources && $this->response) {
foreach ($this->getResourcesMapping() as $site => $key) {
if (Arr::get($this->response, $key) !== null) {
$resources[$site] = Arr::get($this->response, $key);
}
}
}
return $resources;
}
/**
* Get the mapped images.
*
* @return string[]
*/
public function getImages(): array
{
$images = [];
if ($this instanceof BackfillImages && $this->response) {
foreach ($this->getImagesMapping() as $facet => $key) {
if (Arr::get($this->response, $key) !== null) {
$images[$facet] = Arr::get($this->response, $key);
}
}
}
return $images;
}
/**
* Get the mapped synonyms.
*
* @return Collection<int, string>
*/
public function getSynonyms(): Collection
{
$synonyms = collect();
if ($this instanceof BackfillSynonyms && $this->response) {
foreach ($this->getSynonymsMapping() as $type => $key) {
$synonyms->put($type, Arr::get($this->response, $key));
}
}
return $synonyms
->filter(fn (?string $text): bool => filled($text))
->reject(function (string $text, int $type) use ($synonyms) {
if ($type !== SynonymType::OTHER->value) {
return false;
}
return $synonyms
->except([SynonymType::OTHER->value])
->containsStrict($text);
});
}
}
@@ -1,212 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Image;
use App\Actions\ActionResult;
use App\Constants\Config\ImageConstants;
use App\Enums\Actions\ActionStatus;
use App\Models\Wiki\Image;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class OptimizeImageAction
{
public function __construct(
protected readonly Image $image,
protected readonly ?string $extension = null,
protected readonly ?int $width = null,
protected readonly ?int $height = null,
) {}
/**
* @throws Exception
*/
public function handle(): ActionResult
{
if ($this->extension === null && $this->width === null && $this->height === null) {
return new ActionResult(
ActionStatus::SKIPPED,
'No optimization parameters provided, nothing to process.'
);
}
try {
DB::beginTransaction();
$directory = File::dirname($this->image->path);
$optimizedImage = $this->handleFFmpeg();
if ($optimizedImage === null) {
DB::rollBack();
return new ActionResult(
ActionStatus::FAILED,
"Failed to optimize image '{$this->image->path}'.",
);
}
$path = $this->uploadImage(
Storage::disk('local')->path($optimizedImage),
$directory
);
// Delete the old image from the bucket and local.
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
Storage::disk('local')->delete($optimizedImage);
$this->image->update([
Image::ATTRIBUTE_PATH => $path,
]);
DB::commit();
} catch (Exception $e) {
DB::rollBack();
Log::error($e->getMessage());
throw $e;
}
return new ActionResult(ActionStatus::PASSED);
}
protected function handleFFmpeg(): ?string
{
$filesToDelete = [];
$imagePath = null;
try {
Storage::disk('local')->put(
$this->image->path,
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->get($this->image->path),
);
$imagePath = $this->image->path;
$filesToDelete[] = $imagePath;
if ($this->extension !== null) {
$imagePath = $this->convertImage($imagePath);
$filesToDelete[] = $imagePath;
}
if ($this->width !== null && $this->height !== null) {
$imagePath = $this->downscaleImage($imagePath);
$filesToDelete[] = $imagePath;
}
return $imagePath;
} catch (Exception $e) {
Log::error($e->getMessage());
} finally {
Storage::disk('local')->delete(Arr::where($filesToDelete, fn (string $file): bool => $file !== $imagePath));
}
return null;
}
/**
* Convert the image to given extension.
*
* @throws Exception
*/
protected function convertImage(string $path): string
{
[$command, $imagePath] = match ($this->extension) {
'avif' => static::getAvifCommand($path),
default => throw new Exception("Unsupported image extension: {$this->extension}"),
};
Process::run($command)->throw();
return $imagePath;
}
/**
* Downscale the image to given width and height.
*
* @throws Exception
*/
protected function downscaleImage(string $path): string
{
[$command, $imagePath] = static::getDownscaleCommand($path, $this->width, $this->height);
Process::run($command)->throw();
return $imagePath;
}
/**
* Upload the image to the bucket.
*/
protected function uploadImage(string $image, string $directory): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter $fs */
$fs = Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED));
$fsFile = $fs->putFile($directory, $image);
Log::info("Uploading optimized Image {$fsFile}");
return $fsFile;
}
/**
* @return array{0:array<int, string>, 1:string}
*/
public static function getAvifCommand(string $path): array
{
$imagePath = Storage::disk('local')->path(
$newPath = Str::replaceLast(File::extension($path), 'avif', $path)
);
return [
[
'ffmpeg',
'-i',
Storage::disk('local')->path($path),
'-c:v',
'libaom-av1',
'-crf',
'30',
'-pix_fmt',
'yuv420p',
$imagePath,
],
$newPath,
];
}
/**
* @return array{0:array<int, string>, 1:string}
*/
public static function getDownscaleCommand(string $path, int $width = 100, int $height = 150): array
{
$tempPath = Storage::disk('local')->path(
$newPath = Str::replaceLast('.', '_scaled.', $path)
);
return [
[
'ffmpeg',
'-y',
'-i',
Storage::disk('local')->path($path),
'-vf',
"scale={$width}:{$height}",
$tempPath,
],
$newPath,
];
}
}
@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Song;
use App\Models\Wiki\Song;
use App\Models\Wiki\Song\Performance;
use App\Pivots\Wiki\ArtistMember;
use Exception;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ManageSongPerformances
{
protected Song $song;
public function __construct(
Song|int $song,
/** @var Collection<int, non-empty-array<string, mixed>> */
protected Collection $performances = new Collection(),
/** @var Collection<int, array<string, mixed>> */
protected Collection $members = new Collection(),
) {
$this->song = $song instanceof Song ? $song : Song::query()->find($song);
}
public function addArtist(
int $artist,
?int $member = null,
?string $alias = null,
?string $as = null,
?string $memberAlias = null,
?string $memberAs = null
): static {
$this->performances->push([
Performance::ATTRIBUTE_SONG => $this->song->getKey(),
Performance::ATTRIBUTE_ARTIST => $artist,
Performance::ATTRIBUTE_MEMBER => $member,
Performance::ATTRIBUTE_ALIAS => filled($alias) ? trim($alias) : null,
Performance::ATTRIBUTE_AS => filled($as) ? trim($as) : null,
Performance::ATTRIBUTE_MEMBER_ALIAS => filled($memberAlias) ? trim($memberAlias) : null,
Performance::ATTRIBUTE_MEMBER_AS => filled($memberAs) ? trim($memberAs) : null,
]);
if ($member !== null) {
$this->members->put($member, [
ArtistMember::ATTRIBUTE_ARTIST => $artist,
ArtistMember::ATTRIBUTE_MEMBER => $member,
ArtistMember::ATTRIBUTE_ALIAS => filled($memberAlias) ? trim($memberAlias) : null,
ArtistMember::ATTRIBUTE_AS => filled($memberAs) ? trim($memberAs) : null,
]);
}
return $this;
}
public function commit(): static
{
try {
DB::beginTransaction();
$new = collect($this->performances)
->keyBy(fn (array $p): string => $p[Performance::ATTRIBUTE_ARTIST].':'.($p[Performance::ATTRIBUTE_MEMBER] ?? ''));
$existing = Performance::query()
->whereBelongsTo($this->song)
->get()
->keyBy(fn (Performance $p): string => $p->artist_id.':'.($p->member_id ?? ''));
$models = $new->map(
fn (array $performance) => Performance::query()->updateOrCreate(
Arr::only($performance, [Performance::ATTRIBUTE_SONG, Performance::ATTRIBUTE_ARTIST, Performance::ATTRIBUTE_MEMBER]),
Arr::only($performance, [Performance::ATTRIBUTE_ALIAS, Performance::ATTRIBUTE_AS, Performance::ATTRIBUTE_MEMBER_ALIAS, Performance::ATTRIBUTE_MEMBER_AS])
)
);
$existing->diffKeys($new)->each->delete();
Performance::setNewOrder($models->pluck(Performance::ATTRIBUTE_ID)->all());
// Update artist_member table to match member performances
ArtistMember::query()->upsert(
$this->members->all(),
[ArtistMember::ATTRIBUTE_ARTIST, ArtistMember::ATTRIBUTE_MEMBER],
[ArtistMember::ATTRIBUTE_ALIAS, ArtistMember::ATTRIBUTE_AS],
);
static::setArtistMemberRelevance();
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
}
return $this;
}
/**
* Complex query to set relevance for artist members based on created_at order.
*/
public static function setArtistMemberRelevance(): void
{
DB::statement('
WITH base AS (
SELECT
artist_id,
COALESCE(MAX(relevance), 0) AS max_rel
FROM artist_member
GROUP BY artist_id
),
ranked AS (
SELECT
artist_id,
member_id,
ROW_NUMBER() OVER (
PARTITION BY artist_id
ORDER BY created_at
) AS rn
FROM artist_member
WHERE relevance IS NULL
)
UPDATE artist_member am
JOIN ranked r
ON am.artist_id = r.artist_id
AND am.member_id = r.member_id
JOIN base b
ON b.artist_id = r.artist_id
SET am.relevance = r.rn + b.max_rel;
');
}
}
@@ -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);
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Studio\ExternalApi;
use App\Actions\Models\Wiki\ExternalApiAction;
use App\Contracts\Actions\Models\Wiki\BackfillImages;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Studio;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class MalStudioExternalApiAction extends ExternalApiAction implements BackfillImages
{
public function getSite(): ResourceSite
{
return ResourceSite::MAL;
}
/**
* Set the response after the request.
*
* @param BelongsToMany<ExternalResource, Studio> $resources
*/
public function handle(BelongsToMany $resources): static
{
$resource = $resources->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL->value);
if ($resource instanceof ExternalResource) {
$this->response = [
'images' => [
'large' => "https://cdn.myanimelist.net/images/company/$resource->external_id.png",
],
];
}
return $this;
}
/**
* Get the mapping for the images.
*
* @return string[]
*/
public function getImagesMapping(): array
{
return [
ImageFacet::LARGE_COVER->value => 'images.large',
];
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Studio\Image;
use App\Actions\Models\Wiki\Studio\BackfillStudioImageAction;
use App\Enums\Models\Wiki\ImageFacet;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\ExternalResource;
use App\Models\Wiki\Image;
use Illuminate\Http\Client\RequestException;
/**
* Class BackfillLargeCoverImageAction.
*/
class BackfillLargeCoverImageAction extends BackfillStudioImageAction
{
/**
* Get the facet to backfill.
*
* @return ImageFacet
*/
protected function getFacet(): ImageFacet
{
return ImageFacet::COVER_LARGE();
}
/**
* Query third-party APIs to find Image.
*
* @return Image|null
*
* @throws RequestException
*/
protected function getImage(): ?Image
{
$malResource = $this->getModel()->resources()->firstWhere(ExternalResource::ATTRIBUTE_SITE, ResourceSite::MAL);
if ($malResource instanceof ExternalResource) {
return $this->getMalImage($malResource);
}
return null;
}
/**
* Query MAL API for large cover image.
*
* @param ExternalResource $malResource
* @return Image|null
*
* @throws RequestException
*/
protected function getMalImage(ExternalResource $malResource): ?Image
{
return $this->createImage("https://cdn.myanimelist.net/images/company/$malResource->external_id.png");
}
}
@@ -5,91 +5,92 @@ declare(strict_types=1);
namespace App\Actions\Models\Wiki\Video\Audio;
use App\Actions\ActionResult;
use App\Actions\Models\BackfillAction;
use App\Actions\Storage\Wiki\Audio\MoveAudioAction;
use App\Actions\Storage\Wiki\Audio\UploadAudioAction;
use App\Actions\Models\BaseAction;
use App\Actions\Repositories\ReconcileResults;
use App\Actions\Repositories\Wiki\Audio\ReconcileAudioRepositoriesAction;
use App\Constants\Config\AudioConstants;
use App\Constants\Config\VideoConstants;
use App\Contracts\Repositories\RepositoryInterface;
use App\Enums\Actions\ActionStatus;
use App\Enums\Actions\Models\Wiki\Video\DeriveSourceVideo;
use App\Enums\Actions\Models\Wiki\Video\OverwriteAudio;
use App\Enums\Actions\Models\Wiki\Video\ReplaceRelatedAudio;
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\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg;
/**
* @extends BackfillAction<Video>
* Class BackfillVideoAudioAction.
*
* @extends BaseAction<Video>
*/
class BackfillAudioAction extends BackfillAction
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 = DeriveSourceVideo::YES,
protected readonly OverwriteAudio $overwriteAudio = OverwriteAudio::NO,
protected readonly ReplaceRelatedAudio $replaceRelatedAudio = ReplaceRelatedAudio::NO,
protected readonly DeriveSourceVideo $deriveSourceVideo = new DeriveSourceVideo(DeriveSourceVideo::YES),
protected readonly OverwriteAudio $overwriteAudio = new OverwriteAudio(OverwriteAudio::NO)
) {
parent::__construct($video);
}
/**
* @throws Exception
* Handle action.
*
* @return ActionResult
*/
public function handle(): ActionResult
{
try {
DB::beginTransaction();
if ($this->relation()->getQuery()->exists() && ! $this->overwriteAudio()) {
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Audio'.");
if ($this->relation()->getQuery()->exists() && ! $this->overwriteAudio()) {
DB::rollBack();
Log::info("{$this->label()} '{$this->getModel()->getName()}' already has Audio'.");
return new ActionResult(ActionStatus::SKIPPED);
}
$audio = $this->getAudio();
if ($audio instanceof Audio) {
$this->attachAudio($audio);
}
if ($this->relation()->getQuery()->doesntExist()) {
DB::rollBack();
return new ActionResult(
ActionStatus::FAILED,
"{$this->label()} '{$this->getModel()->getName()}' has no Audio after backfilling. Please review."
);
}
DB::commit();
} catch (Exception $e) {
Log::error($e->getMessage());
DB::rollBack();
throw $e;
return new ActionResult(ActionStatus::SKIPPED());
}
return new ActionResult(ActionStatus::PASSED);
$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
{
@@ -98,63 +99,54 @@ class BackfillAudioAction extends BackfillAction
/**
* 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 $this->deriveSourceVideo === DeriveSourceVideo::YES;
return DeriveSourceVideo::YES()->is($this->deriveSourceVideo);
}
/**
* Determine if audio should be overwritten.
*
* @return bool
*/
protected function overwriteAudio(): bool
{
return $this->overwriteAudio === OverwriteAudio::YES;
}
protected function replaceRelatedAudio(): bool
{
return $this->replaceRelatedAudio === ReplaceRelatedAudio::YES;
return OverwriteAudio::YES()->is($this->overwriteAudio);
}
/**
* Get or Create Audio.
*
* @throws Exception
* @return Audio|null
*/
protected function getAudio(): ?Audio
{
// Allow bypassing of source video derivation
if ($this->replaceRelatedAudio()) {
$sourceVideo = $this->getSourceVideo(false);
} elseif ($this->deriveSourceVideo()) {
$sourceVideo = $this->getSourceVideo();
} else {
$sourceVideo = $this->getModel();
}
$sourceVideo = $this->deriveSourceVideo()
? $this->getSourceVideo()
: $this->getModel();
// It's possible that the video is not attached to any themes, exit early.
if (! $sourceVideo instanceof Video) {
Log::error('Could not derive source video');
if ($sourceVideo === null) {
return null;
}
// First, attempt to set audio from the source video
$audio = $sourceVideo->audio;
// When uploading a BD version we should get the parent audio of a WEB version and
// move the file overwriting the content later. Therefore, the old model is not deleted.
if ($this->replaceRelatedAudio() && $audio instanceof Audio) {
$moveAction = new MoveAudioAction($audio, Str::replace('webm', 'ogg', $this->getModel()->path()));
$storageResults = $moveAction->handle();
$audio = $moveAction->then($storageResults);
}
// Anticipate audio path for FFmpeg save file
$audioPath = $audio === null
? Str::replace('webm', 'ogg', $sourceVideo->path)
@@ -166,26 +158,44 @@ class BackfillAudioAction extends BackfillAction
}
// Finally, extract audio from the source video
if ($audio === null || $this->overwriteAudio() || $this->replaceRelatedAudio()) {
if ($this->replaceRelatedAudio()) {
$sourceVideo = $this->getModel();
}
if ($audio === null || $this->overwriteAudio()) {
Log::info("Extracting Audio from Video '{$sourceVideo->getName()}'");
return $this->extractAudio($sourceVideo);
$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;
}
protected function getSourceVideo(bool $descending = true): ?Video
/**
* Get the source video for the given video.
*
* @return Video|null
*/
protected function getSourceVideo(): ?Video
{
return $this->getAdjacentVideos()->sortBy(
fn (Video $video) => $video->getAttribute(Video::ATTRIBUTE_PRIORITY),
descending: $descending
)->first();
$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
@@ -196,7 +206,7 @@ class BackfillAudioAction extends BackfillAction
$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_START_DATE]);
$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())
@@ -214,51 +224,57 @@ class BackfillAudioAction extends BackfillAction
/**
* Extract audio stream from video and store in filesystem.
*
* @param Video $video
* @param string $audioPath
* @return void
*/
protected function extractAudio(Video $video): ?Audio
protected function extractAudio(Video $video, string $audioPath): void
{
$audioBasename = Str::replace('webm', 'ogg', $video->basename);
$audioPath = Storage::disk('local')->path($audioBasename);
try {
Storage::disk('local')->put(
$video->basename,
Storage::disk(Config::get(VideoConstants::DEFAULT_DISK_QUALIFIED))->get($video->path)
);
Process::run([
'ffmpeg',
'-v',
'quiet',
'-i',
Storage::disk('local')->path($video->basename),
'-vn',
'-acodec',
'copy',
'-f',
'ogg',
'-y',
$audioPath,
])
->throw();
$uploadAudio = new UploadAudioAction(
new UploadedFile($audioPath, $audioBasename),
File::dirname($video->path)
);
$storageResults = $uploadAudio->handle();
return $uploadAudio->then($storageResults);
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 {
Storage::disk('local')->delete([$video->basename, $audioBasename]);
FFMpeg::cleanupTemporaryFiles();
}
return null;
}
/**
* Reconcile audio repositories.
*
* @param string $audioPath
* @return ReconcileResults
*/
protected function reconcileAudio(string $audioPath): ReconcileResults
{
$action = new ReconcileAudioRepositoriesAction();
/** @var RepositoryInterface $sourceRepository */
$sourceRepository = App::make(AudioSourceRepository::class);
$sourceRepository->handleFilter('path', File::dirname($audioPath));
/** @var RepositoryInterface $destinationRepository */
$destinationRepository = App::make(AudioDestinationRepository::class);
$destinationRepository->handleFilter('path', File::dirname($audioPath));
return $action->reconcileRepositories($sourceRepository, $destinationRepository);
}
/**
* Attach Audio to model.
*
* @param Audio $audio
* @return void
*/
protected function attachAudio(Audio $audio): void
{
if ($this->relation()->isNot($audio)) {
@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileDumpRepositories.
*
* @extends ReconcileRepositoriesAction<Dump>
*/
class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
@@ -31,10 +33,12 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Dump $first, Dump $second): int => [$first->path] <=> [$second->path];
return fn (Dump $first, Dump $second) => [$first->path] <=> [$second->path];
}
/**
@@ -49,14 +53,20 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (): int => 0;
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
{
@@ -65,6 +75,11 @@ class ReconcileDumpRepositoriesAction extends ReconcileRepositoriesAction
/**
* Get reconciliation results.
*
* @param Collection $created
* @param Collection $deleted
* @param Collection $updated
* @return ReconcileResults
*/
protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults
{
@@ -8,6 +8,8 @@ use App\Actions\Repositories\ReconcileResults;
use App\Models\Admin\Dump;
/**
* Class ReconcileDumpResults.
*
* @extends ReconcileResults<Dump>
*/
class ReconcileDumpResults extends ReconcileResults
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Balance;
use App\Actions\Repositories\ReconcileRepositoriesAction;
use App\Actions\Repositories\ReconcileResults;
use App\Enums\Http\Api\Filter\AllowedDateFormat;
use App\Models\Billing\Balance;
use Closure;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Class ReconcileBalanceRepositories.
*
* @extends ReconcileRepositoriesAction<Balance>
*/
class ReconcileBalanceRepositoriesAction extends ReconcileRepositoriesAction
{
/**
* The columns used for create and delete set operations.
*
* @return string[]
*/
protected function columnsForCreateDelete(): array
{
return [
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
];
}
/**
* Callback for create and delete set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForCreateDelete(): Closure
{
return fn (Balance $first, Balance $second) => $first->date->format(AllowedDateFormat::YM) <=> $second->date->format(AllowedDateFormat::YM);
}
/**
* The columns used for update set operation.
*
* @return string[]
*/
protected function columnsForUpdate(): array
{
return [
Balance::ATTRIBUTE_BALANCE,
Balance::ATTRIBUTE_DATE,
Balance::ATTRIBUTE_FREQUENCY,
Balance::ATTRIBUTE_ID,
Balance::ATTRIBUTE_SERVICE,
Balance::ATTRIBUTE_USAGE,
];
}
/**
* Callback for update set operation item comparison.
*
* @return Closure
*/
protected function diffCallbackForUpdate(): Closure
{
return fn (Balance $first, Balance $second) => [$first->date->format(AllowedDateFormat::YMD), $first->usage, $first->balance]
<=> [$second->date->format(AllowedDateFormat::YMD), $second->usage, $second->balance];
}
/**
* Get source model that has been updated for destination model.
*
* @param Collection $sourceModels
* @param Model $destinationModel
* @return Model|null
*/
protected function resolveUpdatedModel(Collection $sourceModels, Model $destinationModel): ?Model
{
$formattedDestinationDate = $destinationModel->getAttribute(Balance::ATTRIBUTE_DATE)->format(AllowedDateFormat::YM);
return $sourceModels->first(
fn (Balance $balance) => $balance->date->format(AllowedDateFormat::YM) === $formattedDestinationDate
);
}
/**
* Get reconciliation results.
*
* @param Collection $created
* @param Collection $deleted
* @param Collection $updated
* @return ReconcileResults
*/
protected function getResults(Collection $created, Collection $deleted, Collection $updated): ReconcileResults
{
return new ReconcileBalanceResults($created, $deleted, $updated);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Actions\Repositories\Billing\Balance;
use App\Actions\Repositories\ReconcileResults;
use App\Models\Billing\Balance;
/**
* Class ReconcileBalanceResults.
*
* @extends ReconcileResults<Balance>
*/
class ReconcileBalanceResults extends ReconcileResults
{
/**
* Get the model of the reconciliation results.
*
* @return class-string<Balance>
*/
protected function model(): string
{
return Balance::class;
}
}

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