Studio: make grid checkbox rectangular and commit table changes

This commit is contained in:
2026-03-01 08:43:48 +01:00
parent 211dc58884
commit e3ca845a6d
89 changed files with 7323 additions and 475 deletions

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Collect hourly metric snapshots for artworks.
*
* Runs on cron every hour. Inserts a row per artwork into
* artwork_metric_snapshots_hourly with the current totals.
* Deltas are computed by the heat recalculation command.
*
* Usage: php artisan nova:metrics-snapshot-hourly
* php artisan nova:metrics-snapshot-hourly --days=30 --chunk=500 --dry-run
*/
class MetricsSnapshotHourlyCommand extends Command
{
protected $signature = 'nova:metrics-snapshot-hourly
{--days=60 : Only snapshot artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--dry-run : Log what would be written without persisting}';
protected $description = 'Collect hourly metric snapshots for rising/heat calculation';
public function handle(): int
{
$days = (int) $this->option('days');
$chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$bucketHour = now()->startOfHour();
$this->info("[nova:metrics-snapshot-hourly] bucket={$bucketHour->toDateTimeString()} days={$days} chunk={$chunk}" . ($dryRun ? ' (dry-run)' : ''));
$snapshotCount = 0;
$skipCount = 0;
// Query artworks eligible for snapshotting:
// - created within $days OR has a ranking_score above 0
// First collect eligible IDs, then process in chunks
$eligibleIds = DB::table('artworks')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->where(function ($q) use ($days) {
$q->where('artworks.created_at', '>=', now()->subDays($days))
->orWhere(function ($q2) {
$q2->whereNotNull('s.ranking_score')
->where('s.ranking_score', '>', 0);
});
})
->whereNull('artworks.deleted_at')
->where('artworks.is_approved', true)
->pluck('artworks.id');
if ($eligibleIds->isEmpty()) {
$this->info('No eligible artworks found.');
return self::SUCCESS;
}
foreach ($eligibleIds->chunk($chunk) as $chunkIds) {
$artworkIds = $chunkIds->values()->all();
$stats = DB::table('artwork_stats')
->whereIn('artwork_id', $artworkIds)
->get()
->keyBy('artwork_id');
$rows = [];
foreach ($artworkIds as $artworkId) {
$stat = $stats->get($artworkId);
$rows[] = [
'artwork_id' => $artworkId,
'bucket_hour' => $bucketHour,
'views_count' => (int) ($stat?->views ?? 0),
'downloads_count' => (int) ($stat?->downloads ?? 0),
'favourites_count' => (int) ($stat?->favorites ?? 0),
'comments_count' => (int) ($stat?->comments_count ?? 0),
'shares_count' => (int) ($stat?->shares_count ?? 0),
'created_at' => now(),
];
}
if ($dryRun) {
$snapshotCount += count($rows);
continue;
}
if (!empty($rows)) {
// Upsert: if (artwork_id, bucket_hour) already exists, update totals
DB::table('artwork_metric_snapshots_hourly')->upsert(
$rows,
['artwork_id', 'bucket_hour'],
['views_count', 'downloads_count', 'favourites_count', 'comments_count', 'shares_count']
);
$snapshotCount += count($rows);
}
}
$this->info("Snapshots written: {$snapshotCount} | Skipped: {$skipCount}");
Log::info('[nova:metrics-snapshot-hourly] completed', [
'bucket' => $bucketHour->toDateTimeString(),
'written' => $snapshotCount,
'skipped' => $skipCount,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Prune old hourly metric snapshots to prevent unbounded table growth.
*
* Usage: php artisan nova:prune-metric-snapshots
* php artisan nova:prune-metric-snapshots --keep-days=7
*/
class PruneMetricSnapshotsCommand extends Command
{
protected $signature = 'nova:prune-metric-snapshots
{--keep-days=7 : Keep snapshots for this many days}';
protected $description = 'Delete old hourly metric snapshots beyond the retention window';
public function handle(): int
{
$keepDays = (int) $this->option('keep-days');
$cutoff = now()->subDays($keepDays);
$deleted = DB::table('artwork_metric_snapshots_hourly')
->where('bucket_hour', '<', $cutoff)
->delete();
$this->info("Pruned {$deleted} snapshot rows older than {$keepDays} days.");
Log::info('[nova:prune-metric-snapshots] completed', [
'deleted' => $deleted,
'keep_days' => $keepDays,
]);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,166 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Recalculate heat_score for artworks based on hourly metric snapshots.
*
* Runs every 1015 minutes via scheduler.
*
* Formula:
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
* + comments_delta*8 + shares_delta*12
*
* age_factor = 1 / (1 + hours_since_upload / 24)
*
* heat_score = raw_heat * age_factor
*
* Usage: php artisan nova:recalculate-heat
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
*/
class RecalculateHeatCommand extends Command
{
protected $signature = 'nova:recalculate-heat
{--days=60 : Only process artworks created within this many days}
{--chunk=1000 : Chunk size for DB queries}
{--dry-run : Compute scores without writing to DB}';
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
/** Delta weights per the spec */
private const WEIGHTS = [
'views' => 1,
'downloads' => 3,
'favourites' => 6,
'comments' => 8,
'shares' => 12,
];
public function handle(): int
{
$days = (int) $this->option('days');
$chunk = (int) $this->option('chunk');
$dryRun = (bool) $this->option('dry-run');
$now = now();
$currentHour = $now->copy()->startOfHour();
$prevHour = $currentHour->copy()->subHour();
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
$updatedCount = 0;
$skippedCount = 0;
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
->whereIn('bucket_hour', [$currentHour, $prevHour])
->distinct()
->pluck('artwork_id');
if ($artworkIds->isEmpty()) {
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
return self::SUCCESS;
}
// Load all snapshots for the two hours in bulk
$snapshots = DB::table('artwork_metric_snapshots_hourly')
->whereIn('bucket_hour', [$currentHour, $prevHour])
->whereIn('artwork_id', $artworkIds)
->get()
->groupBy('artwork_id');
// Load artwork published_at dates for age factor (use published_at, fall back to created_at)
$artworkDates = DB::table('artworks')
->whereIn('id', $artworkIds)
->whereNull('deleted_at')
->where('is_approved', true)
->select('id', 'published_at', 'created_at')
->get()
->mapWithKeys(fn ($row) => [
$row->id => \Carbon\Carbon::parse($row->published_at ?? $row->created_at),
]);
// Process in chunks
foreach ($artworkIds->chunk($chunk) as $chunkIds) {
$upsertRows = [];
foreach ($chunkIds as $artworkId) {
$createdAt = $artworkDates->get($artworkId);
if (!$createdAt) {
$skippedCount++;
continue;
}
$artworkSnapshots = $snapshots->get($artworkId);
if (!$artworkSnapshots || $artworkSnapshots->isEmpty()) {
$skippedCount++;
continue;
}
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
// If we only have one snapshot, use it as current with zero deltas
if (!$currentSnapshot && !$prevSnapshot) {
$skippedCount++;
continue;
}
// Calculate deltas
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
// Raw heat
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
+ ($downloadsDelta * self::WEIGHTS['downloads'])
+ ($favouritesDelta * self::WEIGHTS['favourites'])
+ ($commentsDelta * self::WEIGHTS['comments'])
+ ($sharesDelta * self::WEIGHTS['shares']);
// Age factor: favors newer works
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
$ageFactor = 1.0 / (1.0 + ($hoursSinceUpload / 24.0));
// Final heat score
$heatScore = max(0, $rawHeat * $ageFactor);
$upsertRows[] = [
'artwork_id' => $artworkId,
'heat_score' => round($heatScore, 4),
'heat_score_updated_at' => $now,
'views_1h' => $viewsDelta,
'downloads_1h' => $downloadsDelta,
'favourites_1h' => $favouritesDelta,
'comments_1h' => $commentsDelta,
'shares_1h' => $sharesDelta,
];
$updatedCount++;
}
if (!$dryRun && !empty($upsertRows)) {
DB::table('artwork_stats')->upsert(
$upsertRows,
['artwork_id'],
['heat_score', 'heat_score_updated_at', 'views_1h', 'downloads_1h', 'favourites_1h', 'comments_1h', 'shares_1h']
);
}
}
$this->info("Heat scores updated: {$updatedCount} | Skipped: {$skippedCount}");
Log::info('[nova:recalculate-heat] completed', [
'updated' => $updatedCount,
'skipped' => $skippedCount,
'dry_run' => $dryRun,
]);
return self::SUCCESS;
}
}

View File

@@ -14,6 +14,8 @@ use App\Console\Commands\AiTagArtworksCommand;
use App\Console\Commands\CompareFeedAbCommand;
use App\Console\Commands\RecalculateTrendingCommand;
use App\Console\Commands\RecalculateRankingsCommand;
use App\Console\Commands\MetricsSnapshotHourlyCommand;
use App\Console\Commands\RecalculateHeatCommand;
use App\Jobs\RankComputeArtworkScoresJob;
use App\Jobs\RankBuildListsJob;
use App\Uploads\Commands\CleanupUploadsCommand;
@@ -42,6 +44,8 @@ class Kernel extends ConsoleKernel
\App\Console\Commands\MigrateFollows::class,
RecalculateTrendingCommand::class,
RecalculateRankingsCommand::class,
MetricsSnapshotHourlyCommand::class,
RecalculateHeatCommand::class,
];
/**
@@ -68,6 +72,23 @@ class Kernel extends ConsoleKernel
->name('ranking-v2')
->withoutOverlapping()
->runInBackground();
// ── Rising Engine (Heat / Momentum) ─────────────────────────────────
// Step 1: snapshot metric totals every hour at :00
$schedule->command('nova:metrics-snapshot-hourly')
->hourly()
->name('metrics-snapshot-hourly')
->withoutOverlapping()
->runInBackground();
// Step 2: recalculate heat scores every 15 minutes
$schedule->command('nova:recalculate-heat')
->everyFifteenMinutes()
->name('recalculate-heat')
->withoutOverlapping()
->runInBackground();
// Step 3: prune old snapshots daily at 04:00
$schedule->command('nova:prune-metric-snapshots --keep-days=7')
->dailyAt('04:00');
}
/**

View File

@@ -7,29 +7,33 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\Recommendations\HybridSimilarArtworksService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Http\Request;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks based on:
* 1. Tag overlap (primary signal)
* 2. Same category
* 3. Similar orientation
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
* with a Meilisearch-based fallback if no precomputed data exists.
*
* Uses Meilisearch via ArtworkSearchService for fast retrieval.
* Current artwork and its creator are excluded from results.
* Query params:
* ?type=similar (default) | visual | tags | behavior
*
* Priority (default):
* 1. Hybrid precomputed (tag + behavior + optional vector)
* 2. Meilisearch tag-overlap fallback (legacy)
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
/** Spec §5: cache similar artworks 3060 min; using config with 30 min default. */
private const CACHE_TTL = 1800; // 30 minutes
private const LIMIT = 12;
public function __construct(private readonly ArtworkSearchService $search) {}
public function __construct(
private readonly ArtworkSearchService $search,
private readonly HybridSimilarArtworksService $hybridService,
) {}
public function __invoke(int $id): JsonResponse
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
@@ -40,22 +44,64 @@ final class SimilarArtworksController extends Controller
return response()->json(['error' => 'Artwork not found'], 404);
}
$cacheKey = "api.similar.{$artwork->id}";
$type = $request->query('type');
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
if ($type !== null && ! in_array($type, $validTypes, true)) {
$type = null; // ignore invalid, fall through to default
}
$items = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork) {
return $this->findSimilar($artwork);
});
// Service handles its own caching (6h TTL), no extra controller-level cache
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
if ($hybridResults->isNotEmpty()) {
// Eager-load relations needed for formatting
$ids = $hybridResults->pluck('id')->all();
$loaded = Artwork::query()
->whereIn('id', $ids)
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
->get()
->keyBy('id');
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
$full = $loaded->get($a->id) ?? $a;
return $this->formatArtwork($full);
})->all();
return response()->json(['data' => $items]);
}
// Fall back to Meilisearch tag-overlap search
$items = $this->findSimilarViaSearch($artwork);
return response()->json(['data' => $items]);
}
private function findSimilar(Artwork $artwork): array
private function formatArtwork(Artwork $artwork): array
{
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb' => $artwork->thumbUrl('md'),
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'author' => $artwork->user?->name ?? 'Artist',
'author_avatar' => $artwork->user?->profile?->avatar_url,
'author_id' => $artwork->user_id,
'orientation' => $this->orientation($artwork),
'width' => $artwork->width,
'height' => $artwork->height,
];
}
/**
* Legacy Meilisearch-based similar artworks (fallback).
*/
private function findSimilarViaSearch(Artwork $artwork): array
{
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
$srcOrientation = $this->orientation($artwork);
// Build Meilisearch filter: exclude self and same creator
$filterParts = [
'is_public = true',
'is_approved = true',
@@ -63,7 +109,6 @@ final class SimilarArtworksController extends Controller
'author_id != ' . $artwork->user_id,
];
// Priority 1: tag overlap (OR match across tags)
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
@@ -71,7 +116,6 @@ final class SimilarArtworksController extends Controller
));
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
// Fallback to category if no tags
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
$categorySlugs
@@ -79,7 +123,6 @@ final class SimilarArtworksController extends Controller
$filterParts[] = '(' . $catFilter . ')';
}
// ── Fetch 200-candidate pool from Meilisearch ─────────────────────────
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
@@ -90,9 +133,6 @@ final class SimilarArtworksController extends Controller
$collection = $results->getCollection();
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
// ── PHP reranking ──────────────────────────────────────────────────────
// Weights: tag_overlap ×0.60, orientation_bonus +0.10, resolution_bonus
// +0.05, popularity (log-views) ≤0.15, freshness (exp decay) ×0.10
$srcTagSet = array_flip($tagSlugs);
$srcW = (int) ($artwork->width ?? 0);
$srcH = (int) ($artwork->height ?? 0);
@@ -103,15 +143,12 @@ final class SimilarArtworksController extends Controller
$cTagSlugs = $candidate->tags->pluck('slug')->all();
$cTagSet = array_flip($cTagSlugs);
// Tag overlap (SørensenDice-like)
$common = count(array_intersect_key($srcTagSet, $cTagSet));
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
$tagOverlap = $common / $total;
// Orientation bonus
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
// Resolution proximity bonus (both axes within 25 %)
$cW = (int) ($candidate->width ?? 0);
$cH = (int) ($candidate->height ?? 0);
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
@@ -119,11 +156,9 @@ final class SimilarArtworksController extends Controller
&& abs($cH - $srcH) / $srcH <= 0.25
) ? 0.05 : 0.0;
// Popularity boost (log-normalised views, capped at 0.15)
$views = max(0, (int) ($candidate->stats?->views ?? 0));
$popularity = min(0.15, log(1 + $views) / 13.0);
// Freshness boost (exp decay, 60-day half-life, weight 0.10)
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
$freshness = exp(-$ageDays / 60.0) * 0.10;
@@ -140,20 +175,10 @@ final class SimilarArtworksController extends Controller
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
return array_values(
array_map(fn (array $item): array => [
'id' => $item['artwork']->id,
'title' => $item['artwork']->title,
'slug' => $item['artwork']->slug,
'thumb' => $item['artwork']->thumbUrl('md'),
'url' => '/art/' . $item['artwork']->id . '/' . $item['artwork']->slug,
'author' => $item['artwork']->user?->name ?? 'Artist',
'author_avatar' => $item['artwork']->user?->profile?->avatar_url,
'author_id' => $item['artwork']->user_id,
'orientation' => $this->orientation($item['artwork']),
'width' => $item['artwork']->width,
'height' => $item['artwork']->height,
'score' => round((float) $item['score'], 5),
], array_slice($scored, 0, self::LIMIT))
array_map(fn (array $item): array => array_merge(
$this->formatArtwork($item['artwork']),
['score' => round((float) $item['score'], 5)]
), array_slice($scored, 0, self::LIMIT))
);
}

