diff --git a/CHANGELOG.md b/CHANGELOG.md index e385d916..0d0a2c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Plex activity via Tautulli (optional).** A new read-only Tautulli integration (URL + API key in `/admin/settings`, behind the per-service health circuit breaker) surfaces current Plex activity. The dashboard gets a "Current Plex activity" widget — active streams, Direct Play / Direct Stream / Transcode counts, total / LAN / WAN bandwidth and a per-session card (quality, HDR/SDR badge, source→target codec when transcoding) — that hydrates on its own and refreshes every 10 s. A dedicated **Plex Activity** page (own sidebar entry) adds a now-playing strip, watch statistics with a 7 / 30 / 90-day toggle (top movies / shows / users / platforms), plays-over-time graphs with a Media-type ⇄ Stream-type toggle plus by-hour and by-day-of-week breakdowns and a platform × stream-type "problem clients" chart, a dense watch-history grid, and per-library item counts. Each title opens an in-app info modal (synopsis, ratings, cast/crew). The API key never leaves the server, every response is sanitised before it reaches the browser, and each section fails open independently so a down/misconfigured Tautulli never breaks the dashboard or page. Chart.js is self-hosted (`public/static/chart/`) for CSP compliance and reused by the existing Radarr stats chart. - **In-place quick-look on the dashboard.** Clicking any media tile — hero spotlight, upcoming, weekly trending, watchlist or latest additions — now opens a read-only detail modal right on the dashboard (poster, year, status, rating, genres, synopsis) instead of navigating away, with a deep-link to manage the item in Radarr/Sonarr or open it in Discover. Library items resolve from the already-cached dashboard aggregate (zero extra upstream calls on a warm page, falling back to a direct fetch on a miss); TMDb items resolve straight from TMDb. The fragment is read-only and fails open to a small graceful body, and the modal is Turbo-safe (declarative trigger, escaped values). - **Plex Activity page — statistics, graphs & a per-user filter.** The activity page's statistics gain Most Popular Movies / Shows tiles (ranked by distinct viewers), a Most Concurrent Streams tile, and a **Play Count ⇄ Play Duration** toggle that reformats every count-based tile and chart into watch time. Four new graphs are added — plays by source resolution, plays by stream resolution, streams by user, and concurrent streams over time (always a count, never reformatted as duration) — alongside a new privacy-safe **Users** overview table (friendly name, relative last-seen, last played, play count, total watch time). A page-wide **per-user filter** dropdown scopes the statistics, all graphs and the history grid to a single user, or all users (the Users overview table always lists everyone). The metric and user-id are clamped/validated server-side (`metric` ∈ `plays|duration`, user filter digits-only), every new client method and endpoint fails open to its neutral shape, and the allow-list keeps email / IP / avatar / Plex login off the wire — the opaque Tautulli user-id is a filter token only and never rendered. All data comes from read-only Tautulli commands (`get_home_stats`, `get_users_table`, `get_user_names`, `get_plays_by_*`, `get_concurrent_streams_by_stream_type`); strings are i18n'd (en/fr). +- **Rich detail modal from top-bar search, with release dates.** Clicking a search result now opens the quick-look detail modal (instead of jumping straight to the quick-add form), enriched with release/air dates: movies show theater / digital / physical dates (the next upcoming one emphasised), TV shows a Continuing/Ended status with first-aired and next-episode (or end) dates. The action button is context-aware — **Manage** (deep-link) for items already in your library, **Add** (the existing quick-add flow) for ones that aren't. In-library results use the authoritative Radarr/Sonarr dates; not-yet-added results read from TMDb (theater/digital/physical parsed from TMDb's `release_dates`, FR→US fallback; series air dates from `next/last_episode_to_air`). The quick-look modal is now app-global (extracted to a shared partial), so it's available from search on every page and the dashboard tiles reuse it. Labels are i18n'd (en/fr); the modal stays read-only and fails open. ### Changed +- **Plex items open the app-global quick-look modal.** Clicking a title on the Plex Activity page or the dashboard's Plex widget now opens the same rich detail modal used everywhere else (poster, synopsis, ratings, watchlist button, Manage/Discover deep-links) instead of the bespoke Tautulli metadata pop-up. The click resolves the item's TMDb id server-side (`/tautulli/api/quicklook/{ratingKey}`, one `get_metadata` call — episodes/seasons resolve to their show, with a grandparent hop for older Tautulli payloads that lack show-level guids); only the numeric TMDb id reaches the browser, keeping raw Plex guids inside the existing allow-list sanitization. Items with no TMDb match (music, home videos) fall back to the legacy Plex metadata modal, so every click keeps working. - **Dashboard service-health chips show latency.** `HealthService::statusFor()` returns a status word plus a round-trip reading (cached 10 s like the existing bool path, which now delegates to it); the dashboard chips render five states — up / slow / very_slow / down / degraded — with a coloured dot, so a reachable-but-slow service is visibly distinct from a healthy one. `isHealthy()` keeps its old contract for every existing caller. ## [1.1.1] - 2026-06-10 diff --git a/symfony/src/Controller/DashboardController.php b/symfony/src/Controller/DashboardController.php index 1e6db0bb..986efad3 100644 --- a/symfony/src/Controller/DashboardController.php +++ b/symfony/src/Controller/DashboardController.php @@ -504,6 +504,89 @@ private function localizedDayName(\DateTimeImmutable $d): string return $this->translator->trans($keys[(int) $d->format('N') - 1]); } + /** + * Build the ordered release-date chips for a movie quick-look. Fixed + * semantic order cinema→digital→physical; nulls skipped; each chip flags + * whether the date is today-or-later (so the template can emphasize the + * next upcoming event). @return list + */ + private function movieReleaseChips(?\DateTimeImmutable $cinema, ?\DateTimeImmutable $digital, ?\DateTimeImmutable $physical, \DateTimeImmutable $today): array + { + $defs = [ + ['kind' => 'cinema', 'at' => $cinema, 'key' => 'dashboard.quicklook.date.cinema'], + ['kind' => 'digital', 'at' => $digital, 'key' => 'dashboard.quicklook.date.digital'], + ['kind' => 'physical', 'at' => $physical, 'key' => 'dashboard.quicklook.date.physical'], + ]; + $chips = []; + foreach ($defs as $d) { + if (!$d['at'] instanceof \DateTimeImmutable) continue; + $chips[] = [ + 'kind' => $d['kind'], + 'label' => $this->translator->trans($d['key']), + 'date' => $d['at'], + 'upcoming' => $d['at']->setTime(0, 0) >= $today, + ]; + } + return $chips; + } + + /** + * Extract cinema/digital/physical dates from TMDb's release_dates append, + * preferring FR then US then the first country that has each type. TMDb + * type codes: 2/3 = theatrical, 4 = digital, 5 = physical. + * @return list + */ + private function tmdbMovieReleaseDates(array $results, \DateTimeImmutable $today): array + { + $byCountry = []; + foreach ($results as $r) { + $cc = $r['iso_3166_1'] ?? ''; + foreach ($r['release_dates'] ?? [] as $rd) { + $byCountry[$cc][(int) ($rd['type'] ?? 0)] = $rd['release_date'] ?? null; + } + } + $order = ['FR', 'US']; + foreach (array_keys($byCountry) as $cc) { + if (!in_array($cc, $order, true)) $order[] = $cc; + } + $pick = function (array $types) use ($byCountry, $order): ?\DateTimeImmutable { + foreach ($order as $cc) { + foreach ($types as $t) { + $raw = $byCountry[$cc][$t] ?? null; + if ($raw) return new \DateTimeImmutable($raw); + } + } + return null; + }; + return $this->movieReleaseChips($pick([3, 2]), $pick([4]), $pick([5]), $today); + } + + /** + * Air-date chips for a series quick-look: first-aired (if known), then + * either the next upcoming episode (continuing) or the last-aired date + * (ended). @return list + */ + private function seriesReleaseChips(?\DateTimeImmutable $firstAired, ?\DateTimeImmutable $nextEpisode, ?\DateTimeImmutable $lastEpisode, bool $ended, \DateTimeImmutable $today): array + { + $chip = fn(string $kind, string $key, \DateTimeImmutable $d): array => [ + 'kind' => $kind, 'label' => $this->translator->trans($key), + 'date' => $d, 'upcoming' => $d->setTime(0, 0) >= $today, + ]; + $chips = []; + if ($firstAired) $chips[] = $chip('first_aired', 'dashboard.quicklook.date.first_aired', $firstAired); + if (!$ended && $nextEpisode) { + $chips[] = $chip('next_episode', 'dashboard.quicklook.date.next_episode', $nextEpisode); + } elseif ($ended && $lastEpisode) { + $chips[] = $chip('ended', 'dashboard.quicklook.date.ended', $lastEpisode); + } + return $chips; + } + + private function parseDate(?string $raw): ?\DateTimeImmutable + { + return ($raw !== null && $raw !== '') ? new \DateTimeImmutable($raw) : null; + } + /** * Return the earliest release date that is today or later for a Radarr * movie, together with a human-readable badge identifying which date @@ -742,6 +825,9 @@ private function quickLookLibrary(string $type, string $slug, int $id): ?array $badgeKind = $hasFile ? 'downloaded' : ($monitored ? 'monitored' : 'missing'); $badgeKey = 'dashboard.quicklook.status.' . $badgeKind; + $ended = ($row['ended'] ?? false) === true || ($row['status'] ?? '') === 'ended'; + $airStatus = $type === 'series' ? ($ended ? 'ended' : 'continuing') : null; + if ($type === 'series') { $metaLine = $row['network'] ?? null; $actionUrl = $this->generateUrl('app_media_series', ['slug' => $slug]) . '?open=' . $id; @@ -752,17 +838,33 @@ private function quickLookLibrary(string $type, string $slug, int $id): ?array } return [ - 'title' => $row['title'] ?? '—', - 'year' => $row['year'] ?? null, - 'poster' => $row['poster'] ?? null, - 'backdrop' => $row['fanart'] ?? null, - 'overview' => $row['overview'] ?? null, - 'genres' => array_slice($row['genres'] ?? [], 0, 4), - 'rating' => $row['ratings'] ?? null, - 'metaLine' => $metaLine, - 'statusBadge' => ['label' => $this->translator->trans($badgeKey), 'kind' => $badgeKind], - 'actionUrl' => $actionUrl, - 'actionLabel' => $this->translator->trans('dashboard.quicklook.manage'), + 'title' => $row['title'] ?? '—', + 'year' => $row['year'] ?? null, + 'poster' => $row['poster'] ?? null, + 'backdrop' => $row['fanart'] ?? null, + 'overview' => $row['overview'] ?? null, + 'genres' => array_slice($row['genres'] ?? [], 0, 4), + 'rating' => $row['ratings'] ?? null, + 'metaLine' => $metaLine, + 'statusBadge' => ['label' => $this->translator->trans($badgeKey), 'kind' => $badgeKind], + 'actionUrl' => $actionUrl, + 'actionLabel' => $this->translator->trans('dashboard.quicklook.manage'), + 'inLibrary' => true, + 'airStatus' => $airStatus, + 'releaseDates' => $type === 'series' + ? $this->seriesReleaseChips( + $row['firstAired'] ?? null, + $row['nextAiring'] ?? null, + $row['previousAiring'] ?? null, + $ended, + new \DateTimeImmutable('today'), + ) + : $this->movieReleaseChips( + $row['inCinemasAt'] ?? null, + $row['digitalAt'] ?? null, + $row['physicalAt'] ?? null, + new \DateTimeImmutable('today'), + ), ]; } @@ -806,18 +908,192 @@ private function quickLookTmdb(string $type, int $id): ?array $metaLine = $runtime ? $this->translator->trans('dashboard.quicklook.runtime', ['min' => $runtime]) : null; } + $ended = $isTv && in_array($data['status'] ?? '', ['Ended', 'Canceled'], true); + $airStatus = $isTv ? ($ended ? 'ended' : 'continuing') : null; + + $extras = $this->quickLookExtras($data); + + // Unify the action model with the old Explorer modal: a title already + // in a Radarr/Sonarr library deep-links to Manage (exact instance); + // otherwise the body renders an Add affordance. Reuses the cached + // library aggregates — no extra upstream calls. + $match = $this->quickLookLibraryMatch($type, $id); + if ($match !== null) { + $statusBadge = [ + 'label' => $this->translator->trans('dashboard.quicklook.status.' . $match['status']), + 'kind' => $match['status'], + ]; + $actionUrl = $isTv + ? $this->generateUrl('app_media_series', ['slug' => $match['slug']]) . '?open=' . $match['id'] + : $this->generateUrl('app_media_films', ['slug' => $match['slug']]) . '?open=' . $match['id']; + $actionLabel = $this->translator->trans('dashboard.quicklook.manage'); + } else { + $statusBadge = null; + $actionUrl = $this->generateUrl('tmdb_index') . '?detail=' . $type . '/' . $id; + $actionLabel = $this->translator->trans('dashboard.quicklook.discover'); + } + + return [ + 'title' => $data['title'] ?? $data['name'] ?? '—', + 'year' => $year, + 'poster' => $this->tmdbImage($data['poster_path'] ?? null, 'w342'), + 'posterPath' => $data['poster_path'] ?? null, + 'backdrop' => $this->tmdbImage($data['backdrop_path'] ?? null, 'w1280'), + 'overview' => $data['overview'] ?? null, + 'genres' => array_slice(array_map(fn($g) => $g['name'] ?? '', $data['genres'] ?? []), 0, 4), + 'rating' => $data['vote_average'] ?? null, + 'metaLine' => $metaLine, + 'statusBadge' => $statusBadge, + 'actionUrl' => $actionUrl, + 'actionLabel' => $actionLabel, + 'inLibrary' => $match !== null, + 'airStatus' => $airStatus, + 'cast' => $extras['cast'], + 'providers' => $extras['providers'], + 'trailerKey' => $extras['trailerKey'], + 'imdbId' => $extras['imdbId'], + 'tmdbId' => $id, + 'tmdbType' => $type, + 'releaseDates' => $isTv + ? $this->seriesReleaseChips( + $this->parseDate($data['first_air_date'] ?? null), + $this->parseDate($data['next_episode_to_air']['air_date'] ?? null), + $this->parseDate($data['last_episode_to_air']['air_date'] ?? null), + $ended, + new \DateTimeImmutable('today'), + ) + : $this->tmdbMovieReleaseDates( + $data['release_dates']['results'] ?? [], + new \DateTimeImmutable('today'), + ), + ]; + } + + /** + * Locate a TMDb id within the aggregated Radarr/Sonarr libraries so the + * quick-look can deep-link to Manage (and badge the status) for titles + * already added, and show an Add affordance otherwise. Reuses the + * per-request cached movies()/series() aggregates — no extra upstream + * calls. $type is the TMDb media type ('movie'|'tv'). + * + * @return array{slug: string, id: int, status: string}|null + */ + private function quickLookLibraryMatch(string $type, int $tmdbId): ?array + { + // Fail open: if the library can't be read (service down / not + // configured), treat the title as not-in-library so the modal still + // offers Add rather than erroring — same philosophy as the widgets. + try { + $rows = $type === 'movie' ? $this->movies() : $this->series(); + } catch (\Throwable $e) { + return null; + } + + if ($type === 'movie') { + foreach ($rows as $m) { + if ((int) ($m['tmdbId'] ?? 0) !== $tmdbId) { + continue; + } + $slug = $m['_instanceSlug'] ?? null; + if ($slug === null) { + continue; + } + $status = ($m['hasFile'] ?? false) === true + ? 'downloaded' + : (($m['monitored'] ?? false) === true ? 'monitored' : 'missing'); + return ['slug' => $slug, 'id' => (int) ($m['id'] ?? 0), 'status' => $status]; + } + return null; + } + + foreach ($rows as $s) { + if ((int) ($s['tmdbId'] ?? 0) !== $tmdbId) { + continue; + } + $slug = $s['_instanceSlug'] ?? null; + if ($slug === null) { + continue; + } + $status = ($s['monitored'] ?? false) === true ? 'monitored' : 'missing'; + return ['slug' => $slug, 'id' => (int) ($s['id'] ?? 0), 'status' => $status]; + } + return null; + } + + /** + * Extract the richer detail bits (cast, streaming providers, trailer, IMDb + * id) from a TMDb detail payload. TmdbClient::getMovie/getTv already pull + * credits/videos/watch-providers/external_ids via append_to_response, so + * this is pure extraction — no extra API call. + * + * @param array $data + * @return array{cast: list, providers: list, trailerKey: ?string, imdbId: ?string} + */ + private function quickLookExtras(array $data): array + { + $cast = []; + foreach (array_slice($data['credits']['cast'] ?? [], 0, 6) as $c) { + $cast[] = [ + 'name' => $c['name'] ?? '', + 'profile' => TmdbClient::posterUrl($c['profile_path'] ?? null, 'w185'), + ]; + } + + // Streaming (flatrate) providers, FR-first then common fallbacks — + // mirrors TmdbController::pickProviders' country priority. + $providers = []; + foreach (['FR', 'BE', 'LU', 'US', 'GB'] as $cc) { + $flat = $data['watch/providers']['results'][$cc]['flatrate'] ?? []; + if ($flat === []) { + continue; + } + foreach ($flat as $p) { + $providers[] = [ + 'name' => $p['provider_name'] ?? '', + 'logo' => TmdbClient::posterUrl($p['logo_path'] ?? null, 'w92'), + ]; + } + break; + } + + // Best YouTube trailer/teaser — official + EN/FR preferred, mirrors + // TmdbController::pickTrailer's scoring (trimmed for the modal). + $trailerKey = null; + $videos = array_filter( + $data['videos']['results'] ?? [], + static fn($v) => ($v['site'] ?? '') === 'YouTube', + ); + $score = static function (array $v): int { + $s = 0; + if (($v['type'] ?? '') === 'Trailer') { + $s += 100; + } elseif (($v['type'] ?? '') === 'Teaser') { + $s += 50; + } + if (($v['official'] ?? false) === true) { + $s += 40; + } + $lang = strtolower($v['iso_639_1'] ?? ''); + if ($lang === 'en') { + $s += 20; + } elseif ($lang === 'fr') { + $s += 15; + } + return $s; + }; + usort($videos, static fn($a, $b) => $score($b) <=> $score($a)); + $first = reset($videos); + if ($first) { + $trailerKey = $first['key'] ?? null; + } + + $imdbId = $data['imdb_id'] ?? ($data['external_ids']['imdb_id'] ?? null); + return [ - 'title' => $data['title'] ?? $data['name'] ?? '—', - 'year' => $year, - 'poster' => $this->tmdbImage($data['poster_path'] ?? null, 'w342'), - 'backdrop' => $this->tmdbImage($data['backdrop_path'] ?? null, 'w1280'), - 'overview' => $data['overview'] ?? null, - 'genres' => array_slice(array_map(fn($g) => $g['name'] ?? '', $data['genres'] ?? []), 0, 4), - 'rating' => $data['vote_average'] ?? null, - 'metaLine' => $metaLine, - 'statusBadge' => null, - 'actionUrl' => $this->generateUrl('tmdb_index') . '?detail=' . $type . '/' . $id, - 'actionLabel' => $this->translator->trans('dashboard.quicklook.discover'), + 'cast' => $cast, + 'providers' => $providers, + 'trailerKey' => $trailerKey, + 'imdbId' => $imdbId, ]; } diff --git a/symfony/src/Controller/MediaController.php b/symfony/src/Controller/MediaController.php index 63619f74..924cd792 100644 --- a/symfony/src/Controller/MediaController.php +++ b/symfony/src/Controller/MediaController.php @@ -2115,6 +2115,7 @@ public function globalSearchOnline(Request $request): JsonResponse $results[] = [ 'type' => 'film', 'id' => $m['tmdbId'] ?? $id, + 'tmdbId' => $m['tmdbId'] ?? $id, 'title' => $m['title'] ?? '—', 'year' => $m['year'] ?? null, 'poster' => $m['poster'] ?? null, @@ -2133,6 +2134,8 @@ public function globalSearchOnline(Request $request): JsonResponse $results[] = [ 'type' => 'serie', 'id' => $s['tvdbId'] ?? $id, + 'tmdbId' => $s['tmdbId'] ?? null, + 'tvdbId' => $s['tvdbId'] ?? null, 'title' => $s['title'] ?? '—', 'year' => $s['year'] ?? null, 'poster' => $s['poster'] ?? null, diff --git a/symfony/src/Controller/TautulliController.php b/symfony/src/Controller/TautulliController.php index 2cfefd8c..5ea86993 100644 --- a/symfony/src/Controller/TautulliController.php +++ b/symfony/src/Controller/TautulliController.php @@ -129,6 +129,25 @@ public function apiMetadata(string $ratingKey, Request $request): Response ]); } + /** + * GET /tautulli/api/quicklook/{ratingKey} — resolves a Plex rating key to + * the TMDb {type, id} the app-global quick-look modal opens with. Returns + * nulls (not an error status) when the item has no tmdb guid — the client + * then falls back to the legacy Plex metadata modal, so music and home + * videos keep a working click. Fails open like every endpoint here. + */ + #[Route('/api/quicklook/{ratingKey}', name: 'api_quicklook', methods: ['GET'], requirements: ['ratingKey' => '\d+'])] + public function apiQuickLook(string $ratingKey): JsonResponse + { + try { + $resolved = $this->tautulli->resolveTmdbId($ratingKey); + } catch (\Throwable) { + $resolved = null; + } + + return $this->json($resolved ?? ['type' => null, 'id' => null]); + } + /** * GET /tautulli — the full Plex activity page. Server-renders the cheap * "Now Playing" section; heavier sections hydrate client-side. Guarded by diff --git a/symfony/src/Service/Media/TautulliClient.php b/symfony/src/Service/Media/TautulliClient.php index 0e1ddae0..b21c8df5 100644 --- a/symfony/src/Service/Media/TautulliClient.php +++ b/symfony/src/Service/Media/TautulliClient.php @@ -165,6 +165,83 @@ public function getMetadata(string $ratingKey): array 'metadata' => self::normalizeMetadata($resp['data'])]; } + /** + * Map a raw `get_metadata` payload to the {type, id} pair the global + * quick-look modal consumes, or null when the item has no usable + * show/movie-level tmdb guid. + * + * movie/show read the item's own guids; season reads parent_guids and + * episode grandparent_guids (both = the show) — an episode-level tmdb + * guid identifies the episode, which the quick-look can't render. + * Only the numeric TMDb id survives; raw guids stay out of every + * client payload, same allow-list stance as the normalizers. + * + * @param array $data + * @return ?array{type: string, id: int} + */ + public static function tmdbIdFromMetadata(array $data): ?array + { + [$type, $guidField] = match (self::str($data['media_type'] ?? null)) { + 'movie' => ['movie', 'guids'], + 'show' => ['tv', 'guids'], + 'season' => ['tv', 'parent_guids'], + 'episode' => ['tv', 'grandparent_guids'], + default => [null, null], + }; + if ($type === null || !is_array($data[$guidField] ?? null)) { + return null; + } + foreach ($data[$guidField] as $guid) { + if (is_string($guid) && preg_match('#^tmdb://(\d+)$#', $guid, $m) === 1) { + return ['type' => $type, 'id' => (int) $m[1]]; + } + } + return null; + } + + /** + * Resolve a Plex rating key to the TMDb {type, id} the global quick-look + * opens with. Fails open to null (disabled / unconfigured / unreachable / + * no tmdb guid) so the caller can fall back to the legacy Plex modal. + * When an episode/season payload lacks show-level guids (older Tautulli), + * one extra metadata hop to the show's own rating key covers it. + * + * @return ?array{type: string, id: int} + */ + public function resolveTmdbId(string $ratingKey): ?array + { + $this->ensureConfig(); + if (!$this->enabled || $this->baseUrl === '' || $this->apiKey === '') { + return null; + } + if ($ratingKey === '' || !ctype_digit($ratingKey)) { + return null; + } + + $resp = $this->request(['cmd' => 'get_metadata', 'rating_key' => $ratingKey]); + if ($resp === null || $resp['ok'] !== true || $resp['data'] === []) { + return null; + } + $data = $resp['data']; + if (($found = self::tmdbIdFromMetadata($data)) !== null) { + return $found; + } + + $showKey = match (self::str($data['media_type'] ?? null)) { + 'episode' => self::str($data['grandparent_rating_key'] ?? null), + 'season' => self::str($data['parent_rating_key'] ?? null), + default => '', + }; + if ($showKey === '' || !ctype_digit($showKey)) { + return null; + } + $resp = $this->request(['cmd' => 'get_metadata', 'rating_key' => $showKey]); + if ($resp === null || $resp['ok'] !== true || $resp['data'] === []) { + return null; + } + return self::tmdbIdFromMetadata($resp['data']); + } + /** * Recent watch history, normalized + sanitized. Returns a plain list (no * envelope) — an empty list covers disabled/unconfigured/unreachable so the diff --git a/symfony/templates/_quicklook.html.twig b/symfony/templates/_quicklook.html.twig new file mode 100644 index 00000000..f0b68a79 --- /dev/null +++ b/symfony/templates/_quicklook.html.twig @@ -0,0 +1,277 @@ + + +{% include 'dashboard/_quicklook_modal.html.twig' with {} only %} + + diff --git a/symfony/templates/base.html.twig b/symfony/templates/base.html.twig index 76ac2b6e..8537b478 100644 --- a/symfony/templates/base.html.twig +++ b/symfony/templates/base.html.twig @@ -1369,6 +1369,8 @@ + {% include '_quicklook.html.twig' with {} only %} + {# Strings translated server-side and exposed to inline JS. Keep this block @@ -1391,6 +1393,8 @@ 'search.forget': 'topbar.search.forget'|trans, 'search.in_library_badge': 'topbar.search.in_library_badge'|trans, 'search.not_added_badge': 'topbar.search.not_added_badge'|trans, + 'search.add': 'topbar.search.add'|trans, + 'dashboard.quicklook.manage': 'dashboard.quicklook.manage'|trans, 'theme.label_light': 'topbar.theme.label_light'|trans, 'theme.label_dark': 'topbar.theme.label_dark'|trans, 'health.all_ok': 'topbar.health.summary_ok'|trans, @@ -1479,7 +1483,7 @@ var poster = item.poster ? '' : '
' + typeIcon + '
'; - var dataAttr = ' data-quick-add=\'' + JSON.stringify(item).replace(/'/g, ''') + '\''; + var dataAttr = ' data-item=\'' + JSON.stringify(item).replace(/'/g, ''') + '\''; // v1.1.0 multi-instance: when the result comes from a non-default // instance, surface its name in the meta line so the user knows // exactly which Radarr 4K / Sonarr Anime owns the match. Default @@ -1619,6 +1623,49 @@ var lastLocal = []; var lastOnline = []; + function openDetailFor(item) { + if (!window.prismarrQl) return; + var isFilm = item.type === 'film'; + var url; + if (item.inLibrary && item.instance && item.instance.slug && item.id) { + // Local library result — authoritative Radarr/Sonarr data + Manage deep-link. + url = '/tableau-de-bord/quicklook/' + (isFilm ? 'movie' : 'series') + + '/' + item.instance.slug + '/' + item.id; + } else { + // Online result — TMDb data. Movies key on tmdbId (== id); series need tmdbId. + var tmdbId = item.tmdbId || (isFilm ? item.id : null); + if (!tmdbId) { + // No TMDb id (e.g. TVDB-only series) → can't show TMDb detail. + // Fall back to the existing quick-add flow so the user can still add it. + closeResults(); + var trigger = document.createElement('a'); + trigger.href = '#'; + trigger.setAttribute('data-quick-add', JSON.stringify(item)); + trigger.style.display = 'none'; + document.body.appendChild(trigger); + trigger.click(); // bubbles to the document-level [data-quick-add] handler + trigger.remove(); + return; + } + url = '/tableau-de-bord/quicklook/tmdb/' + (isFilm ? 'movie' : 'tv') + '/' + tmdbId; + } + closeResults(); + window.prismarrQl.open(); + fetch(url, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) + .then(function (r) { return r.ok ? r.text() : ''; }) + .then(function (html) { + var b = window.prismarrQl.body(); + if (!b) return; + b.innerHTML = (html || '').trim() || '
'; + // The quick-look body now renders the correct primary action + // server-side (Manage deep-link to the exact instance when the + // title is in a library, otherwise an Add button wired to the + // shared resolve→quick-add flow), so no client-side patching is + // needed here. + }) + .catch(function () { window.prismarrQl.close(); }); + } + function runSearch(q) { showSpinner(); var ctrl = new AbortController(); @@ -1715,7 +1762,10 @@ } var item = e.target.closest('.prismarr-search-item'); if (item) { + e.preventDefault(); + e.stopPropagation(); saveRecent(input.value.trim()); + try { openDetailFor(JSON.parse(item.dataset.item)); } catch (err) {} } }); @@ -1857,6 +1907,7 @@ if (!qaModal) return; e.preventDefault(); e.stopPropagation(); + if (window.prismarrQl) { window.prismarrQl.close(); } qaItem = JSON.parse(el.dataset.quickAdd); var isFilm = qaItem.type === 'film'; diff --git a/symfony/templates/dashboard/_plex_info_modal.html.twig b/symfony/templates/dashboard/_plex_info_modal.html.twig index 2b814a20..2662e32a 100644 --- a/symfony/templates/dashboard/_plex_info_modal.html.twig +++ b/symfony/templates/dashboard/_plex_info_modal.html.twig @@ -1,11 +1,18 @@ -{# Shared Plex info modal: shell + hidden data-API trigger + the delegated - open/keydown handler. Included by the dashboard and the Tautulli page. +{# Shared Plex item click handling. Included by the dashboard and the Tautulli + page. A click on [data-plex-rating-key] first resolves the item's TMDb id + (/tautulli/api/quicklook/{ratingKey}); on a hit it opens the app-global + quick-look modal by clicking the hidden [data-ql-trigger] ghost below — + the delegated handler in _quicklook.html.twig does the rest (open, fetch, + watchlist decoration). Items with no tmdb guid (music, home videos) fall + back to the legacy Plex metadata modal kept underneath, so every click + keeps working. Tabler doesn't expose window.bootstrap, so we click a hidden data-bs-toggle trigger (same pattern as the usenet/qBit detail modals). Include exactly ONCE per rendered document (the dashboard and the Tautulli page are separate pages, so this holds); the window._plexModalDelegated guard only de-dupes the JS listener, not the modal DOM itself. #} + diff --git a/symfony/templates/dashboard/index.html.twig b/symfony/templates/dashboard/index.html.twig index 3eb5d0fd..7388e6ff 100644 --- a/symfony/templates/dashboard/index.html.twig +++ b/symfony/templates/dashboard/index.html.twig @@ -461,43 +461,6 @@ .dash-skel-line { height: .7rem; width: 80%; margin-top: .45rem; border-radius: .25rem; } @keyframes dash-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } } - /* ── Quick-look modal ───────────────────────────────────────────── */ - .ql-modal-content { background: var(--tblr-bg-surface); overflow: hidden; border-radius: 14px; } - .ql-loading { padding: 4rem; text-align: center; } - .ql-backdrop { - position: relative; min-height: 180px; - background-size: cover; background-position: center 25%; - background-color: var(--tblr-bg-surface-secondary); - } - .ql-backdrop-fade { - position: absolute; inset: 0; - background: linear-gradient(to top, var(--tblr-bg-surface) 2%, rgba(0,0,0,.15) 60%, rgba(0,0,0,.35) 100%); - } - .ql-close { position: absolute; top: .75rem; right: .75rem; z-index: 2; filter: drop-shadow(0 0 2px rgba(0,0,0,.8)); } - .ql-body { display: flex; gap: 1.25rem; padding: 0 1.5rem 1.5rem; margin-top: -60px; position: relative; z-index: 1; } - @media (max-width: 576px) { .ql-body { flex-direction: column; align-items: center; text-align: center; } } - .ql-poster { width: 120px; aspect-ratio: 2/3; object-fit: cover; border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.45); flex-shrink: 0; } - .ql-poster-empty { display: flex; align-items: center; justify-content: center; background: var(--tblr-bg-surface-secondary); color: var(--tblr-secondary); } - .ql-main { flex: 1; min-width: 0; padding-top: 64px; } - @media (max-width: 576px) { .ql-main { padding-top: .5rem; } } - .ql-title { font-size: 1.4rem; font-weight: 700; margin: 0 0 .5rem; color: var(--tblr-body-color); } - .ql-year { font-weight: 400; color: var(--tblr-secondary); } - .ql-meta { display: flex; flex-wrap: wrap; gap: .5rem .7rem; align-items: center; margin-bottom: .85rem; font-size: .8rem; color: var(--tblr-secondary); } - .ql-meta .ql-rating { color: #f59e0b; font-weight: 600; } - .ql-chip { padding: .12rem .5rem; background: rgba(148,163,184,.18); border-radius: 4px; font-size: .7rem; } - .ql-badge { padding: .15rem .55rem; border-radius: 999px; font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; } - .ql-badge.is-downloaded { background: rgba(34,197,94,.18); color: #22c55e; } - .ql-badge.is-monitored { background: rgba(99,102,241,.18); color: #6366f1; } - .ql-badge.is-missing { background: rgba(245,158,11,.18); color: #f59e0b; } - .ql-overview { font-size: .88rem; line-height: 1.55; color: var(--tblr-body-color); margin-bottom: 1rem; } - .ql-action { - display: inline-flex; align-items: center; gap: .4rem; - padding: .5rem 1rem; border-radius: 8px; - background: var(--tblr-primary); color: #fff; font-weight: 600; font-size: .85rem; - text-decoration: none !important; - } - .ql-action:hover { filter: brightness(1.08); color: #fff; } - .ql-error { padding: 3rem 1.5rem; text-align: center; color: var(--tblr-secondary); } {% include 'dashboard/_plex_styles.html.twig' %} {% endblock %} @@ -731,7 +694,6 @@ {% include 'dashboard/_plex_info_modal.html.twig' with {} only %} -{% include 'dashboard/_quicklook_modal.html.twig' with {} only %} {% endblock %} {% block javascripts %} @@ -835,91 +797,6 @@ if (window._dashPlexTimer) { clearInterval(window._dashPlexTimer); window._dashPlexTimer = null; } }); - // ── Quick-look modal: delegated open + fetch + manual show/hide. - // Tiles carry [data-ql-trigger] + data-ql-source/type/slug/id and keep - // their href as a no-JS fallback. We intercept the click, open the shell, - // fetch the matching fragment, and swap it into [data-ql-body]. - // Re-query the modal on every use: it lives in the content block, so Turbo - // replaces the node on each render. Caching it once would leave the - // document-level delegated listeners pointing at a detached node after a - // navigation, and the quick-look would silently stop opening. - function qlEl() { return document.getElementById('ql-modal'); } - function qlBody() { var m = qlEl(); return m ? m.querySelector('[data-ql-body]') : null; } - - function qlOpen() { - var m = qlEl(); - if (!m) return; - m.classList.add('show'); - m.style.display = 'block'; - m.removeAttribute('aria-hidden'); - m.setAttribute('aria-modal', 'true'); - document.body.classList.add('modal-open'); - if (!document.querySelector('.modal-backdrop.ql-backdrop-el')) { - var bd = document.createElement('div'); - bd.className = 'modal-backdrop fade show ql-backdrop-el'; - document.body.appendChild(bd); - } - } - function qlClose() { - var m = qlEl(); - if (!m) return; - m.classList.remove('show'); - m.style.display = 'none'; - m.setAttribute('aria-hidden', 'true'); - m.removeAttribute('aria-modal'); - var bd = document.querySelector('.modal-backdrop.ql-backdrop-el'); - if (bd) bd.remove(); - if (!document.querySelector('.modal.show')) { document.body.classList.remove('modal-open'); } - var b = qlBody(); - if (b) { b.innerHTML = '
'; } - } - - function qlUrl(t) { - var src = t.getAttribute('data-ql-source'); - var type = t.getAttribute('data-ql-type'); - var id = t.getAttribute('data-ql-id'); - if (!type || !id) return null; - if (src === 'tmdb') { return '/tableau-de-bord/quicklook/tmdb/' + type + '/' + id; } - var slug = t.getAttribute('data-ql-slug'); - if (!slug) return null; - return '/tableau-de-bord/quicklook/' + type + '/' + slug + '/' + id; - } - - if (!window._qlDelegated) { - window._qlDelegated = true; - document.addEventListener('click', function (e) { - var trigger = e.target.closest('[data-ql-trigger]'); - if (trigger) { - var url = qlUrl(trigger); - if (!url) return; // missing data → let the href navigate (fallback) - e.preventDefault(); - qlOpen(); - fetch(url, { headers: { 'X-Requested-With': 'fetch' }, credentials: 'same-origin' }) - .then(function (r) { return r.ok ? r.text() : ''; }) - .then(function (html) { - var b = qlBody(); - if (!b) return; - if ((html || '').trim()) { b.innerHTML = html; } - else { window.location.href = trigger.getAttribute('href'); } // empty → fall back to navigation - }) - .catch(function () { window.location.href = trigger.getAttribute('href'); }); - return; - } - // Close on backdrop click or [data-ql-dismiss] (the × in the body). - if (e.target.closest('[data-ql-dismiss]') || e.target === qlEl()) { - qlClose(); - } - }); - document.addEventListener('keydown', function (e) { - var m = qlEl(); - if (e.key === 'Escape' && m && m.classList.contains('show')) { qlClose(); } - }); - // Tidy up if Turbo navigates away with the modal open. - document.addEventListener('turbo:before-render', function () { - var m = qlEl(); - if (m && m.classList.contains('show')) { qlClose(); } - }); - } })(); {% endblock %} diff --git a/symfony/templates/decouverte/explorer.html.twig b/symfony/templates/decouverte/explorer.html.twig index 4e32bbfb..a7816c7e 100644 --- a/symfony/templates/decouverte/explorer.html.twig +++ b/symfony/templates/decouverte/explorer.html.twig @@ -251,48 +251,6 @@ - - - - -