From af2946155ef470810a2e16ec488b2c3058f0a6d3 Mon Sep 17 00:00:00 2001 From: Kyrch Date: Wed, 4 Feb 2026 13:24:03 -0300 Subject: [PATCH] feat: video priority attribute (#1079) --- .../Wiki/Video/Audio/BackfillAudioAction.php | 23 ++---- app/Enums/Models/Wiki/VideoSource.php | 1 - .../Fields/Wiki/Video/VideoPriorityField.php | 33 +++++++++ app/GraphQL/Schema/Types/Wiki/VideoType.php | 2 + app/Models/Admin/Dump.php | 13 ++-- app/Models/User/Notification.php | 5 +- .../User/Submission/SubmissionVirtual.php | 8 +-- app/Models/Wiki/Audio.php | 13 ++-- app/Models/Wiki/Image.php | 17 +++-- app/Models/Wiki/Video.php | 72 ++++++++++--------- app/Models/Wiki/Video/VideoScript.php | 15 ++-- tests/Unit/Models/Wiki/VideoTest.php | 2 +- 12 files changed, 121 insertions(+), 83 deletions(-) create mode 100644 app/GraphQL/Schema/Fields/Wiki/Video/VideoPriorityField.php diff --git a/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php index 73ec30826..1782c4d4d 100644 --- a/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php +++ b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php @@ -128,7 +128,7 @@ class BackfillAudioAction extends BackfillAction { // Allow bypassing of source video derivation if ($this->replaceRelatedAudio()) { - $sourceVideo = $this->getSourceVideo('<'); + $sourceVideo = $this->getSourceVideo(false); } elseif ($this->deriveSourceVideo()) { $sourceVideo = $this->getSourceVideo(); } else { @@ -177,23 +177,12 @@ class BackfillAudioAction extends BackfillAction return $audio; } - protected function getSourceVideo(string $operation = '>'): ?Video + protected function getSourceVideo(bool $descending = true): ?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; + return $this->getAdjacentVideos()->sortBy( + fn (Video $video) => $video->getAttribute(Video::ATTRIBUTE_PRIORITY), + descending: $descending + )->first(); } /** diff --git a/app/Enums/Models/Wiki/VideoSource.php b/app/Enums/Models/Wiki/VideoSource.php index 5ba9882cd..0c0eef689 100644 --- a/app/Enums/Models/Wiki/VideoSource.php +++ b/app/Enums/Models/Wiki/VideoSource.php @@ -22,7 +22,6 @@ enum VideoSource: int implements HasLabel /** * Score sources to help prioritize videos. - * TODO: This should be refactored into attributes. */ public function getPriority(): int { diff --git a/app/GraphQL/Schema/Fields/Wiki/Video/VideoPriorityField.php b/app/GraphQL/Schema/Fields/Wiki/Video/VideoPriorityField.php new file mode 100644 index 000000000..b1bbd4b3c --- /dev/null +++ b/app/GraphQL/Schema/Fields/Wiki/Video/VideoPriorityField.php @@ -0,0 +1,33 @@ +hasAttribute($this->getRouteKeyName()) && $this->exists) { - return route('dump.show', $this); - } + return Attribute::make(function (): ?string { + if ($this->hasAttribute($this->getRouteKeyName()) && $this->exists) { + return route('dump.show', $this); + } - return null; + return null; + }); } public function getName(): string diff --git a/app/Models/User/Notification.php b/app/Models/User/Notification.php index 1b13d13e5..eaa2274ac 100644 --- a/app/Models/User/Notification.php +++ b/app/Models/User/Notification.php @@ -6,6 +6,7 @@ namespace App\Models\User; use App\Models\List\ExternalProfile; use Database\Factories\User\NotificationFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Notifications\DatabaseNotification; @@ -33,9 +34,9 @@ class Notification extends DatabaseNotification /** * Virtual attribute to use in relations. */ - protected function getProfileIdAttribute(): ?int + protected function profileId(): Attribute { - return Arr::get($this->getAttribute(self::ATTRIBUTE_DATA), 'profileId'); + return Attribute::make(fn () => Arr::get($this->getAttribute(self::ATTRIBUTE_DATA), 'profileId')); } /** diff --git a/app/Models/User/Submission/SubmissionVirtual.php b/app/Models/User/Submission/SubmissionVirtual.php index 4077bdcab..43492c883 100644 --- a/app/Models/User/Submission/SubmissionVirtual.php +++ b/app/Models/User/Submission/SubmissionVirtual.php @@ -8,6 +8,7 @@ use App\Models\Auth\User; use App\Models\BaseModel; use App\Observers\User\Submission\SubmissionVirtualObserver; use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\Relation; @@ -75,12 +76,9 @@ class SubmissionVirtual extends BaseModel return strval($this->getKey()); } - /** - * @return class-string - */ - protected function getModelAttribute(): string + protected function model(): Attribute { - return Relation::getMorphedModel($this->model_type) ?? $this->model_type; + return Attribute::make(fn () => Relation::getMorphedModel($this->model_type) ?? $this->model_type); } /** diff --git a/app/Models/Wiki/Audio.php b/app/Models/Wiki/Audio.php index 9396d68d2..c0a1863d6 100644 --- a/app/Models/Wiki/Audio.php +++ b/app/Models/Wiki/Audio.php @@ -17,6 +17,7 @@ use App\Events\Wiki\Audio\AudioRestored; use App\Events\Wiki\Audio\AudioUpdated; use App\Models\BaseModel; use Database\Factories\Wiki\AudioFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; @@ -118,13 +119,15 @@ class Audio extends BaseModel implements Auditable, HasAggregateViews, SoftDelet ]; } - protected function getLinkAttribute(): ?string + protected function link(): Attribute { - if ($this->hasAttribute($this->getRouteKeyName()) && $this->exists) { - return route('audio.show', $this); - } + return Attribute::make(function (): ?string { + if ($this->hasAttribute($this->getRouteKeyName()) && $this->exists) { + return route('audio.show', $this); + } - return null; + return null; + }); } /** diff --git a/app/Models/Wiki/Image.php b/app/Models/Wiki/Image.php index f962e5af8..24cbae65c 100644 --- a/app/Models/Wiki/Image.php +++ b/app/Models/Wiki/Image.php @@ -17,6 +17,7 @@ use App\Models\BaseModel; use App\Models\List\Playlist; use App\Pivots\Morph\Imageable; use Database\Factories\Wiki\ImageFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Collection; @@ -128,16 +129,18 @@ class Image extends BaseModel implements Auditable, SoftDeletable return $this->path; } - protected function getLinkAttribute(): ?string + protected function link(): Attribute { - if ($this->hasAttribute(Image::ATTRIBUTE_PATH) && $this->exists) { - /** @var \Illuminate\Filesystem\FilesystemAdapter $fs */ - $fs = Storage::disk(Config::get('image.disk')); + return Attribute::make(function () { + if ($this->hasAttribute(Image::ATTRIBUTE_PATH) && $this->exists) { + /** @var \Illuminate\Filesystem\FilesystemAdapter $fs */ + $fs = Storage::disk(Config::get('image.disk')); - return $fs->url($this->getAttribute(Image::ATTRIBUTE_PATH)); - } + return $fs->url($this->getAttribute(Image::ATTRIBUTE_PATH)); + } - return null; + return null; + }); } /** diff --git a/app/Models/Wiki/Video.php b/app/Models/Wiki/Video.php index 73fcdbec2..ba1bcea21 100644 --- a/app/Models/Wiki/Video.php +++ b/app/Models/Wiki/Video.php @@ -28,6 +28,7 @@ use App\Pivots\Wiki\AnimeThemeEntryVideo; use Database\Factories\Wiki\VideoFactory; use Elastic\ScoutDriverPlus\Searchable; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -49,6 +50,7 @@ use OwenIt\Auditing\Contracts\Auditable; * @property bool $nc * @property VideoOverlap $overlap * @property string $path + * @property int $priority * @property Collection $tracks * @property int|null $resolution * @property VideoScript|null $videoscript @@ -82,6 +84,7 @@ class Video extends BaseModel implements Auditable, HasAggregateViews, SoftDelet final public const string ATTRIBUTE_NC = 'nc'; final public const string ATTRIBUTE_OVERLAP = 'overlap'; final public const string ATTRIBUTE_PATH = 'path'; + final public const string ATTRIBUTE_PRIORITY = 'priority'; final public const string ATTRIBUTE_RESOLUTION = 'resolution'; final public const string ATTRIBUTE_SIZE = 'size'; final public const string ATTRIBUTE_SOURCE = 'source'; @@ -156,6 +159,7 @@ class Video extends BaseModel implements Auditable, HasAggregateViews, SoftDelet */ protected $appends = [ Video::ATTRIBUTE_LINK, + Video::ATTRIBUTE_PRIORITY, Video::ATTRIBUTE_TAGS, ]; @@ -177,21 +181,47 @@ class Video extends BaseModel implements Auditable, HasAggregateViews, SoftDelet ]; } - protected function getLinkAttribute(): ?string + protected function link(): Attribute { - if ($this->hasAttribute($this->getRouteKeyName()) && $this->exists) { - return route('video.show', $this); + return Attribute::make(function (): ?string { + if ($this->hasAttribute($this->getRouteKeyName()) && $this->exists) { + return route('video.show', $this); + } + + return null; + }); + } + + /** + * Get the priority score for the video. + * Higher scores increase the likelihood of the video to be the source of an audio track. + */ + protected function priority(): Attribute + { + $priority = intval($this->source?->getPriority()); + + // Videos that play over the episode will likely have compressed audio + if ($this->overlap === VideoOverlap::OVER) { + $priority -= 8; } - return null; + // Videos that transition to or from the episode may have compressed audio + if ($this->overlap === VideoOverlap::TRANS) { + $priority -= 5; + } + + // De-prioritize hardsubbed videos + if ($this->lyrics || $this->subbed) { + $priority--; + } + + return Attribute::make(fn (): int => $priority); } /** * The array of tags used to uniquely identify the video within the context of a theme. - * - * @return string[] */ - protected function getTagsAttribute(): array + protected function tags(): Attribute { $tags = []; @@ -211,33 +241,7 @@ class Video extends BaseModel implements Auditable, HasAggregateViews, SoftDelet $tags[] = 'Lyrics'; } - return $tags; - } - - /** - * Get the priority score for the video. - * Higher scores increase the likelihood of the video to be the source of an audio track. - */ - public function getSourcePriority(): int - { - $priority = intval($this->source?->getPriority()); - - // Videos that play over the episode will likely have compressed audio - if ($this->overlap === VideoOverlap::OVER) { - $priority -= 8; - } - - // Videos that transition to or from the episode may have compressed audio - if ($this->overlap === VideoOverlap::TRANS) { - $priority -= 5; - } - - // De-prioritize hardsubbed videos - if ($this->lyrics || $this->subbed) { - $priority--; - } - - return $priority; + return Attribute::make(fn (): array => $tags); } /** diff --git a/app/Models/Wiki/Video/VideoScript.php b/app/Models/Wiki/Video/VideoScript.php index f1e5a54fe..412bb519f 100644 --- a/app/Models/Wiki/Video/VideoScript.php +++ b/app/Models/Wiki/Video/VideoScript.php @@ -16,6 +16,7 @@ use App\Http\Api\Schema\Wiki\Video\ScriptSchema; use App\Models\BaseModel; use App\Models\Wiki\Video; use Database\Factories\Wiki\Video\VideoScriptFactory; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use OwenIt\Auditing\Auditable as HasAudits; @@ -93,14 +94,16 @@ class VideoScript extends BaseModel implements Auditable, InteractsWithSchema, S VideoScript::ATTRIBUTE_LINK, ]; - protected function getLinkAttribute(): ?string + protected function link(): Attribute { - // Necessary for 'make' factories. - if ($this->hasAttribute(VideoScript::ATTRIBUTE_ID)) { - return route('videoscript.show', $this); - } + return Attribute::make(function (): ?string { + // Necessary for 'make' factories. + if ($this->hasAttribute(VideoScript::ATTRIBUTE_ID)) { + return route('videoscript.show', $this); + } - return null; + return null; + }); } public function getName(): string diff --git a/tests/Unit/Models/Wiki/VideoTest.php b/tests/Unit/Models/Wiki/VideoTest.php index aae4c0222..7543190c3 100644 --- a/tests/Unit/Models/Wiki/VideoTest.php +++ b/tests/Unit/Models/Wiki/VideoTest.php @@ -165,7 +165,7 @@ test('source priority', function (array $a, array $b) { $second = Video::factory()->createOne($b); - $this->assertGreaterThan($first->getSourcePriority(), $second->getSourcePriority()); + $this->assertGreaterThan($first->getAttribute(Video::ATTRIBUTE_PRIORITY), $second->getAttribute(Video::ATTRIBUTE_PRIORITY)); })->with('priorityProvider'); test('entries', function () { $entryCount = fake()->randomDigitNotNull();