View File

@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Recommendations\SimilarArtworksService;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -97,84 +96,12 @@ class ArtworkController extends Controller
abort(404);
}
$foundArtwork->loadMissing(['categories.contentType', 'user']);
$defaultAlgoVersion = (string) config('recommendations.embedding.algo_version', 'clip-cosine-v1');
$selectedAlgoVersion = $this->selectAlgoVersionForRequest($request, $defaultAlgoVersion);
$similarService = app(SimilarArtworksService::class);
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $selectedAlgoVersion);
if ($similarArtworks->isEmpty() && $selectedAlgoVersion !== $defaultAlgoVersion) {
$similarArtworks = $similarService->forArtwork((int) $foundArtwork->id, 12, $defaultAlgoVersion);
$selectedAlgoVersion = $defaultAlgoVersion;
}
$similarArtworks->each(static function (Artwork $item): void {
$item->loadMissing(['categories.contentType', 'user']);
});
$similarItems = $similarArtworks
->map(function (Artwork $item): ?array {
$category = $item->categories->first();
$contentType = $category?->contentType;
if (! $category || ! $contentType || empty($item->slug)) {
return null;
}
return [
'id' => (int) $item->id,
'title' => html_entity_decode((string) $item->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'author' => html_entity_decode((string) optional($item->user)->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'thumb' => (string) ($item->thumb_url ?? $item->thumb ?? '/gfx/sb_join.jpg'),
'thumb_srcset' => (string) ($item->thumb_srcset ?? ''),
'url' => route('artworks.show', [
'contentTypeSlug' => (string) $contentType->slug,
'categoryPath' => (string) $category->slug,
'artwork' => (string) $item->slug,
]),
];
})
->filter()
->values();
return view('artworks.show', [
'artwork' => $foundArtwork,
'similarItems' => $similarItems,
'similarAlgoVersion' => $selectedAlgoVersion,
]);
}
private function selectAlgoVersionForRequest(Request $request, string $default): string
{
$configured = (array) config('recommendations.ab.algo_versions', []);
$versions = array_values(array_filter(array_map(static fn ($value): string => trim((string) $value), $configured)));
if ($versions === []) {
return $default;
}
if (! in_array($default, $versions, true)) {
array_unshift($versions, $default);
$versions = array_values(array_unique($versions));
}
$forced = trim((string) $request->query('algo_version', ''));
if ($forced !== '' && in_array($forced, $versions, true)) {
return $forced;
}
if (count($versions) === 1) {
return $versions[0];
}
$visitorKey = $request->user()?->id
? 'u:' . (string) $request->user()->id
: 's:' . (string) $request->session()->getId();
$bucket = abs(crc32($visitorKey)) % count($versions);
return $versions[$bucket] ?? $default;
// Delegate to the canonical ArtworkPageController which builds all
// required view data ($meta, thumbnails, related items, comments, etc.)
return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show(
$request,
(int) $foundArtwork->id,
$foundArtwork->slug,
);
}
}

View File

