*/ public array $episodes = []; /** @var array */ public array $validation = []; public bool $overrideValidation = false; public bool $saving = false; public function mount(): void { $this->episodes = [$this->defaultEpisode()]; } private function defaultEpisode(): array { return [ 'description' => '', 'cover' => null, 'gallery' => [], 'downloads' => ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''], ]; } public function addEpisode(): void { $this->episodes[] = $this->defaultEpisode(); $this->episodeCount = count($this->episodes); if (trim($this->baseurl) !== '') { $this->validateStream(); } } public function removeEpisode(int $index): void { if (count($this->episodes) <= 1) { return; } unset($this->episodes[$index]); $this->episodes = array_values($this->episodes); $this->episodeCount = count($this->episodes); $this->pruneValidation(); if (trim($this->baseurl) !== '') { $this->validateStream(); } } public function getHasInvalidValidationProperty(): bool { foreach ($this->validation as $status) { if (($status['state'] ?? '') === 'invalid') { return true; } } return false; } public function updatedEpisodes(mixed $value, string $key): void { if (preg_match('/^(\d+)\.downloads\.(fhd|fhdi|uhd|uhdi)$/', $key, $matches)) { $this->validateDownload((int) $matches[1], $matches[2]); } } public function updatedBaseurl(): void { $this->validateStream(); } public function validateDownload(int $episodeIndex, string $quality): void { $key = "episodes.{$episodeIndex}.downloads.{$quality}"; $url = $this->episodes[$episodeIndex]['downloads'][$quality] ?? ''; if (trim($url) === '') { $this->validation[$key] = ['state' => 'idle', 'message' => '', 'mirrors' => []]; return; } $this->validation[$key] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []]; $result = app(CdnPathValidator::class)->validateDownload($this->qualityToType($quality), $url); $this->validation[$key] = [ 'state' => $result['valid'] ? 'valid' : 'invalid', 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', 'mirrors' => $result['mirrors'], ]; } public function validateStream(): void { $baseurl = trim($this->baseurl); if ($baseurl === '') { $this->validation['stream'] = ['state' => 'idle', 'message' => '', 'mirrors' => []]; return; } $this->validation['stream'] = ['state' => 'checking', 'message' => 'Checking mirrors…', 'mirrors' => []]; $result = app(CdnPathValidator::class)->validateStream($baseurl, count($this->episodes)); $this->validation['stream'] = [ 'state' => $result['valid'] ? 'valid' : 'invalid', 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', 'mirrors' => $result['mirrors'], ]; } public function save(): void { $this->validate([ 'title' => 'required|string|max:255', 'titleJpn' => 'required|string|max:255', 'studio' => 'required|string|max:255', 'tags' => 'required|array|min:1', 'releasedate' => 'required|date', 'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'], 'episodes.*.description' => 'required|string', 'episodes.*.cover' => 'required|image|max:20480', 'episodes.*.gallery.*' => 'nullable|image|max:20480', 'episodes.*.downloads.fhd' => 'required|string', 'episodes.*.downloads.uhd' => 'required|string', ]); $this->saving = true; // 1) Re-validate every non-empty download path + stream against the CDN. $validator = app(CdnPathValidator::class); $sizes = []; foreach ($this->episodes as $index => $episode) { foreach ($episode['downloads'] as $quality => $url) { if (trim($url) === '') { continue; } $type = $this->qualityToType($quality); $key = "episodes.{$index}.downloads.{$quality}"; $result = $validator->validateDownload($type, $url, true); $this->validation[$key] = [ 'state' => $result['valid'] ? 'valid' : 'invalid', 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', 'mirrors' => $result['mirrors'], ]; $sizes[$index][$type] = $result['size']; if (! $result['valid'] && ! $this->overrideValidation) { $this->addError($key, 'Path not found on all CDN mirrors.'); $this->saving = false; return; } } } if (trim($this->baseurl) !== '') { $result = $validator->validateStream($this->baseurl, count($this->episodes), true); $this->validation['stream'] = [ 'state' => $result['valid'] ? 'valid' : 'invalid', 'message' => $result['valid'] ? 'Verified on all mirrors' : 'Missing on one or more mirrors', 'mirrors' => $result['mirrors'], ]; if (! $result['valid'] && ! $this->overrideValidation) { $this->addError('baseurl', 'Stream path not found on all CDN mirrors.'); $this->saving = false; return; } } // 2) Duplicate-guard: block re-releasing a title created today. $episodeService = app(EpisodeService::class); $slug = $episodeService->generateSlug($this->title); $existing = Hentai::where('slug', $slug)->first(); if ($existing && $existing->created_at->isToday()) { $this->addError('title', 'A release with this title was already created today. Refine the title to avoid a duplicate.'); $this->saving = false; return; } try { // 3) Persist. $hentai = Hentai::firstOrCreate( ['slug' => $slug], ['description' => $this->episodes[0]['description']] ); $studio = $episodeService->getOrCreateStudio(trim($this->studio)); $galleryService = app(GalleryService::class); $downloadService = app(DownloadService::class); $releasedEpisodes = []; foreach ($this->episodes as $index => $episode) { $episodeNumber = $index + 1; $episodeModel = $episodeService->createEpisodeFromArray([ 'title' => $this->title, 'title_jpn' => $this->titleJpn, 'baseurl' => $this->baseurl, 'description' => $episode['description'], 'releasedate' => $this->releasedate, 'tags' => $this->tags, 'interpolated_uhd' => trim($episode['downloads']['uhdi'] ?? '') !== '', ], $hentai, $episodeNumber, $studio); if ($episode['cover']) { $episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $episodeNumber, $episode['cover']); } if (! empty($episode['gallery'])) { $galleryService->saveGalleryFiles($hentai, $episodeModel, $episodeNumber, $episode['gallery']); } $downloads = []; foreach ($episode['downloads'] as $quality => $url) { if (trim($url) === '') { continue; } $type = $this->qualityToType($quality); $downloads[$type] = [ 'url' => trim($url), 'size' => $sizes[$index][$type] ?? null, ]; } $downloadService->createOrUpdateDownloadsFromArray($episodeModel, $downloads); $releasedEpisodes[] = $episodeModel->slug; } // 4) Discord notifications + cache flush. if ($this->censored) { DiscordReleaseNotification::dispatch($this->title, 'release-censored'); } else { foreach ($releasedEpisodes as $episodeSlug) { DiscordReleaseNotification::dispatch($episodeSlug, 'release'); } } cache()->flush(); } catch (\Throwable $e) { Log::error('Failed to create release from Livewire form: '.$e->getMessage(), [ 'exception' => $e, 'slug' => $slug, ]); $this->saving = false; $this->addError('title', 'Something went wrong while saving. Please try again.'); return; } // 5) Redirect. $this->redirectRoute('home.index'); } private function qualityToType(string $quality): string { return match ($quality) { 'fhd' => 'FHD', 'fhdi' => 'FHDi', 'uhd' => 'UHD', 'uhdi' => 'UHDi', }; } private function pruneValidation(): void { $count = count($this->episodes); foreach (array_keys($this->validation) as $key) { if (preg_match('/^episodes\.(\d+)\./', $key, $matches) && (int) $matches[1] >= $count) { unset($this->validation[$key]); } } } public function render() { return view('livewire.admin-release-form'); } }