The grid served Hungarian titles, but every other card surface (Discover rails, the show page's episodes, search, collections, the person page, playlists, Home's resume rail, hero and comfort shelf) served English. The card serializers now take a required keyword-only lang (a forgotten call site fails instead of going English), and each of those reads takes ?lang=hu, with the language in its query key. A Hungarian search finds an episode by its Hungarian title too. An episode on a poster rail showed its 16:9 still cropped into a poster, titled only with the episode name; it now shows the show's poster and name, with S·E · title under it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the number in the reader's language; a season with a name of its own keeps it. Home's comfort-shelf move wrote its optimistic order to the key without the language, which nothing read any more; it writes the query's own key.
471 lines
15 KiB
Python
471 lines
15 KiB
Python
"""Plex playlists: the current user's personal, orderable playlists of playable items."""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from typing import Annotated
|
|
|
|
from pydantic import BaseModel, Field, model_validator
|
|
from sqlalchemy import delete, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.models import (
|
|
PlexItem,
|
|
PlexPlaylist,
|
|
PlexPlaylistItem,
|
|
PlexShow,
|
|
PlexState,
|
|
User,
|
|
)
|
|
from app.plex.cards import display_title, leaf_card, norm_lang
|
|
from app.plex.lookups import by_rating_key
|
|
from app.routes._common import owned_or_404, not_found
|
|
from app.refusal import refused
|
|
|
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|
|
|
|
|
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(pid: int):
|
|
return (
|
|
select(PlexPlaylistItem, PlexItem)
|
|
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
|
|
.where(PlexPlaylistItem.playlist_id == pid)
|
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
|
)
|
|
|
|
|
|
@router.get("/playlists")
|
|
def playlists(
|
|
contains: str | None = None,
|
|
contains_group: 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 single 'add to playlist' dialog).
|
|
`contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the
|
|
group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
|
|
contains_id = None
|
|
if contains:
|
|
contains_id = db.scalar(select(PlexItem.id).where(PlexItem.rating_key == str(contains)))
|
|
group_ids: set[int] | None = None
|
|
if contains_group is not None:
|
|
rks = [s for s in (contains_group or "").split(",") if s]
|
|
group_ids = set(
|
|
db.execute(select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""]))).scalars()
|
|
)
|
|
out = []
|
|
for pl in db.execute(
|
|
select(PlexPlaylist)
|
|
.where(PlexPlaylist.user_id == user.id)
|
|
.order_by(PlexPlaylist.updated_at.desc())
|
|
).scalars():
|
|
count = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id)
|
|
)
|
|
or 0
|
|
)
|
|
first = db.execute(_playlist_item_query(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.execute(
|
|
select(PlexPlaylistItem.id).where(
|
|
PlexPlaylistItem.playlist_id == pl.id,
|
|
PlexPlaylistItem.item_id == contains_id,
|
|
)
|
|
).first()
|
|
is not None
|
|
)
|
|
if group_ids is not None:
|
|
card["group_in"] = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(
|
|
PlexPlaylistItem.playlist_id == pl.id,
|
|
PlexPlaylistItem.item_id.in_(group_ids or [0]),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
out.append(card)
|
|
resp: dict = {"playlists": out}
|
|
if group_ids is not None:
|
|
resp["group_size"] = len(group_ids)
|
|
return resp
|
|
|
|
|
|
class CreatePlexPlaylistIn(BaseModel):
|
|
title: str = ""
|
|
item_rating_key: str = ""
|
|
|
|
|
|
@router.post("/playlists")
|
|
def create_playlist(
|
|
body: CreatePlexPlaylistIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
title = body.title.strip()
|
|
if not title:
|
|
raise refused(400, "title_required")
|
|
pl = PlexPlaylist(user_id=user.id, title=title)
|
|
db.add(pl)
|
|
db.flush()
|
|
seed = body.item_rating_key
|
|
count = 0
|
|
thumb_rk = None
|
|
if seed:
|
|
it = by_rating_key(db, PlexItem, seed)
|
|
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,
|
|
lang: str = "en",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
lang = norm_lang(lang)
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
|
rows = db.execute(_playlist_item_query(pid)).all()
|
|
items = [it for (_pi, it) in rows]
|
|
states = {
|
|
s.item_id: s
|
|
for s in db.execute(
|
|
select(PlexState).where(
|
|
PlexState.user_id == user.id,
|
|
PlexState.item_id.in_([it.id for it in items] or [0]),
|
|
)
|
|
).scalars()
|
|
}
|
|
shows = {
|
|
sh.id: sh
|
|
for sh in db.execute(
|
|
select(PlexShow).where(
|
|
PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])
|
|
)
|
|
).scalars()
|
|
}
|
|
cards = [
|
|
leaf_card(
|
|
it,
|
|
states.get(it.id),
|
|
display_title(shows[it.show_id], lang) if it.show_id in shows else None,
|
|
shows[it.show_id].rating_key if it.show_id in shows else None,
|
|
lang=lang,
|
|
)
|
|
for it in items
|
|
]
|
|
return {"id": pl.id, "title": pl.title, "items": cards}
|
|
|
|
|
|
class RenamePlexPlaylistIn(BaseModel):
|
|
title: str = ""
|
|
|
|
|
|
@router.patch("/playlists/{pid}")
|
|
def rename_playlist(
|
|
pid: int,
|
|
body: RenamePlexPlaylistIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
|
title = body.title.strip()
|
|
if not title:
|
|
raise refused(400, "title_required")
|
|
pl.title = title
|
|
db.commit()
|
|
count = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pl.id)
|
|
)
|
|
or 0
|
|
)
|
|
first = db.execute(_playlist_item_query(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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
|
db.delete(pl)
|
|
db.commit()
|
|
return {"deleted": pid}
|
|
|
|
|
|
class PlexItemRefIn(BaseModel):
|
|
item_rating_key: str = ""
|
|
|
|
|
|
@router.post("/playlists/{pid}/items")
|
|
def playlist_add_item(
|
|
pid: int,
|
|
body: PlexItemRefIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
# Locked (like every add / reorder here): two adds otherwise read the same max position.
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
|
if it is None:
|
|
raise not_found("item")
|
|
exists = (
|
|
db.execute(
|
|
select(PlexPlaylistItem).where(
|
|
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
|
)
|
|
)
|
|
.scalars()
|
|
.first()
|
|
)
|
|
if exists is None:
|
|
maxpos = db.scalar(
|
|
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
|
PlexPlaylistItem.playlist_id == pid
|
|
)
|
|
)
|
|
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:
|
|
# Locked like the adds and the reorder: the list row first, then its items — a remove that
|
|
# locked an item row before a reorder locked the list deadlocked against it.
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
it = by_rating_key(db, PlexItem, item_rating_key)
|
|
if it is not None:
|
|
db.execute(
|
|
delete(PlexPlaylistItem).where(
|
|
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
|
|
)
|
|
)
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return {"ok": True}
|
|
|
|
|
|
class PlexItemKeysIn(BaseModel):
|
|
item_rating_keys: list[str] = []
|
|
|
|
|
|
def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
|
"""Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys)."""
|
|
order = [str(k) for k in rks if str(k)]
|
|
if not order:
|
|
return []
|
|
by_rk = {
|
|
it.rating_key: it
|
|
for it in db.execute(select(PlexItem).where(PlexItem.rating_key.in_(order))).scalars()
|
|
}
|
|
seen: set[str] = set()
|
|
out: list[PlexItem] = []
|
|
for rk in order:
|
|
it = by_rk.get(rk)
|
|
if it is not None and rk not in seen:
|
|
seen.add(rk)
|
|
out.append(it)
|
|
return out
|
|
|
|
|
|
class PlexItemAddBulkIn(PlexItemKeysIn):
|
|
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
|
|
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
|
|
# at the end whenever the second call fails, and the order it sends would overwrite anything
|
|
# another tab changed in between.
|
|
positions: list[Annotated[int, Field(ge=0)]] | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _one_position_per_key(self):
|
|
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
|
|
raise ValueError("positions must pair one-to-one with item_rating_keys")
|
|
return self
|
|
|
|
|
|
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
|
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
|
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
|
already in the list does not move: the add stays idempotent."""
|
|
rows = list(
|
|
db.execute(
|
|
select(PlexPlaylistItem)
|
|
.where(PlexPlaylistItem.playlist_id == pid)
|
|
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
|
).scalars()
|
|
)
|
|
present = {r.item_id for r in rows}
|
|
added = 0
|
|
for at, it in sorted(placed, key=lambda p: p[0]):
|
|
if it.id in present:
|
|
continue
|
|
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
|
db.add(row)
|
|
rows.insert(min(at, len(rows)), row)
|
|
present.add(it.id)
|
|
added += 1
|
|
for i, r in enumerate(rows):
|
|
r.position = i
|
|
return added
|
|
|
|
|
|
@router.post("/playlists/{pid}/items/bulk")
|
|
def playlist_add_bulk(
|
|
pid: int,
|
|
body: PlexItemAddBulkIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
|
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
|
instead of appended. Returns how many were added + the new total."""
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
if body.positions is not None:
|
|
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
|
placed = [
|
|
(at, by_rk[str(rk)])
|
|
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
|
if str(rk) in by_rk
|
|
]
|
|
added = _insert_at(db, pid, placed)
|
|
if added:
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
total = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
|
)
|
|
or 0
|
|
)
|
|
return {"ok": True, "added": added, "item_count": total}
|
|
items = _items_by_rks(db, body.item_rating_keys)
|
|
existing = set(
|
|
db.execute(
|
|
select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid)
|
|
).scalars()
|
|
)
|
|
pos = int(
|
|
db.scalar(
|
|
select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
|
|
PlexPlaylistItem.playlist_id == pid
|
|
)
|
|
)
|
|
)
|
|
added = 0
|
|
for it in items:
|
|
if it.id in existing:
|
|
continue
|
|
pos += 1
|
|
db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos))
|
|
existing.add(it.id)
|
|
added += 1
|
|
if added:
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
total = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
|
)
|
|
or 0
|
|
)
|
|
return {"ok": True, "added": added, "item_count": total}
|
|
|
|
|
|
@router.post("/playlists/{pid}/items/remove-bulk")
|
|
def playlist_remove_bulk(
|
|
pid: int,
|
|
body: PlexItemKeysIn,
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
|
removed + the new total."""
|
|
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
|
removed = 0
|
|
if ids:
|
|
removed = db.execute(
|
|
delete(PlexPlaylistItem).where(
|
|
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)
|
|
),
|
|
execution_options={"synchronize_session": False},
|
|
).rowcount
|
|
if removed:
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
total = (
|
|
db.scalar(
|
|
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
|
)
|
|
or 0
|
|
)
|
|
return {"ok": True, "removed": removed, "item_count": total}
|
|
|
|
|
|
@router.put("/playlists/{pid}/order")
|
|
def reorder_playlist(
|
|
pid: int,
|
|
body: PlexItemKeysIn,
|
|
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 = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
|
order = body.item_rating_keys
|
|
id_by_rk = {
|
|
it.rating_key: it.id
|
|
for it in db.execute(
|
|
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
|
).scalars()
|
|
}
|
|
rows = {
|
|
r.item_id: r
|
|
for r in db.execute(
|
|
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
|
|
).scalars()
|
|
}
|
|
pos = 0
|
|
seen: set[int] = set()
|
|
for rk in order:
|
|
row = rows.get(id_by_rk.get(rk, -1))
|
|
if row is not None and row.item_id not in seen:
|
|
row.position = pos
|
|
pos += 1
|
|
seen.add(row.item_id)
|
|
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
|
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
|
for row in sorted(
|
|
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
|
):
|
|
row.position = pos
|
|
pos += 1
|
|
pl.updated_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
return {"ok": True}
|