@@ -30,7 +30,7 @@ class LatestCommentsController extends Controller
$user = $c->user;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url']) : '/gfx/sb_join.jpg';
$thumb = $present ? ($present['url']) : 'https://files.skinbase.org/default/missing_md.webp';
return (object) [
'comment_id' => $c->getKey(),

View File

@@ -43,7 +43,7 @@ class TodayDownloadsController extends Controller
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null; // legacy encoding unavailable; leave null
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
$categoryId = $art->categories->first()->id ?? null;
return (object) [

View File

@@ -58,7 +58,7 @@ class LegacyController extends Controller
(object) [
'id' => 0,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'category' => null,
'datum' => now(),
'category_name' => 'Photography',
@@ -289,7 +289,7 @@ class LegacyController extends Controller
$featured = (object) [
'id' => 0,
'name' => 'Featured Artwork',
'picture' => '/gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
];
}
@@ -298,7 +298,7 @@ class LegacyController extends Controller
$memberFeatured = (object) [
'id' => 0,
'name' => 'Members Pick',
'picture' => '/gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
'votes' => 0,
];
@@ -430,7 +430,7 @@ class LegacyController extends Controller
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],

View File

@@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Services\Studio\StudioArtworkQueryService;
use App\Services\Studio\StudioBulkActionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
/**
* JSON API endpoints for the Studio artwork manager.
*/
final class StudioArtworksApiController extends Controller
{
public function __construct(
private readonly StudioArtworkQueryService $queryService,
private readonly StudioBulkActionService $bulkService,
) {}
/**
* GET /api/studio/artworks
* List artworks with search, filter, sort, pagination.
*/
public function index(Request $request): JsonResponse
{
$userId = $request->user()->id;
$filters = $request->only([
'q', 'status', 'category', 'tags', 'date_from', 'date_to',
'performance', 'sort',
]);
$perPage = (int) $request->get('per_page', 24);
$perPage = min(max($perPage, 12), 100);
$paginator = $this->queryService->list($userId, $filters, $perPage);
// Transform the paginator items to a clean DTO
$items = collect($paginator->items())->map(fn ($artwork) => $this->transformArtwork($artwork));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
/**
* POST /api/studio/artworks/bulk
* Execute bulk operations.
*/
public function bulk(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
'artwork_ids' => 'required|array|min:1|max:200',
'artwork_ids.*' => 'integer',
'params' => 'sometimes|array',
'params.category_id' => 'sometimes|integer|exists:categories,id',
'params.tag_ids' => 'sometimes|array',
'params.tag_ids.*' => 'integer|exists:tags,id',
'confirm' => 'required_if:action,delete|string',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$data = $validator->validated();
// Require explicit DELETE confirmation
if ($data['action'] === 'delete' && ($data['confirm'] ?? '') !== 'DELETE') {
return response()->json([
'errors' => ['confirm' => ['You must type DELETE to confirm permanent deletion.']],
], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$data['action'],
$data['artwork_ids'],
$data['params'] ?? [],
);
$statusCode = $result['failed'] > 0 && $result['success'] === 0 ? 422 : 200;
return response()->json($result, $statusCode);
}
/**
* PUT /api/studio/artworks/{id}
* Update artwork details (title, description, visibility).
*/
public function update(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$validated = $request->validate([
'title' => 'sometimes|string|max:255',
'description' => 'sometimes|nullable|string|max:5000',
'is_public' => 'sometimes|boolean',
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
'tags' => 'sometimes|array|max:15',
'tags.*' => 'string|max:64',
]);
if (isset($validated['is_public'])) {
if ($validated['is_public'] && !$artwork->is_public) {
$validated['published_at'] = $artwork->published_at ?? now();
}
}
// Extract tags and category before updating core fields
$tags = $validated['tags'] ?? null;
$categoryId = $validated['category_id'] ?? null;
unset($validated['tags'], $validated['category_id']);
$artwork->update($validated);
// Sync category
if ($categoryId !== null) {
$artwork->categories()->sync([(int) $categoryId]);
}
// Sync tags (by slug/name)
if ($tags !== null) {
$tagIds = [];
foreach ($tags as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
['slug' => \Illuminate\Support\Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
$tagIds[$tag->id] = ['source' => 'studio_edit', 'confidence' => 1.0];
}
$artwork->tags()->sync($tagIds);
}
// Reindex in Meilisearch
try {
$artwork->searchable();
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags']);
$primaryCategory = $artwork->categories->first();
return response()->json([
'success' => true,
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'slug' => $artwork->slug,
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
],
]);
}
/**
* POST /api/studio/artworks/{id}/toggle
* Toggle publish/unpublish/archive for a single artwork.
*/
public function toggle(Request $request, int $id): JsonResponse
{
$validator = Validator::make($request->all(), [
'action' => 'required|string|in:publish,unpublish,archive,unarchive',
]);
if ($validator->fails()) {
return response()->json(['errors' => $validator->errors()], 422);
}
$result = $this->bulkService->execute(
$request->user()->id,
$validator->validated()['action'],
[$id],
);
if ($result['success'] === 0) {
return response()->json(['error' => 'Action failed', 'details' => $result['errors']], 404);
}
return response()->json(['success' => true]);
}
/**
* GET /api/studio/artworks/{id}/analytics
* Analytics data for a single artwork.
*/
public function analytics(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()
->with(['stats', 'awardStat'])
->findOrFail($id);
$stats = $artwork->stats;
return response()->json([
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
}
private function transformArtwork($artwork): array
{
$stats = $artwork->stats ?? null;
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'published_at' => $artwork->published_at?->toIso8601String(),
'created_at' => $artwork->created_at?->toIso8601String(),
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
'category' => $artwork->categories->first()?->name,
'category_slug' => $artwork->categories->first()?->slug,
'tags' => $artwork->tags->pluck('slug')->values()->all(),
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
];
}
/**
* GET /api/studio/tags/search?q=...
* Search active tags by name for the bulk tag picker.
*/
public function searchTags(Request $request): JsonResponse
{
$query = trim((string) $request->input('q'));
$tags = \App\Models\Tag::query()
->where('is_active', true)
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
->orderByDesc('usage_count')
->limit(30)
->get(['id', 'name', 'slug', 'usage_count']);
return response()->json($tags);
}
/**
* POST /api/studio/artworks/{id}/replace-file
* Replace the artwork's primary image file and regenerate derivatives.
*/
public function replaceFile(Request $request, int $id): JsonResponse
{
$artwork = $request->user()->artworks()->findOrFail($id);
$request->validate([
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50MB
]);
$file = $request->file('file');
$tempPath = $file->getRealPath();
// Compute SHA-256 hash
$hash = hash_file('sha256', $tempPath);
try {
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
$storage = app(\App\Services\Uploads\UploadStorageService::class);
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
// Store original
$originalPath = $derivatives->storeOriginal($tempPath, $hash);
$originalRelative = $storage->sectionRelativePath('originals', $hash, 'orig.webp');
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, 'image/webp', (int) filesize($originalPath));
// Generate public derivatives
$publicAbsolute = $derivatives->generatePublicDerivatives($tempPath, $hash);
foreach ($publicAbsolute as $variant => $absolutePath) {
$filename = $variant . '.webp';
$relativePath = $storage->publicRelativePath($hash, $filename);
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) filesize($absolutePath));
}
// Get dimensions
$dimensions = @getimagesize($tempPath);
$width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : $artwork->width;
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : $artwork->height;
// Update artwork record
$artwork->update([
'file_name' => 'orig.webp',
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'mime_type' => 'image/webp',
'hash' => $hash,
'file_ext' => 'webp',
'thumb_ext' => 'webp',
'width' => max(1, $width),
'height' => max(1, $height),
]);
// Reindex
try {
$artwork->searchable();
} catch (\Throwable) {}
return response()->json([
'success' => true,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'width' => $artwork->width,
'height' => $artwork->height,
'file_size' => $artwork->file_size,
]);
} catch (\Throwable $e) {
return response()->json([
'success' => false,
'error' => 'File processing failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Studio;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\Studio\StudioMetricsService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* Serves Studio Inertia pages for authenticated creators.
*/
final class StudioController extends Controller
{
public function __construct(
private readonly StudioMetricsService $metrics,
) {}
/**
* Studio Overview Dashboard (/studio)
*/
public function index(Request $request): Response
{
$userId = $request->user()->id;
return Inertia::render('Studio/StudioDashboard', [
'kpis' => $this->metrics->getDashboardKpis($userId),
'topPerformers' => $this->metrics->getTopPerformers($userId, 6),
'recentComments' => $this->metrics->getRecentComments($userId, 5),
]);
}
/**
* Artwork Manager (/studio/artworks)
*/
public function artworks(Request $request): Response
{
return Inertia::render('Studio/StudioArtworks', [
'categories' => $this->getCategories(),
]);
}
/**
* Drafts (/studio/artworks/drafts)
*/
public function drafts(Request $request): Response
{
return Inertia::render('Studio/StudioDrafts', [
'categories' => $this->getCategories(),
]);
}
/**
* Archived (/studio/artworks/archived)
*/
public function archived(Request $request): Response
{
return Inertia::render('Studio/StudioArchived', [
'categories' => $this->getCategories(),
]);
}
/**
* Edit artwork (/studio/artworks/:id/edit)
*/
public function edit(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'categories.contentType', 'tags'])
->findOrFail($id);
$primaryCategory = $artwork->categories->first();
return Inertia::render('Studio/StudioArtworkEdit', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'description' => $artwork->description,
'is_public' => (bool) $artwork->is_public,
'is_approved' => (bool) $artwork->is_approved,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'file_name' => $artwork->file_name,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'content_type_id' => $primaryCategory?->contentType?->id,
'category_id' => $primaryCategory?->id,
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
],
'contentTypes' => $this->getCategories(),
]);
}
/**
* Analytics v1 (/studio/artworks/:id/analytics)
*/
public function analytics(Request $request, int $id): Response
{
$artwork = $request->user()->artworks()
->with(['stats', 'awardStat'])
->findOrFail($id);
$stats = $artwork->stats;
return Inertia::render('Studio/StudioArtworkAnalytics', [
'artwork' => [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb_url' => $artwork->thumbUrl('md'),
],
'analytics' => [
'views' => (int) ($stats?->views ?? 0),
'favourites' => (int) ($stats?->favorites ?? 0),
'shares' => (int) ($stats?->shares_count ?? 0),
'comments' => (int) ($stats?->comments_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
'heat_score' => (float) ($stats?->heat_score ?? 0),
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
],
]);
}
/**
* Studio-wide Analytics (/studio/analytics)
*/
public function analyticsOverview(Request $request): Response
{
$userId = $request->user()->id;
$data = $this->metrics->getAnalyticsOverview($userId);
return Inertia::render('Studio/StudioAnalytics', [
'totals' => $data['totals'],
'topArtworks' => $data['top_artworks'],
'contentBreakdown' => $data['content_breakdown'],
'recentComments' => $this->metrics->getRecentComments($userId, 8),
]);
}
private function getCategories(): array
{
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
return [
'id' => $ct->id,
'name' => $ct->name,
'slug' => $ct->slug,
'categories' => $ct->rootCategories->map(function ($c) {
return [
'id' => $c->id,
'name' => $c->name,
'slug' => $c->slug,
'children' => $c->children->map(fn ($ch) => [
'id' => $ch->id,
'name' => $ch->name,
'slug' => $ch->slug,
])->values()->all(),
];
})->values()->all(),
];
})->values()->all();
}
}

View File

@@ -39,7 +39,7 @@ class TodayDownloadsController extends Controller
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
$encoded = null;
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? $present['url'] : '/gfx/sb_join.jpg';
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
$categoryId = $art->categories->first()->id ?? null;
return (object) [

View File

@@ -68,11 +68,11 @@ class TodayInHistoryController extends Controller
/** @var ?Artwork $art */
$art = $modelsById->get($row->id);
if ($art) {
$row->thumb_url = $art->thumbUrl('md') ?? '/gfx/sb_join.jpg';
$row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
$row->art_url = '/art/' . $art->id . '/' . $art->slug;
$row->name = $art->title ?: ($row->name ?? 'Untitled');
} else {
$row->thumb_url = '/gfx/sb_join.jpg';
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
$row->art_url = '/art/' . $row->id;
$row->name = $row->name ?? 'Untitled';
}

View File

@@ -49,6 +49,23 @@ final class DiscoverController extends Controller
]);
}
// ─── /discover/rising ────────────────────────────────────────────────────
public function rising(Request $request)
{
$perPage = 24;
$results = $this->searchService->discoverRising($perPage);
$this->hydrateDiscoverSearchResults($results);
return view('web.discover.index', [
'artworks' => $results,
'page_title' => 'Rising Now',
'section' => 'rising',
'description' => 'Fastest growing artworks right now.',
'icon' => 'fa-rocket',
]);
}
// ─── /discover/fresh ─────────────────────────────────────────────────────
public function fresh(Request $request)

View File

