merge: Plex Playlists Phase 1 — per-user ordered watch-lists — v0.30.0
This commit is contained in:
commit
22f36d9d7f
16 changed files with 796 additions and 18 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.29.0
|
0.30.0
|
||||||
|
|
|
||||||
58
backend/alembic/versions/0048_plex_playlists.py
Normal file
58
backend/alembic/versions/0048_plex_playlists.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -1109,3 +1109,33 @@ class PlexState(Base, UpdatedAtMixin):
|
||||||
synced_to_plex: Mapped[bool] = mapped_column(
|
synced_to_plex: Mapped[bool] = mapped_column(
|
||||||
Boolean, default=False, server_default="false"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,17 @@ from app import sysconfig
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, resolved_user_id
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import SessionLocal, get_db
|
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 paths as plex_paths
|
||||||
from app.plex import stream as plex_stream
|
from app.plex import stream as plex_stream
|
||||||
from app.plex import sync as plex_sync
|
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]:
|
def _csv(v: str | None) -> list[str]:
|
||||||
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
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)
|
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}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ export default function App() {
|
||||||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||||
|
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||||
const plexFilters = useMemo<PlexFilters>(() => {
|
const plexFilters = useMemo<PlexFilters>(() => {
|
||||||
|
|
@ -741,6 +742,7 @@ export default function App() {
|
||||||
setSort={setPlexSort}
|
setSort={setPlexSort}
|
||||||
filters={plexFilters}
|
filters={plexFilters}
|
||||||
setFilters={setPlexFilters}
|
setFilters={setPlexFilters}
|
||||||
|
onOpenPlaylist={setPlexPlaylistOpen}
|
||||||
collapsed={filterCollapsed}
|
collapsed={filterCollapsed}
|
||||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -824,6 +826,8 @@ export default function App() {
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
filters={plexFilters}
|
filters={plexFilters}
|
||||||
setFilters={setPlexFilters}
|
setFilters={setPlexFilters}
|
||||||
|
openPlaylist={plexPlaylistOpen}
|
||||||
|
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,14 @@ import { useHistorySubview } from "../lib/history";
|
||||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
|
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
|
||||||
|
|
||||||
type Sub =
|
type Sub =
|
||||||
| { kind: "grid" }
|
| { kind: "grid" }
|
||||||
| { kind: "show"; id: string }
|
| { kind: "show"; id: string }
|
||||||
| { kind: "player"; id: string }
|
| { kind: "player"; id: string; queue?: string[] }
|
||||||
| { kind: "info"; id: 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
|
// 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
|
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||||
|
|
@ -30,6 +32,9 @@ type Props = {
|
||||||
sort: string;
|
sort: string;
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
setFilters: (f: PlexFilters) => void;
|
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;
|
const PAGE = 40;
|
||||||
|
|
@ -41,12 +46,32 @@ function dur(n?: number | null): string {
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
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 { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const dq = useDebounced(q.trim(), 350);
|
const dq = useDebounced(q.trim(), 350);
|
||||||
const sub = useHistorySubview<Sub>({ kind: "grid" });
|
const sub = useHistorySubview<Sub>({ 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
|
// 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
|
// 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
|
// 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") {
|
if (sub.view.kind === "player") {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
|
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
|
||||||
<PlexPlayer itemId={sub.view.id} onClose={sub.back} />
|
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (sub.view.kind === "playlist") {
|
||||||
|
const pid = sub.view.id;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||||
|
<PlexPlaylistView
|
||||||
|
playlistId={pid}
|
||||||
|
onBack={sub.back}
|
||||||
|
onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
ListPlus,
|
||||||
Play,
|
Play,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
|
@ -14,6 +15,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
|
|
||||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
// 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
|
// 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 isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||||
const [customizing, setCustomizing] = useState(false);
|
const [customizing, setCustomizing] = useState(false);
|
||||||
const custBtnRef = useRef<HTMLButtonElement>(null);
|
const custBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const custMenuRef = useRef<HTMLDivElement>(null);
|
const custMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -367,6 +370,15 @@ export default function PlexInfo({
|
||||||
{t("plex.info.clearResume")}
|
{t("plex.info.clearResume")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{detail.kind === "movie" && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPlaylistOpen(true)}
|
||||||
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
|
>
|
||||||
|
<ListPlus className="w-4 h-4" />
|
||||||
|
{t("plex.playlistAdd.manage")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{isAdmin && detail.kind === "movie" && library && (
|
{isAdmin && detail.kind === "movie" && library && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditorOpen(true)}
|
onClick={() => setEditorOpen(true)}
|
||||||
|
|
@ -513,6 +525,9 @@ export default function PlexInfo({
|
||||||
onChanged={() => onStateChange?.()}
|
onChanged={() => onStateChange?.()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{playlistOpen && (
|
||||||
|
<PlexPlaylistAdd item={{ id: detail.id, title: detail.title }} onClose={() => setPlaylistOpen(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||||
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
|
||||||
// restart the session at the target. Watch state / resume persist to plex_states.
|
// restart the session at the target. Watch state / resume persist to plex_states.
|
||||||
|
|
||||||
type Props = { itemId: string; onClose: () => void };
|
// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
|
||||||
|
// the queue instead of the item's own episode neighbours.
|
||||||
|
type Props = { itemId: string; onClose: () => void; queue?: string[] };
|
||||||
|
|
||||||
function fmt(t: number): string {
|
function fmt(t: number): string {
|
||||||
if (!isFinite(t) || t < 0) t = 0;
|
if (!isFinite(t) || t < 0) t = 0;
|
||||||
|
|
@ -43,7 +45,7 @@ function fmt(t: number): string {
|
||||||
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlexPlayer({ itemId, onClose }: Props) {
|
export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [id, setId] = useState(itemId);
|
const [id, setId] = useState(itemId);
|
||||||
|
|
@ -302,6 +304,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
|
||||||
|
const qIdx = queue ? queue.indexOf(id) : -1;
|
||||||
|
const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
|
||||||
|
const nextId = queue ? (qIdx >= 0 && qIdx < queue.length - 1 ? queue[qIdx + 1] : null) : detail?.next_id;
|
||||||
|
|
||||||
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
|
||||||
const download = useCallback(() => {
|
const download = useCallback(() => {
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|
@ -385,11 +392,11 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
const onEnded = () => {
|
const onEnded = () => {
|
||||||
api.plexSetState(id, "watched").catch(() => {});
|
api.plexSetState(id, "watched").catch(() => {});
|
||||||
if (detail?.next_id) go(detail.next_id);
|
if (nextId) go(nextId);
|
||||||
};
|
};
|
||||||
video.addEventListener("ended", onEnded);
|
video.addEventListener("ended", onEnded);
|
||||||
return () => video.removeEventListener("ended", onEnded);
|
return () => video.removeEventListener("ended", onEnded);
|
||||||
}, [id, detail, go]);
|
}, [id, nextId, go]);
|
||||||
|
|
||||||
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
// Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
|
||||||
const wake = useCallback(() => {
|
const wake = useCallback(() => {
|
||||||
|
|
@ -418,12 +425,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
break;
|
break;
|
||||||
case "ArrowLeft":
|
case "ArrowLeft":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) go(detail?.prev_id);
|
if (e.shiftKey) go(prevId);
|
||||||
else seekTo(absRef.current - 10);
|
else seekTo(absRef.current - 10);
|
||||||
break;
|
break;
|
||||||
case "ArrowRight":
|
case "ArrowRight":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) go(detail?.next_id);
|
if (e.shiftKey) go(nextId);
|
||||||
else seekTo(absRef.current + 10);
|
else seekTo(absRef.current + 10);
|
||||||
break;
|
break;
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
|
|
@ -464,7 +471,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, onClose, wake]);
|
}, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, prevId, nextId, onClose, wake]);
|
||||||
|
|
||||||
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
const activeMarker: PlexMarker | undefined = detail?.markers.find(
|
||||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||||
|
|
@ -590,12 +597,12 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
<Ctrl label={t("plex.player.stop")} onClick={onClose}>
|
||||||
<Square className="w-5 h-5" />
|
<Square className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
{detail?.kind === "episode" && (
|
{(detail?.kind === "episode" || queue) && (
|
||||||
<>
|
<>
|
||||||
<Ctrl label={t("plex.player.prev")} onClick={() => go(detail?.prev_id)} disabled={!detail?.prev_id}>
|
<Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
|
||||||
<SkipBack className="w-5 h-5" />
|
<SkipBack className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
<Ctrl label={t("plex.player.next")} onClick={() => go(detail?.next_id)} disabled={!detail?.next_id}>
|
<Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
|
||||||
<SkipForward className="w-5 h-5" />
|
<SkipForward className="w-5 h-5" />
|
||||||
</Ctrl>
|
</Ctrl>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
115
frontend/src/components/PlexPlaylistAdd.tsx
Normal file
115
frontend/src/components/PlexPlaylistAdd.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, Plus } from "lucide-react";
|
||||||
|
import { api, type PlexPlaylist } from "../lib/api";
|
||||||
|
import Modal from "./Modal";
|
||||||
|
|
||||||
|
// "Add this title to a playlist" dialog — per-user (every user has their own playlists), opened from
|
||||||
|
// the movie info page. Lists my playlists with an in/out toggle (seeded from the backend's has_item)
|
||||||
|
// and a field to create a new one seeded with this title. Playlists are personal (no admin gate).
|
||||||
|
export default function PlexPlaylistAdd({
|
||||||
|
item,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: { id: string; title: string };
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [override, setOverride] = useState<Map<number, boolean>>(new Map()); // optimistic toggle state
|
||||||
|
|
||||||
|
const listQ = useQuery({
|
||||||
|
queryKey: ["plex-playlists", "contains", item.id],
|
||||||
|
queryFn: () => api.plexPlaylists(item.id),
|
||||||
|
});
|
||||||
|
const playlists = listQ.data?.playlists ?? [];
|
||||||
|
const isIn = (p: PlexPlaylist) => (override.has(p.id) ? override.get(p.id)! : !!p.has_item);
|
||||||
|
const refresh = () => qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||||
|
|
||||||
|
async function toggle(p: PlexPlaylist) {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
const cur = isIn(p);
|
||||||
|
try {
|
||||||
|
if (cur) await api.plexPlaylistRemoveItem(p.id, item.id);
|
||||||
|
else await api.plexPlaylistAddItem(p.id, item.id);
|
||||||
|
setOverride((m) => new Map(m).set(p.id, !cur));
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNew() {
|
||||||
|
const title = newName.trim();
|
||||||
|
if (!title || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const pl = await api.plexCreatePlaylist(title, item.id);
|
||||||
|
setNewName("");
|
||||||
|
setOverride((m) => new Map(m).set(pl.id, true));
|
||||||
|
refresh();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={t("plex.playlistAdd.title", { title: item.title })} onClose={onClose}>
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && createNew()}
|
||||||
|
placeholder={t("plex.playlistAdd.newPlaceholder")}
|
||||||
|
className="glass-card min-w-0 flex-1 rounded-lg px-3 py-2 text-sm outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={createNew}
|
||||||
|
disabled={!newName.trim() || busy}
|
||||||
|
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("plex.playlistAdd.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 space-y-1 overflow-y-auto">
|
||||||
|
{listQ.isLoading ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||||
|
) : playlists.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-sm text-muted">{t("plex.playlistAdd.none")}</p>
|
||||||
|
) : (
|
||||||
|
playlists.map((p) => {
|
||||||
|
const inIt = isIn(p);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => toggle(p)}
|
||||||
|
disabled={busy}
|
||||||
|
className={`glass-card flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm disabled:opacity-50 ${
|
||||||
|
inIt ? "text-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`grid h-5 w-5 shrink-0 place-items-center rounded border ${
|
||||||
|
inIt ? "border-accent bg-accent/20" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{inIt && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted">
|
||||||
|
{t("plex.playlistAdd.count", { count: p.item_count })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
frontend/src/components/PlexPlaylistView.tsx
Normal file
184
frontend/src/components/PlexPlaylistView.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="w-[90%] max-w-[1100px] mx-auto p-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="glass-card glass-hover mb-4 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{t("plex.backToLibrary")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="glass rounded-2xl p-4 sm:p-6">
|
||||||
|
{/* Header: title (rename) + Play all + delete */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
{renaming ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1 className="min-w-0 flex-1 truncate text-2xl font-bold">{q.data?.title ?? " "}</h1>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setName(q.data?.title ?? "");
|
||||||
|
setRenaming(true);
|
||||||
|
}}
|
||||||
|
title={t("plex.playlist.rename")}
|
||||||
|
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => onPlay(order[0], order)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Play className="h-5 w-5 fill-current" />
|
||||||
|
{t("plex.playlist.playAll")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={del}
|
||||||
|
title={t("plex.playlist.delete")}
|
||||||
|
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{q.isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted">{t("plex.loading")}</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-muted">{t("plex.playlist.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{items.map((it, idx) => (
|
||||||
|
<li key={it.id} className="glass-card flex items-center gap-3 rounded-lg p-2">
|
||||||
|
<span className="w-5 shrink-0 text-center text-xs text-muted">{idx + 1}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => onPlay(it.id, order)}
|
||||||
|
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||||
|
>
|
||||||
|
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded border border-border bg-surface">
|
||||||
|
<img src={it.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover:opacity-100">
|
||||||
|
<Play className="h-5 w-5 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium">
|
||||||
|
{it.type === "episode" && it.show_title ? `${it.show_title} — ${it.title}` : it.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted">{it.year}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => move(idx, -1)}
|
||||||
|
disabled={busy || idx === 0}
|
||||||
|
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
|
||||||
|
title={t("plex.playlist.up")}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => move(idx, 1)}
|
||||||
|
disabled={busy || idx === items.length - 1}
|
||||||
|
className="rounded p-1 text-muted hover:text-fg disabled:opacity-30"
|
||||||
|
title={t("plex.playlist.down")}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(it.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded p-1 text-muted hover:text-red-400 disabled:opacity-30"
|
||||||
|
title={t("plex.playlist.remove")}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
|
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
|
||||||
|
|
@ -20,6 +20,7 @@ type Props = {
|
||||||
setSort: (v: string) => void;
|
setSort: (v: string) => void;
|
||||||
filters: PlexFilters;
|
filters: PlexFilters;
|
||||||
setFilters: (f: PlexFilters) => void;
|
setFilters: (f: PlexFilters) => void;
|
||||||
|
onOpenPlaylist: (id: number) => void;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -45,11 +46,23 @@ export default function PlexSidebar({
|
||||||
setSort,
|
setSort,
|
||||||
filters,
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
|
onOpenPlaylist,
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
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 libs = libsQ.data?.libraries ?? [];
|
||||||
const activeLib = libs.find((l) => l.key === library);
|
const activeLib = libs.find((l) => l.key === library);
|
||||||
const isMovieLib = activeLib?.kind === "movie";
|
const isMovieLib = activeLib?.kind === "movie";
|
||||||
|
|
@ -143,6 +156,45 @@ export default function PlexSidebar({
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
|
||||||
|
a filter. Available in any library. */}
|
||||||
|
<Section label={t("plex.playlist.section")}>
|
||||||
|
<div className="mb-1.5 flex gap-1.5">
|
||||||
|
<input
|
||||||
|
value={newPlaylist}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={createPlaylist}
|
||||||
|
disabled={!newPlaylist.trim()}
|
||||||
|
title={t("plex.playlist.create")}
|
||||||
|
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{playlistsQ.data!.playlists.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => onOpenPlaylist(p.id)}
|
||||||
|
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||||
|
>
|
||||||
|
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{anyActive && (
|
{anyActive && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
|
|
|
||||||
|
|
@ -151,5 +151,27 @@
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||||
"transcode": "Braucht Transcoding (aufwendiger)"
|
"transcode": "Braucht Transcoding (aufwendiger)"
|
||||||
|
},
|
||||||
|
"playlistAdd": {
|
||||||
|
"manage": "Zur Playlist",
|
||||||
|
"title": "Zur Playlist — {{title}}",
|
||||||
|
"newPlaceholder": "Name der neuen Playlist…",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"none": "Noch keine Playlists. Erstelle oben eine.",
|
||||||
|
"count": "{{count}} Titel"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"section": "Playlists",
|
||||||
|
"newPlaceholder": "Neue Playlist…",
|
||||||
|
"create": "Playlist erstellen",
|
||||||
|
"none": "Noch keine Playlists.",
|
||||||
|
"deleteConfirm": "Playlist „{{title}}“ löschen?",
|
||||||
|
"rename": "Umbenennen",
|
||||||
|
"playAll": "Alle abspielen",
|
||||||
|
"delete": "Playlist löschen",
|
||||||
|
"empty": "Diese Playlist ist leer. Füge Titel über deren Infoseite hinzu.",
|
||||||
|
"up": "Nach oben",
|
||||||
|
"down": "Nach unten",
|
||||||
|
"remove": "Entfernen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,5 +151,27 @@
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
"remux": "Needs a light remux (no video re-encode)",
|
"remux": "Needs a light remux (no video re-encode)",
|
||||||
"transcode": "Needs transcoding (heavier)"
|
"transcode": "Needs transcoding (heavier)"
|
||||||
|
},
|
||||||
|
"playlistAdd": {
|
||||||
|
"manage": "Add to playlist",
|
||||||
|
"title": "Add to playlist — {{title}}",
|
||||||
|
"newPlaceholder": "New playlist name…",
|
||||||
|
"create": "Create",
|
||||||
|
"none": "No playlists yet. Create one above.",
|
||||||
|
"count": "{{count}} items"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"section": "Playlists",
|
||||||
|
"newPlaceholder": "New playlist…",
|
||||||
|
"create": "Create playlist",
|
||||||
|
"none": "No playlists yet.",
|
||||||
|
"deleteConfirm": "Delete the playlist \"{{title}}\"?",
|
||||||
|
"rename": "Rename",
|
||||||
|
"playAll": "Play all",
|
||||||
|
"delete": "Delete playlist",
|
||||||
|
"empty": "This playlist is empty. Add titles from their info page.",
|
||||||
|
"up": "Move up",
|
||||||
|
"down": "Move down",
|
||||||
|
"remove": "Remove"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,5 +151,27 @@
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||||
"transcode": "Transcode kell (nehezebb)"
|
"transcode": "Transcode kell (nehezebb)"
|
||||||
|
},
|
||||||
|
"playlistAdd": {
|
||||||
|
"manage": "Listához adás",
|
||||||
|
"title": "Listához adás — {{title}}",
|
||||||
|
"newPlaceholder": "Új lista neve…",
|
||||||
|
"create": "Létrehozás",
|
||||||
|
"none": "Még nincs lista. Hozz létre egyet fent.",
|
||||||
|
"count": "{{count}} elem"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"section": "Lejátszási listák",
|
||||||
|
"newPlaceholder": "Új lista…",
|
||||||
|
"create": "Lista létrehozása",
|
||||||
|
"none": "Még nincs lista.",
|
||||||
|
"deleteConfirm": "Törlöd a(z) „{{title}}” listát?",
|
||||||
|
"rename": "Átnevezés",
|
||||||
|
"playAll": "Összes lejátszása",
|
||||||
|
"delete": "Lista törlése",
|
||||||
|
"empty": "Ez a lista üres. Adj hozzá címeket az info-oldalukról.",
|
||||||
|
"up": "Fel",
|
||||||
|
"down": "Le",
|
||||||
|
"remove": "Eltávolítás"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -652,6 +652,7 @@ export interface PlexCard {
|
||||||
season_count?: number | null; // show
|
season_count?: number | null; // show
|
||||||
season_number?: number | null; // episode
|
season_number?: number | null; // episode
|
||||||
episode_number?: number | null; // episode
|
episode_number?: number | null; // episode
|
||||||
|
show_title?: string | null; // episode (in a mixed playlist)
|
||||||
summary?: string | null; // episode
|
summary?: string | null; // episode
|
||||||
}
|
}
|
||||||
export interface PlexBrowseResult {
|
export interface PlexBrowseResult {
|
||||||
|
|
@ -730,6 +731,18 @@ export interface PlexCollection {
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
|
||||||
}
|
}
|
||||||
|
export interface PlexPlaylist {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
item_count: number;
|
||||||
|
thumb?: string | null;
|
||||||
|
has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
|
||||||
|
}
|
||||||
|
export interface PlexPlaylistDetail {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
items: PlexCard[];
|
||||||
|
}
|
||||||
export interface PlexCastMember {
|
export interface PlexCastMember {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string | null;
|
role?: string | null;
|
||||||
|
|
@ -1164,6 +1177,22 @@ export const api = {
|
||||||
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
|
req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
|
||||||
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
|
plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
|
||||||
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
|
req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
|
||||||
|
// --- Playlists (Siftlode-native, per-user, ordered) ---
|
||||||
|
plexPlaylists: (contains?: string): Promise<{ playlists: PlexPlaylist[] }> =>
|
||||||
|
req(`/api/plex/playlists${contains ? `?contains=${encodeURIComponent(contains)}` : ""}`),
|
||||||
|
plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
|
||||||
|
plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
|
||||||
|
req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
|
||||||
|
plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
|
||||||
|
req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
|
||||||
|
plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
|
||||||
|
req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
|
||||||
|
plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
|
||||||
|
plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
|
||||||
|
req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
|
||||||
|
plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
|
||||||
|
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,15 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.30.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
summary: "Plex playlists — your own ordered watch-lists.",
|
||||||
|
features: [
|
||||||
|
"Plex playlists: create your own ordered lists of films (each user has their own — no Plex account needed). Add a film to a playlist from its info page (\"Add to playlist\"), then find your playlists in the Plex sidebar.",
|
||||||
|
"Plex playlists: open a playlist to reorder (up/down), remove items, rename or delete it, and hit \"Play all\" to watch straight through — the player steps through the whole list in order, auto-advancing to the next.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.29.0",
|
version: "0.29.0",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue