Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\ActivityEvent;
use App\Jobs\IndexArtworkJob;
use App\Services\Activity\UserActivityService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -85,14 +86,8 @@ class PublishScheduledArtworksCommand extends Command
$artwork->artwork_status = 'published';
$artwork->save();
// Trigger Meilisearch reindex via Scout (if searchable trait present)
if (method_exists($artwork, 'searchable')) {
try {
$artwork->searchable();
} catch (\Throwable $e) {
Log::warning("PublishScheduled: scout reindex failed for #{$artwork->id}: {$e->getMessage()}");
}
}
// Trigger Meilisearch reindex directly — no Scout hop.
IndexArtworkJob::dispatch((int) $artwork->id);
// Record activity event
try {

View File

@@ -5,26 +5,205 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\ArtworkSearchIndexer;
use App\Models\Artwork;
use Illuminate\Console\Command;
use Meilisearch\Client as MeilisearchClient;
class RebuildArtworkSearchIndex extends Command
{
protected $signature = 'artworks:search-rebuild {--chunk=500 : Number of artworks per chunk}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based).';
protected $signature = 'artworks:search-rebuild
{--chunk=500 : Number of artworks per chunk}
{--limit= : Stop after processing this many artworks (useful for testing)}
{--reverse : Process artworks newest-first (highest ID first)}
{--sync : Write directly to Meilisearch (no queue) and show per-artwork results}';
protected $description = 'Re-queue all artworks for Meilisearch indexing (non-blocking, chunk-based). Use --sync for verbose direct writes.';
public function __construct(private readonly ArtworkSearchIndexer $indexer)
{
parent::__construct();
}
public function handle(): int
public function handle(MeilisearchClient $client): int
{
$chunk = (int) $this->option('chunk');
$chunk = max(1, (int) $this->option('chunk'));
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
$reverse = (bool) $this->option('reverse');
$sync = (bool) $this->option('sync');
$this->info("Dispatching index jobs in chunks of {$chunk}");
$this->indexer->rebuildAll($chunk);
$this->info('All jobs dispatched. Workers will process them asynchronously.');
if ($sync) {
return $this->handleSync($client, $chunk, $limit, $reverse);
}
return $this->handleQueue($chunk, $limit, $reverse);
}
// ── Queue mode (default) ──────────────────────────────────────────────────
private function handleQueue(int $chunk, ?int $limit, bool $reverse): int
{
$uncapped = Artwork::query()->public()->published()->count();
$total = $limit !== null ? min($limit, $uncapped) : $uncapped;
if ($total === 0) {
$this->warn('No public, published artworks matched the rebuild query. Nothing was queued.');
return self::SUCCESS;
}
$estimatedChunks = (int) ceil($total / $chunk);
$this->info(sprintf(
'Queueing Meilisearch rebuild for %d artwork(s) in %d chunk(s) of up to %d%s%s.',
$total,
$estimatedChunks,
$chunk,
$reverse ? ', newest first' : '',
$limit !== null ? " (limit {$limit})" : '',
));
$this->line('This command only dispatches queue jobs. Workers process the actual indexing asynchronously.');
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$startedAt = microtime(true);
$stats = $this->indexer->rebuildAll(
$chunk,
function (int $chunkNumber, int $chunkCount, int $dispatched, int $totalItems, int $firstId, int $lastId) use ($bar): void {
$bar->advance($chunkCount);
if ($this->output->isVerbose()) {
$bar->clear();
$this->line(sprintf(
'Chunk %d queued %d artwork(s) [ids %d-%d] (%d/%d dispatched).',
$chunkNumber,
$chunkCount,
$firstId,
$lastId,
$dispatched,
$totalItems,
));
$bar->display();
}
},
$reverse,
$limit,
);
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Queued %d artwork(s) across %d chunk(s) in %.2f seconds.',
$stats['dispatched'],
$stats['chunks'],
$elapsed,
));
$this->line('Workers will process the actual Meilisearch writes asynchronously.');
if ($this->output->isVerbose()) {
$this->line('Tip: use -v for per-chunk output, or monitor Horizon/queue workers for completion.');
}
return self::SUCCESS;
}
// ── Sync mode (--sync) ────────────────────────────────────────────────────
private function handleSync(MeilisearchClient $client, int $chunk, ?int $limit, bool $reverse): int
{
$this->info(sprintf(
'<options=bold>[SYNC MODE]</> Writing directly to Meilisearch%s%s — no queue involved.',
$reverse ? ', newest first' : '',
$limit !== null ? ", limit {$limit}" : '',
));
$this->newLine();
$query = Artwork::with([
'user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat',
])
->withoutGlobalScopes() // include non-public so we can report "why not"
->whereNotNull('id'); // all artworks
if ($reverse) {
$query->orderByDesc('id');
} else {
$query->orderBy('id');
}
if ($limit !== null) {
$query->limit($limit);
}
$total = (clone $query)->count();
$indexed = 0;
$removed = 0;
$failed = 0;
$processed = 0;
$startedAt = microtime(true);
$bar = $this->output->createProgressBar($total);
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%');
$bar->start();
$query->chunk($chunk, function ($artworks) use ($client, $bar, &$indexed, &$removed, &$failed, &$processed): void {
foreach ($artworks as $artwork) {
$processed++;
$id = (int) $artwork->id;
$title = (string) ($artwork->title ?? '(no title)');
// Determine eligibility and reason
$reasons = [];
if (! $artwork->is_public) { $reasons[] = 'not public'; }
if (! $artwork->is_approved) { $reasons[] = 'not approved'; }
if ($artwork->published_at === null) { $reasons[] = 'not published'; }
if ($artwork->deleted_at !== null) { $reasons[] = 'soft-deleted'; }
$eligible = empty($reasons);
try {
$indexName = $artwork->searchableAs();
if ($eligible) {
$document = $artwork->toSearchableArray();
$client->index($indexName)->addDocuments([$document]);
$indexed++;
$bar->clear();
$this->line(sprintf(' <info>✓ indexed</info> #%d "%s"', $id, $title));
} else {
$client->index($indexName)->deleteDocument($id);
$removed++;
$bar->clear();
$this->line(sprintf(' <comment> removed</comment> #%d "%s" [%s]', $id, $title, implode(', ', $reasons)));
}
} catch (\Throwable $e) {
$failed++;
$bar->clear();
$this->line(sprintf(' <error>✗ failed</error> #%d "%s" %s', $id, $title, $e->getMessage()));
}
$bar->advance();
}
});
$bar->finish();
$this->newLine(2);
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf(
'Done in %.2f s — %d indexed, %d removed from index, %d failed (of %d processed).',
$elapsed,
$indexed,
$removed,
$failed,
$processed,
));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -11,7 +11,10 @@ use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\IndexArtworkJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\DetectArtworkMaturityJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService;
@@ -19,6 +22,7 @@ use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB;
@@ -43,6 +47,7 @@ use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use App\Services\Worlds\WorldSubmissionService;
use Illuminate\Support\Str;
final class UploadController extends Controller
@@ -81,11 +86,13 @@ final class UploadController extends Controller
UploadFinishRequest $request,
UploadPipelineService $pipeline,
UploadSessionRepository $sessions,
UploadAuditService $audit
UploadAuditService $audit,
UploadQueueService $queue
) {
$user = $request->user();
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$batchItemId = (int) $request->validated('batch_item_id', 0);
$originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name');
@@ -96,16 +103,33 @@ final class UploadController extends Controller
$session = $sessions->getOrFail($sessionId);
$request->artwork();
$request->batchItem();
$failResponse = function (int $statusCode, string $message, ?string $reason = null) use ($queue, $user, $batchItemId) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, $reason ?? 'upload_failed', $message);
}
return response()->json(array_filter([
'message' => $message,
'reason' => $reason,
], static fn (mixed $value): bool => $value !== null), $statusCode);
};
$validated = $pipeline->validateAndHash($sessionId);
if (! $validated->validation->ok || ! $validated->hash) {
return response()->json([
'message' => 'Upload validation failed.',
'reason' => $validated->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Upload validation failed.',
$validated->validation->reason
);
}
if ($pipeline->originalHashExists($validated->hash)) {
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'duplicate_hash', 'Duplicate upload is not allowed. This file already exists.');
}
return response()->json([
'message' => 'Duplicate upload is not allowed. This file already exists.',
'reason' => 'duplicate_hash',
@@ -115,28 +139,31 @@ final class UploadController extends Controller
$scan = $pipeline->scan($sessionId);
if (! $scan->ok) {
return response()->json([
'message' => 'Upload scan failed.',
'reason' => $scan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Upload scan failed.',
$scan->reason
);
}
$validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([
'message' => 'Archive validation failed.',
'reason' => $validatedArchive->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Archive validation failed.',
$validatedArchive->validation->reason
);
}
$archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) {
return response()->json([
'message' => 'Archive scan failed.',
'reason' => $archiveScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Archive scan failed.',
$archiveScan->reason
);
}
}
@@ -149,18 +176,20 @@ final class UploadController extends Controller
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([
'message' => 'Screenshot validation failed.',
'reason' => $validatedScreenshot->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Screenshot validation failed.',
$validatedScreenshot->validation->reason
);
}
$screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) {
return response()->json([
'message' => 'Screenshot scan failed.',
'reason' => $screenshotScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
return $failResponse(
Response::HTTP_UNPROCESSABLE_ENTITY,
'Screenshot scan failed.',
$screenshotScan->reason
);
}
$validatedAdditionalScreenshots[] = [
@@ -171,7 +200,7 @@ final class UploadController extends Controller
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots, $queue, $batchItemId) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch(
$sessionId,
@@ -181,8 +210,14 @@ final class UploadController extends Controller
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
$validatedAdditionalScreenshots,
$batchItemId > 0 ? $batchItemId : null
)->afterCommit();
if ($batchItemId > 0) {
$queue->markItemProcessingQueued($batchItemId);
}
return 'queued';
}
@@ -197,9 +232,15 @@ final class UploadController extends Controller
$validatedAdditionalScreenshots
);
if ($batchItemId > 0) {
$queue->markItemMediaProcessed($batchItemId);
}
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
return UploadSessionStatus::PROCESSED;
});
@@ -223,6 +264,10 @@ final class UploadController extends Controller
'error' => $e->getMessage(),
]);
if ($batchItemId > 0) {
$queue->markItemFailedForUser($user, $batchItemId, 'upload_finish_failed', $e->getMessage());
}
return response()->json([
'message' => 'Upload finish failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
@@ -559,7 +604,7 @@ final class UploadController extends Controller
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
{
$user = $request->user();
@@ -567,7 +612,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
@@ -584,6 +629,9 @@ final class UploadController extends Controller
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
]);
$mode = $validated['mode'] ?? 'now';
@@ -660,6 +708,7 @@ final class UploadController extends Controller
$artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
@@ -671,14 +720,7 @@ final class UploadController extends Controller
$artwork->published_at = null;
$artwork->save();
try {
$artwork->unsearchable();
} catch (\Throwable $e) {
Log::warning('Failed to remove scheduled artwork from search index', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
return response()->json([
'success' => true,
@@ -699,18 +741,7 @@ final class UploadController extends Controller
$artwork->publish_at = null;
$artwork->save();
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
Log::warning('Failed to sync artwork search index after publish', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
// Record upload activity event
try {
@@ -754,7 +785,7 @@ final class UploadController extends Controller
}
}
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews, WorldSubmissionService $submissions)
{
$user = $request->user();
@@ -762,7 +793,7 @@ final class UploadController extends Controller
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
@@ -776,6 +807,9 @@ final class UploadController extends Controller
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
'world_submissions' => ['nullable', 'array', 'max:12'],
'world_submissions.*.world_id' => ['required', 'integer', 'exists:worlds,id'],
'world_submissions.*.note' => ['nullable', 'string', 'max:1000'],
]);
if (! ctype_digit($id)) {
@@ -797,6 +831,7 @@ final class UploadController extends Controller
}
$artwork = $reviews->submit($group, $artwork, $user, $validated);
$submissions->syncForArtwork($artwork->fresh(), $user, (array) ($validated['world_submissions'] ?? []));
return response()->json([
'success' => true,

View File

@@ -265,16 +265,8 @@ final class StudioArtworksApiController extends Controller
}
}
// Reindex in Meilisearch
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && $artwork->published_at) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
// Meilisearch may be unavailable
}
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
$this->searchIndexer->update($artwork);
// Reload relationships for response
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Group;
use App\Models\ContentType;
use App\Services\ArtworkEvolutionService;
use App\Services\Artworks\ArtworkPublicationService;
use App\Services\GroupMembershipService;
use App\Services\GroupService;
use App\Services\Studio\CreatorStudioAnalyticsService;
@@ -24,10 +25,12 @@ use App\Services\Studio\CreatorStudioPreferenceService;
use App\Services\Studio\CreatorStudioChallengeService;
use App\Services\Studio\CreatorStudioSearchService;
use App\Services\Studio\CreatorStudioScheduledService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\CoverUrl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Inertia\Inertia;
use Inertia\Response;
@@ -74,7 +77,7 @@ final class StudioController extends Controller
public function content(Request $request): Response
{
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
$listing = $this->content->list($request->user(), $request->only(['module', 'bucket', 'q', 'sort', 'page', 'per_page', 'content_type', 'category', 'tag', 'visibility', 'activity_state', 'stale']));
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioContentIndex', [
@@ -92,7 +95,7 @@ final class StudioController extends Controller
{
$provider = $this->content->provider('artworks');
$prefs = $this->preferences->forUser($request->user());
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'category', 'tag']), null, 'artworks');
$listing = $this->content->list($request->user(), $request->only(['q', 'sort', 'bucket', 'page', 'per_page', 'content_type', 'category', 'tag']), null, 'artworks');
$listing['default_view'] = $prefs['default_content_view'];
return Inertia::render('Studio/StudioArtworks', [
@@ -121,6 +124,23 @@ final class StudioController extends Controller
]);
}
public function uploadQueue(Request $request): Response
{
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
$request->user(),
$request->only(['batch_id', 'status', 'sort'])
);
return Inertia::render('Studio/StudioUploadQueue', [
'title' => 'Upload Queue',
'description' => 'Upload multiple artworks, track processing, and publish only when each draft is ready.',
'queue' => $queue,
'contentTypes' => $this->getCategories(),
'chunkSize' => (int) config('uploads.chunk.max_bytes', 5242880),
'chunkRequestTimeoutMs' => (int) config('uploads.chunk.request_timeout_ms', 45000),
]);
}
/**
* Archived (/studio/archived)
*/
@@ -426,6 +446,9 @@ final class StudioController extends Controller
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
->findOrFail($id);
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
$primaryCategory = $artwork->categories->first();
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
$membershipService = app(GroupMembershipService::class);
@@ -455,11 +478,15 @@ final class StudioController extends Controller
'artwork_timezone' => $artwork->artwork_timezone,
'thumb_url' => $artwork->thumbUrl('md'),
'thumb_url_lg' => $artwork->thumbUrl('lg'),
'download_url' => route('art.download', ['id' => $artwork->id]),
'file_name' => $artwork->file_name,
'file_ext' => $artwork->file_ext,
'file_size' => $artwork->file_size,
'width' => $artwork->width,
'height' => $artwork->height,
'mime_type' => $artwork->mime_type,
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
'group_slug' => $artwork->group?->slug,
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
@@ -484,6 +511,7 @@ final class StudioController extends Controller
'version_count' => (int) ($artwork->version_count ?? 1),
'requires_reapproval' => (bool) $artwork->requires_reapproval,
],
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
'contentTypes' => $this->getCategories(),
'groupOptions' => $availableGroups,
'contributorOptionsByGroup' => $contributorOptionsByGroup,
@@ -588,4 +616,51 @@ final class StudioController extends Controller
default => 'studio.index',
};
}
private function screenshotAssetsForArtwork(int $artworkId): array
{
if (! Schema::hasTable('artwork_files')) {
return [];
}
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
return DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', 'like', 'shot%')
->orderBy('variant')
->get(['variant', 'path', 'mime', 'size'])
->map(function ($row, int $index) use ($base): ?array {
$path = trim((string) ($row->path ?? ''), '/');
if ($path === '') {
return null;
}
$url = $base . '/' . $path;
return [
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
'label' => 'Screenshot ' . ($index + 1),
'url' => $url,
'thumb_url' => $url,
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
'size' => (int) ($row->size ?? 0),
];
})
->filter()
->values()
->all();
}
private function artworkHasArchiveFile(int $artworkId): bool
{
if (! Schema::hasTable('artwork_files')) {
return false;
}
return DB::table('artwork_files')
->where('artwork_id', $artworkId)
->where('variant', 'orig_archive')
->exists();
}
}

