diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index d4698502..f3c3b3fd 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -76,6 +76,23 @@ def top_art_keys( return out +def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]: + """Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the + JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime.""" + if not lib_ids or not keys: + return {} + k = func.jsonb_array_elements_text(key_col).label("k") + exp = ( + select(func.coalesce(model.duration_s, 0).label("d"), k) + .where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array") + .subquery() + ) + rows = db.execute( + select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k) + ).all() + return {key: int(total or 0) for key, total in rows} + + def vector_col(model, lang: str): # RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector # (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering. diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index f2d15cdd..45064772 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -16,7 +16,7 @@ from app.models import ( from app.plex import sync as plex_sync from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key -from app.plex.query import like_escape, top_art_keys +from app.plex.query import like_escape, runtime_by_key, top_art_keys from app.routes.plex._shared import _content_libs router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -95,11 +95,22 @@ def collections( func.lower(PlexCollection.title), ) ).scalars().all() - # A 2×2 poster collage per tile: the top movies in each collection (color hash is the client - # fallback for show-only / empty collections). One windowed query across the content movie libs. + # A montage of the top films per tile + the collection's total runtime (color hash + no-runtime is + # the client fallback for show-only / empty collections). Two grouped queries over the movie libs. movie_ids, _show_ids = _content_libs(db) - art = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, [c.rating_key for c in rows]) - return {"collections": [{**_collection_card(c), "art": art.get(c.rating_key, [])} for c in rows]} + keys = [c.rating_key for c in rows] + art = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2) + runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys) + return { + "collections": [ + { + **_collection_card(c), + "art": art.get(c.rating_key, []), + "total_runtime_s": runtime.get(c.rating_key, 0), + } + for c in rows + ] + } def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py index 4b046c1d..8e5d4d89 100644 --- a/backend/tests/test_plex_collections_filter.py +++ b/backend/tests/test_plex_collections_filter.py @@ -10,7 +10,7 @@ import app.state as state from app.auth import current_user from app.db import get_db from app.main import app -from app.models import PlexItem, PlexLibrary, User +from app.models import PlexCollection, PlexItem, PlexLibrary, User def _seed(db): @@ -70,3 +70,25 @@ def test_multiple_collections_all_is_the_intersection(db, monkeypatch): "/api/plex/library?scope=movie&collections=A,B&collection_mode=all", ) assert _titles(resp) == {"A and B"} + + +def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch): + # The browse-tile montage (top films, best-rated first) + the collection's total runtime. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", child_count=2)) + common = dict(library_id=lib.id, kind="movie", collection_keys=["A"]) + db.add_all([ + PlexItem(rating_key="m1", title="M1", duration_s=3600, rating=8.0, + added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common), + PlexItem(rating_key="m2", title="M2", duration_s=1800, rating=9.0, + added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common), + ]) + user = User(email="collart@example.com") + db.add(user) + db.commit() + card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"] + if c["id"] == "A") + assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0) + assert card["total_runtime_s"] == 5400 # 3600 + 1800 diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index 53b5cb3f..b2267837 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -204,9 +204,8 @@ export default function PlexDiscover({ key={g.value} title={g.value} art={g.art} - topRight={{g.count}} onClick={() => onGenre(g.value)} - className="h-20" + className="h-24" /> ))} diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx index 700ac912..82805652 100644 --- a/frontend/src/components/PosterTile.tsx +++ b/frontend/src/components/PosterTile.tsx @@ -1,9 +1,10 @@ import { type ReactNode } from "react"; import { hueOf, hueTileGradient } from "../lib/hueTile"; -// A browse tile: a 2×2 collage of the group's top poster art (served from /api/plex/image/{key}) under -// a dark scrim with the label, falling back to a color hash when the group has no art — so a tile never -// renders blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles. +// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}), +// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable +// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never +// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles. export default function PosterTile({ title, subtitle, @@ -13,24 +14,29 @@ export default function PosterTile({ className, }: { title: string; - subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items") - topRight?: ReactNode; // top-right corner content (a count, or an icon) - art?: string[]; // up to 4 poster rating_keys; fewer/none falls back to the color hash + subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m") + topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon) + art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash onClick: () => void; className?: string; // caller sets the height (e.g. "h-20" / "h-28") }) { - const posters = (art ?? []).slice(0, 4); + const posters = (art ?? []).slice(0, 2); + const band = hueTileGradient(hueOf(title)); return ( ); diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index 12040dc1..efe7ba82 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -1,13 +1,25 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useQuery } from "@tanstack/react-query"; -import { Layers, ListMusic } from "lucide-react"; +import { ListMusic } from "lucide-react"; import { qk } from "../lib/queryKeys"; -import { api } from "../lib/api"; +import { api, type PlexCollection } from "../lib/api"; +import { formatRuntime } from "../lib/format"; import { useDebounced } from "../lib/useDebounced"; import { LoadingState, StateMessage } from "./QueryState"; import PosterTile from "./PosterTile"; +// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only +// collections have no movie runtime). +function collectionMeta(c: PlexCollection, t: TFunction): string { + const parts: string[] = []; + if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); + const rt = formatRuntime(c.total_runtime_s); + if (rt) parts.push(rt); + return parts.join(" · "); +} + // The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, // each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the // title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections @@ -87,10 +99,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: key={c.id} title={c.title} art={c.art} - subtitle={ - c.child_count != null ? t("plex.browse.items", { count: c.child_count }) : undefined - } - topRight={} + subtitle={collectionMeta(c, t)} onClick={() => onPick(c.id, c.title)} className="h-28" /> diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 9b6171c8..bad246c0 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -184,7 +184,8 @@ export interface PlexCollection { source?: string; editable: boolean; can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection - art?: string[]; // up to 4 poster rating_keys for the browse-tile collage (browse union only) + art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only) + total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only) } export interface PlexPlaylist { id: number;