@@ -11,6 +11,18 @@ final class HandleInertiaRequests extends Middleware
{
protected $rootView = 'upload';
/**
* Select the root Blade view based on route prefix.
*/
public function rootView(Request $request): string
{
if (str_starts_with($request->path(), 'studio')) {
return 'studio';
}
return $this->rootView;
}
public function version(Request $request): ?string
{
return parent::version($request);

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Build item-item co-occurrence pairs from user favourites.
*
* Spec §7.1 runs hourly or every few hours.
* For each user: take last N favourites, create pairs, increment weights.
*
* Safety: limits per-user pairs to avoid O() explosion.
*/
final class RecBuildItemPairsFromFavouritesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly int $userBatchSize = 500,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(): void
{
$favCap = (int) config('recommendations.similarity.user_favourites_cap', 50);
// ── Pre-compute per-artwork total favourite counts for cosine normalization ──
$this->artworkLikeCounts = DB::table('artwork_favourites')
->select('artwork_id', DB::raw('COUNT(*) as cnt'))
->groupBy('artwork_id')
->pluck('cnt', 'artwork_id')
->all();
// ── Accumulate co-occurrence counts across all users ──
$coOccurrenceCounts = [];
DB::table('artwork_favourites')
->select('user_id')
->groupBy('user_id')
->orderBy('user_id')
->chunk($this->userBatchSize, function ($userRows) use ($favCap, &$coOccurrenceCounts) {
foreach ($userRows as $row) {
$pairs = $this->pairsForUser((int) $row->user_id, $favCap);
foreach ($pairs as $pair) {
$key = $pair[0] . ':' . $pair[1];
$coOccurrenceCounts[$key] = ($coOccurrenceCounts[$key] ?? 0) + 1;
}
}
});
// ── Normalize to cosine-like scores and flush ──
$normalized = [];
foreach ($coOccurrenceCounts as $key => $count) {
[$a, $b] = explode(':', $key);
$likesA = $this->artworkLikeCounts[(int) $a] ?? 1;
$likesB = $this->artworkLikeCounts[(int) $b] ?? 1;
$normalized[$key] = $count / sqrt($likesA * $likesB);
}
$this->flushPairs($normalized);
}
/** @var array<int, int> artwork_id => total favourite count */
private array $artworkLikeCounts = [];
/**
* Collect pairs from a single user's last N favourites.
*
* @return list<array{0: int, 1: int}>
*/
public function pairsForUser(int $userId, int $cap): array
{
$artworkIds = DB::table('artwork_favourites')
->where('user_id', $userId)
->orderByDesc('created_at')
->limit($cap)
->pluck('artwork_id')
->map(fn ($id) => (int) $id)
->all();
$count = count($artworkIds);
if ($count < 2) {
return [];
}
$pairs = [];
// Cap max pairs per user to avoid explosion: C(50,2) = 1225 worst case = acceptable
for ($i = 0; $i < $count - 1; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$a = min($artworkIds[$i], $artworkIds[$j]);
$b = max($artworkIds[$i], $artworkIds[$j]);
$pairs[] = [$a, $b];
}
}
return $pairs;
}
/**
* Upsert normalized pair weights into rec_item_pairs.
*
* Uses Laravel's DB-agnostic upsert (works on MySQL, Postgres, SQLite).
*
* @param array<string, float> $upserts key = "a:b", value = cosine-normalized weight
*/
private function flushPairs(array $upserts): void
{
if ($upserts === []) {
return;
}
$now = now();
foreach (array_chunk($upserts, 500, preserve_keys: true) as $chunk) {
$rows = [];
foreach ($chunk as $key => $weight) {
[$a, $b] = explode(':', $key);
$rows[] = [
'a_artwork_id' => (int) $a,
'b_artwork_id' => (int) $b,
'weight' => $weight,
'updated_at' => $now,
];
}
DB::table('rec_item_pairs')->upsert(
$rows,
['a_artwork_id', 'b_artwork_id'],
['weight', 'updated_at'],
);
}
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Compute behavior-based (co-like) similarity from precomputed item pairs.
*
* Spec §7.3 runs nightly.
* For each artwork: read top pairs from rec_item_pairs, store top N.
*/
final class RecComputeSimilarByBehaviorJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(): void
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) {
$query->where('id', $this->artworkId);
}
$query->chunkById($this->batchSize, function ($artworks) use ($modelVersion, $resultLimit, $maxPerAuthor) {
foreach ($artworks as $artwork) {
$this->processArtwork($artwork, $modelVersion, $resultLimit, $maxPerAuthor);
}
});
}
private function processArtwork(
Artwork $artwork,
string $modelVersion,
int $resultLimit,
int $maxPerAuthor,
): void {
// Fetch top co-occurring artworks (bi-directional)
$candidates = DB::table('rec_item_pairs')
->where('a_artwork_id', $artwork->id)
->select(DB::raw('b_artwork_id AS related_id'), 'weight')
->union(
DB::table('rec_item_pairs')
->where('b_artwork_id', $artwork->id)
->select(DB::raw('a_artwork_id AS related_id'), 'weight')
)
->orderByDesc('weight')
->limit($resultLimit * 3)
->get();
if ($candidates->isEmpty()) {
return;
}
$relatedIds = $candidates->pluck('related_id')->map(fn ($id) => (int) $id)->all();
// Fetch author info for diversity filtering
$authorMap = DB::table('artworks')
->whereIn('id', $relatedIds)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->whereNull('deleted_at')
->pluck('user_id', 'id')
->all();
// Apply diversity cap
$authorCounts = [];
$final = [];
foreach ($candidates as $cand) {
$relatedId = (int) $cand->related_id;
if (! isset($authorMap[$relatedId])) {
continue; // not public/published
}
$authorId = (int) $authorMap[$relatedId];
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
if ($authorCounts[$authorId] > $maxPerAuthor) {
continue;
}
$final[] = $relatedId;
if (count($final) >= $resultLimit) {
break;
}
}
if ($final === []) {
return;
}
RecArtworkRec::query()->updateOrCreate(
[
'artwork_id' => $artwork->id,
'rec_type' => 'similar_behavior',
'model_version' => $modelVersion,
],
[
'recs' => $final,
'computed_at' => now(),
],
);
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Compute tag-based (+ category boost) similarity for artworks.
*
* Spec §7.2 runs nightly + on-demand.
* For each artwork: find candidates by shared tags/category, score with IDF-weighted
* tag overlap, apply diversity, store top N.
*/
final class RecComputeSimilarByTagsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 600;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(): void
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
$candidatePool = (int) config('recommendations.similarity.candidate_pool', 100);
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
// ── Tag IDF weights (global) ───────────────────────────────────────────
$tagFreqs = DB::table('artwork_tag')
->select('tag_id', DB::raw('COUNT(*) as cnt'))
->groupBy('tag_id')
->pluck('cnt', 'tag_id')
->all();
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) {
$query->where('id', $this->artworkId);
}
$query->chunkById($this->batchSize, function ($artworks) use (
$tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit
) {
foreach ($artworks as $artwork) {
$this->processArtwork($artwork, $tagFreqs, $modelVersion, $candidatePool, $maxPerAuthor, $resultLimit);
}
});
}
private function processArtwork(
Artwork $artwork,
array $tagFreqs,
string $modelVersion,
int $candidatePool,
int $maxPerAuthor,
int $resultLimit,
): void {
// Get source artwork's tags and categories
$srcTagIds = DB::table('artwork_tag')
->where('artwork_id', $artwork->id)
->pluck('tag_id')
->all();
$srcCatIds = DB::table('artwork_category')
->where('artwork_id', $artwork->id)
->pluck('category_id')
->all();
// Source content_type_ids (via categories)
$srcContentTypeIds = $srcCatIds !== []
? DB::table('categories')
->whereIn('id', $srcCatIds)
->whereNotNull('content_type_id')
->pluck('content_type_id')
->unique()
->all()
: [];
if ($srcTagIds === [] && $srcCatIds === []) {
return;
}
// ── Find candidates that share at least one tag ────────────────────────
$candidateQuery = DB::table('artwork_tag')
->join('artworks', 'artworks.id', '=', 'artwork_tag.artwork_id')
->whereIn('artwork_tag.tag_id', $srcTagIds)
->where('artwork_tag.artwork_id', '!=', $artwork->id)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNotNull('artworks.published_at')
->where('artworks.published_at', '<=', now())
->whereNull('artworks.deleted_at')
->select('artwork_tag.artwork_id', 'artworks.user_id')
->groupBy('artwork_tag.artwork_id', 'artworks.user_id')
->orderByRaw('COUNT(*) DESC')
->limit($candidatePool * 3); // over-fetch before scoring
$candidates = $candidateQuery->get();
if ($candidates->isEmpty()) {
return;
}
// Gather tags for all candidates in one query
$candidateIds = $candidates->pluck('artwork_id')->all();
$candidateTagMap = DB::table('artwork_tag')
->whereIn('artwork_id', $candidateIds)
->select('artwork_id', 'tag_id')
->get()
->groupBy('artwork_id');
$candidateCatMap = DB::table('artwork_category')
->whereIn('artwork_id', $candidateIds)
->select('artwork_id', 'category_id')
->get()
->groupBy('artwork_id');
// Build content_type_id lookup for candidates (via categories table)
$allCandidateCatIds = $candidateCatMap->flatten(1)->pluck('category_id')->unique()->all();
$catContentTypeMap = $allCandidateCatIds !== []
? DB::table('categories')
->whereIn('id', $allCandidateCatIds)
->whereNotNull('content_type_id')
->pluck('content_type_id', 'id')
->all()
: [];
$srcContentTypeSet = array_flip($srcContentTypeIds);
$srcTagSet = array_flip($srcTagIds);
$srcCatSet = array_flip($srcCatIds);
// ── Score each candidate ───────────────────────────────────────────────
$scored = [];
foreach ($candidates as $cand) {
$cTagIds = $candidateTagMap->get($cand->artwork_id, collect())->pluck('tag_id')->all();
$cCatIds = $candidateCatMap->get($cand->artwork_id, collect())->pluck('category_id')->all();
// IDF-weighted tag overlap (spec §5.1)
$tagScore = 0.0;
foreach ($cTagIds as $tagId) {
if (isset($srcTagSet[$tagId])) {
$freq = $tagFreqs[$tagId] ?? 1;
$tagScore += 1.0 / log(2 + $freq);
}
}
// Category match bonus
$catScore = 0.0;
foreach ($cCatIds as $catId) {
if (isset($srcCatSet[$catId])) {
$catScore = 1.0;
break;
}
}
// Content type match bonus (spec §5.1)
$ctScore = 0.0;
foreach ($cCatIds as $catId) {
$ctId = $catContentTypeMap[$catId] ?? null;
if ($ctId !== null && isset($srcContentTypeSet[$ctId])) {
$ctScore = 1.0;
break;
}
}
$scored[] = [
'artwork_id' => (int) $cand->artwork_id,
'user_id' => (int) $cand->user_id,
'tag_score' => $tagScore,
'cat_score' => $catScore,
'score' => $tagScore + $catScore * 0.1 + $ctScore * 0.05,
];
}
// Sort by score descending
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
// ── Apply diversity (max per author) ───────────────────────────────────
$authorCounts = [];
$final = [];
foreach ($scored as $item) {
$authorId = $item['user_id'];
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
if ($authorCounts[$authorId] > $maxPerAuthor) {
continue;
}
$final[] = $item['artwork_id'];
if (count($final) >= $resultLimit) {
break;
}
}
// ── Persist ────────────────────────────────────────────────────────────
RecArtworkRec::query()->updateOrCreate(
[
'artwork_id' => $artwork->id,
'rec_type' => 'similar_tags',
'model_version' => $modelVersion,
],
[
'recs' => $final,
'computed_at' => now(),
],
);
}
}

View File