View File

@@ -177,6 +177,7 @@ final class DiscoverController extends Controller
'user:id,name',
'user.profile:user_id,avatar_hash',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->whereRaw('MONTH(published_at) = ?', [$today->month])
->whereRaw('DAY(published_at) = ?', [$today->day])
@@ -558,6 +559,7 @@ final class DiscoverController extends Controller
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'categories:id,name,slug,content_type_id,parent_id,sort_order',
'categories.contentType:id,slug,name',
])
->get()
->keyBy('id');

View File

@@ -14,6 +14,7 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'chat_post',
'chat_post/*',
'api/art/*/view',
// Apple Sign In removed — no special CSRF exception required
];
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\Uploads;
use App\Models\Artwork;
use App\Models\UploadBatchItem;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Http\FormRequest;
@@ -13,6 +14,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class UploadFinishRequest extends FormRequest
{
private ?Artwork $artwork = null;
private ?UploadBatchItem $batchItem = null;
public function authorize(): bool
{
@@ -97,6 +99,22 @@ final class UploadFinishRequest extends FormRequest
$this->denyAsNotFound();
}
$batchItemId = (int) $this->input('batch_item_id');
if ($batchItemId > 0) {
$batchItem = UploadBatchItem::query()->find($batchItemId);
if (! $batchItem || (int) $batchItem->user_id !== (int) $user->id) {
$this->logUnauthorized('batch_item_not_owned_or_missing');
$this->denyAsNotFound();
}
if ((int) ($batchItem->artwork_id ?? 0) > 0 && (int) $batchItem->artwork_id !== $artworkId) {
$this->logUnauthorized('batch_item_artwork_mismatch');
$this->denyAsNotFound();
}
$this->batchItem = $batchItem;
}
$this->artwork = $artwork;
return true;
@@ -109,6 +127,7 @@ final class UploadFinishRequest extends FormRequest
'artwork_id' => 'required|integer',
'upload_token' => 'nullable|string|min:40|max:200',
'file_name' => 'nullable|string|max:255',
'batch_item_id' => 'nullable|integer|min:1',
'archive_session_id' => 'nullable|uuid|different:session_id',
'archive_file_name' => 'nullable|string|max:255',
'additional_screenshot_sessions' => 'nullable|array|max:4',
@@ -126,6 +145,11 @@ final class UploadFinishRequest extends FormRequest
return $this->artwork;
}
public function batchItem(): ?UploadBatchItem
{
return $this->batchItem;
}
private function denyAsNotFound(): void
{
throw new NotFoundHttpException();

View File

@@ -6,6 +6,7 @@ namespace App\Jobs;
use App\Models\Artwork;
use Illuminate\Bus\Queueable;
use Meilisearch\Client as MeilisearchClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -24,12 +25,11 @@ class DeleteArtworkFromIndexJob implements ShouldQueue
public function __construct(public readonly int $artworkId) {}
public function handle(): void
public function handle(MeilisearchClient $client): void
{
// Create a bare model instance just to call unsearchable() with the right ID.
$artwork = new Artwork();
$artwork->id = $this->artworkId;
$artwork->unsearchable();
// Delete directly from the Meilisearch index — no Scout after_commit hop.
$indexName = (new Artwork())->searchableAs();
$client->index($indexName)->deleteDocument($this->artworkId);
}
public function failed(\Throwable $e): void

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Jobs;
use App\Services\Uploads\UploadQueueService;
use App\Services\Uploads\UploadPipelineService;
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Jobs\AutoTagArtworkJob;
@@ -30,11 +31,12 @@ final class GenerateDerivativesJob implements ShouldQueue
private readonly ?string $archiveSessionId = null,
private readonly ?string $archiveHash = null,
private readonly ?string $archiveOriginalFileName = null,
private readonly array $additionalScreenshotSessions = []
private readonly array $additionalScreenshotSessions = [],
private readonly ?int $batchItemId = null,
) {
}
public function handle(UploadPipelineService $pipeline): void
public function handle(UploadPipelineService $pipeline, UploadQueueService $queue): void
{
$pipeline->processAndPublish(
$this->sessionId,
@@ -47,10 +49,27 @@ final class GenerateDerivativesJob implements ShouldQueue
$this->additionalScreenshotSessions
);
if ($this->batchItemId) {
$queue->markItemMediaProcessed($this->batchItemId);
}
// Auto-tagging is async and must never block publish.
AutoTagArtworkJob::dispatch($this->artworkId, $this->hash)->afterCommit();
DetectArtworkMaturityJob::dispatch($this->artworkId, $this->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($this->artworkId, $this->hash)->afterCommit();
AnalyzeArtworkAiAssistJob::dispatch($this->artworkId)->afterCommit();
}
public function failed(\Throwable $exception): void
{
if (! $this->batchItemId) {
return;
}
app(UploadQueueService::class)->markItemFailed(
$this->batchItemId,
'derivatives_failed',
$exception->getMessage()
);
}
}

