mirror of
https://github.com/AnimeThemes/animethemes-server.git
synced 2026-07-11 01:24:46 +02:00
304 lines
9.6 KiB
PHP
304 lines
9.6 KiB
PHP
<?php
|
|
|
|
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\Constants\Config\VideoConstants;
|
|
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 Exception;
|
|
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\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 BackfillVideoAudioAction.
|
|
*
|
|
* @extends BackfillAction<Video>
|
|
*/
|
|
class BackfillAudioAction extends BackfillAction
|
|
{
|
|
public function __construct(
|
|
Video $video,
|
|
protected readonly DeriveSourceVideo $deriveSourceVideo = DeriveSourceVideo::YES,
|
|
protected readonly OverwriteAudio $overwriteAudio = OverwriteAudio::NO,
|
|
protected readonly ReplaceRelatedAudio $replaceRelatedAudio = ReplaceRelatedAudio::NO,
|
|
) {
|
|
parent::__construct($video);
|
|
}
|
|
|
|
/**
|
|
* Handle action.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function handle(): ActionResult
|
|
{
|
|
try {
|
|
DB::beginTransaction();
|
|
|
|
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 !== null) {
|
|
$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::PASSED);
|
|
}
|
|
|
|
/**
|
|
* Get the model the action is handling.
|
|
*/
|
|
protected function getModel(): Video
|
|
{
|
|
return $this->model;
|
|
}
|
|
|
|
/**
|
|
* Get the relation to audio.
|
|
*/
|
|
protected function relation(): BelongsTo
|
|
{
|
|
return $this->getModel()->audio();
|
|
}
|
|
|
|
/**
|
|
* Determine if the source video should be derived.
|
|
*/
|
|
protected function deriveSourceVideo(): bool
|
|
{
|
|
return $this->deriveSourceVideo === DeriveSourceVideo::YES;
|
|
}
|
|
|
|
/**
|
|
* Determine if audio should be overwritten.
|
|
*/
|
|
protected function overwriteAudio(): bool
|
|
{
|
|
return $this->overwriteAudio === OverwriteAudio::YES;
|
|
}
|
|
|
|
/**
|
|
* Determine if the new audio should replace a related one.
|
|
*/
|
|
protected function replaceRelatedAudio(): bool
|
|
{
|
|
return $this->replaceRelatedAudio === ReplaceRelatedAudio::YES;
|
|
}
|
|
|
|
/**
|
|
* Get or Create Audio.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
protected function getAudio(): ?Audio
|
|
{
|
|
// Allow bypassing of source video derivation
|
|
if ($this->replaceRelatedAudio()) {
|
|
$sourceVideo = $this->getSourceVideo('<');
|
|
} elseif ($this->deriveSourceVideo()) {
|
|
$sourceVideo = $this->getSourceVideo();
|
|
} else {
|
|
$sourceVideo = $this->getModel();
|
|
}
|
|
|
|
// It's possible that the video is not attached to any themes, exit early.
|
|
if ($sourceVideo === null) {
|
|
Log::error('Could not derive source video');
|
|
|
|
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)
|
|
: $audio->path;
|
|
|
|
// Second, attempt to set audio from path
|
|
if ($audio === null) {
|
|
$audio = Audio::query()->firstWhere(Audio::ATTRIBUTE_PATH, $audioPath);
|
|
}
|
|
|
|
// Finally, extract audio from the source video
|
|
if ($audio === null || $this->overwriteAudio() || $this->replaceRelatedAudio()) {
|
|
if ($this->replaceRelatedAudio()) {
|
|
$sourceVideo = $this->getModel();
|
|
}
|
|
|
|
return $this->extractAudio($sourceVideo);
|
|
}
|
|
|
|
return $audio;
|
|
}
|
|
|
|
/**
|
|
* Get the source video for the given video.
|
|
*/
|
|
protected function getSourceVideo(string $operation = '>'): ?Video
|
|
{
|
|
$source = null;
|
|
|
|
$sourceCandidates = $this->getAdjacentVideos();
|
|
|
|
foreach ($sourceCandidates as $sourceCandidate) {
|
|
if ($operation === '>' && (! $source instanceof Video || $sourceCandidate->getSourcePriority() > $source->getSourcePriority())) {
|
|
$source = $sourceCandidate;
|
|
}
|
|
|
|
if ($operation === '<' && (! $source instanceof Video || $sourceCandidate->getSourcePriority() < $source->getSourcePriority())) {
|
|
$source = $sourceCandidate;
|
|
}
|
|
}
|
|
|
|
return $source;
|
|
}
|
|
|
|
/**
|
|
* Get the adjacent videos for sourcing.
|
|
*
|
|
* @return Collection<int, Video>
|
|
*/
|
|
protected function getAdjacentVideos(): Collection
|
|
{
|
|
$builder = AnimeTheme::query();
|
|
|
|
$sortRelation = $builder->getRelation(AnimeTheme::RELATION_ANIME);
|
|
|
|
$orderByNameQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_NAME]);
|
|
$orderBySeasonQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_SEASON]);
|
|
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_YEAR]);
|
|
$orderByMediaFormatQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_MEDIA_FORMAT]);
|
|
|
|
return $builder->whereHas(AnimeTheme::RELATION_VIDEOS, fn (Builder $relationBuilder) => $relationBuilder->whereKey($this->getModel()))
|
|
->orderBy($orderByMediaFormatQuery->toBase())
|
|
->orderBy($orderByYearQuery->toBase())
|
|
->orderBy($orderBySeasonQuery->toBase())
|
|
->orderBy($orderByNameQuery->toBase())
|
|
->with([
|
|
AnimeTheme::RELATION_ANIME,
|
|
AnimeTheme::RELATION_AUDIO,
|
|
AnimeTheme::RELATION_ENTRIES => fn (Relation $relation) => $relation->getQuery()->orderBy(AnimeThemeEntry::ATTRIBUTE_VERSION),
|
|
])
|
|
->get()
|
|
->flatMap(fn (AnimeTheme $theme) => $theme->animethemeentries)
|
|
->flatMap(fn (AnimeThemeEntry $entry) => $entry->videos);
|
|
}
|
|
|
|
/**
|
|
* Extract audio stream from video and store in filesystem.
|
|
*/
|
|
protected function extractAudio(Video $video): ?Audio
|
|
{
|
|
$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);
|
|
} catch (Exception $e) {
|
|
Log::error($e->getMessage());
|
|
} finally {
|
|
Storage::disk('local')->delete([$video->basename, $audioBasename]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Attach Audio to model.
|
|
*/
|
|
protected function attachAudio(Audio $audio): void
|
|
{
|
|
if ($this->relation()->isNot($audio)) {
|
|
Log::info("Associating Audio '{$audio->getName()}' with Video '{$this->getModel()->getName()}'");
|
|
$this->relation()->associate($audio)->save();
|
|
}
|
|
}
|
|
}
|