diff --git a/VERSION b/VERSION index ae6dd4e..c25c8e5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.29.0 +0.30.0 diff --git a/backend/alembic/versions/0048_plex_playlists.py b/backend/alembic/versions/0048_plex_playlists.py new file mode 100644 index 0000000..68097cb --- /dev/null +++ b/backend/alembic/versions/0048_plex_playlists.py @@ -0,0 +1,58 @@ +"""Plex playlists (Siftlode-native, per-user, ordered) + +Revision ID: 0048_plex_playlists +Revises: 0047_plex_collections +Create Date: 2026-07-06 + +Per-user ORDERED watch-lists of Plex items, kept in Siftlode's own DB so they work for users without a +Plex account (like plex_states) — the "your own lists" counterpart to the shared, library-level +collections. `plex_playlists` is owned by a user; `plex_playlist_items` holds the ordered entries +(`position`). `plex_rating_key` is reserved for an optional later Plex-direction sync. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0048_plex_playlists" +down_revision: Union[str, None] = "0047_plex_collections" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "plex_playlists", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("title", sa.String(length=512), nullable=False), + sa.Column("plex_rating_key", sa.String(length=32), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_plex_playlists_user_id", "plex_playlists", ["user_id"]) + + op.create_table( + "plex_playlist_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "playlist_id", sa.Integer(), + sa.ForeignKey("plex_playlists.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "item_id", sa.Integer(), + sa.ForeignKey("plex_items.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"), + ) + op.create_index("ix_plex_playlist_items_playlist_id", "plex_playlist_items", ["playlist_id"]) + op.create_index("ix_plex_playlist_items_item_id", "plex_playlist_items", ["item_id"]) + + +def downgrade() -> None: + op.drop_index("ix_plex_playlist_items_item_id", table_name="plex_playlist_items") + op.drop_index("ix_plex_playlist_items_playlist_id", table_name="plex_playlist_items") + op.drop_table("plex_playlist_items") + op.drop_index("ix_plex_playlists_user_id", table_name="plex_playlists") + op.drop_table("plex_playlists") diff --git a/backend/app/models.py b/backend/app/models.py index 354114e..c3a9ace 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1109,3 +1109,33 @@ class PlexState(Base, UpdatedAtMixin): synced_to_plex: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + + +class PlexPlaylist(Base, TimestampMixin, UpdatedAtMixin): + """A per-user ORDERED watch-list of Plex items — Siftlode-native (lives in our own DB, works for + users WITHOUT a Plex account, like plex_states). Unlike collections (shared, library-level), a + playlist is owned by one user and its items are ordered. Optional Plex-direction sync is a later + phase (`plex_rating_key` set once pushed back to the owner's Plex account).""" + + __tablename__ = "plex_playlists" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(512)) + plex_rating_key: Mapped[str | None] = mapped_column(String(32)) + + +class PlexPlaylistItem(Base): + """One ordered entry in a playlist. `position` (0-based) orders playback.""" + + __tablename__ = "plex_playlist_items" + __table_args__ = (UniqueConstraint("playlist_id", "item_id", name="uq_plex_playlist_item"),) + + id: Mapped[int] = mapped_column(primary_key=True) + playlist_id: Mapped[int] = mapped_column( + ForeignKey("plex_playlists.id", ondelete="CASCADE"), index=True + ) + item_id: Mapped[int] = mapped_column( + ForeignKey("plex_items.id", ondelete="CASCADE"), index=True + ) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False) diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index a3673c4..0fd000b 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -25,7 +25,17 @@ from app import sysconfig from app.auth import current_user, resolved_user_id from app.config import settings from app.db import SessionLocal, get_db -from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User +from app.models import ( + PlexCollection, + PlexItem, + PlexLibrary, + PlexPlaylist, + PlexPlaylistItem, + PlexSeason, + PlexShow, + PlexState, + User, +) from app.plex import paths as plex_paths from app.plex import stream as plex_stream from app.plex import sync as plex_sync @@ -176,6 +186,15 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict: } +def _leaf_card(it: PlexItem, st: PlexState | None, show_title: str | None = None) -> dict: + """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.""" + if it.kind == "episode": + card = _episode_card(it, st) + card["show_title"] = show_title + return card + return _movie_card(it, st) + + def _csv(v: str | None) -> list[str]: return [s.strip() for s in v.split(",") if s.strip()] if v else [] @@ -591,6 +610,159 @@ def collection_set_editable( return _collection_card(col) +# --- Playlists (Siftlode-native, per-user, ordered — no Plex account needed; Plex sync is a later phase). +def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist: + pl = db.query(PlexPlaylist).filter_by(id=pid, user_id=user.id).first() + if pl is None: + raise HTTPException(status_code=404, detail="Playlist not found") + return pl + + +def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: + return { + "id": pl.id, + "title": pl.title, + "item_count": count, + "thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None, + } + + +def _playlist_item_query(db: Session, pid: int): + return ( + db.query(PlexPlaylistItem, PlexItem) + .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) + .filter(PlexPlaylistItem.playlist_id == pid) + .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) + ) + + +@router.get("/playlists") +def playlists( + contains: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """The current user's playlists (newest-touched first), each with an item count + a cover thumb. + `contains`=an item rating_key adds `has_item` per playlist (for the 'add to playlist' dialog).""" + contains_id = None + if contains: + row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() + contains_id = row[0] if row else None + out = [] + for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()): + count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + first = _playlist_item_query(db, pl.id).first() + card = _playlist_card(pl, count, first[1].rating_key if first else None) + if contains_id is not None: + card["has_item"] = ( + db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() + is not None + ) + out.append(card) + return {"playlists": out} + + +@router.post("/playlists") +def create_playlist(payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + title = (payload.get("title") or "").strip() + if not title: + raise HTTPException(status_code=422, detail="title is required") + pl = PlexPlaylist(user_id=user.id, title=title) + db.add(pl) + db.flush() + seed = str(payload.get("item_rating_key") or "") + count = 0 + thumb_rk = None + if seed: + it = db.query(PlexItem).filter_by(rating_key=seed).first() + if it is not None: + db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) + count, thumb_rk = 1, it.rating_key + db.commit() + return _playlist_card(pl, count, thumb_rk) + + +@router.get("/playlists/{pid}") +def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + pl = _own_playlist_or_404(db, pid, user) + rows = _playlist_item_query(db, pid).all() + items = [it for (_pi, it) in rows] + states = { + s.item_id: s + for s in db.query(PlexState).filter( + PlexState.user_id == user.id, PlexState.item_id.in_([it.id for it in items] or [0]) + ) + } + shows = { + sh.id: sh.title + for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])) + } + cards = [_leaf_card(it, states.get(it.id), shows.get(it.show_id)) for it in items] + return {"id": pl.id, "title": pl.title, "items": cards} + + +@router.patch("/playlists/{pid}") +def rename_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + pl = _own_playlist_or_404(db, pid, user) + title = (payload.get("title") or "").strip() + if not title: + raise HTTPException(status_code=422, detail="title is required") + pl.title = title + db.commit() + count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + first = _playlist_item_query(db, pl.id).first() + return _playlist_card(pl, count, first[1].rating_key if first else None) + + +@router.delete("/playlists/{pid}") +def delete_playlist(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + pl = _own_playlist_or_404(db, pid, user) + db.delete(pl) + db.commit() + return {"deleted": pid} + + +@router.post("/playlists/{pid}/items") +def playlist_add_item(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + pl = _own_playlist_or_404(db, pid, user) + it = db.query(PlexItem).filter_by(rating_key=str(payload.get("item_rating_key") or "")).first() + if it is None: + raise HTTPException(status_code=404, detail="Unknown item") + exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() + if exists is None: + maxpos = db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar() + db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1)) + pl.updated_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True} + + +@router.delete("/playlists/{pid}/items/{item_rating_key}") +def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + pl = _own_playlist_or_404(db, pid, user) + it = db.query(PlexItem).filter_by(rating_key=str(item_rating_key)).first() + if it is not None: + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).delete() + pl.updated_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True} + + +@router.put("/playlists/{pid}/order") +def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + """Set the playlist order from an explicit list of item rating_keys (0-based positions).""" + pl = _own_playlist_or_404(db, pid, user) + order = [str(k) for k in (payload.get("item_rating_keys") or [])] + id_by_rk = {it.rating_key: it.id for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""]))} + for pos, rk in enumerate(order): + iid = id_by_rk.get(rk) + if iid is not None: + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update({"position": pos}) + pl.updated_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True} + + @router.get("/show/{rating_key}") def show_detail( rating_key: str, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67401b3..127844e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -249,6 +249,7 @@ export default function App() { const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, ""); const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all"); const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added"); + const [plexPlaylistOpen, setPlexPlaylistOpen] = useState(null); // sidebar → open a playlist // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}"); const plexFilters = useMemo(() => { @@ -741,6 +742,7 @@ export default function App() { setSort={setPlexSort} filters={plexFilters} setFilters={setPlexFilters} + onOpenPlaylist={setPlexPlaylistOpen} collapsed={filterCollapsed} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} /> @@ -824,6 +826,8 @@ export default function App() { sort={plexSort} filters={plexFilters} setFilters={setPlexFilters} + openPlaylist={plexPlaylistOpen} + onPlaylistOpened={() => setPlexPlaylistOpen(null)} /> ) : ( import("./PlexPlayer")); const PlexInfo = lazy(() => import("./PlexInfo")); +const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); type Sub = | { kind: "grid" } | { kind: "show"; id: string } - | { kind: "player"; id: string } - | { kind: "info"; id: string }; + | { kind: "player"; id: string; queue?: string[] } + | { kind: "info"; id: string } + | { kind: "playlist"; id: number }; // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the @@ -30,6 +32,9 @@ type Props = { sort: string; filters: PlexFilters; setFilters: (f: PlexFilters) => void; + // The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it. + openPlaylist?: number | null; + onPlaylistOpened?: () => void; }; const PAGE = 40; @@ -41,12 +46,32 @@ function dur(n?: number | null): string { return h ? `${h}h ${m}m` : `${m}m`; } -export default function PlexBrowse({ q, onClearSearch, library, show, sort, filters, setFilters }: Props) { +export default function PlexBrowse({ + q, + onClearSearch, + library, + show, + sort, + filters, + setFilters, + openPlaylist, + onPlaylistOpened, +}: Props) { const { t } = useTranslation(); const qc = useQueryClient(); const dq = useDebounced(q.trim(), 350); const sub = useHistorySubview({ kind: "grid" }); + // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then + // clear the signal so it doesn't re-open. + useEffect(() => { + if (openPlaylist != null) { + sub.open({ kind: "playlist", id: openPlaylist }); + onPlaylistOpened?.(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openPlaylist]); + // Opening a show/player replaces the grid entirely (the component returns early below), so the // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and // restore it when we come back — so the browser/mouse Back from the player lands on the same card @@ -137,7 +162,19 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt if (sub.view.kind === "player") { return ( }> - + + + ); + } + if (sub.view.kind === "playlist") { + const pid = sub.view.id; + return ( + {t("plex.loading")}

}> + sub.open({ kind: "player", id: itemRk, queue })} + />
); } diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 9193f63..dd1ca07 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -6,6 +6,7 @@ import { Check, ExternalLink, FolderPlus, + ListPlus, Play, RotateCcw, SlidersHorizontal, @@ -14,6 +15,7 @@ import { } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import PlexCollectionEditor from "./PlexCollectionEditor"; +import PlexPlaylistAdd from "./PlexPlaylistAdd"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -95,6 +97,7 @@ export default function PlexInfo({ }; const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin"; const [editorOpen, setEditorOpen] = useState(false); + const [playlistOpen, setPlaylistOpen] = useState(false); const [customizing, setCustomizing] = useState(false); const custBtnRef = useRef(null); const custMenuRef = useRef(null); @@ -367,6 +370,15 @@ export default function PlexInfo({ {t("plex.info.clearResume")} )} + {detail.kind === "movie" && ( + + )} {isAdmin && detail.kind === "movie" && library && ( + + +
+ {listQ.isLoading ? ( +

{t("plex.loading")}

+ ) : playlists.length === 0 ? ( +

{t("plex.playlistAdd.none")}

+ ) : ( + playlists.map((p) => { + const inIt = isIn(p); + return ( + + ); + }) + )} +
+ + ); +} diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx new file mode 100644 index 0000000..86083d3 --- /dev/null +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, ChevronDown, ChevronUp, Pencil, Play, Trash2, X } from "lucide-react"; +import { api, type PlexCard } from "../lib/api"; +import { useConfirm } from "./ConfirmProvider"; + +// A playlist's ordered items — reorder (up/down), remove, rename, delete, and "Play all" (which opens +// the player with the whole ordered list as its queue). Rendered as a PlexBrowse subview. +export default function PlexPlaylistView({ + playlistId, + onBack, + onPlay, +}: { + playlistId: number; + onBack: () => void; + onPlay: (itemRk: string, queue: string[]) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const confirm = useConfirm(); + const [renaming, setRenaming] = useState(false); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + + const q = useQuery({ queryKey: ["plex-playlist", playlistId], queryFn: () => api.plexPlaylist(playlistId) }); + const items: PlexCard[] = q.data?.items ?? []; + const order = items.map((i) => i.id); + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["plex-playlist", playlistId] }); + qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + }; + + async function move(idx: number, dir: -1 | 1) { + const to = idx + dir; + if (busy || to < 0 || to >= order.length) return; + setBusy(true); + const next = [...order]; + [next[idx], next[to]] = [next[to], next[idx]]; + try { + await api.plexReorderPlaylist(playlistId, next); + invalidate(); + } finally { + setBusy(false); + } + } + async function remove(itemRk: string) { + if (busy) return; + setBusy(true); + try { + await api.plexPlaylistRemoveItem(playlistId, itemRk); + invalidate(); + } finally { + setBusy(false); + } + } + async function rename() { + const title = name.trim(); + if (!title) return; + await api.plexRenamePlaylist(playlistId, title); + setRenaming(false); + invalidate(); + } + async function del() { + if (!(await confirm({ message: t("plex.playlist.deleteConfirm", { title: q.data?.title }), danger: true }))) + return; + await api.plexDeletePlaylist(playlistId); + qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + onBack(); + } + + return ( +
+ + +
+ {/* Header: title (rename) + Play all + delete */} +
+ {renaming ? ( + setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && rename()} + onBlur={rename} + className="glass-card min-w-0 flex-1 rounded-lg px-3 py-1.5 text-lg font-bold outline-none focus:border-accent" + /> + ) : ( +

{q.data?.title ?? " "}

+ )} + + {items.length > 0 && ( + + )} + +
+ + {q.isLoading ? ( +

{t("plex.loading")}

+ ) : items.length === 0 ? ( +

{t("plex.playlist.empty")}

+ ) : ( +
    + {items.map((it, idx) => ( +
  1. + {idx + 1} + +
    + + + +
    +
  2. + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index f222395..a95fac2 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; @@ -20,6 +20,7 @@ type Props = { setSort: (v: string) => void; filters: PlexFilters; setFilters: (f: PlexFilters) => void; + onOpenPlaylist: (id: number) => void; collapsed: boolean; onToggleCollapse: () => void; }; @@ -45,11 +46,23 @@ export default function PlexSidebar({ setSort, filters, setFilters, + onOpenPlaylist, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries }); + const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); + const [newPlaylist, setNewPlaylist] = useState(""); + const qc = useQueryClient(); + async function createPlaylist() { + const title = newPlaylist.trim(); + if (!title) return; + const pl = await api.plexCreatePlaylist(title); + setNewPlaylist(""); + qc.invalidateQueries({ queryKey: ["plex-playlists"] }); + onOpenPlaylist(pl.id); + } const libs = libsQ.data?.libraries ?? []; const activeLib = libs.find((l) => l.key === library); const isMovieLib = activeLib?.kind === "movie"; @@ -143,6 +156,45 @@ export default function PlexSidebar({ + {/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not + a filter. Available in any library. */} +
+
+ setNewPlaylist(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && createPlaylist()} + placeholder={t("plex.playlist.newPlaceholder")} + className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" + /> + +
+ {(playlistsQ.data?.playlists ?? []).length === 0 ? ( +

{t("plex.playlist.none")}

+ ) : ( +
+ {playlistsQ.data!.playlists.map((p) => ( + + ))} +
+ )} +
+ {anyActive && (