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'));
|
||||
|
||||
@@ -6,76 +6,8 @@
|
||||
|
||||
<!--Modal body-->
|
||||
<div class="relative p-4 pt-0">
|
||||
<form method="POST" action="{{ route('admin.upload.episode') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<input name="episode_id" id="episode_id" type="hidden"/>
|
||||
|
||||
<!-- Episodes -->
|
||||
<div class="grid grid-cols-2">
|
||||
<!-- Cover -->
|
||||
<div class="p-4">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover1">Cover:</label>
|
||||
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodecover1" id="episodecover1" required>
|
||||
</div>
|
||||
<!-- Thumbs -->
|
||||
<div class="p-4">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery1">Gallery:</label>
|
||||
<input class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-800 focus:border-rose-900 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-rose-800 dark:focus:border-rose-900" type="file" name="episodegallery1[]" id="episodegallery1" multiple="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-0">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description">Description:</label>
|
||||
<textarea rows="4" cols="50" id="description" name="description" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-neutral-900 dark:text-gray-300 focus:border-rose-500 dark:focus:border-rose-600 focus:ring-rose-500 dark:focus:ring-rose-600 rounded-md shadow-sm" required>{{ $episode->description }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="baseurl">Stream:</label>
|
||||
<x-text-input id="baseurl" class="block w-full" type="text" name="baseurl" required />
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-0">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p:</label>
|
||||
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" required />
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-0">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated1">Download 1080p 48fps:</label>
|
||||
<x-text-input id="episodedlurlinterpolated1" class="block w-full" type="text" name="episodedlurlinterpolated1" />
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-0">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k1">Download 4k:</label>
|
||||
<x-text-input id="episodedlurl4k1" class="block w-full" type="text" name="episodedlurl4k1" />
|
||||
</div>
|
||||
|
||||
<div class="p-4 pt-0">
|
||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi1">Download 4k 48fps:</label>
|
||||
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center justify-end rounded-b-md p-4">
|
||||
<div class="inline-block mr-2">
|
||||
<input class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:focus:ring-rose-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
type="checkbox" value="true" id="censored" name="censored" />
|
||||
<label class="inline-block hover:cursor-pointer dark:text-white" for="censored">
|
||||
Censored Notification
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="inline-block rounded bg-primary-100 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-primary-700 transition duration-150 ease-in-out hover:bg-primary-accent-100 focus:bg-primary-accent-100 focus:outline-none focus:ring-0 active:bg-primary-accent-200" data-te-modal-dismiss data-te-ripple-init data-te-ripple-color="light">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="ml-1 inline-block rounded bg-rose-600 px-6 pb-2 pt-2.5 text-xs font-medium uppercase leading-normal text-white transition duration-150 ease-in-out hover:bg-rose-700 focus:bg-rose-600" data-te-ripple-init data-te-ripple-color="light">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@livewire('admin-episode-form', ['episodeId' => $episode->id])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modals JS -->
|
||||
<script>
|
||||
document.getElementById('episode_id').value = document.getElementById('e_id').value;
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<form wire:submit="save">
|
||||
{{-- Info card --}}
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase mb-3">Adding Episode {{ $episodeNumber }} to {{ $title }}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Studio</label>
|
||||
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center">{{ $studio }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Release Date</label>
|
||||
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center">{{ $releasedate }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Title JPN</label>
|
||||
<div class="w-full h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 flex items-center truncate">{{ $titleJpn }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Tags</label>
|
||||
<div class="w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 dark:bg-neutral-900 dark:border-neutral-600 dark:text-white px-3 py-2">{{ implode(', ', $tags) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stream card --}}
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase mb-3">Stream</h2>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="baseurl">Base URL (e.g. 2026/Title) *</label>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input wire:model.live.debounce.800ms="baseurl" id="baseurl" type="text" autocomplete="off"
|
||||
placeholder="2026/Title"
|
||||
class="flex-1 min-w-[200px] h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3">
|
||||
@include('livewire.partials.validation-badge', ['status' => $validation['stream'] ?? null])
|
||||
</div>
|
||||
@error('baseurl') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Files card --}}
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4"
|
||||
x-data="{
|
||||
coverProgress: null,
|
||||
galleryProgress: null,
|
||||
uploadingCover: false,
|
||||
uploadingGallery: false,
|
||||
onUploadStart(e) {
|
||||
if (e.detail.name === 'cover') { this.uploadingCover = true; this.coverProgress = 0; }
|
||||
if (e.detail.name === 'gallery') { this.uploadingGallery = true; this.galleryProgress = 0; }
|
||||
},
|
||||
onUploadProgress(e) {
|
||||
if (e.detail.name === 'cover') { this.coverProgress = e.detail.progress; }
|
||||
if (e.detail.name === 'gallery') { this.galleryProgress = e.detail.progress; }
|
||||
},
|
||||
onUploadFinish(e) {
|
||||
if (e.detail.name === 'cover') { this.uploadingCover = false; }
|
||||
if (e.detail.name === 'gallery') { this.uploadingGallery = false; }
|
||||
},
|
||||
onUploadError(e) {
|
||||
if (e.detail.name === 'cover') { this.uploadingCover = false; }
|
||||
if (e.detail.name === 'gallery') { this.uploadingGallery = false; }
|
||||
}
|
||||
}"
|
||||
x-on:livewire-upload-start.window="onUploadStart"
|
||||
x-on:livewire-upload-finish.window="onUploadFinish"
|
||||
x-on:livewire-upload-error.window="onUploadError"
|
||||
x-on:livewire-upload-progress.window="onUploadProgress">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{{-- Cover --}}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="cover">Cover *</label>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-1">
|
||||
<input wire:model="cover" id="cover" type="file" accept="image/*" required
|
||||
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white file:mr-3 file:rounded-md file:border-0 file:bg-rose-600 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-rose-700">
|
||||
@error('cover') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||
|
||||
<div x-show="uploadingCover" class="mt-2" x-cloak>
|
||||
<div class="h-1.5 w-full bg-gray-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-rose-600 transition-all duration-150" :style="'width:' + (coverProgress || 0) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400" x-text="Math.round(coverProgress || 0) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($cover)
|
||||
<img src="{{ $cover->temporaryUrl() }}" alt="Cover preview"
|
||||
class="w-24 h-36 object-cover rounded-lg border border-gray-200 dark:border-neutral-600 flex-shrink-0">
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Gallery --}}
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="gallery">Gallery (multiple)</label>
|
||||
<input wire:model="gallery" id="gallery" type="file" accept="image/*" multiple
|
||||
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white file:mr-3 file:rounded-md file:border-0 file:bg-rose-600 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-rose-700">
|
||||
@error('gallery') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||
|
||||
<div x-show="uploadingGallery" class="mt-2" x-cloak>
|
||||
<div class="h-1.5 w-full bg-gray-200 dark:bg-neutral-700 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-rose-600 transition-all duration-150" :style="'width:' + (galleryProgress || 0) + '%'"></div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400" x-text="Math.round(galleryProgress || 0) + '%'"></span>
|
||||
</div>
|
||||
|
||||
@if (! empty($gallery))
|
||||
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||
@foreach ($gallery as $galleryImage)
|
||||
<img wire:key="gallery-{{ $loop->index }}" src="{{ $galleryImage->temporaryUrl() }}"
|
||||
alt="Gallery preview" class="w-full h-16 object-cover rounded-md border border-gray-200 dark:border-neutral-600">
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="description">Description *</label>
|
||||
<textarea wire:model="description" id="description" rows="3"
|
||||
class="mt-1 block w-full text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3 py-2"></textarea>
|
||||
@error('description') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@php
|
||||
$downloadFields = [
|
||||
'fhd' => 'Download 1080p *',
|
||||
'fhdi' => 'Download 1080p 48fps',
|
||||
'uhd' => 'Download 4k *',
|
||||
'uhdi' => 'Download 4k 48fps',
|
||||
];
|
||||
@endphp
|
||||
@foreach ($downloadFields as $quality => $label)
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="dl-{{ $quality }}">{{ $label }}</label>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<input wire:model.live.debounce.800ms="downloads.{{ $quality }}" id="dl-{{ $quality }}" type="text" autocomplete="off"
|
||||
placeholder="2026/Title/E0{{ $episodeNumber }}.mkv"
|
||||
class="flex-1 min-w-[180px] h-9 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-rose-500 focus:border-rose-600 dark:bg-neutral-900 dark:border-neutral-600 dark:placeholder-gray-400 dark:text-white px-3">
|
||||
@include('livewire.partials.validation-badge', ['status' => $validation['downloads.' . $quality] ?? null])
|
||||
</div>
|
||||
@error("downloads.{$quality}") <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Action bar --}}
|
||||
<div class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 flex flex-wrap items-center justify-end gap-4">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
|
||||
<input type="checkbox" wire:model="censored"
|
||||
class="w-4 h-4 text-rose-600 bg-gray-100 border-gray-300 rounded focus:ring-rose-500 dark:bg-gray-700 dark:border-gray-600">
|
||||
Censored Notification
|
||||
</label>
|
||||
|
||||
@if ($this->hasInvalidValidation)
|
||||
<label class="inline-flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 cursor-pointer">
|
||||
<input type="checkbox" wire:model="overrideValidation"
|
||||
class="w-4 h-4 text-amber-600 bg-gray-100 border-gray-300 rounded focus:ring-amber-500 dark:bg-gray-700 dark:border-gray-600">
|
||||
Save anyway (skip CDN validation)
|
||||
</label>
|
||||
@endif
|
||||
|
||||
<button type="button" data-te-modal-dismiss
|
||||
class="inline-block rounded bg-gray-200 dark:bg-neutral-700 px-6 py-2.5 text-xs font-medium uppercase leading-normal text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-neutral-600 transition">
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button type="submit" wire:loading.attr="disabled" wire:target="save"
|
||||
class="inline-flex items-center gap-2 rounded bg-rose-600 px-6 py-2.5 text-xs font-medium uppercase leading-normal text-white hover:bg-rose-700 disabled:opacity-60 transition">
|
||||
<svg wire:loading wire:target="save" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span wire:loading.remove wire:target="save">Add</span>
|
||||
<span wire:loading wire:target="save">Saving…</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -42,9 +42,6 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
|
||||
// Release
|
||||
Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index');
|
||||
|
||||
// Episode
|
||||
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
|
||||
|
||||
// Get Tags used for Upload Form
|
||||
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
|
||||
Route::get('/admin/studios', [AdminApiController::class, 'getStudios'])->name('admin.studios');
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Livewire;
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Livewire\AdminEpisodeForm;
|
||||
use App\Models\Downloads;
|
||||
use App\Models\Episode;
|
||||
use App\Models\Hentai;
|
||||
use App\Models\Studios;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Features\SupportTesting\Testable;
|
||||
use Livewire\Livewire;
|
||||
use Tests\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminEpisodeFormTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Queue::fake();
|
||||
Cache::flush();
|
||||
Storage::fake('public');
|
||||
|
||||
config([
|
||||
'hstream.download_domain' => ['https://dl-a.test'],
|
||||
'hstream.download_domain_4k' => ['https://dl4k-a.test'],
|
||||
'hstream.stream_domain' => ['https://stream-a.test'],
|
||||
]);
|
||||
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->addRole(UserRole::ADMINISTRATOR);
|
||||
$this->actingAs($this->admin);
|
||||
}
|
||||
|
||||
private function makeReferenceEpisode(string $slug = 'amazing-title-2026', array $attributes = []): Episode
|
||||
{
|
||||
$hentai = Hentai::factory()->create([
|
||||
'slug' => $slug,
|
||||
'description' => 'An existing series',
|
||||
]);
|
||||
$studio = Studios::factory()->create([
|
||||
'name' => 'Test Studio',
|
||||
'slug' => 'test-studio',
|
||||
]);
|
||||
$episode = Episode::factory()->create(array_merge([
|
||||
'hentai_id' => $hentai->id,
|
||||
'studios_id' => $studio->id,
|
||||
'title' => 'Amazing Title 2026',
|
||||
'title_search' => 'Amazing Title 2026',
|
||||
'title_jpn' => '素晴らしいタイトル',
|
||||
'slug' => $slug.'-1',
|
||||
'episode' => 1,
|
||||
'description' => 'A lovely description',
|
||||
'url' => '2026/AmazingTitle/E01',
|
||||
'cover_url' => '/images/hentai/'.$slug.'/cover-ep-1.webp',
|
||||
'interpolated' => 1,
|
||||
'interpolated_uhd' => 1,
|
||||
'release_date' => '2026-08-04',
|
||||
'view_count' => 0,
|
||||
], $attributes));
|
||||
$episode->tag('Action');
|
||||
|
||||
return $episode;
|
||||
}
|
||||
|
||||
private function makeForm(?Episode $episode = null): Testable
|
||||
{
|
||||
$episode ??= $this->makeReferenceEpisode();
|
||||
|
||||
return Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id]);
|
||||
}
|
||||
|
||||
public function test_valid_episode_is_persisted(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
|
||||
|
||||
$this->makeForm()
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('gallery', [
|
||||
UploadedFile::fake()->image('g1.jpg'),
|
||||
UploadedFile::fake()->image('g2.jpg'),
|
||||
])
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhdi', '2026/AmazingTitle/E02.mkv')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
|
||||
|
||||
$this->assertDatabaseCount('episodes', 2);
|
||||
|
||||
$episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail();
|
||||
$this->assertSame('Amazing Title 2026', $episode->title);
|
||||
$this->assertSame('素晴らしいタイトル', $episode->title_jpn);
|
||||
$this->assertSame('Test Studio', $episode->studio->name);
|
||||
$this->assertSame(2, $episode->episode);
|
||||
$this->assertSame('2026/AmazingTitle/E02', $episode->url);
|
||||
$this->assertSame('2026-08-04', $episode->release_date);
|
||||
$this->assertSame(1, $episode->interpolated);
|
||||
$this->assertSame(1, $episode->interpolated_uhd);
|
||||
$this->assertTrue($episode->tags->contains('name', 'Action'));
|
||||
|
||||
$this->assertDatabaseCount('gallery', 2);
|
||||
$this->assertDatabaseCount('downloads', 3);
|
||||
|
||||
$fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail();
|
||||
$this->assertSame('2026/AmazingTitle/E02.mkv', $fhd->url);
|
||||
$this->assertEquals(9999, $fhd->size);
|
||||
$this->assertNotNull($fhd->validated_at);
|
||||
|
||||
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
|
||||
$this->assertEquals(9999, $uhd->size);
|
||||
|
||||
$uhdi = Downloads::where('episode_id', $episode->id)->where('type', 'UHDi')->firstOrFail();
|
||||
$this->assertEquals(9999, $uhdi->size);
|
||||
|
||||
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/cover-ep-2.webp'));
|
||||
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-0.webp'));
|
||||
$this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-2-1.webp'));
|
||||
}
|
||||
|
||||
public function test_invalid_cdn_path_blocks_save_with_error(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
|
||||
]);
|
||||
|
||||
$this->makeForm()
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->call('save')
|
||||
->assertHasErrors('downloads.fhd');
|
||||
|
||||
$this->assertDatabaseCount('episodes', 1);
|
||||
}
|
||||
|
||||
public function test_override_validation_allows_saving_invalid_paths(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://dl4k-a.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404),
|
||||
]);
|
||||
|
||||
$this->makeForm()
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('overrideValidation', true)
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
|
||||
|
||||
$this->assertDatabaseCount('episodes', 2);
|
||||
|
||||
$episode = Episode::where('slug', 'amazing-title-2026-2')->firstOrFail();
|
||||
$uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail();
|
||||
$this->assertSame('2026/AmazingTitle/E02.mkv', $uhd->url);
|
||||
$this->assertNull($uhd->size);
|
||||
}
|
||||
|
||||
public function test_validation_requires_required_fields(): void
|
||||
{
|
||||
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
|
||||
|
||||
$component = $this->makeForm()
|
||||
->set('baseurl', '')
|
||||
->set('description', '')
|
||||
->set('downloads.fhd', '')
|
||||
->set('downloads.uhd', '')
|
||||
->call('save')
|
||||
->assertHasErrors([
|
||||
'baseurl' => 'required',
|
||||
'description' => 'required',
|
||||
'cover' => 'required',
|
||||
'downloads.fhd' => 'required',
|
||||
'downloads.uhd' => 'required',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('episodes', 1);
|
||||
|
||||
// The base URL must match the directory pattern (2026/Title).
|
||||
$component
|
||||
->set('baseurl', 'no-slash')
|
||||
->set('description', 'A lovely description')
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->call('save')
|
||||
->assertHasErrors(['baseurl' => 'regex']);
|
||||
|
||||
$this->assertDatabaseCount('episodes', 1);
|
||||
}
|
||||
|
||||
public function test_save_uses_fresh_cdn_check_ignoring_cached_results(): void
|
||||
{
|
||||
$valid = false;
|
||||
|
||||
Http::fake(function ($request) use (&$valid) {
|
||||
return Http::response(['valid' => $valid, 'type' => 'file', 'size' => 9999]);
|
||||
});
|
||||
|
||||
$component = $this->makeForm()
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv');
|
||||
|
||||
// Live typing validation caches the invalid CDN result.
|
||||
$validation = $component->get('validation');
|
||||
$this->assertSame('invalid', $validation['downloads.fhd']['state'] ?? null);
|
||||
|
||||
// The file gets "fixed" on the CDN within the cache window.
|
||||
$valid = true;
|
||||
|
||||
// Saving must re-check the CDN without using the cached invalid result.
|
||||
$component
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
|
||||
|
||||
$this->assertDatabaseCount('episodes', 2);
|
||||
$this->assertDatabaseCount('downloads', 2);
|
||||
}
|
||||
|
||||
public function test_mount_and_save_require_admin_role(): void
|
||||
{
|
||||
$episode = $this->makeReferenceEpisode();
|
||||
|
||||
// Guests are rejected when the component mounts.
|
||||
Auth::logout();
|
||||
|
||||
Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id])
|
||||
->assertStatus(403);
|
||||
|
||||
// Authenticated non-admins are rejected when the component mounts.
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(AdminEpisodeForm::class, ['episodeId' => $episode->id])
|
||||
->assertStatus(403);
|
||||
|
||||
// Administrators can mount and save.
|
||||
$admin = User::factory()->create();
|
||||
$admin->addRole(UserRole::ADMINISTRATOR);
|
||||
$this->actingAs($admin);
|
||||
|
||||
Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]);
|
||||
|
||||
$this->makeForm($episode)
|
||||
->set('cover', UploadedFile::fake()->image('cover.jpg'))
|
||||
->set('downloads.fhd', '2026/AmazingTitle/E02.mkv')
|
||||
->set('downloads.uhd', '2026/AmazingTitle/E02.mkv')
|
||||
->call('save')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('hentai.index', 'amazing-title-2026-2'));
|
||||
|
||||
$this->assertDatabaseCount('episodes', 2);
|
||||
}
|
||||
|
||||
public function test_stream_page_renders_episode_modal_for_admin(): void
|
||||
{
|
||||
$episode = $this->makeReferenceEpisode('amazing-title-2026', [
|
||||
'interpolated' => 0,
|
||||
'interpolated_uhd' => 0,
|
||||
]);
|
||||
|
||||
$this->get(route('hentai.index', $episode->slug))
|
||||
->assertOk()
|
||||
->assertSee('admin-episode-form');
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,24 @@ class CdnPathValidatorTest extends TestCase
|
||||
Http::assertSentCount(4);
|
||||
}
|
||||
|
||||
public function test_stream_can_target_specific_episode_manifest(): void
|
||||
{
|
||||
Http::fake([
|
||||
'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
||||
'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 1024]),
|
||||
]);
|
||||
|
||||
$result = app(CdnPathValidator::class)->validateStream('2026/Title', 1, false, 2);
|
||||
|
||||
$this->assertTrue($result['valid']);
|
||||
$this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']);
|
||||
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
|
||||
$this->assertArrayHasKey('hentai-stream/2026/Title/E02/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
|
||||
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']);
|
||||
$this->assertArrayNotHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-b.test']);
|
||||
$this->assertSame(4, count(Http::recorded()));
|
||||
}
|
||||
|
||||
public function test_results_are_cached_for_short_period(): void
|
||||
{
|
||||
Http::fake([
|
||||
|
||||
Reference in New Issue
Block a user