diff --git a/app/Http/Controllers/Admin/ReleaseController.php b/app/Http/Controllers/Admin/ReleaseController.php index 0da6bc4..1198869 100644 --- a/app/Http/Controllers/Admin/ReleaseController.php +++ b/app/Http/Controllers/Admin/ReleaseController.php @@ -3,33 +3,10 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Jobs\DiscordReleaseNotification; -use App\Models\Hentai; -use App\Services\DownloadService; -use App\Services\EpisodeService; -use App\Services\GalleryService; -use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; use Illuminate\View\View; class ReleaseController extends Controller { - protected EpisodeService $episodeService; - - protected GalleryService $galleryService; - - protected DownloadService $downloadService; - - public function __construct( - EpisodeService $episodeService, - GalleryService $galleryService, - DownloadService $downloadService - ) { - $this->episodeService = $episodeService; - $this->galleryService = $galleryService; - $this->downloadService = $downloadService; - } - /** * Display release page */ @@ -37,55 +14,4 @@ class ReleaseController extends Controller { return view('admin.release.create'); } - - /** - * Upload New Hentai with One or Multipe Episodes - */ - public function store(Request $request): RedirectResponse - { - // Create new Hentai or find existing one - $slug = $this->episodeService->generateSlug($request->input('title')); - - $hentai = Hentai::where('slug', $slug)->first(); - - // If hentai exists and was created today, return to home - if ($hentai?->created_at->isToday()) { - return to_route('home.index'); - } - - // If hentai does not exist, create a new instance - $hentai = Hentai::firstOrCreate( - ['slug' => $slug], - ['description' => $request->input('description1')] - ); - - // Studio - $studio = $this->episodeService->getOrCreateStudio(json_decode($request->input('studio'))[0]->value); - - // Create Episode(s) - $releasedEpisodes = []; - for ($i = 1; $i <= $request->input('episodes'); $i++) { - - $episode = $this->episodeService->createEpisode($request, $hentai, $i, $studio); - - $this->episodeService->createOrUpdateCover($request, $episode, $slug, $i); - $this->downloadService->createOrUpdateDownloads($request, $episode, $i); - $this->galleryService->createOrUpdateGallery($request, $hentai, $episode, $i); - - $releasedEpisodes[] = $episode->slug; - } - - if ($request->has('censored')) { - DiscordReleaseNotification::dispatch($request->input('title'), 'release-censored'); - } else { - foreach ($releasedEpisodes as $slug) { - // Dispatch Discord Alert - DiscordReleaseNotification::dispatch($slug, 'release'); - } - } - - cache()->flush(); - - return to_route('home.index'); - } } diff --git a/app/Livewire/AdminReleaseForm.php b/app/Livewire/AdminReleaseForm.php new file mode 100644 index 0000000..d04d2e4 --- /dev/null +++ b/app/Livewire/AdminReleaseForm.php @@ -0,0 +1,331 @@ + */ + 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'); + } +} diff --git a/app/Models/Downloads.php b/app/Models/Downloads.php index 38ac060..a097305 100644 --- a/app/Models/Downloads.php +++ b/app/Models/Downloads.php @@ -18,6 +18,8 @@ class Downloads extends Model 'episode_id', 'type', 'url', + 'size', + 'validated_at', ]; /** diff --git a/app/Services/CdnPathValidator.php b/app/Services/CdnPathValidator.php new file mode 100644 index 0000000..fd533a5 --- /dev/null +++ b/app/Services/CdnPathValidator.php @@ -0,0 +1,115 @@ + 'hentai-1080p/', + 'FHDi' => 'hentai-1080p/', + 'UHD' => 'hentai/', + 'UHDi' => 'hentai/', + ]; + + private const STREAM_PREFIX = 'hentai-stream/'; + + private const CACHE_TTL = 300; + + public function validateDownload(string $type, string $url, bool $fresh = false): array + { + $path = self::DOWNLOAD_PREFIX[$type].ltrim($url, '/'); + $domains = config('hstream.download_domain_4k'); + + return $this->checkMirrors($domains, [$path], $fresh); + } + + public function validateStream(string $baseurl, int $episodeCount, bool $fresh = false): array + { + $domains = config('hstream.download_domain_4k'); + + // Validate the base directory, plus the first 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'; + } + + return $this->checkMirrors($domains, $paths, $fresh); + } + + private function checkMirrors(array $domains, array $paths, bool $fresh = false): array + { + $cacheKey = 'cdncheck:'.sha1(serialize([$domains, $paths])); + + if ($fresh) { + Cache::forget($cacheKey); + } + + return Cache::remember($cacheKey, self::CACHE_TTL, fn () => $this->runChecks($domains, $paths)); + } + + private function runChecks(array $domains, array $paths): array + { + $entries = []; + foreach ($domains as $domain) { + foreach ($paths as $path) { + $entries[] = ['domain' => $domain, 'path' => $path]; + } + } + + try { + $responses = Http::pool(function ($pool) use ($entries) { + foreach ($entries as $entry) { + $pool->timeout(4) + ->withHeaders(['Accept' => 'application/json']) + ->get($entry['domain'].'/check/'.$this->tokenize($entry['path']).'/'.$this->tokenize(now()->addHours(6))); + } + }); + } catch (\Throwable $e) { + $mirrors = []; + foreach ($entries as $entry) { + $mirrors[$entry['domain']][$entry['path']] = false; + } + + return [ + 'valid' => false, + 'mirrors' => $mirrors, + 'size' => null, + ]; + } + + $mirrors = []; + $allValid = true; + $firstSize = null; + + foreach ($entries as $index => $entry) { + $response = $responses[$index]; + $ok = $response instanceof Response + && $response->ok() + && $response->json('valid') === true; + + $mirrors[$entry['domain']][$entry['path']] = $ok; + + if (! $ok) { + $allValid = false; + } elseif ($firstSize === null) { + $firstSize = $response->json('size'); + } + } + + return [ + 'valid' => $allValid, + 'mirrors' => $mirrors, + 'size' => $allValid ? $firstSize : null, + ]; + } + + private function tokenize(string $value): string + { + return urlencode(Crypt::encryptString($value)); + } +} diff --git a/app/Services/DownloadService.php b/app/Services/DownloadService.php index fbb3d12..dba6e6b 100644 --- a/app/Services/DownloadService.php +++ b/app/Services/DownloadService.php @@ -33,4 +33,28 @@ class DownloadService } } } + + /** + * Persist validated download URLs for an episode. Sizes are written + * directly from the CDN validation response, so no size job is dispatched. + * + * @param array $downloads + */ + public function createOrUpdateDownloadsFromArray(Episode $episode, array $downloads): void + { + foreach ($downloads as $type => $data) { + if (empty($data['url'])) { + continue; + } + + Downloads::updateOrCreate([ + 'episode_id' => $episode->id, + 'type' => $type, + ], [ + 'url' => $data['url'], + 'size' => $data['size'] ?? null, + 'validated_at' => now(), + ]); + } + } } diff --git a/app/Services/EpisodeService.php b/app/Services/EpisodeService.php index 60b07ac..2506169 100644 --- a/app/Services/EpisodeService.php +++ b/app/Services/EpisodeService.php @@ -4,14 +4,16 @@ namespace App\Services; use App\Models\Episode; use App\Models\Hentai; -use App\Models\Studios; use App\Models\ModLog; +use App\Models\Studios; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Laravel\Facades\Image; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class EpisodeService { @@ -171,19 +173,60 @@ class EpisodeService ); } + /** + * Create an episode from an array of data (used by the Livewire release form). + * + * @param array{title: string, title_jpn: string, baseurl: string, description: string, releasedate: string, tags: string[], interpolated_uhd?: bool} $data + */ + public function createEpisodeFromArray( + array $data, + Hentai $hentai, + int $episodeNumber, + ?Studios $studio = null + ): Episode { + $episode = new Episode; + $episode->title = $data['title']; + $episode->title_search = preg_replace('/[^A-Za-z0-9 ]/', '', $episode->title); + $episode->title_jpn = $data['title_jpn']; + $episode->slug = "{$hentai->slug}-{$episodeNumber}"; + $episode->hentai_id = $hentai->id; + $episode->studios_id = $studio->id; + $episode->episode = $episodeNumber; + $episode->description = $data['description']; + $episode->url = rtrim($data['baseurl'], '/').'/E'.str_pad($episodeNumber, 2, '0', STR_PAD_LEFT); + $episode->view_count = 0; + $episode->interpolated = true; + $episode->interpolated_uhd = $data['interpolated_uhd'] ?? false; + $episode->is_dvd_aspect = false; + $episode->release_date = Carbon::parse($data['releasedate'])->format('Y-m-d'); + $episode->cover_url = "/images/hentai/{$hentai->slug}/cover-ep-{$episodeNumber}.webp"; + $episode->save(); + + foreach ($data['tags'] as $tag) { + $episode->tag($tag); + } + + return $episode; + } + public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void { if (! $request->hasFile("episodecover{$episodeNumber}")) { return; } + $this->saveCoverFromFile($episode, $slug, $episodeNumber, $request->file("episodecover{$episodeNumber}")); + } + + public function saveCoverFromFile(Episode $episode, string $slug, int $episodeNumber, UploadedFile|TemporaryUploadedFile $file): void + { // Create Folder for Image Upload if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) { Storage::disk('public')->makeDirectory("/images/hentai/{$slug}"); } // Encode and save cover image - Image::read($request->file("episodecover{$episodeNumber}")->getRealPath()) + Image::read($file->getRealPath()) ->cover(268, 394) ->encode(new WebpEncoder) ->save(Storage::disk('public')->path($episode->cover_url)); diff --git a/app/Services/GalleryService.php b/app/Services/GalleryService.php index 8d100c4..643fb4b 100644 --- a/app/Services/GalleryService.php +++ b/app/Services/GalleryService.php @@ -6,9 +6,11 @@ use App\Models\Episode; use App\Models\Gallery; use App\Models\Hentai; use Illuminate\Http\Request; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Laravel\Facades\Image; +use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; class GalleryService { @@ -17,17 +19,26 @@ class GalleryService $galleryInputNumber = $override ? 1 : $episodeNumber; if ($request->hasFile('episodegallery'.$galleryInputNumber)) { + $this->saveGalleryFiles($hentai, $episode, $episodeNumber, $request->file('episodegallery'.$galleryInputNumber)); + } + } - $this->deleteOldGallery($episode); + /** + * Save a gallery for the given episode from a list of uploaded files. + * + * @param array $files + */ + public function saveGalleryFiles(Hentai $hentai, Episode $episode, int $episodeNumber, array $files): void + { + $this->deleteOldGallery($episode); - $this->createGalleryFolder($hentai); + $this->createGalleryFolder($hentai); - $counter = 0; - foreach ($request->file('episodegallery'.$galleryInputNumber) as $file) { - $gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter); - $this->saveGalleryImage($gallery, $file); - $counter += 1; - } + $counter = 0; + foreach ($files as $file) { + $gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter); + $this->saveGalleryImage($gallery, $file); + $counter += 1; } } @@ -51,7 +62,7 @@ class GalleryService return $gallery; } - private function saveGalleryImage(Gallery $gallery, $sourceImage): void + private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void { Image::read($sourceImage->getRealPath()) ->cover(1920, 1080) diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..2a3f35b --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,186 @@ + 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component + | as an entire page via `Route::get('/post/create', CreatePost::class);`. + | In this case, the view returned by CreatePost will render into $slot. + | + */ + + 'layout' => 'components.layouts.app', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'lazy_placeholder' => null, + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => null, // Example: 'local', 's3' | Default: 'default' + 'rules' => ['required', 'file', 'max:51200'], // 20MB uploads for the release form covers/galleries + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 60, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => false, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', +]; diff --git a/database/migrations/2026_08_04_193201_add_validated_at_to_downloads_and_unique_slug_to_hentais.php b/database/migrations/2026_08_04_193201_add_validated_at_to_downloads_and_unique_slug_to_hentais.php new file mode 100644 index 0000000..559dd23 --- /dev/null +++ b/database/migrations/2026_08_04_193201_add_validated_at_to_downloads_and_unique_slug_to_hentais.php @@ -0,0 +1,36 @@ +timestamp('validated_at')->nullable()->after('size'); + }); + + Schema::table('hentais', function (Blueprint $table) { + $table->unique('slug'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('hentais', function (Blueprint $table) { + $table->dropUnique(['slug']); + }); + + Schema::table('downloads', function (Blueprint $table) { + $table->dropColumn('validated_at'); + }); + } +}; diff --git a/database/schema/mysql-schema.sql b/database/schema/mysql-schema.sql index b8b7c95..a43c7c0 100644 --- a/database/schema/mysql-schema.sql +++ b/database/schema/mysql-schema.sql @@ -62,6 +62,7 @@ CREATE TABLE `downloads` ( `type` char(5) NOT NULL, `url` varchar(255) NOT NULL, `size` double DEFAULT NULL, + `validated_at` timestamp NULL DEFAULT NULL, `count` bigint(20) NOT NULL DEFAULT 0, `created_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, @@ -149,7 +150,8 @@ CREATE TABLE `hentais` ( `description` text NOT NULL, `created_at` timestamp NULL DEFAULT NULL, `updated_at` timestamp NULL DEFAULT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `hentais_slug_unique` (`slug`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; DROP TABLE IF EXISTS `jobs`; @@ -543,3 +545,4 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (67,'2026_05_06_144 INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (68,'2026_05_06_181115_create_mod_logs_table',31); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (73,'2026_07_18_115452_drop_subscription_key_from_users_table',32); INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (74,'2026_07_19_141500_create_video_engagement_table',32); +INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (75,'2026_08_04_193201_add_validated_at_to_downloads_and_unique_slug_to_hentais',33); diff --git a/resources/js/admin-release.js b/resources/js/admin-release.js new file mode 100644 index 0000000..3d41886 --- /dev/null +++ b/resources/js/admin-release.js @@ -0,0 +1,66 @@ +import Tagify from '@yaireo/tagify'; +import '@yaireo/tagify/dist/tagify.css'; + +document.addEventListener('livewire:init', () => { + const Alpine = window.Alpine; + + const dropdownOptions = { + classname: "color-blue", + enabled: 0, + maxItems: 10, + position: "text", + closeOnSelect: false, + highlightFirst: true + }; + + Alpine.data('adminReleaseTags', () => ({ + tagify: null, + + init() { + const el = this.$refs.tags; + + window.axios.get('/admin/tags').then((response) => { + if (response.status != 200) { + return; + } + + this.tagify = new Tagify(el, { + whitelist: response.data.tags, + dropdown: dropdownOptions + }); + + this.tagify.on('change', () => { + this.$wire.set('tags', this.tagify.value.map((t) => t.value)); + }); + }).catch((error) => { + console.log(error); + }); + } + })); + + Alpine.data('adminReleaseStudio', () => ({ + tagify: null, + + init() { + const el = this.$refs.studio; + + window.axios.get('/admin/studios').then((response) => { + if (response.status != 200) { + return; + } + + this.tagify = new Tagify(el, { + whitelist: response.data.studios, + dropdown: dropdownOptions + }); + + this.tagify.on('change', () => { + const values = this.tagify.value.map((t) => t.value); + this.$wire.set('studio', values.length ? values[values.length - 1] : ''); + }); + }).catch((error) => { + console.log(error); + }); + } + })); +}); diff --git a/resources/js/upload.js b/resources/js/upload.js deleted file mode 100644 index a2a2db9..0000000 --- a/resources/js/upload.js +++ /dev/null @@ -1,113 +0,0 @@ -import Tagify from '@yaireo/tagify'; -import '@yaireo/tagify/dist/tagify.css'; - -const taginput = document.querySelector("#tags"); -const studioinput = document.querySelector("#studio"); - -// Get Tags from API -window.axios.get('/admin/tags').then(function (response) { - if (response.status != 200) { - return; - } - - new Tagify(taginput, { - whitelist: response.data.tags, - dropdown: { - classname: "color-blue", - enabled: 0, // show the dropdown immediately on focus - maxItems: 10, - position: "text", // place the dropdown near the typed text - closeOnSelect: false, // keep the dropdown open after selecting a suggestion - highlightFirst: true - } - }); -}).catch(function (error) { - console.log(error); -}); - -// Get Studios from API -window.axios.get('/admin/studios').then(function (response) { - if (response.status != 200) { - return; - } - - new Tagify(studioinput, { - whitelist: response.data.studios, - dropdown: { - classname: "color-blue", - enabled: 0, // show the dropdown immediately on focus - maxItems: 10, - position: "text", // place the dropdown near the typed text - closeOnSelect: false, // keep the dropdown open after selecting a suggestion - highlightFirst: true - } - }); -}).catch(function (error) { - console.log(error); -}); - -let eps = 1; - -function dynEpisode() { - let amount = this.value; - - if (amount > eps) { - eps += 1; - var episodeUploads = ` -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- `; - - var element = document.getElementById('moreEpisodes'); - element.innerHTML = element.innerHTML + episodeUploads; - } else if (amount < eps) { - if (amount == 0) { - this.value = 1; - return; - } - document.getElementById("dynU" + eps).remove(); - document.getElementById("dynD" + eps).remove(); - document.getElementById("dynD4k" + eps).remove(); - document.getElementById("dynD48fps" + eps).remove(); - document.getElementById("dynDUHD48fps" + eps).remove(); - document.getElementById("dynB" + eps).remove(); - eps -= 1; - } -} - - -document.getElementById("episodes").addEventListener('change', dynEpisode); \ No newline at end of file diff --git a/resources/views/admin/release/create.blade.php b/resources/views/admin/release/create.blade.php index 6179e71..765fcff 100644 --- a/resources/views/admin/release/create.blade.php +++ b/resources/views/admin/release/create.blade.php @@ -1,111 +1,7 @@ @extends('admin.layout') @section('content') -
-
-
-
- @csrf + @livewire('admin-release-form') +@endsection -
-
- - -
- -
- - -
- -
- - -
-
- -
- - -
- -
-
- - -
- -
- - -
-
- -
- - -
- - -
- -
- - -
- -
- - -
-
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
-
- -
-
- - -
- - -
-
-
-
-
-@vite(['resources/js/upload.js']) -@endsection \ No newline at end of file +@vite(['resources/js/admin-release.js']) diff --git a/resources/views/livewire/admin-release-form.blade.php b/resources/views/livewire/admin-release-form.blade.php new file mode 100644 index 0000000..3881ce6 --- /dev/null +++ b/resources/views/livewire/admin-release-form.blade.php @@ -0,0 +1,234 @@ +
+
+
+ + {{-- Metadata card --}} +
+

Release Metadata

+ +
+
+ + + @error('title') {{ $message }} @enderror +
+ +
+ + + @error('titleJpn') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('studio') {{ $message }} @enderror +
+ +
+ + + @error('releasedate') {{ $message }} @enderror +
+
+ +
+ +
+ +
+ @error('tags') {{ $message }} @enderror +
+ +
+
+ Episodes +
+ + {{ $episodeCount }} + +
+
+
+
+ + {{-- Stream card --}} +
+

Stream

+
+ +
+ + @include('livewire.partials.validation-badge', ['status' => $validation['stream'] ?? null]) +
+ @error('baseurl') {{ $message }} @enderror +
+
+ + {{-- Episode cards --}} + @foreach ($episodes as $index => $episode) +
+ +
+

Episode {{ $index + 1 }}

+ @if (count($episodes) > 1) + + @endif +
+ +
+ {{-- Cover --}} +
+ +
+
+ + @error("episodes.{$index}.cover") {{ $message }} @enderror + +
+
+
+
+ +
+
+ + @if ($episode['cover']) + Cover preview + @endif +
+
+ + {{-- Gallery --}} +
+ + + @error("episodes.{$index}.gallery") {{ $message }} @enderror + +
+
+
+
+ +
+ + @if (! empty($episode['gallery'])) +
+ @foreach ($episode['gallery'] as $gallery) + Gallery preview + @endforeach +
+ @endif +
+
+ +
+ + + @error("episodes.{$index}.description") {{ $message }} @enderror +
+ +
+ @php + $downloadFields = [ + 'fhd' => 'Download 1080p *', + 'fhdi' => 'Download 1080p 48fps', + 'uhd' => 'Download 4k *', + 'uhdi' => 'Download 4k 48fps', + ]; + @endphp + @foreach ($downloadFields as $quality => $label) +
+ +
+ + @include('livewire.partials.validation-badge', ['status' => $validation['episodes.' . $index . '.downloads.' . $quality] ?? null]) +
+ @error("episodes.{$index}.downloads.{$quality}") {{ $message }} @enderror +
+ @endforeach +
+
+ @endforeach + + {{-- Action bar --}} +
+ + + @if ($this->hasInvalidValidation) + + @endif + + + Cancel + + + +
+ +
+
+
diff --git a/resources/views/livewire/partials/validation-badge.blade.php b/resources/views/livewire/partials/validation-badge.blade.php new file mode 100644 index 0000000..e388c12 --- /dev/null +++ b/resources/views/livewire/partials/validation-badge.blade.php @@ -0,0 +1,41 @@ +@props(['status' => null]) + +@php + $state = is_array($status) ? ($status['state'] ?? 'idle') : 'idle'; + $message = is_array($status) ? ($status['message'] ?? '') : ''; + $mirrors = is_array($status) ? ($status['mirrors'] ?? []) : []; + $missing = []; + foreach ($mirrors as $domain => $paths) { + foreach ((array) $paths as $ok) { + if (! $ok) { + $missing[] = preg_replace('#^https?://#', '', $domain); + break; + } + } + } +@endphp + +@if ($state === 'checking') +
+ + + + + {{ $message }} +
+@elseif ($state === 'valid') +
+ + {{ $message }} +
+@elseif ($state === 'invalid') +
+ +
+ {{ $message }} + @if (! empty($missing)) +
Missing: {{ implode(', ', $missing) }}
+ @endif +
+
+@endif diff --git a/routes/admin.php b/routes/admin.php index 4e9ac3a..e2c9360 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -41,11 +41,9 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () { // Release Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index'); - Route::post('/admin/release/upload', [ReleaseController::class, 'store'])->name('admin.upload'); // 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'); @@ -69,4 +67,4 @@ Route::group(['middleware' => ['auth', 'auth.moderator']], function () { // Edit Episode Route::post('/admin/episode/edit', [EpisodeController::class, 'update'])->name('admin.episode.edit'); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Livewire/AdminReleaseFormTest.php b/tests/Feature/Livewire/AdminReleaseFormTest.php new file mode 100644 index 0000000..8e23010 --- /dev/null +++ b/tests/Feature/Livewire/AdminReleaseFormTest.php @@ -0,0 +1,238 @@ + ['https://dl-a.test'], + 'hstream.download_domain_4k' => ['https://dl4k-a.test'], + 'hstream.stream_domain' => ['https://stream-a.test'], + ]); + } + + private function makeForm(): Testable + { + return Livewire::test(AdminReleaseForm::class) + ->set('title', 'Amazing Title 2026') + ->set('titleJpn', '素晴らしいタイトル') + ->set('studio', 'Test Studio') + ->set('tags', ['Action', 'Romance']) + ->set('releasedate', '2026-08-04') + ->set('baseurl', '2026/AmazingTitle') + ->set('episodes.0.description', 'A lovely description'); + } + + public function test_valid_release_is_persisted_with_sizes(): void + { + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + $this->makeForm() + ->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg')) + ->set('episodes.0.gallery', [ + UploadedFile::fake()->image('g1.jpg'), + UploadedFile::fake()->image('g2.jpg'), + ]) + ->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.uhdi', '2026/AmazingTitle/E01.mkv') + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('home.index')); + + $this->assertDatabaseCount('hentais', 1); + $this->assertDatabaseHas('hentais', ['slug' => 'amazing-title-2026']); + + $hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail(); + $this->assertDatabaseCount('episodes', 1); + + $episode = Episode::where('hentai_id', $hentai->id)->firstOrFail(); + $this->assertSame('2026/AmazingTitle/E01', $episode->url); + $this->assertSame(1, $episode->interpolated); + $this->assertSame(1, $episode->interpolated_uhd); + + $this->assertDatabaseCount('gallery', 2); + $this->assertDatabaseCount('downloads', 3); + + $fhd = Downloads::where('episode_id', $episode->id)->where('type', 'FHD')->firstOrFail(); + $this->assertSame('2026/AmazingTitle/E01.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-1.webp')); + $this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-0.webp')); + $this->assertTrue(Storage::disk('public')->exists('/images/hentai/amazing-title-2026/gallery-ep-1-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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg')) + ->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv') + ->call('save') + ->assertHasErrors('episodes.0.downloads.fhd'); + + $this->assertDatabaseCount('hentais', 0); + $this->assertDatabaseCount('episodes', 0); + } + + 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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg')) + ->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv') + ->set('overrideValidation', true) + ->call('save') + ->assertHasNoErrors() + ->assertRedirect(route('home.index')); + + $this->assertDatabaseCount('hentais', 1); + + $hentai = Hentai::where('slug', 'amazing-title-2026')->firstOrFail(); + $episode = Episode::where('hentai_id', $hentai->id)->firstOrFail(); + + $uhd = Downloads::where('episode_id', $episode->id)->where('type', 'UHD')->firstOrFail(); + $this->assertSame('2026/AmazingTitle/E01.mkv', $uhd->url); + $this->assertNull($uhd->size); + } + + public function test_existing_release_created_today_is_blocked(): void + { + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + Hentai::create(['slug' => 'amazing-title-2026', 'description' => 'existing']); + + $this->makeForm() + ->set('episodes.0.cover', UploadedFile::fake()->image('cover.jpg')) + ->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv') + ->call('save') + ->assertHasErrors('title'); + + $this->assertDatabaseCount('hentais', 1); + $this->assertDatabaseCount('episodes', 0); + } + + public function test_validation_requires_required_fields(): void + { + Http::fake(['*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 9999])]); + + Livewire::test(AdminReleaseForm::class) + ->call('save') + ->assertHasErrors([ + 'title' => 'required', + 'titleJpn' => 'required', + 'studio' => 'required', + 'tags' => 'required', + 'releasedate' => 'required', + 'baseurl' => 'required', + 'episodes.0.description' => 'required', + 'episodes.0.cover' => 'required', + 'episodes.0.downloads.fhd' => 'required', + 'episodes.0.downloads.uhd' => 'required', + ]); + + $this->assertDatabaseCount('hentais', 0); + } + + 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('episodes.0.cover', UploadedFile::fake()->image('cover.jpg')) + ->set('episodes.0.downloads.uhd', '2026/AmazingTitle/E01.mkv') + ->set('episodes.0.downloads.fhd', '2026/AmazingTitle/E01.mkv'); + + // Live typing validation caches the invalid CDN result. + $validation = $component->get('validation'); + $this->assertSame('invalid', $validation['episodes.0.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('home.index')); + + $this->assertDatabaseCount('hentais', 1); + $this->assertDatabaseCount('downloads', 2); + } + + public function test_admin_release_page_renders(): void + { + $user = User::factory()->create(); + $user->addRole(UserRole::ADMINISTRATOR); + + $this->actingAs($user) + ->get(route('admin.upload.index')) + ->assertOk() + ->assertSee('admin-release-form'); + } + + public function test_tagify_inputs_are_excluded_from_livewire_morphing(): void + { + $component = Livewire::test(AdminReleaseForm::class); + + // Tagify inputs must live inside wire:ignore scopes so Livewire's + // DOM morphing never replaces the Tagify-generated markup. + $component + ->assertSeeHtml('wire:ignore x-data="adminReleaseTags"') + ->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"'); + + // The wrappers must survive a server round-trip (e.g. a debounced + // download/baseurl update) untouched. + $component + ->set('title', 'Changed Title') + ->assertSeeHtml('wire:ignore x-data="adminReleaseTags"') + ->assertSeeHtml('wire:ignore x-data="adminReleaseStudio"'); + } +} diff --git a/tests/Unit/CdnPathValidatorTest.php b/tests/Unit/CdnPathValidatorTest.php new file mode 100644 index 0000000..831d4cf --- /dev/null +++ b/tests/Unit/CdnPathValidatorTest.php @@ -0,0 +1,156 @@ + ['https://dl4k-a.test', 'https://dl4k-b.test'], + ]); + } + + public function test_fhd_uses_4k_domains_and_hentai_1080p_prefix(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]), + 'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]), + ]); + + $result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv'); + + $this->assertTrue($result['valid']); + $this->assertSame(12345, $result['size']); + $this->assertSame([ + 'https://dl4k-a.test' => ['hentai-1080p/2026/Title/E01.mkv' => true], + 'https://dl4k-b.test' => ['hentai-1080p/2026/Title/E01.mkv' => true], + ], $result['mirrors']); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'https://dl4k-a.test/check/')); + } + + public function test_uhd_uses_download_domain_4k_and_hentai_prefix(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]), + 'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 777]), + ]); + + $result = app(CdnPathValidator::class)->validateDownload('UHD', '2026/Title/E01.mkv'); + + $this->assertTrue($result['valid']); + $this->assertArrayHasKey('hentai/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']); + $this->assertArrayNotHasKey('hentai-1080p/2026/Title/E01.mkv', $result['mirrors']['https://dl4k-a.test']); + } + + public function test_missing_on_one_mirror_is_invalid(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]), + 'https://dl4k-b.test/*' => Http::response(['valid' => false, 'error' => 'File not found!'], 404), + ]); + + $result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv'); + + $this->assertFalse($result['valid']); + $this->assertNull($result['size']); + $this->assertTrue($result['mirrors']['https://dl4k-a.test']['hentai-1080p/2026/Title/E01.mkv']); + $this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']); + } + + public function test_connection_error_is_invalid(): void + { + Http::fake(function ($request) { + if (str_starts_with($request->url(), 'https://dl4k-a.test/')) { + return Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]); + } + + throw new ConnectionException('Connection timed out', 0); + }); + + $result = app(CdnPathValidator::class)->validateDownload('FHD', '2026/Title/E01.mkv'); + + $this->assertFalse($result['valid']); + $this->assertNull($result['size']); + $this->assertFalse($result['mirrors']['https://dl4k-b.test']['hentai-1080p/2026/Title/E01.mkv']); + } + + public function test_stream_checks_base_directory_and_first_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', 3); + + $this->assertTrue($result['valid']); + $this->assertArrayHasKey('hentai-stream/2026/Title', $result['mirrors']['https://dl4k-a.test']); + $this->assertArrayHasKey('hentai-stream/2026/Title/E01/720/manifest.mpd', $result['mirrors']['https://dl4k-a.test']); + + Http::assertSentCount(4); + } + + public function test_results_are_cached_for_short_period(): void + { + Http::fake([ + 'https://dl4k-a.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]), + 'https://dl4k-b.test/*' => Http::response(['valid' => true, 'type' => 'file', 'size' => 12345]), + ]); + + $validator = app(CdnPathValidator::class); + $first = $validator->validateDownload('FHD', '2026/Title/E01.mkv'); + $second = $validator->validateDownload('FHD', '2026/Title/E01.mkv'); + + $this->assertTrue($first['valid']); + $this->assertTrue($second['valid']); + Http::assertSentCount(2); + } + + public function test_fresh_check_bypasses_cache(): void + { + $valid = true; + + Http::fake(function ($request) use (&$valid) { + return Http::response( + $valid ? ['valid' => true, 'type' => 'file', 'size' => 12345] : ['valid' => false, 'error' => 'File not found!'], + $valid ? 200 : 404 + ); + }); + + $validator = app(CdnPathValidator::class); + + $cached = $validator->validateDownload('FHD', '2026/Title/E01.mkv'); + $this->assertTrue($cached['valid']); + $this->assertCount(2, Http::recorded()); + + // The CDN result changes within the cache window, but a non-fresh + // check still serves the cached valid result. + $valid = false; + + $stale = $validator->validateDownload('FHD', '2026/Title/E01.mkv'); + $this->assertTrue($stale['valid']); + $this->assertCount(2, Http::recorded()); + + // A fresh check bypasses the cache and overwrites the stored result. + $fresh = $validator->validateDownload('FHD', '2026/Title/E01.mkv', true); + $this->assertFalse($fresh['valid']); + $this->assertCount(4, Http::recorded()); + + // The fresh (invalid) result is now what subsequent cached checks serve. + $after = $validator->validateDownload('FHD', '2026/Title/E01.mkv'); + $this->assertFalse($after['valid']); + $this->assertCount(4, Http::recorded()); + } +} diff --git a/vite.config.js b/vite.config.js index 80f511a..ff9448b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -16,7 +16,7 @@ export default defineConfig({ 'resources/js/player-data.js', 'resources/js/player.js', 'resources/js/playlist.js', - 'resources/js/upload.js', + 'resources/js/admin-release.js', 'resources/js/user-blacklist.js', 'resources/js/admin-edit.js', 'resources/js/admin-subtitles.js',