From cb080efe8fad1662c8a2052049624f1b805dce59 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Mon, 25 May 2026 10:12:18 +0200 Subject: [PATCH] Add option to add tvshows in admin interface --- .../Admin/AdminShowsController.php | 95 ++++ app/Services/TvProcessing/TvShowAdder.php | 502 ++++++++++++++++++ .../js/alpine/components/admin/show-add.js | 175 ++++++ resources/js/alpine/lazy-loader.js | 1 + resources/views/admin/shows/add.blade.php | 165 ++++++ resources/views/admin/shows/index.blade.php | 10 +- resources/views/partials/admin-menu.blade.php | 3 + routes/web.php | 2 + 8 files changed, 951 insertions(+), 2 deletions(-) create mode 100644 app/Services/TvProcessing/TvShowAdder.php create mode 100644 resources/js/alpine/components/admin/show-add.js create mode 100644 resources/views/admin/shows/add.blade.php diff --git a/app/Http/Controllers/Admin/AdminShowsController.php b/app/Http/Controllers/Admin/AdminShowsController.php index 2c64e8000..1437b7abb 100644 --- a/app/Http/Controllers/Admin/AdminShowsController.php +++ b/app/Http/Controllers/Admin/AdminShowsController.php @@ -7,6 +7,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\BasePageController; use App\Models\Release; use App\Models\Video; +use App\Services\TvProcessing\TvShowAdder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -59,6 +61,99 @@ class AdminShowsController extends BasePageController return view('admin.shows.edit', compact('show', 'title', 'meta_title')); } + /** + * Show the "Add TV Show" form and handle submissions. + * + * Accepts any supported external ID (tvdb, tvmaze, tmdb, trakt, imdb), + * resolves it via TvShowAdder and persists it through the regular TV + * provider pipeline so the videos / tv_info / videos_aliases tables and + * the search index all stay in sync. + */ + public function create(Request $request, TvShowAdder $adder): View|RedirectResponse + { + $this->setAdminPrefs(); + + $meta_title = $title = 'Add TV Show'; + $sources = TvShowAdder::SUPPORTED_SOURCES; + + if ($request->isMethod('post')) { + $data = $request->validate([ + 'source' => 'required|string|in:'.implode(',', $sources), + 'external_id' => 'required|string|max:32', + 'type' => 'nullable|integer|in:0,2', + ]); + + try { + $result = $adder->add( + $data['source'], + (string) $data['external_id'], + (int) ($data['type'] ?? 0), + ); + } catch (\InvalidArgumentException $e) { + return back()->with('error', $e->getMessage())->withInput(); + } catch (\Throwable $e) { + return back()->with('error', 'Lookup failed: '.$e->getMessage())->withInput(); + } + + if ($result['videoId'] <= 0) { + return back()->with('error', 'No show could be added for that ID.')->withInput(); + } + + $flashKey = $result['existed'] ? 'warning' : 'success'; + $flashMsg = $result['existed'] + ? sprintf('Show "%s" already exists — opened existing record.', $result['title'] ?? '') + : sprintf('TV show "%s" added successfully.', $result['title'] ?? ''); + + return redirect()->to('/admin/show-edit?id='.$result['videoId'])->with($flashKey, $flashMsg); + } + + return view('admin.shows.add', compact('title', 'meta_title', 'sources')); + } + + /** + * JSON endpoint for the optional "preview before submit" button. + */ + public function lookup(Request $request, TvShowAdder $adder): JsonResponse + { + $this->setAdminPrefs(); + + $data = $request->validate([ + 'source' => 'required|string|in:'.implode(',', TvShowAdder::SUPPORTED_SOURCES), + 'external_id' => 'required|string|max:32', + ]); + + try { + $preview = $adder->preview($data['source'], (string) $data['external_id']); + } catch (\InvalidArgumentException $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => 'Lookup failed: '.$e->getMessage()], 500); + } + + if ($preview === null) { + return response()->json(['ok' => false, 'error' => 'No show found for that ID.'], 404); + } + + return response()->json([ + 'ok' => true, + 'show' => [ + 'title' => $preview['title'] ?? null, + 'summary' => $preview['summary'] ?? null, + 'started' => $preview['started'] ?? null, + 'publisher' => $preview['publisher'] ?? null, + 'poster' => $preview['poster'] ?? null, + 'ids' => [ + 'tvdb' => $preview['tvdb'] ?? 0, + 'tvmaze' => $preview['tvmaze'] ?? 0, + 'tmdb' => $preview['tmdb'] ?? 0, + 'trakt' => $preview['trakt'] ?? 0, + 'imdb' => $preview['imdb'] ?? '', + 'tvrage' => $preview['tvrage'] ?? 0, + ], + ], + ]); + } + /** * Remove video ID from releases */ diff --git a/app/Services/TvProcessing/TvShowAdder.php b/app/Services/TvProcessing/TvShowAdder.php new file mode 100644 index 000000000..22834535b --- /dev/null +++ b/app/Services/TvProcessing/TvShowAdder.php @@ -0,0 +1,502 @@ +normalizeId($source, $id); + + if (! in_array($source, self::SUPPORTED_SOURCES, true)) { + throw new InvalidArgumentException("Unsupported external source: {$source}"); + } + + // Short-circuit if we already have this video by external id. + $column = $this->videoColumnForSource($source); + $existing = Video::query()->where($column, $id)->first(['id', 'title']); + if ($existing !== null) { + return [ + 'videoId' => (int) $existing->id, + 'existed' => true, + 'source' => $source, + 'externalId' => (string) $id, + 'title' => (string) $existing->title, + ]; + } + + return DB::transaction(function () use ($source, $id, $type) { + return $this->dispatch($source, $id, $type); + }); + } + + /** + * Dry-run resolve. Returns the formatted show array (without persisting) + * for preview UIs. Returns null when nothing was found. + * + * @return array|null + */ + public function preview(string $source, string $id): ?array + { + $source = strtolower(trim($source)); + $id = $this->normalizeId($source, $id); + + switch ($source) { + case 'tvdb': + $provider = new TvdbProvider; + $show = $this->fetchTvdb($provider, (int) $id); + + return $show ? $provider->formatShowInfo($show) : null; + case 'tvmaze': + $provider = new TvMazeProvider; + $show = $this->fetchTvMaze((int) $id, 'tvmaze'); + + return $show ? $provider->formatShowInfo($show) : null; + case 'tmdb': + $provider = new TmdbProvider; + $show = $this->fetchTmdb((int) $id); + + return $show ? $provider->formatShowInfo($show) : null; + case 'trakt': + $provider = new TraktProvider; + $show = $this->fetchTrakt($id); + + return $show ? $provider->formatShowInfo($show) : null; + case 'imdb': + return $this->previewImdb($id); + } + + return null; + } + + /** + * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} + */ + private function dispatch(string $source, string $id, int $type): array + { + switch ($source) { + case 'tvdb': + return $this->addViaTvdb((int) $id, $type); + case 'tvmaze': + return $this->addViaTvMaze((int) $id, $type); + case 'tmdb': + return $this->addViaTmdb((int) $id, $type); + case 'trakt': + return $this->addViaTrakt($id, $type); + case 'imdb': + return $this->addViaImdb($id, $type); + } + + throw new InvalidArgumentException("Unsupported external source: {$source}"); + } + + // ------------------------------------------------------------------------- + // TVDB + // ------------------------------------------------------------------------- + + private function fetchTvdb(TvdbProvider $provider, int $tvdbId): ?object + { + try { + $extended = $provider->client->series()->extended($tvdbId); + } catch (\Throwable $e) { + Log::warning('TvShowAdder: TVDB lookup failed', ['id' => $tvdbId, 'error' => $e->getMessage()]); + + return null; + } + + if (! is_object($extended)) { + return null; + } + + // TvdbProvider::formatShowInfo() expects fields shaped like SearchResult + // (tvdb_id, name, overview, first_air_time, aliases). The extended + // endpoint returns SeriesExtendedRecord (camelCase). Adapt it. + $aliases = []; + if (! empty($extended->aliases) && is_array($extended->aliases)) { + foreach ($extended->aliases as $alias) { + if (is_object($alias) && isset($alias->name)) { + $aliases[] = (string) $alias->name; + } elseif (is_string($alias)) { + $aliases[] = $alias; + } + } + } + + return (object) [ + 'tvdb_id' => (string) ($extended->id ?? $tvdbId), + 'name' => (string) ($extended->name ?? ''), + 'overview' => (string) ($extended->overview ?? ''), + 'first_air_time' => (string) ($extended->firstAired ?? ''), + 'aliases' => $aliases, + ]; + } + + private function addViaTvdb(int $tvdbId, int $type): array + { + $provider = new TvdbProvider; + $show = $this->fetchTvdb($provider, $tvdbId); + if ($show === null) { + throw new RuntimeException("No TVDB show found for ID {$tvdbId}."); + } + + $data = $provider->formatShowInfo($show); + $data['type'] = $type; + + return $this->persist($provider, $data, 'tvdb', (string) $tvdbId); + } + + // ------------------------------------------------------------------------- + // TVMaze + // ------------------------------------------------------------------------- + + /** + * @return array|null + */ + private function fetchTvMaze(int|string $id, string $kind): ?array + { + try { + $client = new TVMazeClient; + if ($kind === 'tvmaze') { + $show = $client->getShowByShowID((int) $id); + } else { + // 'tvrage' | 'thetvdb' | 'imdb' + $show = $client->getShowBySiteID($kind, $id); + } + } catch (\Throwable $e) { + Log::warning('TvShowAdder: TVMaze lookup failed', ['id' => $id, 'kind' => $kind, 'error' => $e->getMessage()]); + + return null; + } + + return is_array($show) ? $show : null; + } + + private function addViaTvMaze(int $tvmazeId, int $type): array + { + $provider = new TvMazeProvider; + $show = $this->fetchTvMaze($tvmazeId, 'tvmaze'); + if ($show === null) { + throw new RuntimeException("No TVMaze show found for ID {$tvmazeId}."); + } + + $data = $provider->formatShowInfo($show); + $data['type'] = $type; + + return $this->persist($provider, $data, 'tvmaze', (string) $tvmazeId); + } + + // ------------------------------------------------------------------------- + // TMDB + // ------------------------------------------------------------------------- + + /** + * @return array|null + */ + private function fetchTmdb(int $tmdbId): ?array + { + $client = app(TmdbClient::class); + if (! $client->isConfigured()) { + throw new RuntimeException('TMDB API is not configured.'); + } + + $show = $client->getTvShow($tmdbId, ['external_ids', 'alternative_titles']); + if ($show === null) { + return null; + } + + // Mirror the enrichment that TmdbProvider::matchShowInfo() does so + // formatShowInfo() has the keys it expects. + $alternativeTitles = []; + $altRoot = TmdbClient::getArray($show, 'alternative_titles'); + $results = TmdbClient::getArray($altRoot, 'results'); + foreach ($results as $aka) { + if (is_array($aka) && isset($aka['title'])) { + $alternativeTitles[] = $aka['title']; + } + } + $show['alternative_titles'] = $alternativeTitles; + + $networks = TmdbClient::getArray($show, 'networks'); + $show['network'] = ! empty($networks[0]['name']) ? (string) $networks[0]['name'] : ''; + + return $show; + } + + private function addViaTmdb(int $tmdbId, int $type): array + { + $provider = new TmdbProvider; + $show = $this->fetchTmdb($tmdbId); + if ($show === null) { + throw new RuntimeException("No TMDB show found for ID {$tmdbId}."); + } + + $data = $provider->formatShowInfo($show); + $data['type'] = $type; + + return $this->persist($provider, $data, 'tmdb', (string) $tmdbId); + } + + // ------------------------------------------------------------------------- + // Trakt + // ------------------------------------------------------------------------- + + /** + * @return array|null + */ + private function fetchTrakt(int|string $id): ?array + { + $client = app(TraktService::class); + if (! $client->isConfigured()) { + throw new RuntimeException('Trakt API is not configured.'); + } + + $show = $client->getShowSummary((string) $id, 'full'); + + return is_array($show) && ! empty($show['ids']) ? $show : null; + } + + private function addViaTrakt(int|string $traktId, int $type): array + { + $provider = new TraktProvider; + $show = $this->fetchTrakt($traktId); + if ($show === null) { + throw new RuntimeException("No Trakt show found for ID {$traktId}."); + } + + $data = $provider->formatShowInfo($show); + $data['type'] = $type; + + return $this->persist($provider, $data, 'trakt', (string) $traktId); + } + + // ------------------------------------------------------------------------- + // IMDB (fallback chain: TMDB → Trakt → TVMaze) + // ------------------------------------------------------------------------- + + /** + * @return array|null + */ + private function previewImdb(string $imdbId): ?array + { + // Try TMDB first + try { + $client = app(TmdbClient::class); + if ($client->isConfigured()) { + $found = $client->findTvByExternalId('tt'.$imdbId, 'imdb_id'); + if ($found !== null) { + $tmdbId = TmdbClient::getInt($found, 'id'); + if ($tmdbId > 0) { + $provider = new TmdbProvider; + $show = $this->fetchTmdb($tmdbId); + if ($show !== null) { + return $provider->formatShowInfo($show); + } + } + } + } + } catch (\Throwable $e) { + Log::info('TvShowAdder: IMDB→TMDB preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); + } + + // Then Trakt + try { + $trakt = app(TraktService::class); + if ($trakt->isConfigured()) { + $results = $trakt->searchById($imdbId, 'imdb', 'show'); + if (is_array($results) && ! empty($results[0]['show']['ids']['trakt'])) { + $traktId = (int) $results[0]['show']['ids']['trakt']; + $provider = new TraktProvider; + $show = $this->fetchTrakt($traktId); + if ($show !== null) { + return $provider->formatShowInfo($show); + } + } + } + } catch (\Throwable $e) { + Log::info('TvShowAdder: IMDB→Trakt preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); + } + + // Then TVMaze + try { + $provider = new TvMazeProvider; + $show = $this->fetchTvMaze('tt'.$imdbId, 'imdb'); + if ($show !== null) { + return $provider->formatShowInfo($show); + } + } catch (\Throwable $e) { + Log::info('TvShowAdder: IMDB→TVMaze preview failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); + } + + return null; + } + + private function addViaImdb(string $imdbId, int $type): array + { + // TMDB + try { + $client = app(TmdbClient::class); + if ($client->isConfigured()) { + $found = $client->findTvByExternalId('tt'.$imdbId, 'imdb_id'); + if ($found !== null) { + $tmdbId = TmdbClient::getInt($found, 'id'); + if ($tmdbId > 0) { + return $this->addViaTmdb($tmdbId, $type); + } + } + } + } catch (\Throwable $e) { + Log::info('TvShowAdder: IMDB→TMDB add failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); + } + + // Trakt + try { + $trakt = app(TraktService::class); + if ($trakt->isConfigured()) { + $results = $trakt->searchById($imdbId, 'imdb', 'show'); + if (is_array($results) && ! empty($results[0]['show']['ids']['trakt'])) { + return $this->addViaTrakt((int) $results[0]['show']['ids']['trakt'], $type); + } + } + } catch (\Throwable $e) { + Log::info('TvShowAdder: IMDB→Trakt add failed', ['imdb' => $imdbId, 'error' => $e->getMessage()]); + } + + // TVMaze (last resort, no extra API key required) + $provider = new TvMazeProvider; + $show = $this->fetchTvMaze('tt'.$imdbId, 'imdb'); + if ($show !== null) { + $data = $provider->formatShowInfo($show); + $data['type'] = $type; + + return $this->persist($provider, $data, 'imdb', $imdbId); + } + + throw new RuntimeException("No show found for IMDB ID tt{$imdbId} via TMDB, Trakt, or TVMaze."); + } + + // ------------------------------------------------------------------------- + // Persistence helper + // ------------------------------------------------------------------------- + + /** + * @param array $data + * @return array{videoId: int, existed: bool, source: string, externalId: string, title: ?string} + */ + private function persist(object $provider, array $data, string $source, string $externalId): array + { + // Ensure required keys formatShowInfo callers might omit + $data += [ + 'country' => '', + 'aliases' => '', + 'localzone' => "''", + 'imdb' => '', + 'tvdb' => 0, + 'trakt' => 0, + 'tvrage' => 0, + 'tvmaze' => 0, + 'tmdb' => 0, + 'summary' => '', + 'publisher' => '', + 'started' => null, + 'type' => 0, + 'source' => 0, + 'title' => '', + ]; + + if (empty($data['title'])) { + throw new RuntimeException('Provider returned a show without a title.'); + } + + /** @var int $videoId */ + $videoId = $provider->add($data); // @phpstan-ignore-line - AbstractTvProvider::add() + + if ($videoId > 0 && method_exists($provider, 'getPoster')) { + try { + $provider->getPoster($videoId); + } catch (\Throwable $e) { + Log::info('TvShowAdder: poster fetch failed', ['videoId' => $videoId, 'error' => $e->getMessage()]); + } + } + + return [ + 'videoId' => (int) $videoId, + 'existed' => false, + 'source' => $source, + 'externalId' => $externalId, + 'title' => (string) $data['title'], + ]; + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function videoColumnForSource(string $source): string + { + return match ($source) { + 'tvdb' => 'tvdb', + 'tvmaze' => 'tvmaze', + 'tmdb' => 'tmdb', + 'trakt' => 'trakt', + 'imdb' => 'imdb', + default => throw new InvalidArgumentException("Unsupported source: {$source}"), + }; + } + + private function normalizeId(string $source, string $id): string + { + $id = trim($id); + if ($source === 'imdb') { + // Accept "tt0944947" or "0944947" — store/lookup as the numeric portion. + if (preg_match('/^tt?(\d{5,10})$/i', $id, $m)) { + return $m[1]; + } + if (ctype_digit($id)) { + return $id; + } + throw new InvalidArgumentException('IMDB ID must look like "tt1234567" or its numeric part.'); + } + + if (! ctype_digit($id) || (int) $id <= 0) { + throw new InvalidArgumentException(strtoupper($source).' ID must be a positive integer.'); + } + + return $id; + } +} + diff --git a/resources/js/alpine/components/admin/show-add.js b/resources/js/alpine/components/admin/show-add.js new file mode 100644 index 000000000..bf325aac9 --- /dev/null +++ b/resources/js/alpine/components/admin/show-add.js @@ -0,0 +1,175 @@ +/** + * Alpine.data('showAddForm') - Admin "Add TV Show" form with live preview. + * + * CSP-safe: all logic lives here; the Blade template only references + * properties and zero-arg methods on this component. + */ +import Alpine from '@alpinejs/csp'; + +Alpine.data('showAddForm', () => ({ + // --- state --- + source: 'tvdb', + externalId: '', + type: '0', + loading: false, + previewData: null, + previewError: '', + lookupUrl: '', + csrfToken: '', + + placeholders: { + tvdb: 'e.g. 81189', + tvmaze: 'e.g. 169', + tmdb: 'e.g. 1396', + trakt: 'e.g. 1388 or breaking-bad', + imdb: 'e.g. tt0903747 or 0903747', + }, + + init() { + const root = this.$root; + const config = root ? root.getAttribute('data-config') : null; + if (config) { + try { + const parsed = JSON.parse(config); + this.source = parsed.source || 'tvdb'; + this.externalId = parsed.externalId || ''; + this.type = parsed.type || '0'; + this.lookupUrl = parsed.lookupUrl || ''; + this.csrfToken = parsed.csrfToken || ''; + } catch (e) { + // ignore - fall back to defaults + } + } + }, + + // --- computed-style getters consumed via x-text / x-bind --- + get placeholder() { + return this.placeholders[this.source] || 'External ID'; + }, + + get buttonLabel() { + return this.loading ? 'Looking…' : 'Preview'; + }, + + get buttonClasses() { + return this.loading + ? 'flex-1 px-3 py-2 bg-gray-500 text-white rounded-lg text-sm whitespace-nowrap opacity-50 cursor-wait' + : 'flex-1 px-3 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm whitespace-nowrap'; + }, + + get hasPreview() { + return !!this.previewData; + }, + + get hasError() { + return !!this.previewError; + }, + + get posterUrl() { + return this.previewData && this.previewData.poster ? this.previewData.poster : ''; + }, + + get hasPoster() { + return !!this.posterUrl; + }, + + get title() { + return this.previewData && this.previewData.title ? this.previewData.title : ''; + }, + + get summary() { + return this.previewData && this.previewData.summary ? this.previewData.summary : ''; + }, + + get started() { + return this.previewData && this.previewData.started ? this.previewData.started : ''; + }, + + get publisher() { + return this.previewData && this.previewData.publisher ? this.previewData.publisher : ''; + }, + + get hasPublisher() { + return !!this.publisher; + }, + + /** + * Returns external IDs as an array of {label, value} for x-for rendering. + */ + get idEntries() { + if (!this.previewData || !this.previewData.ids) { + return []; + } + const out = []; + const ids = this.previewData.ids; + const order = ['tvdb', 'tvmaze', 'tmdb', 'trakt', 'tvrage', 'imdb']; + order.forEach((key) => { + const val = ids[key]; + if (val && val !== 0 && val !== '0') { + out.push({ label: key.toUpperCase(), value: String(val) }); + } + }); + + return out; + }, + + // --- actions --- + preview() { + if (!this.externalId) { + this.previewError = 'Enter an ID first.'; + this.previewData = null; + + return; + } + + if (!this.lookupUrl) { + this.previewError = 'Lookup endpoint is not configured.'; + + return; + } + + this.loading = true; + this.previewError = ''; + this.previewData = null; + + const url = new URL(this.lookupUrl, window.location.origin); + url.searchParams.set('source', this.source); + url.searchParams.set('external_id', this.externalId); + + const headers = { + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }; + if (this.csrfToken) { + headers['X-CSRF-TOKEN'] = this.csrfToken; + } + + const self = this; + fetch(url.toString(), { + method: 'GET', + credentials: 'same-origin', + headers: headers, + }) + .then(function (r) { + return r.json().then(function (body) { + return { status: r.status, body: body }; + }).catch(function () { + return { status: r.status, body: { error: 'Invalid JSON (HTTP ' + r.status + ')' } }; + }); + }) + .then(function (resp) { + if (resp.status >= 200 && resp.status < 300 && resp.body && resp.body.ok) { + self.previewData = resp.body.show; + } else { + self.previewError = (resp.body && resp.body.error) ? resp.body.error : ('Lookup failed (HTTP ' + resp.status + ').'); + } + }) + .catch(function (err) { + self.previewError = 'Lookup failed: ' + (err && err.message ? err.message : 'network error'); + }) + .finally(function () { + self.loading = false; + }); + }, +})); + diff --git a/resources/js/alpine/lazy-loader.js b/resources/js/alpine/lazy-loader.js index fda871503..dcbf5fc91 100644 --- a/resources/js/alpine/lazy-loader.js +++ b/resources/js/alpine/lazy-loader.js @@ -33,6 +33,7 @@ const lazyComponentMap = { 'releaseReport': () => import('./components/release-report.js'), 'adminReleaseReports': () => import('./components/release-report.js'), // same file 'adminReleaseList': () => import('./components/admin/release-list.js'), + 'showAddForm': () => import('./components/admin/show-add.js'), 'profileEdit': () => import('./components/profile-edit.js'), 'profilePage': () => import('./components/profile-edit.js'), // same file 'copyToClipboard': () => import('./components/profile-edit.js'), // same file diff --git a/resources/views/admin/shows/add.blade.php b/resources/views/admin/shows/add.blade.php new file mode 100644 index 000000000..4ab64a278 --- /dev/null +++ b/resources/views/admin/shows/add.blade.php @@ -0,0 +1,165 @@ +@extends('layouts.admin') + +@php + $configJson = json_encode([ + 'source' => old('source', 'tvdb'), + 'externalId' => old('external_id', ''), + 'type' => (string) old('type', '0'), + 'lookupUrl' => route('admin.show-add.lookup'), + 'csrfToken' => csrf_token(), + ], JSON_THROW_ON_ERROR); +@endphp + +@section('content') +
+
+ +
+

