feat(admin): rebuild release upload form with Livewire and CDN validation
Replace the server-rendered release upload form with a Livewire component, introducing an interactive admin release form that supports dynamic episode management, real‑time CDN path validation, and direct download file checks. Key changes: - New `AdminReleaseForm` Livewire component with dynamic episode fields, Tagify integration, and upload handling via Livewire's file uploads. - Added `CdnPathValidator` service to verify download and stream paths across all configured mirrors, caching results and capturing file sizes. - Extended `EpisodeService` and `GalleryService` to support array‑based data and temporary uploaded files from Livewire. - Persist validated download sizes and store the validation timestamp on the `downloads` table via a new migration; also add a unique constraint on `hentais.slug` to prevent duplicates. - Remove the old POST `/admin/release/upload` route and controller logic, along with the legacy `upload.js` script. - Add comprehensive feature and unit tests covering the new component and the CDN validation service.
This commit is contained in:
@@ -3,33 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class ReleaseController extends Controller
|
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
|
* Display release page
|
||||||
*/
|
*/
|
||||||
@@ -37,55 +14,4 @@ class ReleaseController extends Controller
|
|||||||
{
|
{
|
||||||
return view('admin.release.create');
|
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Jobs\DiscordReleaseNotification;
|
||||||
|
use App\Models\Hentai;
|
||||||
|
use App\Services\CdnPathValidator;
|
||||||
|
use App\Services\DownloadService;
|
||||||
|
use App\Services\EpisodeService;
|
||||||
|
use App\Services\GalleryService;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
|
||||||
|
class AdminReleaseForm extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public string $title = '';
|
||||||
|
|
||||||
|
public string $titleJpn = '';
|
||||||
|
|
||||||
|
public string $studio = '';
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
public array $tags = [];
|
||||||
|
|
||||||
|
public string $releasedate = '';
|
||||||
|
|
||||||
|
public string $baseurl = '';
|
||||||
|
|
||||||
|
public bool $censored = false;
|
||||||
|
|
||||||
|
public int $episodeCount = 1;
|
||||||
|
|
||||||
|
/** @var array<int, array{description: string, cover: TemporaryUploadedFile|null, gallery: TemporaryUploadedFile[], downloads: array{fhd: string, fhdi: string, uhd: string, uhdi: string}}> */
|
||||||
|
public array $episodes = [];
|
||||||
|
|
||||||
|
/** @var array<string, array{state: string, message: string, mirrors: 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ class Downloads extends Model
|
|||||||
'episode_id',
|
'episode_id',
|
||||||
'type',
|
'type',
|
||||||
'url',
|
'url',
|
||||||
|
'size',
|
||||||
|
'validated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?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): 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, array{url: string, size?: float|int|null}> $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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\Episode;
|
use App\Models\Episode;
|
||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use App\Models\Studios;
|
|
||||||
use App\Models\ModLog;
|
use App\Models\ModLog;
|
||||||
|
use App\Models\Studios;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Intervention\Image\Encoders\WebpEncoder;
|
use Intervention\Image\Encoders\WebpEncoder;
|
||||||
use Intervention\Image\Laravel\Facades\Image;
|
use Intervention\Image\Laravel\Facades\Image;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
|
||||||
class EpisodeService
|
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
|
public function createOrUpdateCover(Request $request, Episode $episode, string $slug, int $episodeNumber): void
|
||||||
{
|
{
|
||||||
if (! $request->hasFile("episodecover{$episodeNumber}")) {
|
if (! $request->hasFile("episodecover{$episodeNumber}")) {
|
||||||
return;
|
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
|
// Create Folder for Image Upload
|
||||||
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
|
if (! Storage::disk('public')->exists("/images/hentai/{$slug}")) {
|
||||||
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
|
Storage::disk('public')->makeDirectory("/images/hentai/{$slug}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode and save cover image
|
// Encode and save cover image
|
||||||
Image::read($request->file("episodecover{$episodeNumber}")->getRealPath())
|
Image::read($file->getRealPath())
|
||||||
->cover(268, 394)
|
->cover(268, 394)
|
||||||
->encode(new WebpEncoder)
|
->encode(new WebpEncoder)
|
||||||
->save(Storage::disk('public')->path($episode->cover_url));
|
->save(Storage::disk('public')->path($episode->cover_url));
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use App\Models\Episode;
|
|||||||
use App\Models\Gallery;
|
use App\Models\Gallery;
|
||||||
use App\Models\Hentai;
|
use App\Models\Hentai;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Intervention\Image\Encoders\WebpEncoder;
|
use Intervention\Image\Encoders\WebpEncoder;
|
||||||
use Intervention\Image\Laravel\Facades\Image;
|
use Intervention\Image\Laravel\Facades\Image;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
|
||||||
class GalleryService
|
class GalleryService
|
||||||
{
|
{
|
||||||
@@ -17,17 +19,26 @@ class GalleryService
|
|||||||
$galleryInputNumber = $override ? 1 : $episodeNumber;
|
$galleryInputNumber = $override ? 1 : $episodeNumber;
|
||||||
|
|
||||||
if ($request->hasFile('episodegallery'.$galleryInputNumber)) {
|
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<int, UploadedFile|TemporaryUploadedFile> $files
|
||||||
|
*/
|
||||||
|
public function saveGalleryFiles(Hentai $hentai, Episode $episode, int $episodeNumber, array $files): void
|
||||||
|
{
|
||||||
|
$this->deleteOldGallery($episode);
|
||||||
|
|
||||||
$this->createGalleryFolder($hentai);
|
$this->createGalleryFolder($hentai);
|
||||||
|
|
||||||
$counter = 0;
|
$counter = 0;
|
||||||
foreach ($request->file('episodegallery'.$galleryInputNumber) as $file) {
|
foreach ($files as $file) {
|
||||||
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
|
$gallery = $this->createGallery($hentai, $episode, $episodeNumber, $counter);
|
||||||
$this->saveGalleryImage($gallery, $file);
|
$this->saveGalleryImage($gallery, $file);
|
||||||
$counter += 1;
|
$counter += 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +62,7 @@ class GalleryService
|
|||||||
return $gallery;
|
return $gallery;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function saveGalleryImage(Gallery $gallery, $sourceImage): void
|
private function saveGalleryImage(Gallery $gallery, UploadedFile|TemporaryUploadedFile $sourceImage): void
|
||||||
{
|
{
|
||||||
Image::read($sourceImage->getRealPath())
|
Image::read($sourceImage->getRealPath())
|
||||||
->cover(1920, 1080)
|
->cover(1920, 1080)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
| Class Namespace
|
||||||
|
|---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value sets the root class namespace for Livewire component classes in
|
||||||
|
| your application. This value will change where component auto-discovery
|
||||||
|
| finds components. It's also referenced by the file creation commands.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'class_namespace' => '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
|
||||||
|
| <head> and <body> 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',
|
||||||
|
];
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('downloads', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -62,6 +62,7 @@ CREATE TABLE `downloads` (
|
|||||||
`type` char(5) NOT NULL,
|
`type` char(5) NOT NULL,
|
||||||
`url` varchar(255) NOT NULL,
|
`url` varchar(255) NOT NULL,
|
||||||
`size` double DEFAULT NULL,
|
`size` double DEFAULT NULL,
|
||||||
|
`validated_at` timestamp NULL DEFAULT NULL,
|
||||||
`count` bigint(20) NOT NULL DEFAULT 0,
|
`count` bigint(20) NOT NULL DEFAULT 0,
|
||||||
`created_at` timestamp NULL DEFAULT NULL,
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
`updated_at` timestamp NULL DEFAULT NULL,
|
`updated_at` timestamp NULL DEFAULT NULL,
|
||||||
@@ -149,7 +150,8 @@ CREATE TABLE `hentais` (
|
|||||||
`description` text NOT NULL,
|
`description` text NOT NULL,
|
||||||
`created_at` timestamp NULL DEFAULT NULL,
|
`created_at` timestamp NULL DEFAULT NULL,
|
||||||
`updated_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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||||
DROP TABLE IF EXISTS `jobs`;
|
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 (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 (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 (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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -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 = `
|
|
||||||
<div class="grid grid-cols-2" id="dynU` + eps + `">
|
|
||||||
<div class="p-4">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodecover` + eps + `">Cover ` + eps + `:</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="episodecover` + eps + `" id="episodecover` + eps + `" required>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodegallery` + eps + `">Gallery ` + eps + `:</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="episodegallery` + eps + `[]" id="episodegallery` + eps + `" multiple="">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0" id="dynB` + eps + `">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="description` + eps + `">Description ` + eps + `:</label>
|
|
||||||
<textarea rows="4" cols="50" id="description` + eps + `" name="description` + eps + `" 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>
|
|
||||||
</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0" id="dynD` + eps + `">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl` + eps + `">Download 1080p ` + eps + `:</label>
|
|
||||||
<input class="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 block w-full" id="episodedlurl` + eps + `" type="text" name="episodedlurl` + eps + `" required="required">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0" id="dynD48fps` + eps + `">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated` + eps + `">Download 1080p48fps ` + eps + `:</label>
|
|
||||||
<input class="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 block w-full" id="episodedlurlinterpolated` + eps + `" type="text" name="episodedlurlinterpolated` + eps + `">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0" id="dynD4k` + eps + `">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl4k` + eps + `">Download 4k ` + eps + `:</label>
|
|
||||||
<input class="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 block w-full" id="episodedlurl4k` + eps + `" type="text" name="episodedlurl4k` + eps + `" required="required">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0" id="dynDUHD48fps` + eps + `">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="downloadUHDi` + eps + `">Download 4k 48fps ` + eps + `:</label>
|
|
||||||
<input class="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 block w-full" id="downloadUHDi` + eps + `" type="text" name="downloadUHDi` + eps + `">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
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);
|
|
||||||
@@ -1,111 +1,7 @@
|
|||||||
@extends('admin.layout')
|
@extends('admin.layout')
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
|
@livewire('admin-release-form')
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<div class="relative p-4 pt-0 bg-white dark:bg-neutral-800 rounded-lg border-t-2 border-b-2 border-pink-700">
|
|
||||||
<form method="POST" action="{{ route('admin.upload') }}" enctype="multipart/form-data">
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3">
|
|
||||||
<div class="p-4">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title">Title:</label>
|
|
||||||
<x-text-input id="title" class="block w-full" type="text" name="title" required autofocus/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="title_jpn">Title JPN:</label>
|
|
||||||
<x-text-input id="title_jpn" class="block w-full" type="text" name="title_jpn" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="studio">Studio:</label>
|
|
||||||
<x-text-input id="studio" class="block w-full" type="text" name="studio" required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="tags">Tags:</label>
|
|
||||||
<x-text-input id="tags" class="block w-full" type="text" name="tags" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2">
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="releasedate">Release Date:</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="date" name="releasedate" id="releasedate" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodes">Episodes:</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="number" name="episodes" id="episodes" value="1" required>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- 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 1:</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 1:</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="description1">Description 1:</label>
|
|
||||||
<textarea rows="4" cols="50" id="description1" name="description1" 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></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurl1">Download 1080p 1:</label>
|
|
||||||
<x-text-input id="episodedlurl1" class="block w-full" type="text" name="episodedlurl1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 pt-0">
|
|
||||||
<label class="leading-tight text-gray-800 dark:text-gray-200 w-full" for="episodedlurlinterpolated1">Download 1080p48fps 1:</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 1:</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 1:</label>
|
|
||||||
<x-text-input id="downloadUHDi1" class="block w-full" type="text" name="downloadUHDi1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="moreEpisodes">
|
|
||||||
</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">
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@vite(['resources/js/upload.js'])
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@vite(['resources/js/admin-release.js'])
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<div class="relative pt-5 text-gray-900 dark:text-white xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="w-full xl:max-w-[95%] 2xl:max-w-[90%]">
|
||||||
|
|
||||||
|
{{-- Metadata 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">Release Metadata</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="title">Title *</label>
|
||||||
|
<input wire:model="title" id="title" type="text" autocomplete="off"
|
||||||
|
class="w-full 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">
|
||||||
|
@error('title') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="titleJpn">Title JPN *</label>
|
||||||
|
<input wire:model="titleJpn" id="titleJpn" type="text" autocomplete="off"
|
||||||
|
class="w-full 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">
|
||||||
|
@error('titleJpn') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="studio">Studio *</label>
|
||||||
|
<div wire:ignore x-data="adminReleaseStudio">
|
||||||
|
<input id="studio" x-ref="studio" type="text" autocomplete="off" placeholder="Select or type a studio"
|
||||||
|
class="w-full 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">
|
||||||
|
</div>
|
||||||
|
@error('studio') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="releasedate">Release Date *</label>
|
||||||
|
<input wire:model="releasedate" id="releasedate" type="date"
|
||||||
|
class="w-full 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">
|
||||||
|
@error('releasedate') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1" for="tags">Tags *</label>
|
||||||
|
<div wire:ignore x-data="adminReleaseTags">
|
||||||
|
<input id="tags" x-ref="tags" type="text" autocomplete="off" placeholder="Add tags"
|
||||||
|
class="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">
|
||||||
|
</div>
|
||||||
|
@error('tags') <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="block text-xs font-medium text-gray-500 dark:text-gray-400 uppercase mb-1">Episodes</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button type="button" wire:click="removeEpisode({{ count($episodes) - 1 }})"
|
||||||
|
class="w-9 h-9 rounded-lg border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-neutral-700 transition text-lg leading-none">−</button>
|
||||||
|
<span class="w-10 text-center text-lg font-semibold">{{ $episodeCount }}</span>
|
||||||
|
<button type="button" wire:click="addEpisode"
|
||||||
|
class="w-9 h-9 rounded-lg border border-gray-300 dark:border-neutral-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-neutral-700 transition text-lg leading-none">+</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{{-- Episode cards --}}
|
||||||
|
@foreach ($episodes as $index => $episode)
|
||||||
|
<div wire:key="episode-card-{{ $index }}"
|
||||||
|
x-data="{
|
||||||
|
coverProgress: null,
|
||||||
|
galleryProgress: null,
|
||||||
|
uploadingCover: false,
|
||||||
|
uploadingGallery: false,
|
||||||
|
onUploadStart(e) {
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = true; this.coverProgress = 0; }
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.uploadingGallery = true; this.galleryProgress = 0; }
|
||||||
|
},
|
||||||
|
onUploadProgress(e) {
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.coverProgress = e.detail.progress; }
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.galleryProgress = e.detail.progress; }
|
||||||
|
},
|
||||||
|
onUploadFinish(e) {
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = false; }
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.gallery') { this.uploadingGallery = false; }
|
||||||
|
},
|
||||||
|
onUploadError(e) {
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.cover') { this.uploadingCover = false; }
|
||||||
|
if (e.detail.name === 'episodes.{{ $index }}.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"
|
||||||
|
class="bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700 p-4 mb-4">
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-900 dark:text-white uppercase">Episode {{ $index + 1 }}</h2>
|
||||||
|
@if (count($episodes) > 1)
|
||||||
|
<button type="button" wire:click="removeEpisode({{ $index }})"
|
||||||
|
class="text-xs text-red-500 hover:text-red-600 dark:hover:text-red-400 font-medium transition">Remove episode</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="episode-cover-{{ $index }}">Cover *</label>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input wire:model="episodes.{{ $index }}.cover" id="episode-cover-{{ $index }}" 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("episodes.{$index}.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 ($episode['cover'])
|
||||||
|
<img src="{{ $episode['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="episode-gallery-{{ $index }}">Gallery (multiple)</label>
|
||||||
|
<input wire:model="episodes.{{ $index }}.gallery" id="episode-gallery-{{ $index }}" 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("episodes.{$index}.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($episode['gallery']))
|
||||||
|
<div class="mt-2 grid grid-cols-3 gap-2">
|
||||||
|
@foreach ($episode['gallery'] as $gallery)
|
||||||
|
<img wire:key="gallery-{{ $index }}-{{ $loop->index }}" src="{{ $gallery->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-{{ $index }}">Description *</label>
|
||||||
|
<textarea wire:model="episodes.{{ $index }}.description" id="description-{{ $index }}" 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("episodes.{$index}.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-{{ $index }}-{{ $quality }}">{{ $label }}</label>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<input wire:model.live.debounce.800ms="episodes.{{ $index }}.downloads.{{ $quality }}" id="dl-{{ $index }}-{{ $quality }}" type="text" autocomplete="off"
|
||||||
|
placeholder="2026/Title/E0{{ $index + 1 }}.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['episodes.' . $index . '.downloads.' . $quality] ?? null])
|
||||||
|
</div>
|
||||||
|
@error("episodes.{$index}.downloads.{$quality}") <span class="text-xs text-red-500 mt-1 block">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
{{-- 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
|
||||||
|
|
||||||
|
<a href="{{ route('home.index') }}"
|
||||||
|
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
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button type="button" wire:click="save" 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">Create</span>
|
||||||
|
<span wire:loading wire:target="save">Saving…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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')
|
||||||
|
<div class="flex items-center gap-2 text-xs text-blue-500">
|
||||||
|
<svg class="w-3.5 h-3.5 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>{{ $message }}</span>
|
||||||
|
</div>
|
||||||
|
@elseif ($state === 'valid')
|
||||||
|
<div class="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||||
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
<span>{{ $message }}</span>
|
||||||
|
</div>
|
||||||
|
@elseif ($state === 'invalid')
|
||||||
|
<div class="flex items-center gap-2 text-xs text-red-500">
|
||||||
|
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg>
|
||||||
|
<div>
|
||||||
|
<span>{{ $message }}</span>
|
||||||
|
@if (! empty($missing))
|
||||||
|
<div class="mt-0.5 text-[10px] text-red-400">Missing: {{ implode(', ', $missing) }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@@ -41,12 +41,10 @@ Route::group(['middleware' => ['auth', 'auth.admin']], function () {
|
|||||||
|
|
||||||
// Release
|
// Release
|
||||||
Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index');
|
Route::get('/admin/release', [ReleaseController::class, 'index'])->name('admin.upload.index');
|
||||||
Route::post('/admin/release/upload', [ReleaseController::class, 'store'])->name('admin.upload');
|
|
||||||
|
|
||||||
// Episode
|
// Episode
|
||||||
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
|
Route::post('/admin/episode/upload', [EpisodeController::class, 'store'])->name('admin.upload.episode');
|
||||||
|
|
||||||
|
|
||||||
// Get Tags used for Upload Form
|
// Get Tags used for Upload Form
|
||||||
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
|
Route::get('/admin/tags', [AdminApiController::class, 'getTags'])->name('admin.tags');
|
||||||
Route::get('/admin/studios', [AdminApiController::class, 'getStudios'])->name('admin.studios');
|
Route::get('/admin/studios', [AdminApiController::class, 'getStudios'])->name('admin.studios');
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Livewire;
|
||||||
|
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use App\Livewire\AdminReleaseForm;
|
||||||
|
use App\Models\Downloads;
|
||||||
|
use App\Models\Episode;
|
||||||
|
use App\Models\Hentai;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
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 AdminReleaseFormTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Services\CdnPathValidator;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CdnPathValidatorTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
config([
|
||||||
|
'hstream.download_domain_4k' => ['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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
'resources/js/player-data.js',
|
'resources/js/player-data.js',
|
||||||
'resources/js/player.js',
|
'resources/js/player.js',
|
||||||
'resources/js/playlist.js',
|
'resources/js/playlist.js',
|
||||||
'resources/js/upload.js',
|
'resources/js/admin-release.js',
|
||||||
'resources/js/user-blacklist.js',
|
'resources/js/user-blacklist.js',
|
||||||
'resources/js/admin-edit.js',
|
'resources/js/admin-edit.js',
|
||||||
'resources/js/admin-subtitles.js',
|
'resources/js/admin-subtitles.js',
|
||||||
|
|||||||
Reference in New Issue
Block a user