View File

@@ -11,35 +11,48 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Meilisearch\Client as MeilisearchClient;
/**
* Queued job: index (or re-index) a single Artwork in Meilisearch.
*
* Writes directly to the Meilisearch HTTP API instead of going through
* Scout's searchable() / MakeSearchable pipeline. This avoids the
* after_commit double-dispatch problem and ensures the document lands
* in the index within this job's execution, with no extra queue hop.
*/
class IndexArtworkJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public int $timeout = 60;
public function __construct(public readonly int $artworkId) {}
public function handle(): void
public function handle(MeilisearchClient $client): void
{
$artwork = Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
->find($this->artworkId);
$artwork = Artwork::with([
'user',
'group',
'tags',
'categories.contentType',
'stats',
'awardStat',
])->find($this->artworkId);
if (! $artwork) {
return;
}
if (! $artwork->is_public || ! $artwork->is_approved || ! $artwork->published_at) {
// Not public/approved — ensure it is removed from the index.
$artwork->unsearchable();
// Not eligible — remove from index if present.
$client->index($artwork->searchableAs())->deleteDocument($this->artworkId);
return;
}
$artwork->searchable();
$document = $artwork->toSearchableArray();
$client->index($artwork->searchableAs())->addDocuments([$document]);
}
public function failed(\Throwable $e): void