+ {{ $title }} +

+ + Back to TV Shows List + +
+ + + @if(session('success')) +
+

+ {{ session('success') }} +

+
+ @endif + @if(session('warning')) +
+

+ {{ session('warning') }} +

+
+ @endif + @if(session('error')) +
+

+ {{ session('error') }} +

+
+ @endif + @if($errors->any()) +
+
    + @foreach($errors->all() as $err) +
  • {{ $err }}
  • + @endforeach +
+
+ @endif + + +
+
+ +
+

Add a TV show to the database by entering any supported external identifier. The matching provider will be queried to fetch title, summary, poster and cross-reference IDs.

+
    +
  • TVDB — numeric series id (e.g. 81189)
  • +
  • TVMaze — numeric show id (e.g. 169)
  • +
  • TMDB — numeric tv id (e.g. 1396)
  • +
  • Trakt — numeric or slug id (e.g. 1388 or breaking-bad)
  • +
  • IMDBtt0903747 or just 0903747 (tries TMDB → Trakt → TVMaze)
  • +
+
+
+
+ + +
+ @csrf +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+

+

+ + +

+

+
+ +
+
+
+
+ +
+ +
+ + + Cancel + +
+
+
+
+@endsection + diff --git a/resources/views/admin/shows/index.blade.php b/resources/views/admin/shows/index.blade.php index b0f05cebf..5c2ee26fd 100644 --- a/resources/views/admin/shows/index.blade.php +++ b/resources/views/admin/shows/index.blade.php @@ -9,8 +9,14 @@

