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

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

116 lines
3.5 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
class CdnPathValidator
{
public const DOWNLOAD_PREFIX = [
'FHD' => '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, int $episodeNumber = 1): array
{
$domains = config('hstream.download_domain_4k');
// 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($episodeNumber, 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));
}
}