@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Compute hybrid similarity by blending tag, behavior, and optionally visual scores.
*
* Spec §7.4 runs nightly.
* Merges candidates from tag + behavior + vector lists, applies hybrid blend weights,
* enforces diversity, stores top 30.
*/
final class RecComputeSimilarHybridJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 2;
public int $timeout = 900;
public function __construct(
private readonly ?int $artworkId = null,
private readonly int $batchSize = 200,
) {
$queue = (string) config('recommendations.queue', 'default');
if ($queue !== '') {
$this->onQueue($queue);
}
}
public function handle(): void
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
$resultLimit = (int) config('recommendations.similarity.result_limit', 30);
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
$minCatsTop12 = (int) config('recommendations.similarity.min_categories_top12', 2);
$weights = $vectorEnabled
? (array) config('recommendations.similarity.weights_with_vector')
: (array) config('recommendations.similarity.weights_without_vector');
$query = Artwork::query()->public()->published()->select('id', 'user_id');
if ($this->artworkId !== null) {
$query->where('id', $this->artworkId);
}
$query->chunkById($this->batchSize, function ($artworks) use (
$modelVersion, $vectorEnabled, $resultLimit, $maxPerAuthor, $minCatsTop12, $weights
) {
foreach ($artworks as $artwork) {
try {
$this->processArtwork(
$artwork, $modelVersion, $vectorEnabled, $resultLimit,
$maxPerAuthor, $minCatsTop12, $weights
);
} catch (\Throwable $e) {
Log::warning("[RecComputeSimilarHybrid] Failed for artwork {$artwork->id}: {$e->getMessage()}");
}
}
});
}
private function processArtwork(
Artwork $artwork,
string $modelVersion,
bool $vectorEnabled,
int $resultLimit,
int $maxPerAuthor,
int $minCatsTop12,
array $weights,
): void {
// ── Collect sub-lists ──────────────────────────────────────────────────
$tagRec = RecArtworkRec::query()
->where('artwork_id', $artwork->id)
->where('rec_type', 'similar_tags')
->where('model_version', $modelVersion)
->first();
$behRec = RecArtworkRec::query()
->where('artwork_id', $artwork->id)
->where('rec_type', 'similar_behavior')
->where('model_version', $modelVersion)
->first();
$tagIds = $tagRec ? ($tagRec->recs ?? []) : [];
$behIds = $behRec ? ($behRec->recs ?? []) : [];
$vecIds = [];
$vecScores = [];
if ($vectorEnabled) {
$vecRec = RecArtworkRec::query()
->where('artwork_id', $artwork->id)
->where('rec_type', 'similar_visual')
->where('model_version', $modelVersion)
->first();
if ($vecRec) {
$vecIds = $vecRec->recs ?? [];
}
}
// Merge all candidate IDs
$allIds = array_values(array_unique(array_merge($tagIds, $behIds, $vecIds)));
if ($allIds === []) {
return;
}
// ── Build normalized score maps ────────────────────────────────────────
$tagScoreMap = $this->rankToScore($tagIds);
$behScoreMap = $this->rankToScore($behIds);
$vecScoreMap = $this->rankToScore($vecIds);
// Fetch artwork metadata for category + author diversity
$metaRows = DB::table('artworks')
->whereIn('id', $allIds)
->where('is_public', true)
->where('is_approved', true)
->whereNotNull('published_at')
->where('published_at', '<=', now())
->whereNull('deleted_at')
->select('id', 'user_id')
->get()
->keyBy('id');
$catMap = DB::table('artwork_category')
->whereIn('artwork_id', $allIds)
->select('artwork_id', 'category_id')
->get()
->groupBy('artwork_id');
// Source artwork categories
$srcCatIds = DB::table('artwork_category')
->where('artwork_id', $artwork->id)
->pluck('category_id')
->all();
$srcCatSet = array_flip($srcCatIds);
// ── Compute hybrid score ───────────────────────────────────────────────
$scored = [];
foreach ($allIds as $candidateId) {
if (! $metaRows->has($candidateId)) {
continue;
}
$meta = $metaRows->get($candidateId);
$candidateCats = $catMap->get($candidateId, collect())->pluck('category_id')->all();
// Category overlap
$catScore = 0.0;
foreach ($candidateCats as $catId) {
if (isset($srcCatSet[$catId])) {
$catScore = 1.0;
break;
}
}
$tagS = $tagScoreMap[$candidateId] ?? 0.0;
$behS = $behScoreMap[$candidateId] ?? 0.0;
$vecS = $vecScoreMap[$candidateId] ?? 0.0;
if ($vectorEnabled) {
$score = ($weights['visual'] ?? 0.45) * $vecS
+ ($weights['tag'] ?? 0.25) * $tagS
+ ($weights['behavior'] ?? 0.20) * $behS
+ ($weights['category'] ?? 0.10) * $catScore;
} else {
$score = ($weights['tag'] ?? 0.55) * $tagS
+ ($weights['behavior'] ?? 0.35) * $behS
+ ($weights['category'] ?? 0.10) * $catScore;
}
$scored[] = [
'artwork_id' => $candidateId,
'user_id' => (int) $meta->user_id,
'cat_ids' => $candidateCats,
'score' => $score,
];
}
usort($scored, fn (array $a, array $b) => $b['score'] <=> $a['score']);
// ── Diversity enforcement ──────────────────────────────────────────────
$authorCounts = [];
$final = [];
$catsInTop12 = [];
foreach ($scored as $item) {
$authorId = $item['user_id'];
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
if ($authorCounts[$authorId] > $maxPerAuthor) {
continue;
}
$final[] = $item;
if (count($final) <= 12) {
foreach ($item['cat_ids'] as $cId) {
$catsInTop12[$cId] = true;
}
}
if (count($final) >= $resultLimit) {
break;
}
}
// ── Min-categories enforcement in top 12 (spec §6) ────────────────────
if (count($catsInTop12) < $minCatsTop12 && count($final) >= 12) {
// Find items beyond the initial selection that introduce a new category
$usedIds = array_flip(array_column($final, 'artwork_id'));
$promotable = [];
foreach ($scored as $item) {
if (isset($usedIds[$item['artwork_id']])) {
continue;
}
$newCats = array_diff($item['cat_ids'], array_keys($catsInTop12));
if ($newCats !== []) {
$promotable[] = $item;
if (count($promotable) >= ($minCatsTop12 - count($catsInTop12))) {
break;
}
}
}
// Inject promoted items at position 12 (end of visible top block)
if ($promotable !== []) {
$top = array_slice($final, 0, 11);
$rest = array_slice($final, 11);
$final = array_merge($top, $promotable, $rest);
$final = array_slice($final, 0, $resultLimit);
}
}
$finalIds = array_column($final, 'artwork_id');
if ($finalIds === []) {
return;
}
RecArtworkRec::query()->updateOrCreate(
[
'artwork_id' => $artwork->id,
'rec_type' => 'similar_hybrid',
'model_version' => $modelVersion,
],
[
'recs' => $finalIds,
'computed_at' => now(),
],
);
}
/**
* Convert a ranked list of IDs into a score map (1.0 at rank 0, decaying).
*
* @param list<int> $ids
* @return array<int, float>
*/
private function rankToScore(array $ids): array
{
$map = [];
$total = count($ids);
if ($total === 0) {
return $map;
}
foreach ($ids as $rank => $id) {
// Linear decay from 1.0 → ~0.0
$map[(int) $id] = 1.0 - ($rank / max(1, $total));
}
return $map;
}
}

View File

@@ -86,7 +86,7 @@ class Artwork extends Model
*/
public function getThumbAttribute(): string
{
return $this->thumbUrl('md') ?? '/gfx/sb_join.jpg';
return $this->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp';
}
/**
@@ -261,6 +261,8 @@ class Artwork extends Model
'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,

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ArtworkMetricSnapshotHourly
*
* Stores hourly totals for artwork metrics. Deltas are computed by
* subtracting the previous hour's snapshot from the current one.
*
* @property int $id
* @property int $artwork_id
* @property \Illuminate\Support\Carbon $bucket_hour
* @property int $views_count
* @property int $downloads_count
* @property int $favourites_count
* @property int $comments_count
* @property int $shares_count
* @property \Illuminate\Support\Carbon $created_at
*/
class ArtworkMetricSnapshotHourly extends Model
{
protected $table = 'artwork_metric_snapshots_hourly';
public $timestamps = false;
protected $fillable = [
'artwork_id',
'bucket_hour',
'views_count',
'downloads_count',
'favourites_count',
'comments_count',
'shares_count',
];
protected $casts = [
'bucket_hour' => 'datetime',
'views_count' => 'integer',
'downloads_count' => 'integer',
'favourites_count' => 'integer',
'comments_count' => 'integer',
'shares_count' => 'integer',
];
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

View File

@@ -34,6 +34,14 @@ class ArtworkStats extends Model
'shares_24h',
'comments_24h',
'favourites_24h',
// Rising / Heat columns
'heat_score',
'heat_score_updated_at',
'views_1h',
'favourites_1h',
'comments_1h',
'shares_1h',
'downloads_1h',
];
public function artwork(): BelongsTo

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Precomputed recommendation list for an artwork.
*
* @property int $id
* @property int $artwork_id
* @property string $rec_type similar_hybrid|similar_visual|similar_tags|similar_behavior
* @property array $recs Ordered array of artwork IDs
* @property string $model_version e.g. "sim_v1"
* @property \Carbon\Carbon $computed_at
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*
* @property-read Artwork $artwork
*/
final class RecArtworkRec extends Model
{
protected $table = 'rec_artwork_recs';
protected $fillable = [
'artwork_id',
'rec_type',
'recs',
'model_version',
'computed_at',
];
protected $casts = [
'artwork_id' => 'integer',
'recs' => 'array',
'computed_at' => 'datetime',
];
// ── Relations ──────────────────────────────────────────────────────────
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'artwork_id');
}
}

54
app/Models/RecEvent.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Recommendation event log entry.
*
* @property int $id
* @property int|null $user_id
* @property string|null $session_id
* @property string $event_type view|favourite|download
* @property int $artwork_id
* @property \Carbon\Carbon $created_at
*
* @property-read User|null $user
* @property-read Artwork $artwork
*/
final class RecEvent extends Model
{
protected $table = 'rec_events';
public $timestamps = false;
protected $fillable = [
'user_id',
'session_id',
'event_type',
'artwork_id',
'created_at',
];
protected $casts = [
'user_id' => 'integer',
'artwork_id' => 'integer',
'created_at' => 'datetime',
];
// ── Relations ──────────────────────────────────────────────────────────
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function artwork(): BelongsTo
{
return $this->belongsTo(Artwork::class);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Item-item co-occurrence pair for behavior-based similarity.
*
* @property int $a_artwork_id
* @property int $b_artwork_id
* @property float $weight
* @property \Carbon\Carbon $updated_at
*
* @property-read Artwork $artworkA
* @property-read Artwork $artworkB
*/
final class RecItemPair extends Model
{
protected $table = 'rec_item_pairs';
public $incrementing = false;
public $timestamps = false;
protected $fillable = [
'a_artwork_id',
'b_artwork_id',
'weight',
'updated_at',
];
protected $casts = [
'a_artwork_id' => 'integer',
'b_artwork_id' => 'integer',
'weight' => 'double',
'updated_at' => 'datetime',
];
// ── Relations ──────────────────────────────────────────────────────────
public function artworkA(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'a_artwork_id');
}
public function artworkB(): BelongsTo
{
return $this->belongsTo(Artwork::class, 'b_artwork_id');
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Observers;
use App\Jobs\RecComputeSimilarByBehaviorJob;
use App\Jobs\RecComputeSimilarHybridJob;
use App\Models\ArtworkFavourite;
use App\Services\UserStatsService;
use Illuminate\Support\Facades\DB;
@@ -24,6 +26,9 @@ class ArtworkFavouriteObserver
if ($creatorId) {
$this->userStats->incrementFavoritesReceived($creatorId);
}
// §7.5 On-demand: recompute behavior similarity when artwork reaches threshold
$this->maybeRecomputeBehavior($favourite->artwork_id);
}
public function deleted(ArtworkFavourite $favourite): void
@@ -42,4 +47,22 @@ class ArtworkFavouriteObserver
return $id !== null ? (int) $id : null;
}
/**
* Dispatch on-demand behavior recomputation when an artwork crosses a
* favourites threshold (5, 10, 25, 50 ).
*/
private function maybeRecomputeBehavior(int $artworkId): void
{
$count = (int) DB::table('artwork_favourites')
->where('artwork_id', $artworkId)
->count();
$thresholds = [5, 10, 25, 50, 100];
if (in_array($count, $thresholds, true)) {
RecComputeSimilarByBehaviorJob::dispatch($artworkId)->delay(now()->addSeconds(30));
RecComputeSimilarHybridJob::dispatch($artworkId)->delay(now()->addMinute());
}
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Observers;
use App\Models\Artwork;
use App\Jobs\RecComputeSimilarByTagsJob;
use App\Jobs\RecComputeSimilarHybridJob;
use App\Services\ArtworkSearchIndexer;
use App\Services\UserStatsService;
@@ -39,6 +41,14 @@ class ArtworkObserver
}
$this->indexer->update($artwork);
// §7.5 On-demand: recompute similarity when tags/categories could have changed.
// The pivot sync happens outside this observer, so we dispatch on every
// meaningful update and let the job be idempotent (cheap if nothing changed).
if ($artwork->is_public && $artwork->published_at) {
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinutes(1));
}
}
/** Soft delete — remove from search and decrement uploads_count. */

