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.
This commit is contained in:
@@ -30,34 +30,6 @@ class EpisodeController extends Controller
|
||||
$this->downloadService = $downloadService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Episode to existing series
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$referenceEpisode = Episode::with('hentai')->where('id', $request->input('episode_id'))->firstOrFail();
|
||||
$episodeNumber = $referenceEpisode->hentai->episodes()->count() + 1;
|
||||
|
||||
// Create Episode
|
||||
$episode = $this->episodeService->createEpisode($request, $referenceEpisode->hentai, $episodeNumber, null, $referenceEpisode);
|
||||
$this->episodeService->createOrUpdateCover($request, $episode, $referenceEpisode->hentai->slug, 1);
|
||||
$this->downloadService->createOrUpdateDownloads($request, $episode, 1);
|
||||
$this->galleryService->createOrUpdateGallery($request, $referenceEpisode->hentai, $episode, $episodeNumber, true);
|
||||
|
||||
// Discord Alert
|
||||
if ($request->has('censored')) {
|
||||
DiscordReleaseNotification::dispatch($referenceEpisode->title.' - '.$episodeNumber, 'release-censored');
|
||||
} else {
|
||||
DiscordReleaseNotification::dispatch($episode->slug, 'release');
|
||||
}
|
||||
|
||||
cache()->flush();
|
||||
|
||||
return to_route('hentai.index', [
|
||||
'title' => $episode->slug,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit Episode
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -28,14 +28,14 @@ class CdnPathValidator
|
||||
return $this->checkMirrors($domains, [$path], $fresh);
|
||||
}
|
||||
|
||||
public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false): array
|
||||
public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false, int $episodeNumber = 1): array
|
||||
{
|
||||
$domains = config('hstream.download_domain_4k');
|
||||
|
||||
// Validate the base directory, plus the first episode manifest as a sanity check.
|
||||
// Validate the base directory, plus the episode manifest as a sanity check.
|
||||
$paths = [self::STREAM_PREFIX.rtrim($baseurl, '/')];
|
||||
if ($episodeCount >= 1) {
|
||||
$paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad(1, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd';
|
||||
$paths[] = self::STREAM_PREFIX.rtrim($baseurl, '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT).'/720/manifest.mpd';
|
||||
}
|
||||
|
||||
return $this->checkMirrors($domains, $paths, $fresh);
|
||||
|
||||
@@ -32,39 +32,6 @@ class EpisodeService
|
||||
return $slug;
|
||||
}
|
||||
|
||||
public function createEpisode(
|
||||
Request $request,
|
||||
Hentai $hentai,
|
||||
int $episodeNumber,
|
||||
?Studios $studio = null,
|
||||
?Episode $referenceEpisode = null
|
||||
): Episode {
|
||||
$episode = new Episode;
|
||||
$episode->title = $referenceEpisode->title ?? $request->input('title');
|
||||
$episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title);
|
||||
$episode->title_jpn = $referenceEpisode->title_jpn ?? $request->input('title_jpn');
|
||||
$episode->slug = "{$hentai->slug}-{$episodeNumber}";
|
||||
$episode->hentai_id = $hentai->id;
|
||||
$episode->studios_id = $referenceEpisode->studio->id ?? $studio->id;
|
||||
$episode->episode = $episodeNumber;
|
||||
$episode->description = $referenceEpisode ? $request->input('description') : $request->input("description{$episodeNumber}");
|
||||
$episode->url = $referenceEpisode ? $request->input('baseurl') : rtrim($request->input('baseurl'), '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT);
|
||||
$episode->view_count = 0;
|
||||
$episode->interpolated = true;
|
||||
$episode->is_dvd_aspect = false;
|
||||
$episode->release_date = $referenceEpisode->release_date ?? Carbon::parse($request->input('releasedate'))->format('Y-m-d');
|
||||
$episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp";
|
||||
$episode->save();
|
||||
|
||||
// Tagging
|
||||
$tags = $referenceEpisode ? $referenceEpisode->tags : json_decode($request->input('tags'));
|
||||
foreach ($tags as $t) {
|
||||
$episode->tag($referenceEpisode ? $t->name : $t->value);
|
||||
}
|
||||
|
||||
return $episode;
|
||||
}
|
||||
|
||||
private function applyTags(Request $request, Episode $episode): void
|
||||
{
|
||||
$tags = json_decode($request->input('tags'));
|
||||
|
||||
Reference in New Issue
Block a user