diff --git a/app/Models/Artwork.php b/app/Models/Artwork.php index 2ed5985c..6c9dbf9f 100644 --- a/app/Models/Artwork.php +++ b/app/Models/Artwork.php @@ -1,19 +1,21 @@ registerSearchableMacros(); - // ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob. - } + /** + * Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable + * on every save). We still register SearchableScope and Builder macros so that + * scout:import and Builder::searchable() continue to work. + * All indexing is managed explicitly via IndexArtworkJob. + */ + public static function bootSearchable(): void + { + static::addGlobalScope(new SearchableScope); + (new static)->registerSearchableMacros(); + // ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob. + } - public const PUBLISHED_AS_USER = 'user'; - public const PUBLISHED_AS_GROUP = 'group'; + public const PUBLISHED_AS_USER = 'user'; - public const VISIBILITY_PUBLIC = 'public'; - public const VISIBILITY_UNLISTED = 'unlisted'; - public const VISIBILITY_PRIVATE = 'private'; + public const PUBLISHED_AS_GROUP = 'group'; - protected $table = 'artworks'; + public const VISIBILITY_PUBLIC = 'public'; - protected $fillable = [ - 'user_id', - 'group_id', - 'uploaded_by_user_id', - 'primary_author_user_id', - 'published_as_type', - 'published_as_id', - 'title', - 'slug', - 'description', - 'file_name', - 'file_path', - 'hash', - 'file_ext', - 'thumb_ext', - 'has_missing_thumbnails', - 'missing_thumbnail_variants_json', - 'thumbnails_checked_at', - 'file_size', - 'mime_type', - 'width', - 'height', - 'is_public', - 'visibility', - 'is_approved', - 'is_mature', - 'maturity_level', - 'maturity_source', - 'maturity_status', - 'maturity_ai_score', - 'maturity_ai_labels', - 'maturity_ai_label', - 'maturity_ai_confidence', - 'maturity_ai_model', - 'maturity_ai_threshold_used', - 'maturity_ai_analysis_time_ms', - 'maturity_ai_action_hint', - 'maturity_ai_advisory', - 'maturity_ai_status', - 'maturity_ai_detected_at', - 'maturity_declared_at', - 'maturity_flagged_at', - 'maturity_flag_reason', - 'maturity_reviewed_by', - 'maturity_reviewed_at', - 'maturity_reviewer_note', - 'maturity_mismatch_count', - 'published_at', + public const VISIBILITY_UNLISTED = 'unlisted'; + + public const VISIBILITY_PRIVATE = 'private'; + + protected $table = 'artworks'; + + protected $fillable = [ + 'user_id', + 'group_id', + 'uploaded_by_user_id', + 'primary_author_user_id', + 'published_as_type', + 'published_as_id', + 'title', + 'slug', + 'description', + 'file_name', + 'file_path', + 'hash', + 'file_ext', + 'thumb_ext', + 'has_missing_thumbnails', + 'missing_thumbnail_variants_json', + 'thumbnails_checked_at', + 'file_size', + 'mime_type', + 'width', + 'height', + 'is_public', + 'visibility', + 'is_approved', + 'is_mature', + 'maturity_level', + 'maturity_source', + 'maturity_status', + 'maturity_ai_score', + 'maturity_ai_labels', + 'maturity_ai_label', + 'maturity_ai_confidence', + 'maturity_ai_model', + 'maturity_ai_threshold_used', + 'maturity_ai_analysis_time_ms', + 'maturity_ai_action_hint', + 'maturity_ai_advisory', + 'maturity_ai_status', + 'maturity_ai_detected_at', + 'maturity_declared_at', + 'maturity_flagged_at', + 'maturity_flag_reason', + 'maturity_reviewed_by', + 'maturity_reviewed_at', + 'maturity_reviewer_note', + 'maturity_mismatch_count', + 'published_at', 'hash', 'thumb_ext', 'file_ext', - 'clip_tags_json', - 'blip_caption', - 'yolo_objects_json', - 'vision_metadata_updated_at', - 'last_vector_indexed_at', - 'ai_status', - 'title_source', - 'description_source', - 'tags_source', - 'category_source', - // Versioning - 'current_version_id', - 'version_count', - 'version_updated_at', - 'requires_reapproval', - // Scheduled publishing - 'publish_at', - 'artwork_status', - 'artwork_timezone', - ]; - - protected $casts = [ - 'is_public' => 'boolean', - 'visibility' => 'string', - 'is_approved' => 'boolean', - 'is_mature' => 'boolean', - 'maturity_level' => 'string', - 'maturity_source' => 'string', - 'maturity_status' => 'string', - 'maturity_ai_score' => 'float', - 'maturity_ai_labels' => 'array', - 'maturity_ai_label' => 'string', - 'maturity_ai_confidence' => 'float', - 'maturity_ai_model' => 'string', - 'maturity_ai_threshold_used' => 'float', - 'maturity_ai_analysis_time_ms' => 'integer', - 'maturity_ai_action_hint' => 'string', - 'maturity_ai_advisory' => 'string', - 'maturity_ai_status' => 'string', - 'maturity_ai_detected_at' => 'datetime', - 'maturity_declared_at' => 'datetime', - 'maturity_flagged_at' => 'datetime', - 'maturity_reviewed_at' => 'datetime', - 'maturity_mismatch_count' => 'integer', - 'has_missing_thumbnails' => 'boolean', - 'published_at' => 'datetime', - 'missing_thumbnail_variants_json' => 'array', - 'thumbnails_checked_at' => 'datetime', - 'published_as_type' => 'string', - 'published_as_id' => 'integer', - 'publish_at' => 'datetime', - 'clip_tags_json' => 'array', - 'yolo_objects_json' => 'array', - 'vision_metadata_updated_at' => 'datetime', - 'last_vector_indexed_at' => 'datetime', - 'ai_status' => 'string', - 'title_source' => 'string', - 'description_source' => 'string', - 'tags_source' => 'string', - 'category_source' => 'string', - 'version_updated_at' => 'datetime', - 'requires_reapproval' => 'boolean', - ]; - - /** - * Thumbnail sizes and their options. - * Keys are the size dir used in the CDN URL. - */ - protected const THUMB_SIZES = [ - 'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'], - 'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'], - 'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'], - 'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'], - ]; - - /** - * Build the thumbnail URL for this artwork. - * Returns null when no hash or thumb_ext is available. - */ - public function thumbUrl(string $size = 'md'): ?string - { - if (empty($this->hash) || empty($this->thumb_ext)) { - return null; - } - - $sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; - return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey); - } - - /** - * Accessor for `$art->thumb` used in legacy views (default medium size). - */ - public function getThumbAttribute(): string - { - return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; - } - - /** - * Accessor for `$art->thumb_url` used in some views. - */ - public function getThumbUrlAttribute(): ?string - { - return $this->thumbUrl('md'); - } - - /** - * Backwards-compatible alias used by legacy views: `$art->thumbnail_url`. - * Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder. - */ - public function getThumbnailUrlAttribute(): ?string - { - $url = $this->getThumbUrlAttribute(); - if (!empty($url)) return $url; - $thumb = $this->getThumbAttribute(); - if (!empty($thumb)) return $thumb; - return '/images/placeholder.jpg'; - } - - /** - * Provide a responsive `srcset` for legacy views. - */ - public function getThumbSrcsetAttribute(): ?string - { - if (empty($this->hash) || empty($this->thumb_ext)) return null; - $sm = $this->thumbUrl('sm'); - $md = $this->thumbUrl('md'); - if (!$sm || !$md) return null; - return $sm . ' 320w, ' . $md . ' 600w'; - } - - // Relations - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function group(): BelongsTo - { - return $this->belongsTo(Group::class); - } - - public function maturityAuditFinding(): HasOne - { - return $this->hasOne(ArtworkMaturityAuditFinding::class); - } - - public function uploadedBy(): BelongsTo - { - return $this->belongsTo(User::class, 'uploaded_by_user_id'); - } - - public function primaryAuthor(): BelongsTo - { - return $this->belongsTo(User::class, 'primary_author_user_id'); - } - - public function contributors(): HasMany - { - return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order'); - } - - public function worldSubmissions(): HasMany - { - return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at'); - } - - public function worldRewardGrants(): HasMany - { - return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id'); - } - - public function isPublishedByGroup(): bool - { - return $this->publishedAsType() === self::PUBLISHED_AS_GROUP; - } - - public function publishedAsType(): string - { - if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) { - return (string) $this->published_as_type; - } - - return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER; - } - - public function publishedAsId(): int - { - if ((int) ($this->published_as_id ?? 0) > 0) { - return (int) $this->published_as_id; - } - - return $this->publishedAsType() === self::PUBLISHED_AS_GROUP - ? (int) ($this->group_id ?? 0) - : (int) $this->user_id; - } - - public function translations(): HasMany - { - return $this->hasMany(ArtworkTranslation::class); - } - - public function stats(): HasOne - { - return $this->hasOne(ArtworkStats::class, 'artwork_id'); - } - - public function categories(): BelongsToMany - { - return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); - } - - public function collections(): BelongsToMany - { - return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id') - ->withPivot(['order_num']) - ->withTimestamps() - ->orderByPivot('order_num'); - } - - public function tags(): BelongsToMany - { - return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id') - ->withPivot(['source', 'confidence']); - } - - public function comments(): HasMany - { - return $this->hasMany(ArtworkComment::class); - } - - public function downloads(): HasMany - { - return $this->hasMany(ArtworkDownload::class); - } - - public function embeddings(): HasMany - { - return $this->hasMany(ArtworkEmbedding::class, 'artwork_id'); - } - - public function similarities(): HasMany - { - return $this->hasMany(ArtworkSimilarity::class, 'artwork_id'); - } - - public function features(): HasMany - { - return $this->hasMany(ArtworkFeature::class, 'artwork_id'); - } - - /** All favourite pivot rows for this artwork. */ - public function favourites(): HasMany - { - return $this->hasMany(ArtworkFavourite::class, 'artwork_id'); - } - - /** Users who have favourited this artwork (many-to-many shortcut). */ - public function favouritedBy(): BelongsToMany - { - return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id') - ->withPivot('legacy_id') - ->withTimestamps(); - } - - public function awards(): HasMany - { - return $this->hasMany(ArtworkAward::class); - } - - public function medals(): HasMany - { - return $this->hasMany(ArtworkMedal::class, 'artwork_id'); - } - - /** All file versions for this artwork (oldest first). */ - public function versions(): HasMany - { - return $this->hasMany(ArtworkVersion::class)->orderBy('version_number'); - } - - public function outgoingEvolutionRelations(): HasMany - { - return $this->hasMany(ArtworkRelation::class, 'source_artwork_id') - ->orderBy('sort_order') - ->orderBy('id'); - } - - public function incomingEvolutionRelations(): HasMany - { - return $this->hasMany(ArtworkRelation::class, 'target_artwork_id') - ->orderByDesc('updated_at') - ->orderByDesc('id'); - } - - /** The currently active version record. */ - public function currentVersion(): BelongsTo - { - return $this->belongsTo(ArtworkVersion::class, 'current_version_id'); - } - - public function artworkAiAssist(): HasOne - { - return $this->hasOne(ArtworkAiAssist::class, 'artwork_id'); - } - - public function awardStat(): HasOne - { - return $this->hasOne(ArtworkAwardStat::class); - } - - public function medalStats(): HasOne - { - return $this->hasOne(ArtworkMedalStat::class, 'artwork_id'); - } - - /** - * Build the Meilisearch document for this artwork. - * Includes all fields required for search, filtering, sorting, and display. - */ - public function toSearchableArray(): array - { - $this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']); - - $stat = $this->stats; - $awardStat = $this->awardStat; - $publishedSortAt = $this->published_at ?? $this->created_at; - $sortedCategories = $this->categories->sortBy( - fn ($category) => sprintf( - '%010d|%s|%010d', - (int) ($category->sort_order ?? 999999999), - strtolower((string) ($category->name ?? '')), - (int) ($category->id ?? 0) - ) - )->values(); - - // Orientation derived from pixel dimensions - $orientation = 'square'; - if ($this->width && $this->height) { - if ($this->width > $this->height) { - $orientation = 'landscape'; - } elseif ($this->height > $this->width) { - $orientation = 'portrait'; - } - } - - // Resolution string e.g. "1920x1080" - $resolution = ($this->width && $this->height) - ? $this->width . 'x' . $this->height - : ''; - - // Primary category slug follows the same sort_order-first semantics used by page presenters. - $primaryCategory = $sortedCategories->first(); - $categorySlugs = $sortedCategories - ->pluck('slug') - ->filter() - ->map(static fn ($slug) => (string) $slug) - ->unique() - ->values() - ->all(); - $contentTypeSlugs = $sortedCategories - ->map(static fn ($category) => $category->contentType?->slug) - ->filter() - ->map(static fn ($slug) => (string) $slug) - ->unique() - ->values() - ->all(); - $category = $primaryCategory?->slug ?? ''; - $content_type = $primaryCategory?->contentType?->slug ?? ''; - - // Tag slugs array - $tags = $this->tags->pluck('slug')->values()->all(); - - return [ - 'id' => $this->id, - 'slug' => $this->slug, - 'title' => $this->title, - 'description' => (string) ($this->description ?? ''), - 'author_id' => $this->publishedAsId(), - 'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase', - 'published_as_type' => $this->publishedAsType(), - 'category' => $category, - 'categories' => $categorySlugs, - 'content_type' => $content_type, - 'content_types' => $contentTypeSlugs, - 'tags' => $tags, - 'ai_clip_tags' => collect((array) ($this->clip_tags_json ?? [])) - ->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '') - ->filter() - ->values() - ->all(), - 'ai_blip_caption' => (string) ($this->blip_caption ?? ''), - 'ai_yolo_objects' => collect((array) ($this->yolo_objects_json ?? [])) - ->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '') - ->filter() - ->values() - ->all(), - 'resolution' => $resolution, - 'orientation' => $orientation, - 'downloads' => (int) ($stat?->downloads ?? 0), - 'likes' => (int) ($stat?->favorites ?? 0), - 'views' => (int) ($stat?->views ?? 0), - 'created_at' => $publishedSortAt?->toDateString() ?? '', - 'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0, - 'is_public' => (bool) $this->is_public, - 'is_approved' => (bool) $this->is_approved, - 'is_mature' => (bool) $this->is_mature, - 'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'), - 'maturity_level' => (string) ($this->maturity_level ?? 'safe'), - 'maturity_status' => (string) ($this->maturity_status ?? 'clear'), - 'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false), - 'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0), - // ── Trending / discovery fields ──────────────────────────────────── - 'trending_score_1h' => (float) ($this->trending_score_1h ?? 0), - 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), - 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), - 'favorites_count' => (int) ($stat?->favorites ?? 0), - 'awards_received_count' => (int) ($awardStat?->score_total ?? 0), - 'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0), - 'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0), - 'downloads_count' => (int) ($stat?->downloads ?? 0), - // ── Ranking V2 fields ─────────────────────────────────────────────── - 'ranking_score' => (float) ($stat?->ranking_score ?? 0), - 'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0), - 'shares_count' => (int) ($stat?->shares_count ?? 0), - 'comments_count' => (int) ($stat?->comments_count ?? 0), - // ── Rising / Heat fields ──────────────────────────────────────────────────── - 'heat_score' => (float) ($stat?->heat_score ?? 0), - 'awards' => [ - 'gold' => $awardStat?->gold_count ?? 0, - 'silver' => $awardStat?->silver_count ?? 0, - 'bronze' => $awardStat?->bronze_count ?? 0, - 'score' => $awardStat?->score_total ?? 0, - 'score_7d' => $awardStat?->score_7d ?? 0, - 'score_30d' => $awardStat?->score_30d ?? 0, - ], - ]; - } - - // Scopes - public function scopePublic(Builder $query): Builder - { - // Compose approved() so behavior is consistent and composable - $table = $this->getTable(); - return $query->approved()->where("{$table}.is_public", true); - } - - public function scopeApproved(Builder $query): Builder - { - // Respect soft deletes and mark approved content - $table = $this->getTable(); - return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true); - } - - public function scopePublished(Builder $query): Builder - { - // Respect soft deletes and only include published items up to now - $table = $this->getTable(); - return $query->whereNull("{$table}.deleted_at") - ->whereNotNull("{$table}.published_at") - ->where("{$table}.published_at", '<=', now()); - } - - public function scopeCatalogVisible(Builder $query): Builder - { - $table = $this->getTable(); - - return $query - ->approved() - ->where("{$table}.is_public", true) - ->where(function (Builder $visibilityQuery) use ($table): void { - $visibilityQuery->whereNull("{$table}.visibility") - ->orWhere("{$table}.visibility", self::VISIBILITY_PUBLIC); - }) - ->published(); - } - - public function scopeSafeForGeneralAudience(Builder $query): Builder - { - $table = $this->getTable(); - - return $query - ->whereRaw('COALESCE(' . $table . '.is_mature, 0) = 0') - ->whereRaw("COALESCE(" . $table . ".maturity_status, 'clear') != ?", ['suspected']); - } - - public function scopeWithoutMissingThumbnails(Builder $query): Builder - { - $table = $this->getTable(); - - return $query->where(function (Builder $thumbnailQuery) use ($table): void { - $thumbnailQuery->whereNull("{$table}.has_missing_thumbnails") - ->orWhere("{$table}.has_missing_thumbnails", false); - }); - } - - public function scopeOrderMissingThumbnailsLast(Builder $query): Builder - { - $table = $this->getTable(); - - return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC"); - } - - public function getRouteKeyName(): string - { - return 'slug'; - } - - protected static function booted(): void - { - static::saving(function (Artwork $artwork): void { - if ($artwork->published_at === null) { - return; - } - - $publishedAt = $artwork->published_at->copy(); - - if ($artwork->created_at === null || ! $artwork->created_at->equalTo($publishedAt)) { - $artwork->created_at = $publishedAt; - } - }); - - static::deleting(function (Artwork $artwork): void { - if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) { - return; - } - - // Cleanup pivot rows and decrement usage counts on force delete. - $tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all(); - if ($tagIds === []) { - return; - } - - DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete(); - DB::table('tags') - ->whereIn('id', $tagIds) - ->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]); - }); - } + 'clip_tags_json', + 'blip_caption', + 'yolo_objects_json', + 'vision_metadata_updated_at', + 'last_vector_indexed_at', + 'ai_status', + 'title_source', + 'description_source', + 'tags_source', + 'category_source', + // Versioning + 'current_version_id', + 'version_count', + 'version_updated_at', + 'requires_reapproval', + // Scheduled publishing + 'publish_at', + 'artwork_status', + 'artwork_timezone', + ]; + + protected $casts = [ + 'is_public' => 'boolean', + 'visibility' => 'string', + 'is_approved' => 'boolean', + 'is_mature' => 'boolean', + 'maturity_level' => 'string', + 'maturity_source' => 'string', + 'maturity_status' => 'string', + 'maturity_ai_score' => 'float', + 'maturity_ai_labels' => 'array', + 'maturity_ai_label' => 'string', + 'maturity_ai_confidence' => 'float', + 'maturity_ai_model' => 'string', + 'maturity_ai_threshold_used' => 'float', + 'maturity_ai_analysis_time_ms' => 'integer', + 'maturity_ai_action_hint' => 'string', + 'maturity_ai_advisory' => 'string', + 'maturity_ai_status' => 'string', + 'maturity_ai_detected_at' => 'datetime', + 'maturity_declared_at' => 'datetime', + 'maturity_flagged_at' => 'datetime', + 'maturity_reviewed_at' => 'datetime', + 'maturity_mismatch_count' => 'integer', + 'has_missing_thumbnails' => 'boolean', + 'published_at' => 'datetime', + 'missing_thumbnail_variants_json' => 'array', + 'thumbnails_checked_at' => 'datetime', + 'published_as_type' => 'string', + 'published_as_id' => 'integer', + 'publish_at' => 'datetime', + 'clip_tags_json' => 'array', + 'yolo_objects_json' => 'array', + 'vision_metadata_updated_at' => 'datetime', + 'last_vector_indexed_at' => 'datetime', + 'ai_status' => 'string', + 'title_source' => 'string', + 'description_source' => 'string', + 'tags_source' => 'string', + 'category_source' => 'string', + 'version_updated_at' => 'datetime', + 'requires_reapproval' => 'boolean', + ]; + + /** + * Thumbnail sizes and their options. + * Keys are the size dir used in the CDN URL. + */ + protected const THUMB_SIZES = [ + 'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'], + 'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'], + 'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'], + 'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'], + ]; + + /** + * Build the thumbnail URL for this artwork. + * Returns null when no hash or thumb_ext is available. + */ + public function thumbUrl(string $size = 'md'): ?string + { + if (empty($this->hash) || empty($this->thumb_ext)) { + return null; + } + + $sizeKey = array_key_exists($size, self::THUMB_SIZES) ? $size : 'md'; + + return ThumbnailService::fromHash($this->hash, $this->thumb_ext, $sizeKey); + } + + /** + * Accessor for `$art->thumb` used in legacy views (default medium size). + */ + public function getThumbAttribute(): string + { + return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; + } + + /** + * Accessor for `$art->thumb_url` used in some views. + */ + public function getThumbUrlAttribute(): ?string + { + return $this->thumbUrl('md'); + } + + /** + * Backwards-compatible alias used by legacy views: `$art->thumbnail_url`. + * Prefer CDN thumbnail URL, then legacy `thumb` accessor, finally a placeholder. + */ + public function getThumbnailUrlAttribute(): ?string + { + $url = $this->getThumbUrlAttribute(); + if (! empty($url)) { + return $url; + } + $thumb = $this->getThumbAttribute(); + if (! empty($thumb)) { + return $thumb; + } + + return '/images/placeholder.jpg'; + } + + /** + * Provide a responsive `srcset` for legacy views. + */ + public function getThumbSrcsetAttribute(): ?string + { + if (empty($this->hash) || empty($this->thumb_ext)) { + return null; + } + $sm = $this->thumbUrl('sm'); + $md = $this->thumbUrl('md'); + if (! $sm || ! $md) { + return null; + } + + return $sm.' 320w, '.$md.' 600w'; + } + + public function featuredThumbnailObjectPath(string $variant = 'desktop'): ?string + { + if (empty($this->hash)) { + return null; + } + + return app(ArtworkFeaturedImagePath::class)->objectPath($this, $variant); + } + + public function featuredThumbnailUrl(string $variant = 'desktop'): string + { + $helper = app(ArtworkFeaturedImagePath::class); + + foreach ($helper->preferredVariantOrder($variant) as $candidate) { + if ($this->hasFeaturedThumbnail($candidate)) { + return $helper->url($this, $candidate); + } + } + + return $this->thumbUrl('xl') + ?? $this->thumbUrl('lg') + ?? 'https://files.skinbase.org/default/missing_xl.webp'; + } + + public function hasFeaturedThumbnail(?string $variant = null): bool + { + if (empty($this->hash)) { + return false; + } + + $helper = app(ArtworkFeaturedImagePath::class); + $variants = $variant !== null ? [$helper->normalizeVariant($variant)] : $helper->variantNames(); + $disk = Storage::disk((string) config('uploads.object_storage.disk', 's3')); + + foreach ($variants as $variantName) { + if ($disk->exists($helper->objectPath($this, $variantName))) { + return true; + } + } + + return false; + } + + public function hasAllFeaturedThumbnails(): bool + { + $helper = app(ArtworkFeaturedImagePath::class); + + foreach ($helper->variantNames() as $variant) { + if (! $this->hasFeaturedThumbnail($variant)) { + return false; + } + } + + return true; + } + + public function featuredImageAltText(): string + { + $title = trim((string) ($this->title ?? '')); + $author = trim((string) ($this->user?->name ?? '')); + + if ($title !== '' && $author !== '') { + return sprintf('%s by %s', $title, $author); + } + + if ($title !== '') { + return $title; + } + + return 'Featured artwork'; + } + + // Relations + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function group(): BelongsTo + { + return $this->belongsTo(Group::class); + } + + public function maturityAuditFinding(): HasOne + { + return $this->hasOne(ArtworkMaturityAuditFinding::class); + } + + public function uploadedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by_user_id'); + } + + public function primaryAuthor(): BelongsTo + { + return $this->belongsTo(User::class, 'primary_author_user_id'); + } + + public function contributors(): HasMany + { + return $this->hasMany(ArtworkContributor::class)->orderBy('sort_order'); + } + + public function worldSubmissions(): HasMany + { + return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at'); + } + + public function worldRewardGrants(): HasMany + { + return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id'); + } + + public function isPublishedByGroup(): bool + { + return $this->publishedAsType() === self::PUBLISHED_AS_GROUP; + } + + public function publishedAsType(): string + { + if (in_array($this->published_as_type, [self::PUBLISHED_AS_USER, self::PUBLISHED_AS_GROUP], true)) { + return (string) $this->published_as_type; + } + + return (int) ($this->group_id ?? 0) > 0 ? self::PUBLISHED_AS_GROUP : self::PUBLISHED_AS_USER; + } + + public function publishedAsId(): int + { + if ((int) ($this->published_as_id ?? 0) > 0) { + return (int) $this->published_as_id; + } + + return $this->publishedAsType() === self::PUBLISHED_AS_GROUP + ? (int) ($this->group_id ?? 0) + : (int) $this->user_id; + } + + public function translations(): HasMany + { + return $this->hasMany(ArtworkTranslation::class); + } + + public function stats(): HasOne + { + return $this->hasOne(ArtworkStats::class, 'artwork_id'); + } + + public function categories(): BelongsToMany + { + return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id'); + } + + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id') + ->withPivot(['order_num']) + ->withTimestamps() + ->orderByPivot('order_num'); + } + + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'artwork_tag', 'artwork_id', 'tag_id') + ->withPivot(['source', 'confidence']); + } + + public function comments(): HasMany + { + return $this->hasMany(ArtworkComment::class); + } + + public function downloads(): HasMany + { + return $this->hasMany(ArtworkDownload::class); + } + + public function embeddings(): HasMany + { + return $this->hasMany(ArtworkEmbedding::class, 'artwork_id'); + } + + public function similarities(): HasMany + { + return $this->hasMany(ArtworkSimilarity::class, 'artwork_id'); + } + + public function features(): HasMany + { + return $this->hasMany(ArtworkFeature::class, 'artwork_id'); + } + + /** All favourite pivot rows for this artwork. */ + public function favourites(): HasMany + { + return $this->hasMany(ArtworkFavourite::class, 'artwork_id'); + } + + /** Users who have favourited this artwork (many-to-many shortcut). */ + public function favouritedBy(): BelongsToMany + { + return $this->belongsToMany(User::class, 'artwork_favourites', 'artwork_id', 'user_id') + ->withPivot('legacy_id') + ->withTimestamps(); + } + + public function awards(): HasMany + { + return $this->hasMany(ArtworkAward::class); + } + + public function medals(): HasMany + { + return $this->hasMany(ArtworkMedal::class, 'artwork_id'); + } + + /** All file versions for this artwork (oldest first). */ + public function versions(): HasMany + { + return $this->hasMany(ArtworkVersion::class)->orderBy('version_number'); + } + + public function outgoingEvolutionRelations(): HasMany + { + return $this->hasMany(ArtworkRelation::class, 'source_artwork_id') + ->orderBy('sort_order') + ->orderBy('id'); + } + + public function incomingEvolutionRelations(): HasMany + { + return $this->hasMany(ArtworkRelation::class, 'target_artwork_id') + ->orderByDesc('updated_at') + ->orderByDesc('id'); + } + + /** The currently active version record. */ + public function currentVersion(): BelongsTo + { + return $this->belongsTo(ArtworkVersion::class, 'current_version_id'); + } + + public function artworkAiAssist(): HasOne + { + return $this->hasOne(ArtworkAiAssist::class, 'artwork_id'); + } + + public function awardStat(): HasOne + { + return $this->hasOne(ArtworkAwardStat::class); + } + + public function medalStats(): HasOne + { + return $this->hasOne(ArtworkMedalStat::class, 'artwork_id'); + } + + /** + * Build the Meilisearch document for this artwork. + * Includes all fields required for search, filtering, sorting, and display. + */ + public function toSearchableArray(): array + { + $this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']); + + $stat = $this->stats; + $awardStat = $this->awardStat; + $publishedSortAt = $this->published_at ?? $this->created_at; + $sortedCategories = $this->categories->sortBy( + fn ($category) => sprintf( + '%010d|%s|%010d', + (int) ($category->sort_order ?? 999999999), + strtolower((string) ($category->name ?? '')), + (int) ($category->id ?? 0) + ) + )->values(); + + // Orientation derived from pixel dimensions + $orientation = 'square'; + if ($this->width && $this->height) { + if ($this->width > $this->height) { + $orientation = 'landscape'; + } elseif ($this->height > $this->width) { + $orientation = 'portrait'; + } + } + + // Resolution string e.g. "1920x1080" + $resolution = ($this->width && $this->height) + ? $this->width.'x'.$this->height + : ''; + + // Primary category slug follows the same sort_order-first semantics used by page presenters. + $primaryCategory = $sortedCategories->first(); + $categorySlugs = $sortedCategories + ->pluck('slug') + ->filter() + ->map(static fn ($slug) => (string) $slug) + ->unique() + ->values() + ->all(); + $contentTypeSlugs = $sortedCategories + ->map(static fn ($category) => $category->contentType?->slug) + ->filter() + ->map(static fn ($slug) => (string) $slug) + ->unique() + ->values() + ->all(); + $category = $primaryCategory?->slug ?? ''; + $content_type = $primaryCategory?->contentType?->slug ?? ''; + + // Tag slugs array + $tags = $this->tags->pluck('slug')->values()->all(); + + return [ + 'id' => $this->id, + 'slug' => $this->slug, + 'title' => $this->title, + 'description' => (string) ($this->description ?? ''), + 'author_id' => $this->publishedAsId(), + 'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase', + 'published_as_type' => $this->publishedAsType(), + 'category' => $category, + 'categories' => $categorySlugs, + 'content_type' => $content_type, + 'content_types' => $contentTypeSlugs, + 'tags' => $tags, + 'ai_clip_tags' => collect((array) ($this->clip_tags_json ?? [])) + ->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '') + ->filter() + ->values() + ->all(), + 'ai_blip_caption' => (string) ($this->blip_caption ?? ''), + 'ai_yolo_objects' => collect((array) ($this->yolo_objects_json ?? [])) + ->map(static fn ($row) => is_array($row) ? (string) ($row['tag'] ?? '') : '') + ->filter() + ->values() + ->all(), + 'resolution' => $resolution, + 'orientation' => $orientation, + 'downloads' => (int) ($stat?->downloads ?? 0), + 'likes' => (int) ($stat?->favorites ?? 0), + 'views' => (int) ($stat?->views ?? 0), + 'created_at' => $publishedSortAt?->toDateString() ?? '', + 'published_at_ts' => $publishedSortAt?->getTimestamp() ?? 0, + 'is_public' => (bool) $this->is_public, + 'is_approved' => (bool) $this->is_approved, + 'is_mature' => (bool) $this->is_mature, + 'is_mature_effective' => (bool) ($this->is_mature || $this->maturity_level === 'mature' || $this->maturity_status === 'suspected'), + 'maturity_level' => (string) ($this->maturity_level ?? 'safe'), + 'maturity_status' => (string) ($this->maturity_status ?? 'clear'), + 'has_missing_thumbnails' => (bool) ($this->has_missing_thumbnails ?? false), + 'missing_thumbnail_rank' => (int) (($this->has_missing_thumbnails ?? false) ? 1 : 0), + // ── Trending / discovery fields ──────────────────────────────────── + 'trending_score_1h' => (float) ($this->trending_score_1h ?? 0), + 'trending_score_24h' => (float) ($this->trending_score_24h ?? 0), + 'trending_score_7d' => (float) ($this->trending_score_7d ?? 0), + 'favorites_count' => (int) ($stat?->favorites ?? 0), + 'awards_received_count' => (int) ($awardStat?->score_total ?? 0), + 'awards_score_7d' => (int) ($awardStat?->score_7d ?? 0), + 'awards_score_30d' => (int) ($awardStat?->score_30d ?? 0), + 'downloads_count' => (int) ($stat?->downloads ?? 0), + // ── Ranking V2 fields ─────────────────────────────────────────────── + 'ranking_score' => (float) ($stat?->ranking_score ?? 0), + 'engagement_velocity' => (float) ($stat?->engagement_velocity ?? 0), + 'shares_count' => (int) ($stat?->shares_count ?? 0), + 'comments_count' => (int) ($stat?->comments_count ?? 0), + // ── Rising / Heat fields ──────────────────────────────────────────────────── + 'heat_score' => (float) ($stat?->heat_score ?? 0), + 'awards' => [ + 'gold' => $awardStat?->gold_count ?? 0, + 'silver' => $awardStat?->silver_count ?? 0, + 'bronze' => $awardStat?->bronze_count ?? 0, + 'score' => $awardStat?->score_total ?? 0, + 'score_7d' => $awardStat?->score_7d ?? 0, + 'score_30d' => $awardStat?->score_30d ?? 0, + ], + ]; + } + + // Scopes + public function scopePublic(Builder $query): Builder + { + // Compose approved() so behavior is consistent and composable + $table = $this->getTable(); + + return $query->approved()->where("{$table}.is_public", true); + } + + public function scopeApproved(Builder $query): Builder + { + // Respect soft deletes and mark approved content + $table = $this->getTable(); + + return $query->whereNull("{$table}.deleted_at")->where("{$table}.is_approved", true); + } + + public function scopePublished(Builder $query): Builder + { + // Respect soft deletes and only include published items up to now + $table = $this->getTable(); + + return $query->whereNull("{$table}.deleted_at") + ->whereNotNull("{$table}.published_at") + ->where("{$table}.published_at", '<=', now()); + } + + public function scopeCatalogVisible(Builder $query): Builder + { + $table = $this->getTable(); + + return $query + ->approved() + ->where("{$table}.is_public", true) + ->where(function (Builder $visibilityQuery) use ($table): void { + $visibilityQuery->whereNull("{$table}.visibility") + ->orWhere("{$table}.visibility", self::VISIBILITY_PUBLIC); + }) + ->published(); + } + + public function scopeSafeForGeneralAudience(Builder $query): Builder + { + $table = $this->getTable(); + + return $query + ->whereRaw('COALESCE('.$table.'.is_mature, 0) = 0') + ->whereRaw('COALESCE('.$table.".maturity_status, 'clear') != ?", ['suspected']); + } + + public function scopeWithoutMissingThumbnails(Builder $query): Builder + { + $table = $this->getTable(); + + return $query->where(function (Builder $thumbnailQuery) use ($table): void { + $thumbnailQuery->whereNull("{$table}.has_missing_thumbnails") + ->orWhere("{$table}.has_missing_thumbnails", false); + }); + } + + public function scopeOrderMissingThumbnailsLast(Builder $query): Builder + { + $table = $this->getTable(); + + return $query->orderByRaw("CASE WHEN {$table}.has_missing_thumbnails = 1 THEN 1 ELSE 0 END ASC"); + } + + public function getRouteKeyName(): string + { + return 'slug'; + } + + protected static function booted(): void + { + static::saving(function (Artwork $artwork): void { + if ($artwork->published_at === null) { + return; + } + + $publishedAt = $artwork->published_at->copy(); + + if ($artwork->created_at === null || ! $artwork->created_at->equalTo($publishedAt)) { + $artwork->created_at = $publishedAt; + } + }); + + static::deleting(function (Artwork $artwork): void { + if (! method_exists($artwork, 'isForceDeleting') || ! $artwork->isForceDeleting()) { + return; + } + + // Cleanup pivot rows and decrement usage counts on force delete. + $tagIds = DB::table('artwork_tag')->where('artwork_id', $artwork->id)->pluck('tag_id')->all(); + if ($tagIds === []) { + return; + } + + DB::table('artwork_tag')->where('artwork_id', $artwork->id)->delete(); + DB::table('tags') + ->whereIn('id', $tagIds) + ->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]); + }); + } }