30a6b080eb
Replace the server-rendered release upload form with a Livewire component, introducing an interactive admin release form that supports dynamic episode management, real‑time CDN path validation, and direct download file checks. Key changes: - New `AdminReleaseForm` Livewire component with dynamic episode fields, Tagify integration, and upload handling via Livewire's file uploads. - Added `CdnPathValidator` service to verify download and stream paths across all configured mirrors, caching results and capturing file sizes. - Extended `EpisodeService` and `GalleryService` to support array‑based data and temporary uploaded files from Livewire. - Persist validated download sizes and store the validation timestamp on the `downloads` table via a new migration; also add a unique constraint on `hentais.slug` to prevent duplicates. - Remove the old POST `/admin/release/upload` route and controller logic, along with the legacy `upload.js` script. - Add comprehensive feature and unit tests covering the new component and the CDN validation service.
332 lines
11 KiB
PHP
332 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Jobs\DiscordReleaseNotification;
|
|
use App\Models\Hentai;
|
|
use App\Services\CdnPathValidator;
|
|
use App\Services\DownloadService;
|
|
use App\Services\EpisodeService;
|
|
use App\Services\GalleryService;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Livewire\Component;
|
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
|
use Livewire\WithFileUploads;
|
|
|
|
class AdminReleaseForm extends Component
|
|
{
|
|
use WithFileUploads;
|
|
|
|
public string $title = '';
|
|
|
|
public string $titleJpn = '';
|
|
|
|
public string $studio = '';
|
|
|
|
/** @var string[] */
|
|
public array $tags = [];
|
|
|
|
public string $releasedate = '';
|
|
|
|
public string $baseurl = '';
|
|
|
|
public bool $censored = false;
|
|
|
|
public int $episodeCount = 1;
|
|
|
|
/** @var array<int, array{description: string, cover: TemporaryUploadedFile|null, gallery: TemporaryUploadedFile[], downloads: array{fhd: string, fhdi: string, uhd: string, uhdi: string}}> */
|
|
public array $episodes = [];
|
|
|
|
/** @var array<string, array{state: string, message: string, mirrors: 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');
|
|
}
|
|
}
|