Files
hstream/app/Livewire/AdminEpisodeForm.php
w33b d08e280ce0 refactor(episode): migrate episode upload to Livewire with real-time CDN verification
Replace the server-rendered episode upload form with a Livewire component,
introducing an interactive admin experience that validates the new episode's
stream path against all configured mirrors before saving. The controller-based
`store` method and its route are removed, and episode creation now flows
through the `AdminEpisodeForm` component with frontend upload handling and
dynamic quality checks.

Key changes:
- Add `AdminEpisodeForm` Livewire component with file uploads, tag handling,
  and debounced stream/base-url validation.
- Extend `CdnPathValidator::validateStream` to accept an episode number so the
  manifest path can point to the specific episode being added.
- Remove the `store` method from `EpisodeController` and its POST route.
- Delete the legacy `createEpisode` method from `EpisodeService`.
- Replace the Blade upload modal with the Livewire view, including progress
  indicators and validation badges.
- Add feature tests for the Livewire component and update unit tests for the
  CDN validator's episode-specific manifest check.
2026-08-08 14:29:05 +02:00

285 lines
9.5 KiB
PHP

<?php
namespace App\Livewire;
use App\Enums\UserRole;
use App\Jobs\DiscordReleaseNotification;
use App\Models\Episode;
use App\Services\CdnPathValidator;
use App\Services\DownloadService;
use App\Services\EpisodeService;
use App\Services\GalleryService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
class AdminEpisodeForm extends Component
{
use WithFileUploads;
public int $referenceEpisodeId;
public int $episodeNumber;
public string $title = '';
public string $titleJpn = '';
public string $studio = '';
/** @var string[] */
public array $tags = [];
public string $releasedate = '';
public string $baseurl = '';
public string $description = '';
public ?TemporaryUploadedFile $cover = null;
/** @var TemporaryUploadedFile[] */
public array $gallery = [];
/** @var array{fhd: string, fhdi: string, uhd: string, uhdi: string} */
public array $downloads = ['fhd' => '', 'fhdi' => '', 'uhd' => '', 'uhdi' => ''];
public bool $censored = false;
/** @var array<string, array{state: string, message: string, mirrors: array}> */
public array $validation = [];
public bool $overrideValidation = false;
public bool $saving = false;
public function mount(int $episodeId): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$reference = Episode::with(['hentai', 'studio'])->findOrFail($episodeId);
$this->referenceEpisodeId = $reference->id;
$this->episodeNumber = $reference->hentai->episodes()->count() + 1;
$this->title = $reference->title;
$this->titleJpn = $reference->title_jpn;
$this->studio = $reference->studio?->name ?? '';
$this->tags = $reference->tags->pluck('name')->all();
$this->releasedate = $reference->release_date
? Carbon::parse($reference->release_date)->format('Y-m-d')
: now()->format('Y-m-d');
$this->baseurl = preg_replace('#/E\d+$#', '', $reference->url) ?? '';
$this->description = $reference->description;
}
public function getHasInvalidValidationProperty(): bool
{
foreach ($this->validation as $status) {
if (($status['state'] ?? '') === 'invalid') {
return true;
}
}
return false;
}
public function updatedBaseurl(): void
{
$this->validateStream();
}
public function updatedDownloads(mixed $value, string $key): void
{
if (preg_match('/^(fhd|fhdi|uhd|uhdi)$/', $key)) {
$this->validateDownload($key);
}
}
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, 1, false, $this->episodeNumber);
$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 validateDownload(string $quality): void
{
$key = "downloads.{$quality}";
$url = $this->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 save(): void
{
abort_unless(auth()->user()?->hasRole(UserRole::ADMINISTRATOR), 403);
$this->validate([
'baseurl' => ['required', 'string', 'regex:/^[A-Za-z0-9_.\-]+\/[A-Za-z0-9_.\-]+/'],
'description' => 'required|string',
'cover' => 'required|image|max:20480',
'gallery.*' => 'nullable|image|max:20480',
'downloads.fhd' => 'required|string',
'downloads.uhd' => 'required|string',
'downloads.fhdi' => 'nullable|string',
'downloads.uhdi' => 'nullable|string',
]);
$this->saving = true;
try {
// Re-load the reference episode fresh so the episode number reflects
// the current episode count rather than the mount-time value.
$referenceEpisode = Episode::with(['hentai', 'studio'])->findOrFail($this->referenceEpisodeId);
$hentai = $referenceEpisode->hentai;
$this->episodeNumber = $hentai->episodes()->count() + 1;
// Re-validate every non-empty download path + stream against the CDN.
$validator = app(CdnPathValidator::class);
$sizes = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$key = "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[$type] = $result['size'];
if (! $result['valid'] && ! $this->overrideValidation) {
$this->addError($key, 'Path not found on all CDN mirrors.');
$this->saving = false;
return;
}
}
$result = $validator->validateStream($this->baseurl, 1, true, $this->episodeNumber);
$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;
}
// Persist.
$episodeService = app(EpisodeService::class);
$studio = $episodeService->getOrCreateStudio(trim($this->studio));
$episodeModel = $episodeService->createEpisodeFromArray([
'title' => $this->title,
'title_jpn' => $this->titleJpn,
'baseurl' => $this->baseurl,
'description' => $this->description,
'releasedate' => $this->releasedate,
'tags' => $this->tags,
'interpolated_uhd' => trim($this->downloads['uhdi'] ?? '') !== '',
], $hentai, $this->episodeNumber, $studio);
if ($this->cover) {
$episodeService->saveCoverFromFile($episodeModel, $hentai->slug, $this->episodeNumber, $this->cover);
}
if (! empty($this->gallery)) {
app(GalleryService::class)->saveGalleryFiles($hentai, $episodeModel, $this->episodeNumber, $this->gallery);
}
$downloads = [];
foreach ($this->downloads as $quality => $url) {
if (trim($url) === '') {
continue;
}
$type = $this->qualityToType($quality);
$downloads[$type] = [
'url' => trim($url),
'size' => $sizes[$type] ?? null,
];
}
app(DownloadService::class)->createOrUpdateDownloadsFromArray($episodeModel, $downloads);
// Discord notifications + cache flush.
if ($this->censored) {
DiscordReleaseNotification::dispatch($this->title.' - '.$this->episodeNumber, 'release-censored');
} else {
DiscordReleaseNotification::dispatch($episodeModel->slug, 'release');
}
cache()->flush();
} catch (\Throwable $e) {
Log::error('Failed to create episode from Livewire form: '.$e->getMessage(), [
'exception' => $e,
'referenceEpisodeId' => $this->referenceEpisodeId,
]);
$this->saving = false;
$this->addError('description', 'Something went wrong while saving. Please try again.');
return;
}
$this->redirectRoute('hentai.index', ['title' => $episodeModel->slug]);
}
private function qualityToType(string $quality): string
{
return match ($quality) {
'fhd' => 'FHD',
'fhdi' => 'FHDi',
'uhd' => 'UHD',
'uhdi' => 'UHDi',
};
}
public function render()
{
return view('livewire.admin-episode-form');
}
}