Wire admin studio SSR and search infrastructure
This commit is contained in:
760
app/Services/Uploads/UploadQueueService.php
Normal file
760
app/Services/Uploads/UploadQueueService.php
Normal file
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Uploads;
|
||||
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\TagService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class UploadQueueService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkDraftService $artworkDrafts,
|
||||
private readonly ArtworkPublicationService $publication,
|
||||
private readonly TagService $tags,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createBatch(User $user, array $files, array $defaults = [], ?string $name = null): UploadBatch
|
||||
{
|
||||
$normalizedFiles = collect($files)
|
||||
->map(fn (mixed $file): array => [
|
||||
'name' => trim((string) data_get($file, 'name', '')),
|
||||
])
|
||||
->filter(fn (array $file): bool => $file['name'] !== '')
|
||||
->values();
|
||||
|
||||
if ($normalizedFiles->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Choose at least one file for the upload queue.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($normalizedFiles->count() > 50) {
|
||||
throw ValidationException::withMessages([
|
||||
'files' => ['Bulk upload is limited to 50 files per batch in v1.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedDefaults = $this->normalizeDefaults($defaults);
|
||||
|
||||
$batch = DB::transaction(function () use ($user, $normalizedFiles, $normalizedDefaults, $name): UploadBatch {
|
||||
$batch = UploadBatch::query()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'name' => $this->normalizeBatchName($name, $normalizedFiles->count()),
|
||||
'status' => UploadBatch::STATUS_UPLOADING,
|
||||
'total_items' => $normalizedFiles->count(),
|
||||
'defaults_json' => $normalizedDefaults === [] ? null : $normalizedDefaults,
|
||||
]);
|
||||
|
||||
foreach ($normalizedFiles as $file) {
|
||||
$draft = $this->artworkDrafts->createDraft(
|
||||
$user,
|
||||
$this->titleFromFilename($file['name']),
|
||||
null,
|
||||
Arr::get($normalizedDefaults, 'category_id'),
|
||||
(bool) Arr::get($normalizedDefaults, 'is_mature', false),
|
||||
Arr::get($normalizedDefaults, 'group')
|
||||
);
|
||||
|
||||
$artwork = Artwork::query()->findOrFail($draft->artworkId);
|
||||
|
||||
$artwork->forceFill([
|
||||
'visibility' => Arr::get($normalizedDefaults, 'visibility', Artwork::VISIBILITY_PUBLIC),
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'draft',
|
||||
])->saveQuietly();
|
||||
|
||||
$defaultTags = (array) Arr::get($normalizedDefaults, 'tags', []);
|
||||
if ($defaultTags !== []) {
|
||||
$this->tags->syncStudioTags($artwork, $defaultTags);
|
||||
}
|
||||
|
||||
$batch->items()->create([
|
||||
'user_id' => (int) $user->id,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'original_filename' => $file['name'],
|
||||
'status' => UploadBatchItem::STATUS_UPLOADED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_QUEUED,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, false, false),
|
||||
'is_ready_to_publish' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return $batch;
|
||||
});
|
||||
|
||||
return $this->refreshBatch($batch->id);
|
||||
}
|
||||
|
||||
public function listPayload(User $user, array $filters = []): array
|
||||
{
|
||||
$requestedBatchId = (int) ($filters['batch_id'] ?? 0);
|
||||
$statusFilter = Str::lower(trim((string) ($filters['status'] ?? 'all')));
|
||||
$sort = Str::lower(trim((string) ($filters['sort'] ?? 'newest')));
|
||||
|
||||
$recentBatches = UploadBatch::query()
|
||||
->where('user_id', (int) $user->id)
|
||||
->latest('id')
|
||||
->limit(12)
|
||||
->get();
|
||||
|
||||
$currentBatch = $requestedBatchId > 0
|
||||
? $recentBatches->firstWhere('id', $requestedBatchId)
|
||||
: $recentBatches->first();
|
||||
|
||||
if ($currentBatch) {
|
||||
$currentBatch = $this->refreshBatch((int) $currentBatch->id);
|
||||
}
|
||||
|
||||
$items = $currentBatch
|
||||
? $currentBatch->items
|
||||
->loadMissing(['artwork.categories.contentType', 'artwork.tags'])
|
||||
->map(fn (UploadBatchItem $item): array => $this->presentItem($this->refreshItem((int) $item->id)))
|
||||
: collect();
|
||||
|
||||
$filteredItems = $this->applyFiltersAndSorting($items, $statusFilter, $sort)->values();
|
||||
$recentBatchPayloads = $recentBatches
|
||||
->map(fn (UploadBatch $batch): UploadBatch => $batch->relationLoaded('items') ? $batch : $this->refreshBatch((int) $batch->id))
|
||||
->map(fn (UploadBatch $batch): array => $this->presentBatch($batch))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'batch_id' => $currentBatch?->id,
|
||||
'status' => $statusFilter,
|
||||
'sort' => $sort,
|
||||
],
|
||||
'batches' => $recentBatchPayloads,
|
||||
'current_batch' => $currentBatch ? $this->presentBatch($currentBatch) : null,
|
||||
'items' => $filteredItems->all(),
|
||||
'status_options' => [
|
||||
['value' => 'all', 'label' => 'All'],
|
||||
['value' => UploadBatchItem::STATUS_PROCESSING, 'label' => 'Processing'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_METADATA, 'label' => 'Needs metadata'],
|
||||
['value' => UploadBatchItem::STATUS_NEEDS_REVIEW, 'label' => 'Needs review'],
|
||||
['value' => UploadBatchItem::STATUS_READY, 'label' => 'Ready'],
|
||||
['value' => UploadBatchItem::STATUS_FAILED, 'label' => 'Failed'],
|
||||
['value' => UploadBatchItem::STATUS_PUBLISHED, 'label' => 'Published'],
|
||||
],
|
||||
'sort_options' => [
|
||||
['value' => 'newest', 'label' => 'Newest first'],
|
||||
['value' => 'oldest', 'label' => 'Oldest first'],
|
||||
['value' => 'filename', 'label' => 'Filename'],
|
||||
['value' => 'status', 'label' => 'Status'],
|
||||
['value' => 'ready', 'label' => 'Ready first'],
|
||||
['value' => 'failed', 'label' => 'Failed first'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function markItemProcessingQueued(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'uploaded_at' => $item->uploaded_at ?: now(),
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemMediaProcessed(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
|
||||
$refreshed = $this->refreshItem((int) $item->id);
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
public function markItemFailed(int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_FAILED,
|
||||
'error_code' => $this->nullableString($errorCode),
|
||||
'error_message' => $this->nullableText($errorMessage),
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
$this->refreshBatch((int) $item->upload_batch_id);
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function markItemFailedForUser(User $user, int $itemId, ?string $errorCode, ?string $errorMessage): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->markItemFailed((int) $item->id, $errorCode, $errorMessage);
|
||||
}
|
||||
|
||||
public function refreshItem(int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->itemQuery()->findOrFail($itemId);
|
||||
$state = $this->evaluateItemState($item);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'published_at' => $state['published_at'],
|
||||
'processed_at' => $state['processed_at'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $item->fresh(['artwork.categories.contentType', 'artwork.tags']) ?? $item;
|
||||
}
|
||||
|
||||
public function refreshBatch(int $batchId): UploadBatch
|
||||
{
|
||||
$batch = UploadBatch::query()
|
||||
->with(['items.artwork.categories.contentType', 'items.artwork.tags'])
|
||||
->findOrFail($batchId);
|
||||
|
||||
$items = $batch->items->map(fn (UploadBatchItem $item): UploadBatchItem => $this->refreshItem((int) $item->id));
|
||||
$summary = $this->summarizeItems($items);
|
||||
|
||||
$batch->forceFill([
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
])->saveQuietly();
|
||||
|
||||
return $batch->fresh(['items.artwork.categories.contentType', 'items.artwork.tags']) ?? $batch;
|
||||
}
|
||||
|
||||
public function bulkAction(User $user, string $action, array $itemIds, array $params = []): array
|
||||
{
|
||||
$items = $this->ownedItems($user, $itemIds);
|
||||
|
||||
if ($items->isEmpty()) {
|
||||
return [
|
||||
'success' => 0,
|
||||
'failed' => count($itemIds),
|
||||
'errors' => ['No owned upload queue items were found.'],
|
||||
];
|
||||
}
|
||||
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
match ($action) {
|
||||
'publish' => $this->publishItem($item),
|
||||
'delete' => $this->deleteItem($item),
|
||||
'apply_category' => $this->applyCategory($item, (int) ($params['category_id'] ?? 0)),
|
||||
'apply_tags' => $this->applyTags($item, (array) ($params['tags'] ?? [])),
|
||||
'set_visibility' => $this->setVisibility($item, (string) ($params['visibility'] ?? '')),
|
||||
'generate_ai' => $this->retryProcessing($item),
|
||||
default => throw ValidationException::withMessages([
|
||||
'action' => ['Unsupported upload queue action.'],
|
||||
]),
|
||||
};
|
||||
|
||||
$success++;
|
||||
} catch (ValidationException $exception) {
|
||||
$failed++;
|
||||
$errors[] = collect($exception->errors())->flatten()->first() ?: 'Action failed.';
|
||||
} catch (\Throwable $exception) {
|
||||
$failed++;
|
||||
$errors[] = $exception->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
$batchIds = $items->pluck('upload_batch_id')->unique()->filter()->all();
|
||||
foreach ($batchIds as $batchId) {
|
||||
$this->refreshBatch((int) $batchId);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
public function retryProcessingForUser(User $user, int $itemId): UploadBatchItem
|
||||
{
|
||||
$item = $this->ownedItems($user, [$itemId])->first();
|
||||
if (! $item) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Upload queue item not found.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->retryProcessing($item);
|
||||
}
|
||||
|
||||
private function retryProcessing(UploadBatchItem $item): UploadBatchItem
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
if (! $artwork || trim((string) ($artwork->hash ?? '')) === '' || trim((string) ($artwork->file_path ?? '')) === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['This item cannot be retried safely. Re-upload the original file instead.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PROCESSING,
|
||||
'processing_stage' => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
|
||||
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
DetectArtworkMaturityJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
GenerateArtworkEmbeddingJob::dispatch((int) $artwork->id, (string) $artwork->hash)->afterCommit();
|
||||
AnalyzeArtworkAiAssistJob::dispatch((int) $artwork->id, true)->afterCommit();
|
||||
|
||||
return $this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function publishItem(UploadBatchItem $item): void
|
||||
{
|
||||
$item = $this->refreshItem((int) $item->id);
|
||||
if (! $item->is_ready_to_publish || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => ['Only ready queue items can be published.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$artwork = $item->artwork;
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->publication->publishNow($artwork->fresh() ?? $artwork);
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_PUBLISHED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
'published_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function deleteItem(UploadBatchItem $item): void
|
||||
{
|
||||
if ($item->artwork && ! $item->artwork->trashed()) {
|
||||
$item->artwork->delete();
|
||||
}
|
||||
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_DELETED,
|
||||
'processing_stage' => UploadBatchItem::STAGE_FINALIZED,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function applyCategory(UploadBatchItem $item, int $categoryId): void
|
||||
{
|
||||
if ($categoryId <= 0 || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'category_id' => ['Choose a valid category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->categories()->sync([$categoryId]);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function applyTags(UploadBatchItem $item, array $tags): void
|
||||
{
|
||||
if (! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Artwork not found for this queue item.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$normalizedTags = collect($tags)
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($normalizedTags->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'tags' => ['Enter at least one tag to apply.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$mergedTags = collect($item->artwork->tags()->pluck('tags.slug')->all())
|
||||
->merge($normalizedTags)
|
||||
->map(fn (string $tag): string => trim($tag))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$this->tags->syncStudioTags($item->artwork->fresh(['tags']) ?? $item->artwork, $mergedTags);
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function setVisibility(UploadBatchItem $item, string $visibility): void
|
||||
{
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true) || ! $item->artwork) {
|
||||
throw ValidationException::withMessages([
|
||||
'visibility' => ['Choose a valid visibility.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->forceFill([
|
||||
'visibility' => $visibility,
|
||||
'is_public' => false,
|
||||
])->saveQuietly();
|
||||
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
private function ownedItems(User $user, array $itemIds): EloquentCollection
|
||||
{
|
||||
$normalizedIds = collect($itemIds)
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->filter(fn (int $id): bool => $id > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->itemQuery()
|
||||
->where('user_id', (int) $user->id)
|
||||
->whereIn('id', $normalizedIds)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function itemQuery()
|
||||
{
|
||||
return UploadBatchItem::query()->with(['artwork.categories.contentType', 'artwork.tags']);
|
||||
}
|
||||
|
||||
private function applyFiltersAndSorting(Collection $items, string $statusFilter, string $sort): Collection
|
||||
{
|
||||
$filtered = $statusFilter === 'all'
|
||||
? $items
|
||||
: $items->filter(fn (array $item): bool => (string) ($item['status'] ?? '') === $statusFilter);
|
||||
|
||||
return match ($sort) {
|
||||
'oldest' => $filtered->sortBy('created_at'),
|
||||
'filename' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['original_filename'] ?? ''))),
|
||||
'status' => $filtered->sortBy(fn (array $item): string => Str::lower((string) ($item['status'] ?? ''))),
|
||||
'ready' => $filtered->sortByDesc(fn (array $item): int => (int) ($item['is_ready_to_publish'] ?? false)),
|
||||
'failed' => $filtered->sortByDesc(fn (array $item): int => (int) ((string) ($item['status'] ?? '') === UploadBatchItem::STATUS_FAILED)),
|
||||
default => $filtered->sortByDesc('created_at'),
|
||||
};
|
||||
}
|
||||
|
||||
private function summarizeItems(EloquentCollection $items): array
|
||||
{
|
||||
$statusCounts = $items->groupBy('status')->map->count();
|
||||
$activeCount = (int) ($statusCounts[UploadBatchItem::STATUS_UPLOADED] ?? 0)
|
||||
+ (int) ($statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0);
|
||||
$failedCount = (int) ($statusCounts[UploadBatchItem::STATUS_FAILED] ?? 0);
|
||||
$publishedCount = (int) ($statusCounts[UploadBatchItem::STATUS_PUBLISHED] ?? 0);
|
||||
$processedCount = $items->filter(fn (UploadBatchItem $item): bool => in_array((string) $item->status, [
|
||||
UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
UploadBatchItem::STATUS_READY,
|
||||
UploadBatchItem::STATUS_FAILED,
|
||||
UploadBatchItem::STATUS_PUBLISHED,
|
||||
UploadBatchItem::STATUS_DELETED,
|
||||
], true))->count();
|
||||
|
||||
$status = UploadBatch::STATUS_COMPLETED;
|
||||
if ($activeCount > 0) {
|
||||
$status = $statusCounts[UploadBatchItem::STATUS_PROCESSING] ?? 0
|
||||
? UploadBatch::STATUS_PROCESSING
|
||||
: UploadBatch::STATUS_UPLOADING;
|
||||
} elseif ($failedCount > 0) {
|
||||
$status = UploadBatch::STATUS_COMPLETED_WITH_ERRORS;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'total_items' => $items->count(),
|
||||
'processed_items' => $processedCount,
|
||||
'failed_items' => $failedCount,
|
||||
'published_items' => $publishedCount,
|
||||
'ready_items' => (int) ($statusCounts[UploadBatchItem::STATUS_READY] ?? 0),
|
||||
'processing_items' => $activeCount,
|
||||
'needs_metadata_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_METADATA] ?? 0),
|
||||
'needs_review_items' => (int) ($statusCounts[UploadBatchItem::STATUS_NEEDS_REVIEW] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function evaluateItemState(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$isDeleted = (string) $item->status === UploadBatchItem::STATUS_DELETED || ($artwork?->trashed() ?? false);
|
||||
$isPublished = $artwork
|
||||
&& (string) ($artwork->artwork_status ?? '') === 'published'
|
||||
&& $artwork->published_at !== null;
|
||||
$hasProcessedMedia = $artwork
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== ''
|
||||
&& trim((string) ($artwork->file_name ?? '')) !== 'pending'
|
||||
&& trim((string) ($artwork->file_path ?? '')) !== ''
|
||||
&& trim((string) ($artwork->hash ?? '')) !== '';
|
||||
$title = trim((string) ($artwork?->title ?? ''));
|
||||
$hasTitle = $title !== '';
|
||||
$hasCategory = (bool) $artwork?->categories?->first();
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$aiStatus = Str::lower((string) ($artwork?->ai_status ?? ''));
|
||||
$visionEnabled = (bool) config('vision.enabled', true);
|
||||
|
||||
$maturityPending = $visionEnabled && in_array($maturityAiStatus, [
|
||||
ArtworkMaturityService::AI_STATUS_PENDING,
|
||||
ArtworkMaturityService::AI_STATUS_NOT_REQUESTED,
|
||||
], true);
|
||||
$maturityFailed = $visionEnabled && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED;
|
||||
$needsReview = $maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED || $maturityFailed;
|
||||
$needsMetadata = ! $hasTitle || ! $hasCategory;
|
||||
$blockingUploadFailure = ! $hasProcessedMedia && ($this->nullableString($item->error_code) !== null || $this->nullableText($item->error_message) !== null);
|
||||
$isReadyToPublish = ! $isDeleted
|
||||
&& ! $isPublished
|
||||
&& ! $blockingUploadFailure
|
||||
&& $hasProcessedMedia
|
||||
&& ! $maturityPending
|
||||
&& ! $needsReview
|
||||
&& ! $needsMetadata
|
||||
&& $artwork
|
||||
&& (int) $artwork->user_id === (int) $item->user_id;
|
||||
|
||||
$status = match (true) {
|
||||
$isDeleted => UploadBatchItem::STATUS_DELETED,
|
||||
$isPublished => UploadBatchItem::STATUS_PUBLISHED,
|
||||
$blockingUploadFailure => UploadBatchItem::STATUS_FAILED,
|
||||
! $hasProcessedMedia || $maturityPending => UploadBatchItem::STATUS_PROCESSING,
|
||||
$needsReview => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
$needsMetadata => UploadBatchItem::STATUS_NEEDS_METADATA,
|
||||
$isReadyToPublish => UploadBatchItem::STATUS_READY,
|
||||
default => UploadBatchItem::STATUS_PROCESSING,
|
||||
};
|
||||
|
||||
$processingStage = match (true) {
|
||||
in_array($status, [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED, UploadBatchItem::STATUS_FAILED], true) => UploadBatchItem::STAGE_FINALIZED,
|
||||
! $hasProcessedMedia && (string) $item->processing_stage === UploadBatchItem::STAGE_QUEUED => UploadBatchItem::STAGE_QUEUED,
|
||||
! $hasProcessedMedia => UploadBatchItem::STAGE_THUMBNAILS,
|
||||
$maturityPending => UploadBatchItem::STAGE_MATURITY_CHECK,
|
||||
in_array($aiStatus, ['queued', 'processing'], true) => UploadBatchItem::STAGE_METADATA_SUGGESTIONS,
|
||||
default => UploadBatchItem::STAGE_FINALIZED,
|
||||
};
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'processing_stage' => $processingStage,
|
||||
'metadata_completeness' => $this->metadataCompleteness($artwork, $hasProcessedMedia, ! $maturityPending && ! $maturityFailed),
|
||||
'is_ready_to_publish' => $isReadyToPublish,
|
||||
'published_at' => $isPublished ? ($item->published_at ?: now()) : null,
|
||||
'processed_at' => $hasProcessedMedia ? ($item->processed_at ?: now()) : $item->processed_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function presentBatch(UploadBatch $batch): array
|
||||
{
|
||||
$summary = $this->summarizeItems($batch->items);
|
||||
|
||||
return [
|
||||
'id' => (int) $batch->id,
|
||||
'name' => $batch->name,
|
||||
'status' => $summary['status'],
|
||||
'total_items' => $summary['total_items'],
|
||||
'processed_items' => $summary['processed_items'],
|
||||
'failed_items' => $summary['failed_items'],
|
||||
'published_items' => $summary['published_items'],
|
||||
'ready_items' => $summary['ready_items'],
|
||||
'processing_items' => $summary['processing_items'],
|
||||
'needs_metadata_items' => $summary['needs_metadata_items'],
|
||||
'needs_review_items' => $summary['needs_review_items'],
|
||||
'defaults' => $batch->defaults_json ?? [],
|
||||
'created_at' => $this->iso($batch->created_at),
|
||||
'updated_at' => $this->iso($batch->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function presentItem(UploadBatchItem $item): array
|
||||
{
|
||||
$artwork = $item->artwork;
|
||||
$state = $this->evaluateItemState($item);
|
||||
$maturityStatus = Str::lower((string) ($artwork?->maturity_status ?? ArtworkMaturityService::STATUS_CLEAR));
|
||||
$maturityAiStatus = Str::lower((string) ($artwork?->maturity_ai_status ?? ArtworkMaturityService::AI_STATUS_NOT_REQUESTED));
|
||||
$missing = [];
|
||||
|
||||
if (! $artwork || trim((string) ($artwork->file_path ?? '')) === '' || trim((string) ($artwork->hash ?? '')) === '') {
|
||||
$missing[] = 'Processing incomplete';
|
||||
}
|
||||
if (! $artwork || trim((string) ($artwork->title ?? '')) === '') {
|
||||
$missing[] = 'Missing title';
|
||||
}
|
||||
if (! $artwork?->categories?->first()) {
|
||||
$missing[] = 'Missing category';
|
||||
}
|
||||
if ($maturityStatus === ArtworkMaturityService::STATUS_SUSPECTED) {
|
||||
$missing[] = 'Needs maturity review';
|
||||
} elseif ((bool) config('vision.enabled', true) && in_array($maturityAiStatus, [ArtworkMaturityService::AI_STATUS_PENDING, ArtworkMaturityService::AI_STATUS_NOT_REQUESTED], true)) {
|
||||
$missing[] = 'Maturity analysis pending';
|
||||
} elseif ((bool) config('vision.enabled', true) && $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED) {
|
||||
$missing[] = 'Maturity check failed';
|
||||
}
|
||||
|
||||
$canRetry = ! empty($artwork?->hash)
|
||||
&& ! empty($artwork?->file_path)
|
||||
&& ($state['status'] === UploadBatchItem::STATUS_FAILED || $maturityAiStatus === ArtworkMaturityService::AI_STATUS_FAILED);
|
||||
|
||||
return [
|
||||
'id' => (int) $item->id,
|
||||
'batch_id' => (int) $item->upload_batch_id,
|
||||
'artwork_id' => $artwork?->id,
|
||||
'title' => $artwork?->title ?: $this->titleFromFilename((string) $item->original_filename),
|
||||
'original_filename' => $item->original_filename,
|
||||
'status' => $state['status'],
|
||||
'processing_stage' => $state['processing_stage'],
|
||||
'metadata_completeness' => $state['metadata_completeness'],
|
||||
'metadata_label' => $state['metadata_completeness'] . '% complete',
|
||||
'is_ready_to_publish' => $state['is_ready_to_publish'],
|
||||
'error_code' => $item->error_code,
|
||||
'error_message' => $item->error_message,
|
||||
'missing' => $missing,
|
||||
'thumbnail_url' => $artwork?->thumbUrl('sm'),
|
||||
'visibility' => $artwork?->visibility,
|
||||
'maturity_status' => $maturityStatus,
|
||||
'maturity_ai_status' => $maturityAiStatus,
|
||||
'ai_status' => Str::lower((string) ($artwork?->ai_status ?? '')) ?: null,
|
||||
'created_at' => $this->iso($item->created_at),
|
||||
'updated_at' => $this->iso($item->updated_at),
|
||||
'published_at' => $this->iso($item->published_at),
|
||||
'edit_url' => $artwork ? route('studio.artworks.edit', ['id' => $artwork->id]) : null,
|
||||
'public_url' => $artwork && $artwork->published_at ? route('art.show', ['id' => $artwork->id]) : null,
|
||||
'actions' => [
|
||||
'can_edit' => $artwork !== null && $state['status'] !== UploadBatchItem::STATUS_DELETED,
|
||||
'can_publish' => $state['is_ready_to_publish'],
|
||||
'can_delete' => ! in_array($state['status'], [UploadBatchItem::STATUS_DELETED, UploadBatchItem::STATUS_PUBLISHED], true),
|
||||
'can_retry_processing' => $canRetry,
|
||||
'can_generate_ai' => $artwork !== null && trim((string) ($artwork->hash ?? '')) !== '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function metadataCompleteness(?Artwork $artwork, bool $hasProcessedMedia, bool $maturityReady): int
|
||||
{
|
||||
$checks = [
|
||||
$hasProcessedMedia,
|
||||
trim((string) ($artwork?->title ?? '')) !== '',
|
||||
(bool) $artwork?->categories?->first(),
|
||||
$maturityReady,
|
||||
];
|
||||
|
||||
return (int) round((collect($checks)->filter()->count() / count($checks)) * 100);
|
||||
}
|
||||
|
||||
private function normalizeDefaults(array $defaults): array
|
||||
{
|
||||
$visibility = (string) ($defaults['visibility'] ?? Artwork::VISIBILITY_PUBLIC);
|
||||
if (! in_array($visibility, [Artwork::VISIBILITY_PUBLIC, Artwork::VISIBILITY_UNLISTED, Artwork::VISIBILITY_PRIVATE], true)) {
|
||||
$visibility = Artwork::VISIBILITY_PUBLIC;
|
||||
}
|
||||
|
||||
return array_filter([
|
||||
'category_id' => ($categoryId = (int) ($defaults['category_id'] ?? 0)) > 0 ? $categoryId : null,
|
||||
'tags' => collect((array) ($defaults['tags'] ?? []))
|
||||
->map(fn (mixed $tag): string => trim((string) $tag))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'visibility' => $visibility,
|
||||
'is_mature' => (bool) ($defaults['is_mature'] ?? false),
|
||||
'group' => $this->nullableString($defaults['group'] ?? null),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
private function titleFromFilename(string $filename): string
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalized = Str::of($base)
|
||||
->replace(['_', '-'], ' ')
|
||||
->squish()
|
||||
->trim();
|
||||
|
||||
return (string) ($normalized !== '' ? Str::limit((string) $normalized, 255, '') : 'Untitled artwork');
|
||||
}
|
||||
|
||||
private function normalizeBatchName(?string $name, int $count): string
|
||||
{
|
||||
$normalized = trim((string) $name);
|
||||
if ($normalized !== '') {
|
||||
return Str::limit($normalized, 160, '');
|
||||
}
|
||||
|
||||
return 'Upload Queue ' . now()->format('M j, Y g:i A') . ' (' . $count . ')';
|
||||
}
|
||||
|
||||
private function iso(CarbonInterface|string|null $value): ?string
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value->toIso8601String();
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function nullableString(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : $normalized;
|
||||
}
|
||||
|
||||
private function nullableText(mixed $value): ?string
|
||||
{
|
||||
$normalized = trim((string) $value);
|
||||
|
||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user