{{ $title }}

-
- Total: {{ $tvshowlist->total() }} shows +
+
+ Total: {{ $tvshowlist->total() }} shows +
+ + Add TV Show +
diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index 2a7058593..e586c7fcc 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -135,6 +135,9 @@ TV Shows List + + Add TV Show + diff --git a/routes/web.php b/routes/web.php index f798da6a7..ed7a63e8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -309,6 +309,8 @@ Route::middleware(['role:Admin', '2fa'])->prefix('admin')->group(function () { Route::get('show-list', [AdminShowsController::class, 'index'])->name('admin.show-list'); Route::match(['GET', 'POST'], 'show-edit', [AdminShowsController::class, 'edit'])->name('admin.show-edit'); + Route::match(['GET', 'POST'], 'show-add', [AdminShowsController::class, 'create'])->name('admin.show-add'); + Route::get('show-add/lookup', [AdminShowsController::class, 'lookup'])->name('admin.show-add.lookup'); Route::get('show-remove/{id}', [AdminShowsController::class, 'destroy'])->name('admin.show-remove'); Route::get('comments-list', [AdminCommentsController::class, 'index'])->name('admin.comments-list'); Route::post('comments-delete/{id}', [AdminCommentsController::class, 'destroy'])->name('admin.comments-delete');