View File

@@ -28,6 +28,19 @@ class Artwork extends Model
{
use HasFactory, SoftDeletes, Searchable;
/**
* Override Scout's bootSearchable to skip the ModelObserver (which fires MakeSearchable
* on every save). We still register SearchableScope and Builder macros so that
* scout:import and Builder::searchable() continue to work.
* All indexing is managed explicitly via IndexArtworkJob.
*/
public static function bootSearchable(): void
{
static::addGlobalScope(new \Laravel\Scout\SearchableScope);
(new static)->registerSearchableMacros();
// ModelObserver intentionally omitted — indexing is handled by IndexArtworkJob.
}
public const PUBLISHED_AS_USER = 'user';
public const PUBLISHED_AS_GROUP = 'group';

View File

@@ -83,6 +83,6 @@ final class Country extends Model
return null;
}
return '/gfx/flags/shiny/24/'.rawurlencode($iso2).'.png';
return rtrim((string) \config('cdn.files_url', ''), '/').'/images/flags/shiny/24/'.rawurlencode($iso2).'.png';
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Jobs\DeleteArtworkFromIndexJob;
use App\Jobs\IndexArtworkJob;
use App\Models\Artwork;
use Closure;
use Illuminate\Support\Facades\Log;
/**
@@ -43,19 +44,63 @@ final class ArtworkSearchIndexer
/**
* Rebuild the entire artworks index in background chunks.
* Run via: php artisan artworks:search-rebuild
*
* @param Closure(int, int, int, int, int, int): void|null $onChunk
* @return array{total:int, dispatched:int, chunks:int}
*/
public function rebuildAll(int $chunkSize = 500): void
public function rebuildAll(int $chunkSize = 500, ?Closure $onChunk = null, bool $reverse = false, ?int $limit = null): array
{
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
$query = Artwork::query()
->public()
->published()
->orderBy('id')
->chunk($chunkSize, function ($artworks): void {
->published();
if ($reverse) {
$query->orderByDesc('id');
} else {
$query->orderBy('id');
}
if ($limit !== null) {
$query->limit($limit);
}
$total = (clone $query)->count();
$dispatched = 0;
$chunks = 0;
$query
->with(['user', 'tags', 'categories', 'stats', 'awardStat'])
->chunk($chunkSize, function ($artworks) use (&$chunks, &$dispatched, $total, $onChunk): void {
$chunks++;
$count = $artworks->count();
$firstId = (int) ($artworks->first()?->id ?? 0);
$lastId = (int) ($artworks->last()?->id ?? 0);
foreach ($artworks as $artwork) {
IndexArtworkJob::dispatch($artwork->id);
$dispatched++;
}
if ($onChunk !== null) {
$onChunk($chunks, $count, $dispatched, $total, $firstId, $lastId);
}
});
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched', [
'total' => $total,
'dispatched' => $dispatched,
'chunks' => $chunks,
'chunk_size' => $chunkSize,
'reverse' => $reverse,
'limit' => $limit,
]);
return [
'total' => $total,
'dispatched' => $dispatched,
'chunks' => $chunks,
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\Group;
use App\Jobs\IndexArtworkJob;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -392,17 +393,6 @@ class GroupArtworkReviewService
private function syncSearchIndex(Artwork $artwork): void
{
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && ! empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $exception) {
Log::warning('Failed to sync artwork search index for group review workflow', [
'artwork_id' => (int) $artwork->id,
'error' => $exception->getMessage(),
]);
}
IndexArtworkJob::dispatch((int) $artwork->id);
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Services\Studio;
use App\Models\Artwork;
use App\Models\Tag;
use App\Jobs\IndexArtworkJob;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -156,12 +157,8 @@ final class StudioBulkActionService
*/
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(),
]);
foreach ($artworks as $artwork) {
IndexArtworkJob::dispatch((int) $artwork->id);
}
}
}