View File

@@ -36,6 +36,12 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(UploadDraftServiceInterface::class, function ($app) {
return new UploadDraftService($app->make('filesystem'));
});
// Bind vector adapter interface for similarity system (resolves via factory)
$this->app->bind(
\App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class,
fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(),
);
}
/**

View File

@@ -269,6 +269,27 @@ final class ArtworkSearchService
});
}
/**
* Rising: sorted by heat_score (recalculated every 15 min).
*
* Surfaces artworks with rapid recent engagement growth.
* Restricts to last 30 days, sorted by heat_score DESC.
*/
public function discoverRising(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("discover.rising.{$page}", 120, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
])
->paginate($perPage);
});
}
/**
* Fresh: newest uploads first.
*/

View File

@@ -44,6 +44,7 @@ final class HomepageService
{
return [
'hero' => $this->getHeroArtwork(),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'tags' => $this->getPopularTags(),
@@ -74,6 +75,7 @@ final class HomepageService
'hero' => $this->getHeroArtwork(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
@@ -132,6 +134,65 @@ final class HomepageService
});
}
/**
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
*
* Surfaces artworks with the fastest recent engagement growth.
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
*/
public function getRising(int $limit = 10): array
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}", 120, function () use ($limit, $cutoff): array {
try {
$results = Artwork::search('')
->options([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
])
->paginate($limit, 'page', 1);
$results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']);
if ($results->isEmpty()) {
return $this->getRisingFromDb($limit);
}
return $results->getCollection()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getRisingFromDb($limit);
}
});
}
/**
* DB-only fallback for rising (Meilisearch unavailable).
*/
private function getRisingFromDb(int $limit): array
{
return Artwork::public()
->published()
->with(['user:id,name,username', 'user.profile:user_id,avatar_hash'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get()
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
*

View File

@@ -49,7 +49,7 @@ class LegacyService
$featured = (object) [
'id' => 0,
'name' => 'Featured Artwork',
'picture' => '/gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
];
}
@@ -58,7 +58,7 @@ class LegacyService
$memberFeatured = (object) [
'id' => 0,
'name' => 'Members Pick',
'picture' => '/gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
'votes' => 0,
];
@@ -106,7 +106,7 @@ class LegacyService
[
'id' => 1,
'name' => 'Sample Artwork',
'picture' => 'gfx/sb_join.jpg',
'picture' => 'https://files.skinbase.org/default/missing_md.webp',
'uname' => 'Skinbase',
'category_name' => 'Photography',
],
@@ -282,7 +282,7 @@ class LegacyService
} else {
$row->ext = null;
$row->encoded = null;
$row->thumb_url = '/gfx/sb_join.jpg';
$row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp';
$row->thumb_srcset = null;
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations;
use App\Models\Artwork;
use App\Models\RecArtworkRec;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Runtime service for the Similar Artworks hybrid recommender (spec §8).
*
* Flow:
* 1. Try precomputed similar_hybrid list
* 2. Else similar_visual (if enabled)
* 3. Else similar_tags
* 4. Else similar_behavior
* 5. Else trending fallback in the same category/content_type
*
* Lists are cached in Redis/cache with a configurable TTL.
* Hydration fetches artworks in one query, preserving stored order.
* An author-cap diversity filter is applied at runtime as a final check.
*/
final class HybridSimilarArtworksService
{
private const FALLBACK_ORDER = [
'similar_hybrid',
'similar_visual',
'similar_tags',
'similar_behavior',
];
/**
* Get similar artworks for the given artwork.
*
* @param string|null $type null|'similar'='hybrid fallback', 'visual', 'tags', 'behavior'
* @return Collection<int, Artwork>
*/
public function forArtwork(int $artworkId, int $limit = 12, ?string $type = null): Collection
{
$modelVersion = (string) config('recommendations.similarity.model_version', 'sim_v1');
$cacheTtl = (int) config('recommendations.similarity.cache_ttl', 6 * 3600);
$maxPerAuthor = (int) config('recommendations.similarity.max_per_author', 2);
$vectorEnabled = (bool) config('recommendations.similarity.vector_enabled', false);
$typeSuffix = $type && $type !== 'similar' ? ":{$type}" : '';
$cacheKey = "rec:artwork:{$artworkId}:similar:{$modelVersion}{$typeSuffix}";
$ids = Cache::remember($cacheKey, $cacheTtl, function () use (
$artworkId, $modelVersion, $vectorEnabled, $type
): array {
return $this->resolveIds($artworkId, $modelVersion, $vectorEnabled, $type);
});
if ($ids === []) {
return collect();
}
// Take requested limit + buffer for author-diversity filtering
$idSlice = array_slice($ids, 0, $limit * 3);
$artworks = Artwork::query()
->whereIn('id', $idSlice)
->public()
->published()
->get()
->keyBy('id');
// Preserve precomputed order + apply author cap
$authorCounts = [];
$result = [];
foreach ($idSlice as $id) {
/** @var Artwork|null $artwork */
$artwork = $artworks->get($id);
if (! $artwork) {
continue;
}
$authorId = $artwork->user_id;
$authorCounts[$authorId] = ($authorCounts[$authorId] ?? 0) + 1;
if ($authorCounts[$authorId] > $maxPerAuthor) {
continue;
}
$result[] = $artwork;
if (count($result) >= $limit) {
break;
}
}
return collect($result);
}
/**
* Resolve the precomputed ID list, falling through rec types.
*
* @return list<int>
*/
private function resolveIds(int $artworkId, string $modelVersion, bool $vectorEnabled, ?string $type = null): array
{
// If a specific type was requested, try only that type + trending fallback
if ($type && $type !== 'similar') {
$recType = match ($type) {
'visual' => 'similar_visual',
'tags' => 'similar_tags',
'behavior' => 'similar_behavior',
default => null,
};
if ($recType) {
$rec = RecArtworkRec::query()
->where('artwork_id', $artworkId)
->where('rec_type', $recType)
->where('model_version', $modelVersion)
->first();
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
return array_map('intval', $rec->recs);
}
}
return $this->trendingFallback($artworkId);
}
// Default: hybrid fallback chain
$tryTypes = $vectorEnabled
? self::FALLBACK_ORDER
: array_filter(self::FALLBACK_ORDER, fn (string $t) => $t !== 'similar_visual');
foreach ($tryTypes as $recType) {
$rec = RecArtworkRec::query()
->where('artwork_id', $artworkId)
->where('rec_type', $recType)
->where('model_version', $modelVersion)
->first();
if ($rec && is_array($rec->recs) && $rec->recs !== []) {
return array_map('intval', $rec->recs);
}
}
// ── Trending fallback (category-scoped) ────────────────────────────────
return $this->trendingFallback($artworkId);
}
/**
* Trending fallback: fetch recent popular artworks in the same category.
*
* @return list<int>
*/
private function trendingFallback(int $artworkId): array
{
$catIds = DB::table('artwork_category')
->where('artwork_id', $artworkId)
->pluck('category_id')
->all();
$query = Artwork::query()
->public()
->published()
->where('id', '!=', $artworkId);
if ($catIds !== []) {
$query->whereHas('categories', function ($q) use ($catIds) {
$q->whereIn('categories.id', $catIds);
});
}
return $query
->orderByDesc('published_at')
->limit(30)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* pgvector adapter uses the artwork_embeddings table with cosine similarity.
*
* Requires PostgreSQL with the pgvector extension installed.
* Schema: artwork_embeddings (artwork_id PK, model, dims, embedding vector(N), ...)
*
* Spec §9 Option A.
*/
final class PgvectorAdapter implements VectorAdapterInterface
{
public function querySimilar(int $artworkId, int $topK = 100): array
{
// Fetch reference embedding
$ref = DB::table('artwork_embeddings')
->where('artwork_id', $artworkId)
->select('embedding_json')
->first();
if (! $ref || ! $ref->embedding_json) {
return [];
}
$embedding = json_decode($ref->embedding_json, true);
if (! is_array($embedding) || $embedding === []) {
return [];
}
// pgvector cosine distance operator: <=>
// Score = 1 - distance (higher = more similar)
$vecLiteral = '[' . implode(',', array_map('floatval', $embedding)) . ']';
try {
$rows = DB::select(
"SELECT artwork_id, 1 - (embedding_json::vector <=> ?::vector) AS score
FROM artwork_embeddings
WHERE artwork_id != ?
ORDER BY embedding_json::vector <=> ?::vector
LIMIT ?",
[$vecLiteral, $artworkId, $vecLiteral, $topK]
);
} catch (\Throwable $e) {
Log::warning("[PgvectorAdapter] Query failed: {$e->getMessage()}");
return [];
}
return array_map(fn ($row) => [
'artwork_id' => (int) $row->artwork_id,
'score' => (float) $row->score,
], $rows);
}
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
{
$json = json_encode($embedding);
DB::table('artwork_embeddings')->updateOrInsert(
['artwork_id' => $artworkId],
[
'embedding_json' => $json,
'model' => $metadata['model'] ?? 'clip',
'model_version' => $metadata['model_version'] ?? 'v1',
'dim' => count($embedding),
'is_normalized' => $metadata['is_normalized'] ?? true,
'generated_at' => now(),
],
);
}
public function deleteEmbedding(int $artworkId): void
{
DB::table('artwork_embeddings')
->where('artwork_id', $artworkId)
->delete();
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Managed vector DB adapter (Pinecone-style REST API).
*
* Spec §9 Option B.
*
* Configuration:
* recommendations.similarity.pinecone.api_key
* recommendations.similarity.pinecone.index_host
* recommendations.similarity.pinecone.index_name
* recommendations.similarity.pinecone.namespace
* recommendations.similarity.pinecone.top_k
*/
final class PineconeAdapter implements VectorAdapterInterface
{
private function apiKey(): string
{
return (string) config('recommendations.similarity.pinecone.api_key', '');
}
private function host(): string
{
return rtrim((string) config('recommendations.similarity.pinecone.index_host', ''), '/');
}
private function namespace(): string
{
return (string) config('recommendations.similarity.pinecone.namespace', '');
}
public function querySimilar(int $artworkId, int $topK = 100): array
{
$configTopK = (int) config('recommendations.similarity.pinecone.top_k', 100);
$effectiveTopK = min($topK, $configTopK);
$vectorId = "artwork:{$artworkId}";
try {
$response = Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/query", array_filter([
'id' => $vectorId,
'topK' => $effectiveTopK + 1, // +1 to exclude self
'includeMetadata' => true,
'namespace' => $this->namespace() ?: null,
'filter' => [
'is_active' => ['$eq' => true],
],
]));
if (! $response->successful()) {
Log::warning("[PineconeAdapter] Query failed: HTTP {$response->status()}");
return [];
}
$matches = $response->json('matches', []);
$results = [];
foreach ($matches as $match) {
$matchId = $match['id'] ?? '';
// Extract artwork ID from "artwork:123" format
if (! str_starts_with($matchId, 'artwork:')) {
continue;
}
$matchArtworkId = (int) substr($matchId, 8);
if ($matchArtworkId === $artworkId) {
continue; // skip self
}
$results[] = [
'artwork_id' => $matchArtworkId,
'score' => (float) ($match['score'] ?? 0.0),
];
}
return $results;
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Query exception: {$e->getMessage()}");
return [];
}
}
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void
{
$vectorId = "artwork:{$artworkId}";
// Spec §9B: metadata should include category_id, content_type, author_id, is_active, nsfw
$pineconeMetadata = array_merge([
'is_active' => true,
'category_id' => $metadata['category_id'] ?? null,
'content_type' => $metadata['content_type'] ?? null,
'author_id' => $metadata['author_id'] ?? null,
'nsfw' => $metadata['nsfw'] ?? false,
], array_diff_key($metadata, array_flip([
'category_id', 'content_type', 'author_id', 'nsfw', 'is_active',
])));
// Remove null values (Pinecone doesn't accept nulls in metadata)
$pineconeMetadata = array_filter($pineconeMetadata, fn ($v) => $v !== null);
try {
$response = Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/vectors/upsert", array_filter([
'vectors' => [
[
'id' => $vectorId,
'values' => array_map('floatval', $embedding),
'metadata' => $pineconeMetadata,
],
],
'namespace' => $this->namespace() ?: null,
]));
if (! $response->successful()) {
Log::warning("[PineconeAdapter] Upsert failed for artwork {$artworkId}: HTTP {$response->status()}");
}
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Upsert exception for artwork {$artworkId}: {$e->getMessage()}");
}
}
public function deleteEmbedding(int $artworkId): void
{
$vectorId = "artwork:{$artworkId}";
try {
Http::withHeaders([
'Api-Key' => $this->apiKey(),
'Content-Type' => 'application/json',
])->timeout(10)->post("{$this->host()}/vectors/delete", array_filter([
'ids' => [$vectorId],
'namespace' => $this->namespace() ?: null,
]));
} catch (\Throwable $e) {
Log::warning("[PineconeAdapter] Delete exception for artwork {$artworkId}: {$e->getMessage()}");
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
use Illuminate\Support\Facades\Log;
/**
* Factory to resolve the configured VectorAdapterInterface implementation.
*/
final class VectorAdapterFactory
{
/**
* @return VectorAdapterInterface|null null when vector similarity is disabled
*/
public static function make(): ?VectorAdapterInterface
{
if (! (bool) config('recommendations.similarity.vector_enabled', false)) {
return null;
}
$adapter = (string) config('recommendations.similarity.vector_adapter', 'pgvector');
return match ($adapter) {
'pgvector' => new PgvectorAdapter(),
'pinecone' => new PineconeAdapter(),
default => self::fallback($adapter),
};
}
private static function fallback(string $adapter): PgvectorAdapter
{
Log::warning("[VectorAdapterFactory] Unknown adapter '{$adapter}', falling back to pgvector.");
return new PgvectorAdapter();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Recommendations\VectorSimilarity;
/**
* Contract for vector-similarity adapters (pgvector, Pinecone, etc.).
*
* Each adapter can query nearest-neighbor artworks for a given artwork ID
* and return an ordered list of (artwork_id, score) pairs.
*/
interface VectorAdapterInterface
{
/**
* Find the most visually similar artworks.
*
* @param int $artworkId Source artwork
* @param int $topK Max neighbors to return
* @return list<array{artwork_id: int, score: float}> Ordered by score descending
*/
public function querySimilar(int $artworkId, int $topK = 100): array;
/**
* Upsert an artwork embedding into the vector store.
*
* @param int $artworkId
* @param array $embedding Raw float vector
* @param array $metadata Optional metadata (category, author, etc.)
*/
public function upsertEmbedding(int $artworkId, array $embedding, array $metadata = []): void;
/**
* Delete an artwork embedding from the vector store.
*/
public function deleteEmbedding(int $artworkId): void;
}

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Log;
/**
* Handles artwork listing queries for Studio, using Meilisearch with DB fallback.
*/
final class StudioArtworkQueryService
{
/**
* List artworks for a creator with search, filter, and sort via Meilisearch.
*
* Supported $filters keys:
* q string free-text search
* status string published|draft|archived
* category string category slug
* tags array tag slugs
* date_from string Y-m-d
* date_to string Y-m-d
* performance string rising|top|low
* sort string created_at:desc (default), ranking_score:desc, heat_score:desc, etc.
*/
public function list(int $userId, array $filters = [], int $perPage = 24): LengthAwarePaginator
{
// Skip Meilisearch when driver is null (e.g. in tests)
$driver = config('scout.driver');
if (empty($driver) || $driver === 'null') {
return $this->listViaDatabase($userId, $filters, $perPage);
}
try {
return $this->listViaMeilisearch($userId, $filters, $perPage);
} catch (\Throwable $e) {
Log::warning('Studio: Meilisearch unavailable, falling back to DB', [
'error' => $e->getMessage(),
]);
return $this->listViaDatabase($userId, $filters, $perPage);
}
}
private function listViaMeilisearch(int $userId, array $filters, int $perPage): LengthAwarePaginator
{
$q = $filters['q'] ?? '';
$filterParts = ["author_id = {$userId}"];
$sort = [];
// Status filter
$status = $filters['status'] ?? null;
if ($status === 'published') {
$filterParts[] = 'is_public = true AND is_approved = true';
} elseif ($status === 'draft') {
$filterParts[] = 'is_public = false';
}
// archived handled at DB level since Meili doesn't see soft-deleted
// Category filter
if (!empty($filters['category'])) {
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
}
// Tag filter
if (!empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
}
}
// Date range
if (!empty($filters['date_from'])) {
$filterParts[] = 'created_at >= "' . $filters['date_from'] . '"';
}
if (!empty($filters['date_to'])) {
$filterParts[] = 'created_at <= "' . $filters['date_to'] . '"';
}
// Performance quick filters
if (!empty($filters['performance'])) {
match ($filters['performance']) {
'rising' => $filterParts[] = 'heat_score > 5',
'top' => $filterParts[] = 'ranking_score > 50',
'low' => $filterParts[] = 'views < 10',
default => null,
};
}
// Sort
$sortParam = $filters['sort'] ?? 'created_at:desc';
$validSortFields = [
'created_at', 'ranking_score', 'heat_score',
'views', 'likes', 'shares_count',
'downloads', 'comments_count', 'favorites_count',
];
$parts = explode(':', $sortParam);
if (count($parts) === 2 && in_array($parts[0], $validSortFields, true)) {
$sort[] = $parts[0] . ':' . ($parts[1] === 'asc' ? 'asc' : 'desc');
}
$options = ['filter' => implode(' AND ', $filterParts)];
if ($sort !== []) {
$options['sort'] = $sort;
}
return Artwork::search($q ?: '')
->options($options)
->query(fn (Builder $query) => $query
->with(['stats', 'categories', 'tags'])
->withCount(['comments', 'downloads'])
)
->paginate($perPage);
}
private function listViaDatabase(int $userId, array $filters, int $perPage): LengthAwarePaginator
{
$query = Artwork::where('user_id', $userId)
->with(['stats', 'categories', 'tags'])
->withCount(['comments', 'downloads']);
$status = $filters['status'] ?? null;
if ($status === 'published') {
$query->where('is_public', true)->where('is_approved', true);
} elseif ($status === 'draft') {
$query->where('is_public', false);
} elseif ($status === 'archived') {
$query->onlyTrashed();
} else {
// Show all except archived by default
$query->whereNull('deleted_at');
}
// Free-text search
if (!empty($filters['q'])) {
$q = $filters['q'];
$query->where(function (Builder $w) use ($q) {
$w->where('title', 'LIKE', "%{$q}%")
->orWhereHas('tags', fn (Builder $t) => $t->where('slug', 'LIKE', "%{$q}%"));
});
}
// Category
if (!empty($filters['category'])) {
$query->whereHas('categories', fn (Builder $c) => $c->where('slug', $filters['category']));
}
// Tags
if (!empty($filters['tags'])) {
foreach ((array) $filters['tags'] as $tag) {
$query->whereHas('tags', fn (Builder $t) => $t->where('slug', $tag));
}
}
// Date range
if (!empty($filters['date_from'])) {
$query->where('created_at', '>=', $filters['date_from']);
}
if (!empty($filters['date_to'])) {
$query->where('created_at', '<=', $filters['date_to']);
}
// Performance
if (!empty($filters['performance'])) {
$query->whereHas('stats', function (Builder $s) use ($filters) {
match ($filters['performance']) {
'rising' => $s->where('heat_score', '>', 5),
'top' => $s->where('ranking_score', '>', 50),
'low' => $s->where('views', '<', 10),
default => null,
};
});
}
// Sort
$sortParam = $filters['sort'] ?? 'created_at:desc';
$parts = explode(':', $sortParam);
$sortField = $parts[0] ?? 'created_at';
$sortDir = ($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
$dbSortMap = [
'created_at' => 'artworks.created_at',
'ranking_score' => 'ranking_score',
'heat_score' => 'heat_score',
'views' => 'views',
'likes' => 'favorites',
'shares_count' => 'shares_count',
'downloads' => 'downloads',
'comments_count' => 'comments_count',
'favorites_count' => 'favorites',
];
$statsSortFields = ['ranking_score', 'heat_score', 'views', 'likes', 'shares_count', 'downloads', 'comments_count', 'favorites_count'];
if (in_array($sortField, $statsSortFields, true)) {
$dbCol = $dbSortMap[$sortField] ?? $sortField;
$query->leftJoin('artwork_stats', 'artworks.id', '=', 'artwork_stats.artwork_id')
->orderBy("artwork_stats.{$dbCol}", $sortDir)
->select('artworks.*');
} else {
$query->orderBy($dbSortMap[$sortField] ?? 'artworks.created_at', $sortDir);
}
return $query->paginate($perPage);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\Tag;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Handles bulk operations on artworks for the Studio module.
*/
final class StudioBulkActionService
{
/**
* Execute a bulk action on the given artwork IDs, enforcing ownership.
*
* @param int $userId The authenticated user ID
* @param string $action publish|unpublish|archive|unarchive|delete|change_category|add_tags|remove_tags
* @param array $artworkIds Array of artwork IDs
* @param array $params Extra params (category_id, tag_ids)
* @return array{success: int, failed: int, errors: array}
*/
public function execute(int $userId, string $action, array $artworkIds, array $params = []): array
{
$result = ['success' => 0, 'failed' => 0, 'errors' => []];
// Validate ownership — fetch only artworks belonging to this user
$query = Artwork::where('user_id', $userId);
if ($action === 'unarchive') {
$query->onlyTrashed();
}
$artworks = $query->whereIn('id', $artworkIds)->get();
$foundIds = $artworks->pluck('id')->all();
$missingIds = array_diff($artworkIds, $foundIds);
foreach ($missingIds as $id) {
$result['failed']++;
$result['errors'][] = "Artwork #{$id}: not found or not owned by you";
}
if ($artworks->isEmpty()) {
return $result;
}
DB::beginTransaction();
try {
foreach ($artworks as $artwork) {
$this->applyAction($artwork, $action, $params);
$result['success']++;
}
DB::commit();
// Reindex affected artworks in Meilisearch
$this->reindexArtworks($artworks);
Log::info('Studio bulk action completed', [
'user_id' => $userId,
'action' => $action,
'count' => $result['success'],
'ids' => $foundIds,
]);
} catch (\Throwable $e) {
DB::rollBack();
$result['failed'] += $result['success'];
$result['success'] = 0;
$result['errors'][] = 'Transaction failed: ' . $e->getMessage();
Log::error('Studio bulk action failed', [
'user_id' => $userId,
'action' => $action,
'error' => $e->getMessage(),
]);
}
return $result;
}
private function applyAction(Artwork $artwork, string $action, array $params): void
{
match ($action) {
'publish' => $this->publish($artwork),
'unpublish' => $this->unpublish($artwork),
'archive' => $artwork->delete(), // Soft delete
'unarchive' => $artwork->restore(),
'delete' => $artwork->forceDelete(),
'change_category' => $this->changeCategory($artwork, $params),
'add_tags' => $this->addTags($artwork, $params),
'remove_tags' => $this->removeTags($artwork, $params),
default => throw new \InvalidArgumentException("Unknown action: {$action}"),
};
}
private function publish(Artwork $artwork): void
{
$artwork->update([
'is_public' => true,
'published_at' => $artwork->published_at ?? now(),
]);
}
private function unpublish(Artwork $artwork): void
{
$artwork->update(['is_public' => false]);
}
private function changeCategory(Artwork $artwork, array $params): void
{
if (empty($params['category_id'])) {
throw new \InvalidArgumentException('category_id required for change_category');
}
$artwork->categories()->sync([(int) $params['category_id']]);
}
private function addTags(Artwork $artwork, array $params): void
{
if (empty($params['tag_ids'])) {
throw new \InvalidArgumentException('tag_ids required for add_tags');
}
$pivotData = [];
foreach ((array) $params['tag_ids'] as $tagId) {
$pivotData[(int) $tagId] = ['source' => 'studio_bulk', 'confidence' => 1.0];
}
$artwork->tags()->syncWithoutDetaching($pivotData);
// Increment usage counts
Tag::whereIn('id', array_keys($pivotData))
->increment('usage_count');
}
private function removeTags(Artwork $artwork, array $params): void
{
if (empty($params['tag_ids'])) {
throw new \InvalidArgumentException('tag_ids required for remove_tags');
}
$tagIds = array_map('intval', (array) $params['tag_ids']);
$artwork->tags()->detach($tagIds);
Tag::whereIn('id', $tagIds)
->where('usage_count', '>', 0)
->decrement('usage_count');
}
/**
* Trigger Meilisearch reindex for the given artworks.
*/
private function reindexArtworks(\Illuminate\Database\Eloquent\Collection $artworks): void
{
try {
$artworks->each->searchable();
} catch (\Throwable $e) {
Log::warning('Studio: Failed to reindex artworks after bulk action', [
'error' => $e->getMessage(),
]);
}
}
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\ArtworkStats;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Provides dashboard KPI data for the Studio overview page.
*/
final class StudioMetricsService
{
private const CACHE_TTL = 300; // 5 minutes
/**
* Get dashboard KPI metrics for a creator.
*
* @return array{total_artworks: int, views_30d: int, favourites_30d: int, shares_30d: int, followers: int}
*/
public function getDashboardKpis(int $userId): array
{
$cacheKey = "studio.kpi.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
$totalArtworks = Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->count();
// Aggregate stats from artwork_stats for this user's artworks
$statsAgg = DB::table('artwork_stats')
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->selectRaw('
COALESCE(SUM(artwork_stats.views), 0) as total_views,
COALESCE(SUM(artwork_stats.favorites), 0) as total_favourites,
COALESCE(SUM(artwork_stats.shares_count), 0) as total_shares
')
->first();
// Views in last 30 days from hourly snapshots if available, fallback to totals
$views30d = 0;
try {
if (\Illuminate\Support\Facades\Schema::hasTable('artwork_metric_snapshots_hourly')) {
$views30d = (int) DB::table('artwork_metric_snapshots_hourly')
->join('artworks', 'artworks.id', '=', 'artwork_metric_snapshots_hourly.artwork_id')
->where('artworks.user_id', $userId)
->where('artwork_metric_snapshots_hourly.bucket_hour', '>=', now()->subDays(30))
->sum('artwork_metric_snapshots_hourly.views_count');
}
} catch (\Throwable $e) {
// Table or column doesn't exist — fall back to totals
}
if ($views30d === 0) {
$views30d = (int) ($statsAgg->total_views ?? 0);
}
$followers = DB::table('user_followers')
->where('user_id', $userId)
->count();
return [
'total_artworks' => $totalArtworks,
'views_30d' => $views30d,
'favourites_30d' => (int) ($statsAgg->total_favourites ?? 0),
'shares_30d' => (int) ($statsAgg->total_shares ?? 0),
'followers' => $followers,
];
});
}
/**
* Get top performing artworks for a creator in the last 7 days.
*
* @return \Illuminate\Support\Collection
*/
public function getTopPerformers(int $userId, int $limit = 6): \Illuminate\Support\Collection
{
$cacheKey = "studio.top_performers.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId, $limit) {
return Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->where('is_public', true)
->with(['stats', 'tags'])
->whereHas('stats')
->orderByDesc(
ArtworkStats::select('heat_score')
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
->limit(1)
)
->limit($limit)
->get()
->map(fn (Artwork $art) => [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug,
'thumb_url' => $art->thumbUrl('md'),
'favourites' => (int) ($art->stats?->favorites ?? 0),
'shares' => (int) ($art->stats?->shares_count ?? 0),
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
]);
});
}
/**
* Get recent comments on a creator's artworks.
*
* @return \Illuminate\Support\Collection
*/
public function getRecentComments(int $userId, int $limit = 5): \Illuminate\Support\Collection
{
return DB::table('artwork_comments')
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
->join('users', 'users.id', '=', 'artwork_comments.user_id')
->where('artworks.user_id', $userId)
->whereNull('artwork_comments.deleted_at')
->orderByDesc('artwork_comments.created_at')
->limit($limit)
->select([
'artwork_comments.id',
'artwork_comments.content as body',
'artwork_comments.created_at',
'users.name as author_name',
'users.username as author_username',
'artworks.title as artwork_title',
'artworks.slug as artwork_slug',
])
->get();
}
/**
* Aggregate analytics across all artworks for the Studio Analytics page.
*
* @return array{totals: array, top_artworks: array, content_breakdown: array}
*/
public function getAnalyticsOverview(int $userId): array
{
$cacheKey = "studio.analytics_overview.{$userId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($userId) {
// Totals
$totals = DB::table('artwork_stats')
->join('artworks', 'artworks.id', '=', 'artwork_stats.artwork_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->selectRaw('
COALESCE(SUM(artwork_stats.views), 0) as views,
COALESCE(SUM(artwork_stats.favorites), 0) as favourites,
COALESCE(SUM(artwork_stats.shares_count), 0) as shares,
COALESCE(SUM(artwork_stats.downloads), 0) as downloads,
COALESCE(SUM(artwork_stats.comments_count), 0) as comments,
COALESCE(AVG(artwork_stats.ranking_score), 0) as avg_ranking,
COALESCE(AVG(artwork_stats.heat_score), 0) as avg_heat
')
->first();
// Top 10 artworks by ranking score
$topArtworks = Artwork::where('user_id', $userId)
->whereNull('deleted_at')
->where('is_public', true)
->with(['stats'])
->whereHas('stats')
->orderByDesc(
ArtworkStats::select('ranking_score')
->whereColumn('artwork_stats.artwork_id', 'artworks.id')
->limit(1)
)
->limit(10)
->get()
->map(fn (Artwork $art) => [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug,
'thumb_url' => $art->thumbUrl('sq'),
'views' => (int) ($art->stats?->views ?? 0),
'favourites' => (int) ($art->stats?->favorites ?? 0),
'shares' => (int) ($art->stats?->shares_count ?? 0),
'downloads' => (int) ($art->stats?->downloads ?? 0),
'comments' => (int) ($art->stats?->comments_count ?? 0),
'ranking_score' => (float) ($art->stats?->ranking_score ?? 0),
'heat_score' => (float) ($art->stats?->heat_score ?? 0),
]);
// Content type breakdown
$contentBreakdown = DB::table('artworks')
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
->join('categories', 'categories.id', '=', 'artwork_category.category_id')
->join('content_types', 'content_types.id', '=', 'categories.content_type_id')
->where('artworks.user_id', $userId)
->whereNull('artworks.deleted_at')
->groupBy('content_types.id', 'content_types.name', 'content_types.slug')
->select([
'content_types.name',
'content_types.slug',
DB::raw('COUNT(DISTINCT artworks.id) as count'),
])
->orderByDesc('count')
->get()
->map(fn ($row) => [
'name' => $row->name,
'slug' => $row->slug,
'count' => (int) $row->count,
])
->values()
->all();
return [
'totals' => [
'views' => (int) ($totals->views ?? 0),
'favourites' => (int) ($totals->favourites ?? 0),
'shares' => (int) ($totals->shares ?? 0),
'downloads' => (int) ($totals->downloads ?? 0),
'comments' => (int) ($totals->comments ?? 0),
'avg_ranking' => round((float) ($totals->avg_ranking ?? 0), 1),
'avg_heat' => round((float) ($totals->avg_heat ?? 0), 1),
],
'top_artworks' => $topArtworks->values()->all(),
'content_breakdown' => $contentBreakdown,
];
});
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Jobs\IndexArtworkJob;
use App\Jobs\RecComputeSimilarByTagsJob;
use App\Jobs\RecComputeSimilarHybridJob;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagNormalizer;
@@ -346,5 +348,12 @@ final class TagService
private function queueReindex(Artwork $artwork): void
{
IndexArtworkJob::dispatch($artwork->id);
// §7.5 On-demand: recompute tag/hybrid similarity when tags change.
// Pivot syncs don't trigger the Artwork "updated" event, so we dispatch here.
if ($artwork->is_public && $artwork->published_at) {
RecComputeSimilarByTagsJob::dispatch($artwork->id)->delay(now()->addSeconds(30));
RecComputeSimilarHybridJob::dispatch($artwork->id)->delay(now()->addMinute());
}
}
}