Files
SkinbaseNova/app/Models/Artwork.php

322 lines
9.3 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
use Laravel\Scout\Searchable;
/**
* App\Models\Artwork
*
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkTranslation[] $translations
* @property-read ArtworkStats $stats
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkComment[] $comments
* @property-read \Illuminate\Database\Eloquent\Collection|ArtworkDownload[] $downloads
*/
class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
protected $table = 'artworks';
protected $fillable = [
'user_id',
'title',
'slug',
'description',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'file_size',
'mime_type',
'width',
'height',
'is_public',
'is_approved',
'published_at',
'hash',
'thumb_ext',
'file_ext'
];
protected $casts = [
'is_public' => 'boolean',
'is_approved' => 'boolean',
'published_at' => 'datetime',
];
/**
* 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') ?? '/gfx/sb_join.jpg';
}
/**
* 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 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 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 awardStat(): HasOne
{
return $this->hasOne(ArtworkAwardStat::class);
}
/**
* Build the Meilisearch document for this artwork.
* Includes all fields required for search, filtering, sorting, and display.
*/
public function toSearchableArray(): array
{
$this->loadMissing(['user', 'tags', 'categories.contentType', 'stats', 'awardStat']);
$stat = $this->stats;
$awardStat = $this->awardStat;
// 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 (first attached category)
$primaryCategory = $this->categories->first();
$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->user_id,
'author_name' => $this->user?->name ?? 'Skinbase',
'category' => $category,
'content_type' => $content_type,
'tags' => $tags,
'resolution' => $resolution,
'orientation' => $orientation,
'downloads' => (int) ($stat?->downloads ?? 0),
'likes' => (int) ($stat?->favorites ?? 0),
'views' => (int) ($stat?->views ?? 0),
'created_at' => $this->published_at?->toDateString() ?? $this->created_at?->toDateString() ?? '',
'is_public' => (bool) $this->is_public,
'is_approved' => (bool) $this->is_approved,
// ── Trending / discovery fields ────────────────────────────────────
'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),
'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),
'awards' => [
'gold' => $awardStat?->gold_count ?? 0,
'silver' => $awardStat?->silver_count ?? 0,
'bronze' => $awardStat?->bronze_count ?? 0,
'score' => $awardStat?->score_total ?? 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 getRouteKeyName(): string
{
return 'slug';
}
protected static function booted(): void
{
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')]);
});
}
}