From da9dcf93d55b2c1b00e94974cd55196054e347c8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:37:09 +0200 Subject: [PATCH 01/28] =?UTF-8?q?feat(playlists):=20backend=20foundation?= =?UTF-8?q?=20=E2=80=94=20model,=20migration,=20CRUD=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-user local playlists: Playlist + PlaylistItem models (with forward-looking kind/source/yt_playlist_id/dirty columns for the later YouTube-sync phases) and migration 0011. New /api/playlists routes: list (with optional contains= membership flags for the add-to-playlist popover), create, get detail (items serialized via the feed serializer, with per-user watch state), rename, delete, add/remove item, and reorder. Watch later (built-in) is protected from deletion. --- backend/alembic/versions/0011_playlists.py | 88 +++++++ backend/app/main.py | 3 +- backend/app/models.py | 61 +++++ backend/app/routes/playlists.py | 261 +++++++++++++++++++++ 4 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0011_playlists.py create mode 100644 backend/app/routes/playlists.py diff --git a/backend/alembic/versions/0011_playlists.py b/backend/alembic/versions/0011_playlists.py new file mode 100644 index 0000000..efd5c05 --- /dev/null +++ b/backend/alembic/versions/0011_playlists.py @@ -0,0 +1,88 @@ +"""local playlists: playlists + playlist_items + +Revision ID: 0011_playlists +Revises: 0010_watch_progress +Create Date: 2026-06-15 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0011_playlists" +down_revision: Union[str, None] = "0010_watch_progress" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "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("name", sa.String(length=255), nullable=False), + sa.Column( + "kind", sa.String(length=16), nullable=False, server_default="user" + ), + sa.Column( + "source", sa.String(length=16), nullable=False, server_default="local" + ), + sa.Column("yt_playlist_id", sa.String(length=64), nullable=True), + sa.Column( + "dirty", sa.Boolean(), nullable=False, server_default="false" + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index("ix_playlists_user_id", "playlists", ["user_id"]) + + op.create_table( + "playlist_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "playlist_id", + sa.Integer(), + sa.ForeignKey("playlists.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "video_id", + sa.String(length=16), + sa.ForeignKey("videos.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "added_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + op.create_index("ix_playlist_items_playlist_id", "playlist_items", ["playlist_id"]) + op.create_index("ix_playlist_items_video_id", "playlist_items", ["video_id"]) + + +def downgrade() -> None: + op.drop_index("ix_playlist_items_video_id", table_name="playlist_items") + op.drop_index("ix_playlist_items_playlist_id", table_name="playlist_items") + op.drop_table("playlist_items") + op.drop_index("ix_playlists_user_id", table_name="playlists") + op.drop_table("playlists") diff --git a/backend/app/main.py b/backend/app/main.py index cb07a68..34fef9e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,7 +26,7 @@ from starlette.middleware.sessions import SessionMiddleware from app import auth from app.config import settings -from app.routes import admin, channels, feed, health, me, quota, sync, tags, version +from app.routes import admin, channels, feed, health, me, playlists, quota, sync, tags, version from app.scheduler import shutdown_scheduler, start_scheduler @@ -66,6 +66,7 @@ app.include_router(tags.router) app.include_router(feed.router) app.include_router(me.router) app.include_router(channels.router) +app.include_router(playlists.router) app.include_router(admin.router) app.include_router(quota.router) app.include_router(version.router) diff --git a/backend/app/models.py b/backend/app/models.py index 46bb24c..546e645 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -301,3 +301,64 @@ class AppState(Base): sync_paused: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false" ) + + +class Playlist(Base): + """A per-user named, ordered collection of videos from the shared catalog. + + `kind`: 'user' (normal) or 'watch_later' (built-in, undeletable, one per user). + `source`: 'local' (created here) or 'youtube' (mirrored from the user's YouTube + playlists). `yt_playlist_id` links a mirrored/exported playlist to its YouTube id. + `dirty` marks a YouTube-linked playlist with local edits not yet pushed, so the read + sync won't clobber them. (source/yt_playlist_id/dirty are forward-looking for the YT + sync phases; the foundation phase only uses local playlists.)""" + + __tablename__ = "playlists" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + kind: Mapped[str] = mapped_column(String(16), default="user", server_default="user") + source: Mapped[str] = mapped_column( + String(16), default="local", server_default="local" + ) + yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) + dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + items: Mapped[list["PlaylistItem"]] = relationship( + back_populates="playlist", + cascade="all, delete-orphan", + order_by="PlaylistItem.position", + ) + + +class PlaylistItem(Base): + """A video's membership in a playlist, with its order within that playlist.""" + + __tablename__ = "playlist_items" + __table_args__ = ( + UniqueConstraint("playlist_id", "video_id", name="uq_playlist_video"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + playlist_id: Mapped[int] = mapped_column( + ForeignKey("playlists.id", ondelete="CASCADE"), index=True + ) + video_id: Mapped[str] = mapped_column( + ForeignKey("videos.id", ondelete="CASCADE"), index=True + ) + position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + added_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + + playlist: Mapped["Playlist"] = relationship(back_populates="items") diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py new file mode 100644 index 0000000..ba90de3 --- /dev/null +++ b/backend/app/routes/playlists.py @@ -0,0 +1,261 @@ +"""Local playlists: per-user named, ordered collections of videos from the shared +catalog. Foundation phase = local only (create / rename / delete, add / remove / reorder +items). YouTube two-way sync (mirror existing playlists, push edits) lands in later phases.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select, and_ +from sqlalchemy.orm import Session, aliased + +from app.auth import current_user +from app.db import get_db +from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState +from app.routes.feed import _serialize + +router = APIRouter(prefix="/api/playlists", tags=["playlists"]) + + +def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: + pl = db.get(Playlist, playlist_id) + if pl is None or pl.user_id != user.id: + raise HTTPException(status_code=404, detail="Unknown playlist") + return pl + + +def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: + cover = db.scalar( + select(Video.thumbnail_url) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + .limit(1) + ) + out = { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "item_count": count, + "cover_thumbnail": cover, + } + if has_video is not None: + out["has_video"] = has_video + return out + + +@router.get("") +def list_playlists( + contains: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> list[dict]: + """The user's playlists (newest custom order). With `contains=`, each entry + also reports whether that video is already in it — used by the add-to-playlist popover.""" + pls = ( + db.execute( + select(Playlist) + .where(Playlist.user_id == user.id) + .order_by(Playlist.position, Playlist.created_at) + ) + .scalars() + .all() + ) + if not pls: + return [] + ids = [p.id for p in pls] + counts = dict( + db.execute( + select(PlaylistItem.playlist_id, func.count()) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) + member: set[int] = set() + if contains: + member = set( + db.execute( + select(PlaylistItem.playlist_id).where( + PlaylistItem.playlist_id.in_(ids), + PlaylistItem.video_id == contains, + ) + ) + .scalars() + .all() + ) + return [ + _summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None) + for p in pls + ] + + +@router.post("") +def create_playlist( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist(user_id=user.id, name=name, position=next_pos) + db.add(pl) + db.commit() + return _summary(db, pl, 0) + + +@router.get("/{playlist_id}") +def get_playlist( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + state = aliased(VideoState) + rows = db.execute( + select( + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + func.coalesce(state.status, "new").label("status"), + func.coalesce(state.position_seconds, 0).label("position_seconds"), + ) + .join(PlaylistItem, PlaylistItem.video_id == Video.id) + .join(Channel, Channel.id == Video.channel_id) + .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ).all() + return { + "id": pl.id, + "name": pl.name, + "kind": pl.kind, + "source": pl.source, + "yt_playlist_id": pl.yt_playlist_id, + "items": [_serialize(r) for r in rows], + } + + +@router.patch("/{playlist_id}") +def rename_playlist( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if "name" in payload: + name = (payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name cannot be empty") + pl.name = name + db.commit() + count = db.scalar( + select(func.count()).where(PlaylistItem.playlist_id == pl.id) + ) + return _summary(db, pl, count or 0) + + +@router.delete("/{playlist_id}") +def delete_playlist( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") + db.delete(pl) # items cascade + db.commit() + return {"deleted": playlist_id} + + +@router.post("/{playlist_id}/items") +def add_item( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add( + PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) + ) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.delete("/{playlist_id}/items/{video_id}") +def remove_item( + playlist_id: int, + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + db.commit() + return {"playlist_id": pl.id, "video_id": video_id} + + +@router.put("/{playlist_id}/order") +def reorder_items( + playlist_id: int, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = _own_playlist(db, user, playlist_id) + order = payload.get("video_ids") or [] + items = { + it.video_id: it + for it in db.execute( + select(PlaylistItem).where(PlaylistItem.playlist_id == pl.id) + ).scalars() + } + pos = 0 + for vid in order: + it = items.get(vid) + if it is not None: + it.position = pos + pos += 1 + db.commit() + return {"playlist_id": pl.id, "count": pos} From 37804ee39399e66be981977048d19b200a0470b4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 14:45:18 +0200 Subject: [PATCH 02/28] =?UTF-8?q?feat(playlists):=20frontend=20foundation?= =?UTF-8?q?=20=E2=80=94=20page,=20add-to-playlist,=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Playlists page (left rail of playlists + detail with drag&drop reorder, inline rename, one-step delete, remove item, Play all / row-click playback via the existing PlayerModal) and an AddToPlaylist popover (portaled, multi-toggle + inline new-playlist) wired into the VideoCard hover overlay and the PlayerModal. New api client methods + Playlist types, a 'playlists' page route + account-menu entry, and trilingual strings. Local only — YouTube sync comes in later phases. --- frontend/src/App.tsx | 3 + frontend/src/components/AddToPlaylist.tsx | 173 ++++++++++ frontend/src/components/Header.tsx | 8 +- frontend/src/components/PlayerModal.tsx | 9 +- frontend/src/components/Playlists.tsx | 349 ++++++++++++++++++++ frontend/src/components/VideoCard.tsx | 2 + frontend/src/i18n/locales/de/header.json | 1 + frontend/src/i18n/locales/de/playlists.json | 15 + frontend/src/i18n/locales/en/header.json | 1 + frontend/src/i18n/locales/en/playlists.json | 15 + frontend/src/i18n/locales/hu/header.json | 1 + frontend/src/i18n/locales/hu/playlists.json | 15 + frontend/src/lib/api.ts | 42 +++ frontend/src/lib/urlState.ts | 4 +- 14 files changed, 634 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/AddToPlaylist.tsx create mode 100644 frontend/src/components/Playlists.tsx create mode 100644 frontend/src/i18n/locales/de/playlists.json create mode 100644 frontend/src/i18n/locales/en/playlists.json create mode 100644 frontend/src/i18n/locales/hu/playlists.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 719da76..af987fc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,7 @@ import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; import Channels, { type ChannelStatusFilter } from "./components/Channels"; +import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; @@ -218,6 +219,8 @@ export default function App() { /> ) : page === "stats" && meQuery.data!.role === "admin" ? ( + ) : page === "playlists" ? ( + ) : ( (null); + const panelRef = useRef(null); + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(false); + + const membership = useQuery({ + queryKey: ["playlist-membership", videoId], + queryFn: () => api.playlists(videoId), + enabled: open, + }); + + function place() { + const r = triggerRef.current?.getBoundingClientRect(); + if (!r) return; + const width = 240; + setCoords({ + top: Math.round(r.bottom + 6), + left: Math.round(Math.min(r.left, window.innerWidth - width - 8)), + }); + } + + function toggleOpen(e: React.MouseEvent) { + e.preventDefault(); + e.stopPropagation(); + if (!open) place(); + setOpen((o) => !o); + } + + useEffect(() => { + if (!open) return; + function onDoc(e: MouseEvent) { + if ( + !panelRef.current?.contains(e.target as Node) && + !triggerRef.current?.contains(e.target as Node) + ) + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + function refresh() { + qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); + qc.invalidateQueries({ queryKey: ["playlists"] }); + } + + async function toggle(pl: Playlist) { + if (busy) return; + setBusy(true); + try { + if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); + else await api.addToPlaylist(pl.id, videoId); + refresh(); + } finally { + setBusy(false); + } + } + + async function createAndAdd(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name || busy) return; + setBusy(true); + try { + const pl = await api.createPlaylist(name); + await api.addToPlaylist(pl.id, videoId); + setNewName(""); + refresh(); + } finally { + setBusy(false); + } + } + + const lists = membership.data ?? []; + + return ( + <> + + + {open && + createPortal( +
e.stopPropagation()} + > +
+ {t("playlists.addToPlaylist")} +
+
+ {lists.length === 0 && !membership.isLoading && ( +
+ {t("playlists.noneYet")} +
+ )} + {lists.map((pl) => ( + + ))} +
+
+ + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + +
, + document.body + )} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 15dc8d0..7729f51 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; +import { BarChart3, Home, Info, Library, ListVideo, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; @@ -194,6 +194,12 @@ function AccountMenu({ {t("header.account.channels")} )} + {page !== "playlists" && ( + + )} {me.role === "admin" && page !== "stats" && ( + {index + 1} + + + {video.duration_seconds != null && ( + + {formatDuration(video.duration_seconds)} + + )} + + + ); +} + +export default function Playlists() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [newName, setNewName] = useState(""); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const [items, setItems] = useState([]); + const [active, setActive] = useState<{ video: Video; startAt: number | null } | null>(null); + + const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); + const playlists = listQuery.data ?? []; + + // Default the selection to the first playlist once the list loads. + useEffect(() => { + if (selectedId == null && playlists.length) setSelectedId(playlists[0].id); + }, [playlists, selectedId]); + + const detailQuery = useQuery({ + queryKey: ["playlist", selectedId], + queryFn: () => api.playlist(selectedId as number), + enabled: selectedId != null, + }); + const detail = detailQuery.data; + + useEffect(() => { + if (detail) setItems(detail.items); + }, [detail]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) + ); + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + function refreshAll() { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + } + + async function onDragEnd(e: DragEndEvent) { + const { active: a, over } = e; + if (!over || a.id === over.id || selectedId == null) return; + const oldIndex = items.findIndex((v) => v.id === a.id); + const newIndex = items.findIndex((v) => v.id === over.id); + if (oldIndex < 0 || newIndex < 0) return; + const next = arrayMove(items, oldIndex, newIndex); + setItems(next); + await api.reorderPlaylist(selectedId, next.map((v) => v.id)); + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + } + + async function removeItem(videoId: string) { + if (selectedId == null) return; + setItems((cur) => cur.filter((v) => v.id !== videoId)); + await api.removeFromPlaylist(selectedId, videoId); + refreshAll(); + } + + async function saveRename() { + if (selectedId == null) return; + const name = renameValue.trim(); + setRenaming(false); + if (name && name !== detail?.name) { + await api.renamePlaylist(selectedId, name); + refreshAll(); + } + } + + async function deletePlaylist() { + if (selectedId == null || !detail) return; + if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) return; + await api.deletePlaylist(selectedId); + setSelectedId(null); + qc.invalidateQueries({ queryKey: ["playlists"] }); + } + + function playerState(id: string, status: string) { + api.setState(id, status).then(() => { + qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + qc.invalidateQueries({ queryKey: ["feed"] }); + }); + } + + return ( +
+ + +
+ {!detail ? ( +
+ {selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} +
+ ) : ( + <> +
+
+ {items[0]?.thumbnail_url && ( + + )} +
+
+ {renaming ? ( + setRenameValue(e.target.value)} + onBlur={saveRename} + onKeyDown={(e) => { + if (e.key === "Enter") saveRename(); + if (e.key === "Escape") setRenaming(false); + }} + className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent" + /> + ) : ( +
+

{detail.name}

+ +
+ )} +
+ {t("playlists.itemCount", { count: items.length })} +
+
+ + +
+
+
+ + {items.length === 0 ? ( +
+
+ + {t("playlists.empty")} +
+
+ ) : ( + + v.id)} + strategy={verticalListSortingStrategy} + > +
+ {items.map((v, i) => ( + setActive({ video: v, startAt: null })} + onRemove={() => removeItem(v.id)} + /> + ))} +
+
+
+ )} + + )} +
+ + {active && ( + setActive(null)} + onState={playerState} + /> + )} +
+ ); +} diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 12cfda4..489b14f 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -11,6 +11,7 @@ import { RotateCcw, } from "lucide-react"; import Avatar from "./Avatar"; +import AddToPlaylist from "./AddToPlaylist"; import clsx from "clsx"; import type { Video } from "../lib/api"; import { formatDuration, formatViews, relativeTime } from "../lib/format"; @@ -56,6 +57,7 @@ function Actions({ > + + + + + )} + + ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index e52f3c9..d9dd279 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -29,6 +29,7 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import PlayerModal from "./PlayerModal"; +import { useConfirm } from "./ConfirmProvider"; function Row({ video, @@ -98,6 +99,7 @@ function Row({ export default function Playlists() { const { t } = useTranslation(); const qc = useQueryClient(); + const confirm = useConfirm(); const [selectedId, setSelectedId] = useState(null); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); @@ -173,7 +175,13 @@ export default function Playlists() { async function deletePlaylist() { if (selectedId == null || !detail) return; - if (!window.confirm(t("playlists.confirmDelete", { name: detail.name }))) return; + const ok = await confirm({ + title: t("playlists.delete"), + message: t("playlists.confirmDelete", { name: detail.name }), + confirmLabel: t("playlists.delete"), + danger: true, + }); + if (!ok) return; await api.deletePlaylist(selectedId); setSelectedId(null); qc.invalidateQueries({ queryKey: ["playlists"] }); diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index fe81995..96a3e73 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -3,6 +3,8 @@ "termsOfService": "Nutzungsbedingungen", "close": "Schließen", "cancel": "Abbrechen", + "confirm": "Bestätigen", + "confirmTitle": "Bist du sicher?", "save": "Speichern", "loading": "Wird geladen…", "language": "Sprache", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index f594fe5..a33b2b4 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -3,6 +3,8 @@ "termsOfService": "Terms of Service", "close": "Close", "cancel": "Cancel", + "confirm": "Confirm", + "confirmTitle": "Are you sure?", "save": "Save", "loading": "Loading…", "language": "Language", diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index 4584d03..d155cd1 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -3,6 +3,8 @@ "termsOfService": "Felhasználási feltételek", "close": "Bezárás", "cancel": "Mégse", + "confirm": "Megerősítés", + "confirmTitle": "Biztos vagy benne?", "save": "Mentés", "loading": "Betöltés…", "language": "Nyelv", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 44320e3..88bb2c6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; +import { ConfirmProvider } from "./components/ConfirmProvider"; import PrivacyPolicy from "./components/legal/PrivacyPolicy"; import Terms from "./components/legal/Terms"; import "./i18n"; @@ -21,7 +22,9 @@ const root = path === "/terms" ? : ( - + + + ); From 26e72d5d007471ca037a7c5c9044f5830f355f7d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:15:56 +0200 Subject: [PATCH 07/28] fix(playlists): correctly clear/reselect after deleting a playlist The post-delete auto-select effect re-picked the just-deleted id from the still-stale playlists cache, so its detail lingered. Select the next remaining playlist directly on delete (or null), drop the deleted detail from the cache, and make the effect re-validate the selection against the current list (clear when empty, reselect when the current id is gone). Also fix the header title showing "Channel manager" on the Playlists page. --- frontend/src/components/Header.tsx | 6 +++++- frontend/src/components/Playlists.tsx | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7729f51..1d535f5 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -90,7 +90,11 @@ export default function Header({ ) : (
- {page === "stats" ? t("header.usageStats") : t("header.channelManager")} + {page === "stats" + ? t("header.usageStats") + : page === "playlists" + ? t("header.account.playlists") + : t("header.channelManager")}
)} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d9dd279..b547cf5 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -110,9 +110,16 @@ export default function Playlists() { const listQuery = useQuery({ queryKey: ["playlists"], queryFn: () => api.playlists() }); const playlists = listQuery.data ?? []; - // Default the selection to the first playlist once the list loads. + // Keep the selection valid: default to the first playlist, and if the selected one + // disappears (e.g. just deleted) drop to the next available, or clear when none remain. useEffect(() => { - if (selectedId == null && playlists.length) setSelectedId(playlists[0].id); + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } }, [playlists, selectedId]); const detailQuery = useQuery({ @@ -182,8 +189,13 @@ export default function Playlists() { danger: true, }); if (!ok) return; - await api.deletePlaylist(selectedId); - setSelectedId(null); + const deletedId = selectedId; + // Pick the next selection from what's left *now* (don't go via null, or the + // auto-select effect would re-pick the just-deleted id from the stale list). + const remaining = playlists.filter((p) => p.id !== deletedId); + setSelectedId(remaining[0]?.id ?? null); + await api.deletePlaylist(deletedId); + qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.invalidateQueries({ queryKey: ["playlists"] }); } From 5400de341812cf9e2d4a558b7480ecf352698673 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:20:28 +0200 Subject: [PATCH 08/28] fix(playlists): refresh playlist detail after add/remove from the feed popover Adding a video to a playlist via the AddToPlaylist popover invalidated the membership and the playlist list, but not the playlist *detail* query, so the Playlists page served stale cached items (the new video only appeared after F5). Invalidate the ["playlist"] detail queries too. --- frontend/src/components/AddToPlaylist.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 84dd169..637bb34 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -86,6 +86,9 @@ export default function AddToPlaylist({ function refresh() { qc.invalidateQueries({ queryKey: ["playlist-membership", videoId] }); qc.invalidateQueries({ queryKey: ["playlists"] }); + // Also invalidate every open/cached playlist detail (["playlist", id]) so the + // Playlists page reflects the add/remove when navigated to, not only after F5. + qc.invalidateQueries({ queryKey: ["playlist"] }); } async function toggle(pl: Playlist) { From 2f661968166a08ab7e589aee3fd529da383704e7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:33:53 +0200 Subject: [PATCH 09/28] feat(playlists): unify Saved into a built-in Watch later playlist The old per-video status='saved' becomes membership in a built-in, undeletable 'watch_later' playlist. Migration 0012 moves existing saved videos into each user's Watch later list and demotes the states to 'new'. The feed/playlist serializers now expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of setting a status. Removed the 'saved' show-filter and status. Watch later shows a localized name and hides rename/delete in the Playlists page and add-to-playlist popover. --- backend/alembic/versions/0012_watch_later.py | 65 ++++++++++++++++ backend/app/routes/feed.py | 38 ++++++++-- backend/app/routes/playlists.py | 80 +++++++++++++++++++- frontend/src/components/AddToPlaylist.tsx | 4 +- frontend/src/components/Feed.tsx | 29 ++++++- frontend/src/components/Playlists.tsx | 45 ++++++----- frontend/src/components/Sidebar.tsx | 2 +- frontend/src/components/VideoCard.tsx | 21 +++-- frontend/src/i18n/locales/de/playlists.json | 1 + frontend/src/i18n/locales/en/playlists.json | 1 + frontend/src/i18n/locales/hu/playlists.json | 1 + frontend/src/lib/api.ts | 9 +++ 12 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 backend/alembic/versions/0012_watch_later.py diff --git a/backend/alembic/versions/0012_watch_later.py b/backend/alembic/versions/0012_watch_later.py new file mode 100644 index 0000000..fd5a847 --- /dev/null +++ b/backend/alembic/versions/0012_watch_later.py @@ -0,0 +1,65 @@ +"""unify Saved into a built-in Watch later playlist + +Revision ID: 0012_watch_later +Revises: 0011_playlists +Create Date: 2026-06-15 + +Migrates the old per-video status='saved' into membership of a built-in 'watch_later' +playlist (one per user who had saved videos), then demotes those states to 'new'. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0012_watch_later" +down_revision: Union[str, None] = "0011_playlists" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 1) A Watch later playlist for each user that has saved videos (and none yet). + op.execute( + """ + INSERT INTO playlists (user_id, name, kind, source, dirty, position, created_at, updated_at) + SELECT DISTINCT vs.user_id, 'Watch later', 'watch_later', 'local', false, 0, now(), now() + FROM video_states vs + WHERE vs.status = 'saved' + AND NOT EXISTS ( + SELECT 1 FROM playlists p + WHERE p.user_id = vs.user_id AND p.kind = 'watch_later' + ) + """ + ) + # 2) Add each saved video as an item, ordered (newest-saved first → position 0). + op.execute( + """ + INSERT INTO playlist_items (playlist_id, video_id, position, added_at) + SELECT p.id, vs.video_id, + row_number() OVER ( + PARTITION BY vs.user_id + ORDER BY COALESCE(vs.watched_at, vs.updated_at) DESC + ) - 1, + COALESCE(vs.watched_at, vs.updated_at, now()) + FROM video_states vs + JOIN playlists p ON p.user_id = vs.user_id AND p.kind = 'watch_later' + WHERE vs.status = 'saved' + ON CONFLICT (playlist_id, video_id) DO NOTHING + """ + ) + # 3) Demote the migrated saved states to the default. + op.execute("UPDATE video_states SET status = 'new' WHERE status = 'saved'") + + +def downgrade() -> None: + # Best-effort reverse: restore status='saved' for videos in watch_later lists, then drop them. + op.execute( + """ + UPDATE video_states vs SET status = 'saved' + FROM playlist_items pi + JOIN playlists p ON p.id = pi.playlist_id AND p.kind = 'watch_later' + WHERE pi.video_id = vs.video_id AND p.user_id = vs.user_id + AND vs.status = 'new' + """ + ) + op.execute("DELETE FROM playlists WHERE kind = 'watch_later'") diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index b743b92..0ef733b 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user from app.db import get_db -from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState +from app.models import ( + Channel, + ChannelTag, + Playlist, + PlaylistItem, + Subscription, + Tag, + User, + Video, + VideoState, +) from app.sync.videos import parse_iso8601_duration from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api", tags=["feed"]) -VALID_STATES = {"new", "watched", "saved", "hidden"} +# "saved" used to be a status; it's now membership in the built-in Watch later playlist. +VALID_STATES = {"new", "watched", "hidden"} HIDDEN_LIVE = ("live", "upcoming") # Resume-position thresholds (mirror the client): positions below this are "didn't @@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str: return f"https://www.youtube.com/channel/{channel_id}" +def _saved_expr(user: User): + """A correlated EXISTS labelled `saved`: is this video in the user's Watch later list? + (Watch later is the built-in playlist that replaced the old per-video 'saved' status.)""" + return ( + select(1) + .select_from(PlaylistItem) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .where( + Playlist.user_id == user.id, + Playlist.kind == "watch_later", + PlaylistItem.video_id == Video.id, + ) + .exists() + .label("saved") + ) + + def _serialize(row) -> dict: return { "id": row.id, @@ -45,6 +73,7 @@ def _serialize(row) -> dict: "live_status": row.live_status, "status": row.status or "new", "position_seconds": row.position_seconds or 0, + "saved": bool(getattr(row, "saved", False)), "watch_url": f"https://www.youtube.com/watch?v={row.id}", } @@ -96,6 +125,7 @@ def _filtered_query( Video.live_status, status_expr.label("status"), position_expr.label("position_seconds"), + _saved_expr(user), ).join(Channel, Channel.id == Video.channel_id) if scope == "all": @@ -176,7 +206,7 @@ def _filtered_query( # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. - explicit_view = show in ("watched", "saved", "hidden") + explicit_view = show in ("watched", "hidden") if not explicit_view: type_clauses = [] if show_normal: @@ -198,8 +228,6 @@ def _filtered_query( ) elif show == "watched": query = query.where(status_expr == "watched") - elif show == "saved": - query = query.where(status_expr == "saved") elif show == "hidden": query = query.where(status_expr == "hidden") else: # all diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index ba90de3..885cd4f 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, aliased from app.auth import current_user from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState -from app.routes.feed import _serialize +from app.routes.feed import _saved_expr, _serialize router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -110,6 +110,83 @@ def create_playlist( return _summary(db, pl, 0) +def _watch_later(db: Session, user: User) -> Playlist: + """The user's built-in Watch later playlist, created on first use.""" + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(Playlist.position), -1)).where( + Playlist.user_id == user.id + ) + ) + + 1 + ) + pl = Playlist( + user_id=user.id, name="Watch later", kind="watch_later", position=next_pos + ) + db.add(pl) + db.commit() + return pl + + +@router.post("/watch-later") +def add_watch_later( + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Bookmark shortcut: add a video to the built-in Watch later playlist.""" + video_id = payload.get("video_id") + if not video_id or db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + pl = _watch_later(db, user) + exists = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if exists is None: + next_pos = ( + db.scalar( + select(func.coalesce(func.max(PlaylistItem.position), -1)).where( + PlaylistItem.playlist_id == pl.id + ) + ) + + 1 + ) + db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)) + db.commit() + return {"saved": True, "playlist_id": pl.id} + + +@router.delete("/watch-later/{video_id}") +def remove_watch_later( + video_id: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + pl = db.scalar( + select(Playlist).where( + Playlist.user_id == user.id, Playlist.kind == "watch_later" + ) + ) + if pl is not None: + item = db.scalar( + select(PlaylistItem).where( + PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id + ) + ) + if item is not None: + db.delete(item) + db.commit() + return {"saved": False} + + @router.get("/{playlist_id}") def get_playlist( playlist_id: int, @@ -134,6 +211,7 @@ def get_playlist( Video.live_status, func.coalesce(state.status, "new").label("status"), func.coalesce(state.position_seconds, 0).label("position_seconds"), + _saved_expr(user), ) .join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(Channel, Channel.id == Video.channel_id) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 637bb34..ab27f28 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -165,7 +165,9 @@ export default function AddToPlaylist({ > {pl.has_video && } - {pl.name} + + {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name} + ))} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 7380bb0..9133f7b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean { return status === "hidden"; case "watched": return status === "watched"; - case "saved": - return status === "saved"; case "unwatched": case "in_progress": // (in_progress is further narrowed server-side by resume position; here we only @@ -42,6 +40,7 @@ export default function Feed({ }) { const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); + const [savedOverrides, setSavedOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( null @@ -64,8 +63,14 @@ export default function Feed({ // arrives — after a refetch the server is authoritative, so a stale override // (e.g. a video reverted to "new" from the notification center) won't keep it // filtered out of the current view. - useEffect(() => setOverrides({}), [filters]); - useEffect(() => setOverrides({}), [query.dataUpdatedAt]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [filters]); + useEffect(() => { + setOverrides({}); + setSavedOverrides({}); + }, [query.dataUpdatedAt]); const countQuery = useQuery({ queryKey: ["feed-count", filters], @@ -139,10 +144,24 @@ export default function Feed({ [filters, setFilters] ); + const onToggleSave = useCallback( + (id: string, saved: boolean) => { + setSavedOverrides((o) => ({ ...o, [id]: saved })); + (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id)) + .then(() => { + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }) + .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved }))); + }, + [qc] + ); + const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); loadedRef.current = loaded; const items: Video[] = loaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) + .map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); if (query.isLoading) return
{t("feed.loading")}
; @@ -192,6 +211,7 @@ export default function Feed({ video={v} view="grid" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> @@ -205,6 +225,7 @@ export default function Feed({ video={v} view="list" onState={onState} + onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} onOpen={openVideo} /> diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index b547cf5..eee2080 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -137,6 +137,11 @@ export default function Playlists() { useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) ); + // Watch later is a built-in playlist: show a localized name and no rename/delete. + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + const builtin = detail?.kind === "watch_later"; + const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), onSuccess: (pl: Playlist) => { @@ -230,7 +235,7 @@ export default function Playlists() { )}
-
{pl.name}
+
{plName(pl)}
{t("playlists.itemCount", { count: pl.item_count })}
@@ -287,17 +292,19 @@ export default function Playlists() { /> ) : (
-

{detail.name}

- +

{plName(detail)}

+ {!builtin && ( + + )}
)}
@@ -311,12 +318,14 @@ export default function Playlists() { > {t("playlists.playAll")} - + {!builtin && ( + + )}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 95a8980..e846ba9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -50,7 +50,7 @@ const SORT_IDS = [ "shuffle", ]; -const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; +const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"]; // Fresh shuffle token for the "surprise me" sort. const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 489b14f..597470c 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format"; function Actions({ video, onState, + onToggleSave, onChannelFilter, }: { video: Video; onState: (id: string, status: string) => void; + onToggleSave: (id: string, saved: boolean) => void; onChannelFilter?: (channelId: string, channelName: string) => void; }) { const { t } = useTranslation(); @@ -31,6 +33,11 @@ function Actions({ e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); }; + const toggleSave = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onToggleSave(video.id, !video.saved); + }; return (
- + ); } @@ -295,7 +304,7 @@ function VideoCard({ {video.channel_title}
{meta}
- + diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 42f11b5..6d4851f 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -1,5 +1,6 @@ { "title": "Wiedergabelisten", + "watchLater": "Später ansehen", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 9aefd88..9bef503 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,5 +1,6 @@ { "title": "Playlists", + "watchLater": "Watch later", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 2aa48ed..dcfcedd 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,5 +1,6 @@ { "title": "Lejátszási listák", + "watchLater": "Megnézem később", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3908a87..f41535c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ export interface Video { live_status: string; status: string; position_seconds: number; + saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; } @@ -335,6 +336,14 @@ export const api = { method: "PUT", body: JSON.stringify({ video_ids: videoIds }), }), + // Bookmark shortcut for the built-in Watch later playlist. + watchLaterAdd: (videoId: string) => + req("/api/playlists/watch-later", { + method: "POST", + body: JSON.stringify({ video_id: videoId }), + }), + watchLaterRemove: (videoId: string) => + req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), From bd085fc88fa8eaad3edb64d87505d7b4e5baf5f0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 16:11:37 +0200 Subject: [PATCH 10/28] feat(playlists): queue playback in the player (auto-advance + prev/next) PlayerModal now accepts an optional queue + startIndex. The active item drives the player; it recreates per item (reusing the single-video resume / auto-watch / progress logic), auto-advances to the next item when one ends, and shows Previous / Next controls with an N / M indicator. The Playlists page passes the playlist as the queue from Play all or a clicked row. Trilingual previous/next strings. --- frontend/src/components/PlayerModal.tsx | 119 ++++++++++++++++------- frontend/src/components/Playlists.tsx | 17 ++-- frontend/src/i18n/locales/de/player.json | 2 + frontend/src/i18n/locales/en/player.json | 2 + frontend/src/i18n/locales/hu/player.json | 2 + 5 files changed, 99 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 50ff483..508b941 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; +import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react"; import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; @@ -184,6 +184,8 @@ function loadYouTubeApi(): Promise { export default function PlayerModal({ video, startAt, + queue, + startIndex, onClose, onState, }: { @@ -191,30 +193,40 @@ export default function PlayerModal({ // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined // to resume from the server-saved position carried on the video. startAt?: number | null; + // Optional playback queue (e.g. a playlist). When present, the player advances through + // it (auto-advance on end + prev/next), recreating per item to reuse the single-video + // resume / auto-watch / progress logic. + queue?: Video[]; + startIndex?: number; onClose: () => void; onState: (id: string, status: string) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); - // Resume point for the video we opened with. - const resumeAt = startAt != null ? startAt : video.position_seconds || 0; + // The active item is queue[index] (falls back to the single opened video). + const [index, setIndex] = useState(startIndex ?? 0); + const active = queue && queue[index] ? queue[index] : video; + const hasQueue = !!queue && queue.length > 1; + // startAt only applies to the item we opened with; advanced items resume their own pos. + const resumeAt = + startAt != null && active.id === video.id ? startAt : active.position_seconds || 0; const mountRef = useRef(null); const playerRef = useRef(null); const autoMarkedRef = useRef(false); // The player can navigate to other videos (YouTube links in a description). The - // currently-playing id may differ from the feed video we opened with. - const [currentVideoId, setCurrentVideoId] = useState(video.id); - const currentIdRef = useRef(video.id); - const navigated = currentVideoId !== video.id; + // currently-playing id may differ from the active item. + const [currentVideoId, setCurrentVideoId] = useState(active.id); + const currentIdRef = useRef(active.id); + const navigated = currentVideoId !== active.id; // Title/author of a navigated-to video, read from the player (free, no API call). const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null); const loadVideo = (id: string, start: number | null) => { const p = playerRef.current; if (!p || typeof p.loadVideoById !== "function") return; - // Explicit start wins; otherwise resume the opened video, start others at 0. - const startSeconds = start != null ? start : id === video.id ? resumeAt : 0; + // Explicit start wins; otherwise resume the active item, start others at 0. + const startSeconds = start != null ? start : id === active.id ? resumeAt : 0; p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 }); currentIdRef.current = id; setCurrentVideoId(id); @@ -223,14 +235,22 @@ export default function PlayerModal({ // Local mirror of watch status so the toggle reacts instantly; changes are // propagated to the feed (and server) via onState. - const [status, setStatus] = useState(video.status); + const [status, setStatus] = useState(active.status); const watched = status === "watched"; const setWatched = (on: boolean) => { const next = on ? "watched" : "new"; setStatus(next); - onState(video.id, next); + onState(active.id, next); }; + // On queue advance (active changes), sync local state to the new item. + useEffect(() => { + setStatus(active.status); + setCurrentVideoId(active.id); + currentIdRef.current = active.id; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active.id]); + const seekTo = (seconds: number) => { const p = playerRef.current; if (p && typeof p.seekTo === "function") { @@ -292,10 +312,11 @@ export default function PlayerModal({ // auto-mark watched once playback reaches the end. useEffect(() => { let cancelled = false; - const id = video.id; + const id = active.id; + autoMarkedRef.current = false; - // Auto-watch only applies to the feed video we opened with — not to other - // videos the player navigated to via description links. + // Auto-watch only applies to the active item — not to other videos the player + // navigated to via description links. const maybeAutoWatch = (current: number, duration: number) => { if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; if (current > duration - FINISH_MARGIN) { @@ -342,10 +363,13 @@ export default function PlayerModal({ const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null; if (d) setLiveData({ title: d.title, author: d.author }); } - // 0 === ended → mark watched (guarded to the feed video inside maybeAutoWatch). - if (e?.data === 0 && !autoMarkedRef.current && currentIdRef.current === id) { - autoMarkedRef.current = true; - setWatched(true); + // 0 === ended → mark watched, then advance to the next queue item if any. + if (e?.data === 0 && currentIdRef.current === id) { + if (!autoMarkedRef.current) { + autoMarkedRef.current = true; + setWatched(true); + } + if (queue && index < queue.length - 1) setIndex(index + 1); } }, }, @@ -358,10 +382,11 @@ export default function PlayerModal({ return () => { cancelled = true; window.clearInterval(timer); - // Final flush, then refresh the feed so the card's resume bar reflects this session. - Promise.resolve(persist()).finally(() => - qc.invalidateQueries({ queryKey: ["feed"] }) - ); + // Final flush, then refresh the feed + playlists so resume bars reflect this session. + Promise.resolve(persist()).finally(() => { + qc.invalidateQueries({ queryKey: ["feed"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + }); const p = playerRef.current; if (p && typeof p.destroy === "function") { try { @@ -373,7 +398,7 @@ export default function PlayerModal({ playerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [video.id]); + }, [active.id]); return (
+ {hasQueue && ( +
+ + + {index + 1} / {queue!.length} + + +
+ )} +
{/* Title row — title (with hover description) on the left, Close on the right. */}
@@ -401,12 +448,12 @@ export default function PlayerModal({ onMouseEnter={openDesc} onMouseLeave={scheduleCloseDesc} > - {navigated ? liveData?.title ?? t("player.loading") : video.title} + {navigated ? liveData?.title ?? t("player.loading") : active.title} {navigated && ( + {readOnly ? ( + + ) : ( + + )} {index + 1} + {!readOnly && ( + + )}
); } @@ -149,6 +160,25 @@ export default function Playlists() { const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; const builtin = detail?.kind === "watch_later"; + // YouTube-mirrored playlists are read-only here until the write-back phase. + const mirrored = detail?.source === "youtube"; + const editable = !builtin && !mirrored; + const [syncing, setSyncing] = useState(false); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ message: t("playlists.syncedToast", { count: r.synced }) }); + } catch { + notify({ level: "warning", message: t("playlists.syncFailed") }); + } finally { + setSyncing(false); + } + } const createMut = useMutation({ mutationFn: (name: string) => api.createPlaylist(name), @@ -166,7 +196,7 @@ export default function Playlists() { async function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; - if (!over || a.id === over.id || selectedId == null) return; + if (!over || a.id === over.id || selectedId == null || mirrored) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; @@ -222,7 +252,19 @@ export default function Playlists() { return (
-
{plName(pl)}
+
+ {plName(pl)} + {pl.source === "youtube" && ( + + )} +
{t("playlists.itemCount", { count: pl.item_count })}
@@ -301,7 +348,7 @@ export default function Playlists() { ) : (

{plName(detail)}

- {!builtin && ( + {editable && ( - {!builtin && ( + {editable && ( )} + {mirrored && ( + + {t("playlists.ytReadonly")} + + )}
@@ -361,6 +413,7 @@ export default function Playlists() { key={v.id} video={v} index={i} + readOnly={mirrored} onPlay={() => setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 6d4851f..4b8728d 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -1,6 +1,10 @@ { "title": "Wiedergabelisten", "watchLater": "Später ansehen", + "syncYoutube": "Von YouTube synchronisieren", + "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", + "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", + "ytReadonly": "Von YouTube synchronisiert — vorerst schreibgeschützt", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 9bef503..85ed5d9 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -1,6 +1,10 @@ { "title": "Playlists", "watchLater": "Watch later", + "syncYoutube": "Sync from YouTube", + "syncedToast": "Synced {{count}} playlists from YouTube", + "syncFailed": "Couldn't sync from YouTube", + "ytReadonly": "Synced from YouTube — read-only for now", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index dcfcedd..0f30811 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -1,6 +1,10 @@ { "title": "Lejátszási listák", "watchLater": "Megnézem később", + "syncYoutube": "Szinkron YouTube-ról", + "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", + "syncFailed": "Nem sikerült a YouTube-szinkron", + "ytReadonly": "YouTube-ról szinkronizálva — egyelőre csak olvasható", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f41535c..3472d9b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -344,6 +344,8 @@ export const api = { }), watchLaterRemove: (videoId: string) => req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), + syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => + req("/api/playlists/sync-youtube", { method: "POST" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), From f1495594af384f079909893d0e69b1c388e23507 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 19:46:37 +0200 Subject: [PATCH 15/28] fix(notifications): use the success level for success toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Synced N playlists' and 'View link copied' toasts used the default info level, which renders in the accent colour and read as an error/alert. Mark them level:success so they show the green check — clearly positive and theme-independent. --- frontend/src/components/Playlists.tsx | 2 +- frontend/src/components/Sidebar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index c38c696..2eb6b4a 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -172,7 +172,7 @@ export default function Playlists() { const r = await api.syncYoutubePlaylists(); qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist"] }); - notify({ message: t("playlists.syncedToast", { count: r.synced }) }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); } catch { notify({ level: "warning", message: t("playlists.syncFailed") }); } finally { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e846ba9..0bb9f7d 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -202,7 +202,7 @@ export default function Sidebar({ async function shareView() { try { await navigator.clipboard.writeText(shareUrl(filters)); - notify({ message: t("sidebar.shareCopied") }); + notify({ level: "success", message: t("sidebar.shareCopied") }); } catch { notify({ level: "warning", message: t("sidebar.shareFailed") }); } From 2b79ba66375cc38fadf3687208588529b4d6c748 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:22:51 +0200 Subject: [PATCH 16/28] feat(playlists): YouTube write API client methods Add OAuth-only write helpers to YouTubeClient (each 50 quota units): create_playlist, add_playlist_item, move_playlist_item, delete_playlist_item, delete_playlist, plus iter_playlist_items_with_ids for diffing. A shared _write helper records quota and raises YouTubeError on non-2xx. --- backend/app/youtube/client.py | 87 +++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 9d22961..4bea310 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -213,6 +213,93 @@ class YouTubeClient: f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}" ) + def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]: + """Yield {item_id, video_id} for each item of one of the user's playlists, in + playlist order. `item_id` is the playlistItem resource id, needed to delete or + reposition the item via the write API. OAuth (private playlists work).""" + page_token = None + while True: + params = { + "part": "contentDetails", + "playlistId": playlist_id, + "maxResults": 50, + } + if page_token: + params["pageToken"] = page_token + data = self._get("playlistItems", params, cost=1, allow_key=False) + for item in data.get("items", []): + vid = item.get("contentDetails", {}).get("videoId") + if vid: + yield {"item_id": item.get("id"), "video_id": vid} + page_token = data.get("nextPageToken") + if not page_token: + break + + # --- write endpoints (OAuth only, never the API key; each costs 50 quota units) --- + def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict: + headers = {"Authorization": f"Bearer {self._access_token()}"} + resp = self._http.request( + method, f"{API_BASE}/{path}", params=params, json=json, headers=headers + ) + quota.record_usage(self.db, 50) + if resp.status_code not in (200, 204): + log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200]) + raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") + return resp.json() if resp.content else {} + + def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: + """Create a new YouTube playlist; returns its id. 50 units.""" + data = self._write( + "POST", + "playlists", + params={"part": "snippet,status"}, + json={ + "snippet": {"title": title[:150], "description": description[:5000]}, + "status": {"privacyStatus": privacy}, + }, + ) + return data["id"] + + def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str: + """Append (or insert at `position`) a video into a playlist; returns the new + playlistItem id. 50 units.""" + snippet = { + "playlistId": playlist_id, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + } + if position is not None: + snippet["position"] = position + data = self._write( + "POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet} + ) + return data["id"] + + def move_playlist_item( + self, item_id: str, playlist_id: str, video_id: str, position: int + ) -> None: + """Reposition an existing playlistItem to absolute 0-based `position`. 50 units.""" + self._write( + "PUT", + "playlistItems", + params={"part": "snippet"}, + json={ + "id": item_id, + "snippet": { + "playlistId": playlist_id, + "position": position, + "resourceId": {"kind": "youtube#video", "videoId": video_id}, + }, + }, + ) + + def delete_playlist_item(self, item_id: str) -> None: + """Remove a playlistItem from its playlist. 50 units.""" + self._write("DELETE", "playlistItems", params={"id": item_id}) + + def delete_playlist(self, playlist_id: str) -> None: + """Delete a whole playlist on YouTube. 50 units.""" + self._write("DELETE", "playlists", params={"id": playlist_id}) + def get_videos(self, video_ids: list[str]) -> list[dict]: items: list[dict] = [] for batch in _chunks(video_ids, 50): From b89c00f90903e544c917fb1e17e5b80eecec3537 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:23:02 +0200 Subject: [PATCH 17/28] feat(playlists): push local playlists to YouTube (export + diff sync) Local 'user' playlists (not Watch later, not YouTube mirrors) can now be pushed to YouTube: create a new playlist on first push, else reconcile membership + order so YouTube matches local (local wins). plan_push computes a dry-run estimate (inserts/deletes/reorders + quota units + divergence) read from the live YouTube state; push refuses if the estimate exceeds the remaining daily quota so a big playlist can't strand half-pushed. Reorder uses an insertion-sort move count that matches what executes. Edits to a linked playlist set dirty; a successful push clears it. DELETE accepts on_youtube to also delete the playlist on YouTube. The read-sync now skips YouTube playlists already owned by a local export, avoiding duplicate read-only mirrors. --- backend/app/routes/playlists.py | 102 ++++++++++++++++++- backend/app/sync/playlists.py | 168 +++++++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 4 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 7cc3bfb..f4ff7d1 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -6,11 +6,12 @@ from sqlalchemy import func, select, and_ from sqlalchemy.orm import Session, aliased from app import quota -from app.auth import current_user, has_read_scope +from app.auth import current_user, has_read_scope, has_write_scope from app.db import get_db from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.routes.feed import _saved_expr, _serialize -from app.sync.playlists import sync_user_playlists +from app.sync.playlists import plan_push, push_playlist, sync_user_playlists +from app.youtube.client import YouTubeClient, YouTubeError router = APIRouter(prefix="/api/playlists", tags=["playlists"]) @@ -22,6 +23,13 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: return pl +def _mark_dirty(pl: Playlist) -> None: + """Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can + offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists.""" + if pl.source == "local" and pl.yt_playlist_id: + pl.dirty = True + + def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: cover = db.scalar( select(Video.thumbnail_url) @@ -36,6 +44,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, "item_count": count, "cover_thumbnail": cover, } @@ -205,6 +214,72 @@ def sync_youtube( return sync_user_playlists(db, user) +def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: + """A local playlist the user may push to YouTube (create or update). The built-in + Watch later and read-only YouTube mirrors are never pushable.""" + pl = _own_playlist(db, user, playlist_id) + if pl.kind == "watch_later": + raise HTTPException( + status_code=400, detail="The Watch later list can't be sent to YouTube." + ) + if pl.source == "youtube": + raise HTTPException( + status_code=400, detail="This playlist already mirrors a YouTube playlist." + ) + return pl + + +@router.get("/{playlist_id}/push-plan") +def push_plan( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a + confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + except YouTubeError: + raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.") + plan["remaining_today"] = quota.remaining_today(db) + plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] + return plan + + +@router.post("/{playlist_id}/push") +def push( + playlist_id: int, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Push a local playlist to YouTube (create it on first push, else reconcile to match + local — local wins). Refuses if the estimated quota exceeds what's left today, so a + big playlist can't strand half-pushed.""" + pl = _pushable(db, user, playlist_id) + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_push"): + plan = plan_push(db, user, pl) + if plan["units_estimate"] > quota.remaining_today(db): + raise HTTPException( + status_code=429, + detail="Not enough YouTube quota left today for this sync.", + ) + return push_playlist(db, user, pl) + except YouTubeError: + db.rollback() + raise HTTPException(status_code=502, detail="YouTube playlist sync failed.") + + @router.get("/{playlist_id}") def get_playlist( playlist_id: int, @@ -243,6 +318,7 @@ def get_playlist( "kind": pl.kind, "source": pl.source, "yt_playlist_id": pl.yt_playlist_id, + "dirty": pl.dirty, "items": [_serialize(r) for r in rows], } @@ -270,12 +346,31 @@ def rename_playlist( @router.delete("/{playlist_id}") def delete_playlist( playlist_id: int, + on_youtube: bool = False, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: pl = _own_playlist(db, user, playlist_id) if pl.kind == "watch_later": raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") + if on_youtube: + if pl.source == "youtube": + raise HTTPException( + status_code=400, + detail="This is a mirror of a YouTube playlist; manage it on YouTube.", + ) + if not pl.yt_playlist_id: + raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") + if not has_write_scope(user): + raise HTTPException( + status_code=403, detail="Enable YouTube editing in Settings first." + ) + try: + with quota.attribute(user.id, "playlist_delete"): + with YouTubeClient(db, user) as yt: + yt.delete_playlist(pl.yt_playlist_id) + except YouTubeError: + raise HTTPException(status_code=502, detail="YouTube delete failed.") db.delete(pl) # items cascade db.commit() return {"deleted": playlist_id} @@ -309,6 +404,7 @@ def add_item( db.add( PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) ) + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -328,6 +424,7 @@ def remove_item( ) if item is not None: db.delete(item) + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -353,5 +450,6 @@ def reorder_items( if it is not None: it.position = pos pos += 1 + _mark_dirty(pl) db.commit() return {"playlist_id": pl.id, "count": pos} diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 1b185e7..6d6c832 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -12,13 +12,16 @@ from sqlalchemy import delete, select from sqlalchemy.orm import Session from app import quota -from app.auth import has_read_scope +from app.auth import has_read_scope, has_write_scope from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video from app.sync.videos import apply_video_details, parse_dt from app.youtube.client import YouTubeClient, YouTubeError log = logging.getLogger("subfeed.sync") +# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete). +WRITE_UNIT_COST = 50 + def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None: """Make sure every given video id exists in the catalog, fetching + ingesting the @@ -81,6 +84,19 @@ def sync_user_playlists(db: Session, user: User) -> dict: ) ).scalars() } + # YouTube playlists that we already own as local (exported) playlists must not be + # re-mirrored, or every exported list would also show up as a read-only duplicate. + owned_yt_ids = set( + db.execute( + select(Playlist.yt_playlist_id).where( + Playlist.user_id == user.id, + Playlist.source == "local", + Playlist.yt_playlist_id.is_not(None), + ) + ) + .scalars() + .all() + ) # Drop mirrors whose YouTube playlist no longer exists. for ytid, pl in existing.items(): if ytid not in yt_ids: @@ -89,7 +105,7 @@ def sync_user_playlists(db: Session, user: User) -> dict: for idx, p in enumerate(yt_playlists): ytid = p.get("id") - if not ytid: + if not ytid or ytid in owned_yt_ids: continue pl = existing.get(ytid) if pl is None: @@ -127,6 +143,154 @@ def sync_user_playlists(db: Session, user: User) -> dict: return {"synced": synced} +def _local_video_ids(db: Session, pl: Playlist) -> list[str]: + return list( + db.execute( + select(PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == pl.id) + .order_by(PlaylistItem.position) + ) + .scalars() + .all() + ) + + +def _reorder_moves(current: list[str], desired: list[str]) -> int: + """Count the playlistItems.update moves an insertion-sort would need to turn + `current` order into `desired` order (both already the same set of video ids). + Items already in place are skipped; this matches what apply_push actually executes.""" + model = list(current) + moves = 0 + for i, want in enumerate(desired): + if i < len(model) and model[i] == want: + continue + j = model.index(want, i) + model.pop(j) + model.insert(i, want) + moves += 1 + return moves + + +def plan_push(db: Session, user: User, pl: Playlist) -> dict: + """Compute (without writing) what pushing this local playlist to YouTube would do: + create vs update, how many inserts/deletes/reorders, the quota estimate, and whether + YouTube has diverged (items present there that local will remove). Costs read units + (1/page) for a linked playlist; nothing for a fresh export.""" + desired = _local_video_ids(db, pl) + if not pl.yt_playlist_id: + ops = 1 + len(desired) # create the playlist + one insert per video + return { + "action": "create", + "to_insert": len(desired), + "to_delete": 0, + "to_reorder": 0, + "yt_extra": 0, + "units_estimate": ops * WRITE_UNIT_COST, + } + with YouTubeClient(db, user) as yt: + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + cur_vids = [it["video_id"] for it in current] + cur_set = set(cur_vids) + desired_set = set(desired) + to_insert = [v for v in desired if v not in cur_set] + to_delete = [v for v in cur_vids if v not in desired_set] + # After membership reconciliation, the YT order is current-minus-deletes plus inserts + # appended; the reorder pass then moves items into the desired order. + after_membership = [v for v in cur_vids if v in desired_set] + to_insert + reorders = _reorder_moves(after_membership, desired) + ops = len(to_insert) + len(to_delete) + reorders + return { + "action": "update", + "to_insert": len(to_insert), + "to_delete": len(to_delete), + "to_reorder": reorders, + "yt_extra": len(to_delete), + "units_estimate": ops * WRITE_UNIT_COST, + } + + +def push_playlist(db: Session, user: User, pl: Playlist) -> dict: + """Push a local playlist to YouTube: create it if needed, then reconcile membership + and order so YouTube matches local (local wins). Marks the playlist clean on success. + Caller must have checked the write scope and the quota budget.""" + desired = _local_video_ids(db, pl) + inserted = deleted = reordered = created = 0 + failures: list[str] = [] + with YouTubeClient(db, user) as yt: + if not pl.yt_playlist_id: + pl.yt_playlist_id = yt.create_playlist(pl.name) + db.commit() + created = 1 + # Brand-new playlist: just append every video in order. + for vid in desired: + try: + yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + # Existing link: diff against the live YouTube state. + current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} + cur_vids = [it["video_id"] for it in current] + desired_set = set(desired) + # Delete extras (local is authoritative). + for it in current: + if it["video_id"] not in desired_set: + try: + yt.delete_playlist_item(it["item_id"]) + deleted += 1 + except YouTubeError: + failures.append(it["video_id"]) + # Insert missing (append; the reorder pass below fixes positions). + cur_set = set(cur_vids) + for vid in desired: + if vid not in cur_set: + try: + item_id_by_vid[vid] = yt.add_playlist_item(pl.yt_playlist_id, vid) + inserted += 1 + except YouTubeError: + failures.append(vid) + # Reorder to match `desired` via insertion-sort moves over a local model. + model = [v for v in cur_vids if v in desired_set] + [ + v for v in desired if v not in cur_set and v in item_id_by_vid + ] + for i, want in enumerate(desired): + if want not in item_id_by_vid: + continue # failed insert; skip + if i < len(model) and model[i] == want: + continue + try: + yt.move_playlist_item(item_id_by_vid[want], pl.yt_playlist_id, want, i) + reordered += 1 + except YouTubeError: + failures.append(want) + continue + j = model.index(want) + model.pop(j) + model.insert(i, want) + pl.dirty = False + db.commit() + return { + "created": created, + "inserted": inserted, + "deleted": deleted, + "reordered": reordered, + "failures": failures, + "yt_playlist_id": pl.yt_playlist_id, + } + + def sync_all_playlists(db: Session) -> dict: """Scheduler entry point: mirror playlists for every user with a read scope + token.""" users = ( From 05c2399b94313349c030a7207e9accc0c309d631 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:23:13 +0200 Subject: [PATCH 18/28] feat(playlists): Sync to YouTube UI + delete-on-YouTube choice Editable local playlists get an Export/Sync to YouTube button (write-scope gated): it fetches a dry-run plan, shows a confirm with the change counts, quota estimate and divergence warning, then pushes. An 'unsynced changes' badge and an accented YouTube icon mark linked playlists with local edits. Deleting a linked playlist offers 'delete on YouTube too' vs 'here only'. Trilingual strings (HU/EN/DE). --- frontend/src/App.tsx | 2 +- frontend/src/components/Playlists.tsx | 108 +++++++++++++++++++- frontend/src/i18n/locales/de/playlists.json | 20 +++- frontend/src/i18n/locales/en/playlists.json | 20 +++- frontend/src/i18n/locales/hu/playlists.json | 20 +++- frontend/src/lib/api.ts | 29 +++++- 6 files changed, 190 insertions(+), 9 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f45520e..21b27e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -232,7 +232,7 @@ export default function App() { ) : page === "stats" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( - + ) : ( 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra }); + const ok = await confirm({ + title: t("playlists.pushTitle"), + message, + confirmLabel: t("playlists.pushConfirm"), + }); + if (!ok) return; + const r = await api.pushPlaylist(selectedId); + refreshAll(); + if (r.failures.length) { + notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) }); + } else { + notify({ level: "success", message: t("playlists.pushDone") }); + } + } catch { + notify({ level: "warning", message: t("playlists.pushFailed") }); + } finally { + setPushing(false); + } + } async function syncYoutube() { if (syncing) return; @@ -232,12 +289,27 @@ export default function Playlists() { danger: true, }); if (!ok) return; + // For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only). + let onYoutube = false; + if (linked && canWrite) { + onYoutube = await confirm({ + title: t("playlists.deleteOnYoutubeTitle"), + message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }), + confirmLabel: t("playlists.deleteOnYoutubeConfirm"), + cancelLabel: t("playlists.deleteHereOnly"), + danger: true, + }); + } const deletedId = selectedId; // Pick the next selection from what's left *now* (don't go via null, or the // auto-select effect would re-pick the just-deleted id from the stale list). const remaining = playlists.filter((p) => p.id !== deletedId); setSelectedId(remaining[0]?.id ?? null); - await api.deletePlaylist(deletedId); + try { + await api.deletePlaylist(deletedId, onYoutube); + } catch { + notify({ level: "warning", message: t("playlists.deleteYtFailed") }); + } qc.removeQueries({ queryKey: ["playlist", deletedId] }); qc.invalidateQueries({ queryKey: ["playlists"] }); } @@ -287,8 +359,10 @@ export default function Playlists() {
{plName(pl)} - {pl.source === "youtube" && ( - + {(pl.source === "youtube" || pl.yt_playlist_id) && ( + )}
@@ -373,6 +447,27 @@ export default function Playlists() { > {t("playlists.playAll")} + {editable && canWrite && ( + + )} {editable && ( )} + {linked && detail.dirty && ( + + {t("playlists.unsynced")} + + )} {mirrored && ( {t("playlists.ytReadonly")} diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 4b8728d..0bb51a5 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -16,5 +16,23 @@ "delete": "Löschen", "rename": "Umbenennen", "confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.", - "addToPlaylist": "Zur Wiedergabeliste hinzufügen" + "addToPlaylist": "Zur Wiedergabeliste hinzufügen", + "exportToYoutube": "Zu YouTube exportieren", + "pushToYoutube": "Mit YouTube synchronisieren", + "unsynced": "Nicht synchronisierte Änderungen", + "pushTitle": "Mit YouTube synchronisieren", + "pushConfirm": "Synchronisieren", + "pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", + "pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)", + "pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.", + "pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.", + "pushUpToDate": "Bereits mit YouTube synchronisiert.", + "pushDone": "Mit YouTube synchronisiert ✓", + "pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.", + "pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.", + "deleteOnYoutubeTitle": "Auch auf YouTube löschen?", + "deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.", + "deleteOnYoutubeConfirm": "Auf YouTube löschen", + "deleteHereOnly": "Nur hier", + "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt." } diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 85ed5d9..bf798db 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -16,5 +16,23 @@ "delete": "Delete", "rename": "Rename", "confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.", - "addToPlaylist": "Add to playlist" + "addToPlaylist": "Add to playlist", + "exportToYoutube": "Export to YouTube", + "pushToYoutube": "Sync to YouTube", + "unsynced": "Unsynced changes", + "pushTitle": "Sync to YouTube", + "pushConfirm": "Sync", + "pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)", + "pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)", + "pushDiverged": "{{count}} video(s) currently on YouTube will be removed.", + "pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.", + "pushUpToDate": "Already in sync with YouTube.", + "pushDone": "Synced to YouTube ✓", + "pushPartial": "Synced, but {{count}} item(s) were skipped.", + "pushFailed": "Couldn't sync to YouTube.", + "deleteOnYoutubeTitle": "Delete on YouTube too?", + "deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.", + "deleteOnYoutubeConfirm": "Delete on YouTube", + "deleteHereOnly": "Here only", + "deleteYtFailed": "Couldn't delete on YouTube; removed here only." } diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 0f30811..16ed77f 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -16,5 +16,23 @@ "delete": "Törlés", "rename": "Átnevezés", "confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.", - "addToPlaylist": "Hozzáadás listához" + "addToPlaylist": "Hozzáadás listához", + "exportToYoutube": "Exportálás YouTube-ra", + "pushToYoutube": "Szinkron YouTube-ra", + "unsynced": "Nem szinkronizált változások", + "pushTitle": "Szinkron YouTube-ra", + "pushConfirm": "Szinkron", + "pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)", + "pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.", + "pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.", + "pushUpToDate": "Már szinkronban van a YouTube-bal.", + "pushDone": "Szinkronizálva a YouTube-ra ✓", + "pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.", + "pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.", + "deleteOnYoutubeTitle": "Törlöd a YouTube-on is?", + "deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.", + "deleteOnYoutubeConfirm": "Törlés a YouTube-on", + "deleteHereOnly": "Csak itt", + "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva." } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3472d9b..96c3b42 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -75,6 +75,7 @@ export interface Playlist { kind: string; // "user" | "watch_later" source: string; // "local" | "youtube" yt_playlist_id: string | null; + dirty: boolean; // linked local playlist has edits not yet pushed to YouTube item_count: number; cover_thumbnail: string | null; has_video?: boolean; // only set when listed with ?contains= @@ -86,9 +87,30 @@ export interface PlaylistDetail { kind: string; source: string; yt_playlist_id: string | null; + dirty: boolean; items: Video[]; } +export interface PushPlan { + action: "create" | "update"; + to_insert: number; + to_delete: number; + to_reorder: number; + yt_extra: number; // items on YouTube that the push would remove (divergence) + units_estimate: number; + remaining_today: number; + affordable: boolean; +} + +export interface PushResult { + created: number; + inserted: number; + deleted: number; + reordered: number; + failures: string[]; + yt_playlist_id: string | null; +} + export interface FeedFilters { tags: number[]; tagMode: "or" | "and"; @@ -323,7 +345,8 @@ export const api = { req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }), renamePlaylist: (id: number, name: string): Promise => req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }), - deletePlaylist: (id: number) => req(`/api/playlists/${id}`, { method: "DELETE" }), + deletePlaylist: (id: number, onYoutube = false) => + req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }), addToPlaylist: (id: number, videoId: string) => req(`/api/playlists/${id}/items`, { method: "POST", @@ -346,6 +369,10 @@ export const api = { req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }), syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> => req("/api/playlists/sync-youtube", { method: "POST" }), + playlistPushPlan: (id: number): Promise => + req(`/api/playlists/${id}/push-plan`), + pushPlaylist: (id: number): Promise => + req(`/api/playlists/${id}/push`, { method: "POST" }), // --- quota usage --- myUsage: (): Promise => req("/api/quota/my-usage"), From 2b072a10ded666b4bcaedafd557aed3e50009235 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:40:13 +0200 Subject: [PATCH 19/28] feat(playlists): make YouTube-mirrored playlists editable + syncable Mirrors (source='youtube') were read-only; per the original two-way design they can now be edited locally (add/remove/reorder/rename) and pushed back. The read-sync skips a dirty mirror so it won't clobber unpushed edits; a successful push clears dirty and lets the mirror refresh. _mark_dirty now covers any YouTube-linked list (exported local or mirror), and rename marks dirty too. push/delete no longer reject mirrors. Push reconciles the title as well (cheap playlists.list compare, then playlists.update only if changed) so a local rename doesn't revert on the next pull. Frontend: editable = not-built-in (was also excluding mirrors); rows/reorder enabled for all owned lists; AddToPlaylist popover lists mirrors too (with a YT marker); the origin chip now reads 'edits sync back'. Trilingual. --- backend/app/routes/playlists.py | 20 +++++++------------- backend/app/sync/playlists.py | 16 +++++++++++++++- backend/app/youtube/client.py | 21 +++++++++++++++++++++ frontend/src/components/AddToPlaylist.tsx | 13 ++++++++----- frontend/src/components/Playlists.tsx | 8 ++++---- frontend/src/i18n/locales/de/playlists.json | 2 +- frontend/src/i18n/locales/en/playlists.json | 2 +- frontend/src/i18n/locales/hu/playlists.json | 2 +- 8 files changed, 58 insertions(+), 26 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index f4ff7d1..e7942f6 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -24,9 +24,10 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: def _mark_dirty(pl: Playlist) -> None: - """Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can - offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists.""" - if pl.source == "local" and pl.yt_playlist_id: + """Flag a YouTube-linked playlist as having unpushed local edits, so the UI can offer + a 'Sync to YouTube' and the read-sync won't clobber them. Covers both exported local + playlists and edited YouTube mirrors; no-op for un-linked lists and Watch later.""" + if pl.kind != "watch_later" and pl.yt_playlist_id: pl.dirty = True @@ -222,10 +223,6 @@ def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: raise HTTPException( status_code=400, detail="The Watch later list can't be sent to YouTube." ) - if pl.source == "youtube": - raise HTTPException( - status_code=400, detail="This playlist already mirrors a YouTube playlist." - ) return pl @@ -335,7 +332,9 @@ def rename_playlist( name = (payload.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name cannot be empty") - pl.name = name + if name != pl.name: + pl.name = name + _mark_dirty(pl) db.commit() count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) @@ -354,11 +353,6 @@ def delete_playlist( if pl.kind == "watch_later": raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.") if on_youtube: - if pl.source == "youtube": - raise HTTPException( - status_code=400, - detail="This is a mirror of a YouTube playlist; manage it on YouTube.", - ) if not pl.yt_playlist_id: raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.") if not has_write_scope(user): diff --git a/backend/app/sync/playlists.py b/backend/app/sync/playlists.py index 6d6c832..5dfc85b 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -108,6 +108,10 @@ def sync_user_playlists(db: Session, user: User) -> dict: if not ytid or ytid in owned_yt_ids: continue pl = existing.get(ytid) + if pl is not None and pl.dirty: + # Local edits not yet pushed: don't clobber them with YouTube's state. + # A successful 'Sync to YouTube' clears dirty and lets the mirror refresh. + continue if pl is None: pl = Playlist( user_id=user.id, @@ -184,11 +188,14 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: "to_insert": len(desired), "to_delete": 0, "to_reorder": 0, + "rename": False, "yt_extra": 0, "units_estimate": ops * WRITE_UNIT_COST, } with YouTubeClient(db, user) as yt: current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + rename = bool(snippet) and (snippet.get("title") or "") != pl.name cur_vids = [it["video_id"] for it in current] cur_set = set(cur_vids) desired_set = set(desired) @@ -198,12 +205,13 @@ def plan_push(db: Session, user: User, pl: Playlist) -> dict: # appended; the reorder pass then moves items into the desired order. after_membership = [v for v in cur_vids if v in desired_set] + to_insert reorders = _reorder_moves(after_membership, desired) - ops = len(to_insert) + len(to_delete) + reorders + ops = len(to_insert) + len(to_delete) + reorders + (1 if rename else 0) return { "action": "update", "to_insert": len(to_insert), "to_delete": len(to_delete), "to_reorder": reorders, + "rename": rename, "yt_extra": len(to_delete), "units_estimate": ops * WRITE_UNIT_COST, } @@ -240,6 +248,12 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: } # Existing link: diff against the live YouTube state. + snippet = yt.get_playlist_snippet(pl.yt_playlist_id) + if snippet is not None and (snippet.get("title") or "") != pl.name: + try: + yt.update_playlist_title(pl.yt_playlist_id, pl.name) + except YouTubeError: + failures.append("title") current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)) item_id_by_vid = {it["video_id"]: it["item_id"] for it in current} cur_vids = [it["video_id"] for it in current] diff --git a/backend/app/youtube/client.py b/backend/app/youtube/client.py index 4bea310..f0d0da4 100644 --- a/backend/app/youtube/client.py +++ b/backend/app/youtube/client.py @@ -247,6 +247,27 @@ class YouTubeClient: raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}") return resp.json() if resp.content else {} + def get_playlist_snippet(self, playlist_id: str) -> dict | None: + """The snippet (title/description) of one playlist, or None if it no longer + exists. 1 unit (OAuth, so private playlists work).""" + data = self._get( + "playlists", + {"part": "snippet", "id": playlist_id, "maxResults": 1}, + cost=1, + allow_key=False, + ) + items = data.get("items", []) + return items[0].get("snippet") if items else None + + def update_playlist_title(self, playlist_id: str, title: str) -> None: + """Rename a playlist on YouTube. 50 units.""" + self._write( + "PUT", + "playlists", + params={"part": "snippet"}, + json={"id": playlist_id, "snippet": {"title": title[:150]}}, + ) + def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str: """Create a new YouTube playlist; returns its id. 50 units.""" data = self._write( diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index e46d447..3124c97 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, ListPlus, Plus } from "lucide-react"; +import { Check, ListPlus, Plus, Youtube } from "lucide-react"; import { api, type Playlist } from "../lib/api"; // A small popover (portaled to body, so the feed grid can't clip it) for toggling a @@ -118,9 +118,9 @@ export default function AddToPlaylist({ } } - // YouTube-mirrored playlists are read-only until the write-back phase, so they can't be - // added to here yet. - const lists = (membership.data ?? []).filter((pl) => pl.source !== "youtube"); + // All of the user's playlists are add targets, including YouTube-linked ones — adding + // marks them dirty, and a later "Sync to YouTube" pushes the change back. + const lists = membership.data ?? []; return ( <> @@ -167,9 +167,12 @@ export default function AddToPlaylist({ > {pl.has_video && } - + {pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name} + {(pl.source === "youtube" || pl.yt_playlist_id) && ( + + )} ))}
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index d6a08bd..6817e78 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -161,9 +161,10 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; const builtin = detail?.kind === "watch_later"; - // YouTube-mirrored playlists are read-only here until the write-back phase. + // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips + // a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. const mirrored = detail?.source === "youtube"; - const editable = !builtin && !mirrored; + const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); @@ -253,7 +254,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { async function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; - if (!over || a.id === over.id || selectedId == null || mirrored) return; + if (!over || a.id === over.id || selectedId == null) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; @@ -513,7 +514,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { key={v.id} video={v} index={i} - readOnly={mirrored} onPlay={() => setPlayingIndex(i)} onRemove={() => removeItem(v.id)} /> diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 0bb51a5..3e4ca7f 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Von YouTube synchronisieren", "syncedToast": "{{count}} Wiedergabelisten von YouTube synchronisiert", "syncFailed": "Synchronisierung von YouTube fehlgeschlagen", - "ytReadonly": "Von YouTube synchronisiert — vorerst schreibgeschützt", + "ytReadonly": "Von YouTube synchronisiert — Änderungen werden zurücksynchronisiert", "noneYet": "Noch keine Wiedergabelisten", "newPlaylist": "Neue Liste…", "itemCount_one": "{{count}} Video", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index bf798db..2f61a01 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Sync from YouTube", "syncedToast": "Synced {{count}} playlists from YouTube", "syncFailed": "Couldn't sync from YouTube", - "ytReadonly": "Synced from YouTube — read-only for now", + "ytReadonly": "Synced from YouTube — edits sync back", "noneYet": "No playlists yet", "newPlaylist": "New playlist…", "itemCount_one": "{{count}} video", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 16ed77f..233cb49 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -4,7 +4,7 @@ "syncYoutube": "Szinkron YouTube-ról", "syncedToast": "{{count}} lista szinkronizálva a YouTube-ról", "syncFailed": "Nem sikerült a YouTube-szinkron", - "ytReadonly": "YouTube-ról szinkronizálva — egyelőre csak olvasható", + "ytReadonly": "YouTube-ról szinkronizálva — a módosítások visszaszinkronizálhatók", "noneYet": "Még nincs lejátszási lista", "newPlaylist": "Új lista…", "itemCount_one": "{{count}} videó", From 2fa5a5c080fc2290601f31e96e3363d95440e49a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 21:52:28 +0200 Subject: [PATCH 20/28] feat(playlists): sort + group-by-channel with reusable undo/redo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a sort toolbar to the Playlists page: Title A-Z/Z-A, shortest/longest first, and a 'group by channel' toggle that groups items by channel (A-Z) and applies the chosen sort within each group. Applying a sort reorders the items and persists via the existing reorder API. The item order is now an undoable value: drag, sort and grouping all go through it, so every change is reversible via undo/redo buttons and Ctrl/Cmd+Z / Ctrl+Y (Ctrl+Shift+Z). Built on two reusable pieces, not playlist-specific: - useUndoable — a generic past/present/future snapshot hook (set/undo/redo/reset + canUndo/canRedo), ref-backed so callbacks are stable and never see a stale snapshot; onApply runs the side effect (here: persist order). - UndoToolbar — undo/redo buttons + keyboard shortcuts, plain props so any undo source can use it. Undo history is reset only when the item set actually changes (different playlist or add/remove), so a refetch confirming our own reorder doesn't wipe it; membership changes (remove) clear history since they aren't reorder-undoable. Trilingual. --- frontend/src/components/Playlists.tsx | 153 ++++++++++++++++++-- frontend/src/components/UndoToolbar.tsx | 61 ++++++++ frontend/src/i18n/locales/de/common.json | 2 + frontend/src/i18n/locales/de/playlists.json | 9 +- frontend/src/i18n/locales/en/common.json | 2 + frontend/src/i18n/locales/en/playlists.json | 9 +- frontend/src/i18n/locales/hu/common.json | 2 + frontend/src/i18n/locales/hu/playlists.json | 9 +- frontend/src/lib/useUndoable.ts | 90 ++++++++++++ 9 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/UndoToolbar.tsx create mode 100644 frontend/src/lib/useUndoable.ts diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 6817e78..0053e4e 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -32,9 +32,57 @@ import { import { api, type Playlist, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; +import { useUndoable } from "../lib/useUndoable"; import PlayerModal from "./PlayerModal"; +import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; +type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc"; + +function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null { + switch (mode) { + case "title-asc": + return (a, b) => (a.title ?? "").localeCompare(b.title ?? ""); + case "title-desc": + return (a, b) => (b.title ?? "").localeCompare(a.title ?? ""); + case "dur-asc": + return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity); + case "dur-desc": + return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity); + default: + return null; + } +} + +// Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered +// by channel name A–Z) and the chosen sort is applied within each group; with sort "manual" +// the within-group order is left as-is. Returns the same array reference when nothing would +// change, so it doesn't create a spurious undo entry. +function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { + const cmp = comparator(mode); + if (!group) return cmp ? [...items].sort(cmp) : items; + const groups = new Map(); + for (const v of items) { + const k = v.channel_title ?? ""; + const g = groups.get(k); + if (g) g.push(v); + else groups.set(k, [v]); + } + const keys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + const out: Video[] = []; + for (const k of keys) { + const g = groups.get(k)!; + out.push(...(cmp ? [...g].sort(cmp) : g)); + } + return out; +} + +const idsKey = (items: Video[]) => + items + .map((v) => v.id) + .sort() + .join(","); + function Row({ video, index, @@ -116,7 +164,23 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); - const [items, setItems] = useState([]); + // The item order is undoable: drag, sort and group all go through `order.set`, so each is + // reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server. + const order = useUndoable([], { + onApply: (next) => { + if (selectedId == null) return; + api + .reorderPlaylist(selectedId, next.map((v) => v.id)) + .then(() => qc.invalidateQueries({ queryKey: ["playlists"] })); + }, + }); + const items = order.value; + const [sortMode, setSortMode] = useState("manual"); + const [groupBy, setGroupBy] = useState(false); + // Tracks the membership (id set) currently loaded into `order`, so a refetch that only + // re-confirms our own reorder doesn't wipe the undo history — we reset history only when + // the actual set of items changes (different playlist, or an add/remove). + const lastSetRef = useRef(""); // The open player, as an index into `items` (the queue); null = closed. const [playingIndex, setPlayingIndex] = useState(null); @@ -150,9 +214,35 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const detail = detailQuery.data; useEffect(() => { - if (detail) setItems(detail.items); + if (!detail) return; + const key = idsKey(detail.items); + if (key !== lastSetRef.current) { + lastSetRef.current = key; + order.reset(detail.items); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [detail]); + // Reset the sort controls when switching playlists (the order itself is per-playlist). + useEffect(() => { + setSortMode("manual"); + setGroupBy(false); + }, [selectedId]); + + function applySort(mode: SortMode, group: boolean) { + order.set(sortItems(items, mode, group)); + } + const undo = () => { + setSortMode("manual"); + setGroupBy(false); + order.undo(); + }; + const redo = () => { + setSortMode("manual"); + setGroupBy(false); + order.redo(); + }; + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) ); @@ -252,21 +342,25 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); } - async function onDragEnd(e: DragEndEvent) { + function onDragEnd(e: DragEndEvent) { const { active: a, over } = e; if (!over || a.id === over.id || selectedId == null) return; const oldIndex = items.findIndex((v) => v.id === a.id); const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; - const next = arrayMove(items, oldIndex, newIndex); - setItems(next); - await api.reorderPlaylist(selectedId, next.map((v) => v.id)); - qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); + // A manual drag breaks any active sort/grouping; reflect that in the controls. + setSortMode("manual"); + setGroupBy(false); + order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists } async function removeItem(videoId: string) { if (selectedId == null) return; - setItems((cur) => cur.filter((v) => v.id !== videoId)); + // Membership changes aren't undoable: reset the order to the new set (clearing history) + // and keep lastSetRef in sync so the follow-up refetch doesn't reset again. + const next = items.filter((v) => v.id !== videoId); + order.reset(next); + lastSetRef.current = idsKey(next); await api.removeFromPlaylist(selectedId, videoId); refreshAll(); } @@ -491,6 +585,47 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
+ {editable && items.length > 1 && ( +
+ {t("playlists.sortLabel")} + + +
+ +
+
+ )} + {items.length === 0 ? (
diff --git a/frontend/src/components/UndoToolbar.tsx b/frontend/src/components/UndoToolbar.tsx new file mode 100644 index 0000000..5588757 --- /dev/null +++ b/frontend/src/components/UndoToolbar.tsx @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Redo2, Undo2 } from "lucide-react"; + +// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z +// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any +// undo source. The shortcuts ignore keystrokes while a text field is focused. +export default function UndoToolbar({ + canUndo, + canRedo, + onUndo, + onRedo, +}: { + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; +}) { + const { t } = useTranslation(); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey)) return; + const tgt = e.target as HTMLElement | null; + if ( + tgt && + (tgt.tagName === "INPUT" || + tgt.tagName === "TEXTAREA" || + tgt.isContentEditable) + ) + return; + const k = e.key.toLowerCase(); + if (k === "z" && !e.shiftKey) { + if (canUndo) { + e.preventDefault(); + onUndo(); + } + } else if (k === "y" || (k === "z" && e.shiftKey)) { + if (canRedo) { + e.preventDefault(); + onRedo(); + } + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [canUndo, canRedo, onUndo, onRedo]); + + const btn = + "p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"; + return ( +
+ + +
+ ); +} diff --git a/frontend/src/i18n/locales/de/common.json b/frontend/src/i18n/locales/de/common.json index 96a3e73..8ae17fd 100644 --- a/frontend/src/i18n/locales/de/common.json +++ b/frontend/src/i18n/locales/de/common.json @@ -6,6 +6,8 @@ "confirm": "Bestätigen", "confirmTitle": "Bist du sicher?", "save": "Speichern", + "undo": "Rückgängig", + "redo": "Wiederholen", "loading": "Wird geladen…", "language": "Sprache", "somethingWrong": "Etwas ist schiefgelaufen.", diff --git a/frontend/src/i18n/locales/de/playlists.json b/frontend/src/i18n/locales/de/playlists.json index 3e4ca7f..cb72cfc 100644 --- a/frontend/src/i18n/locales/de/playlists.json +++ b/frontend/src/i18n/locales/de/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.", "deleteOnYoutubeConfirm": "Auf YouTube löschen", "deleteHereOnly": "Nur hier", - "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt." + "deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt.", + "sortLabel": "Sortierung", + "sortManual": "Eigene Reihenfolge", + "sortTitleAsc": "Titel A–Z", + "sortTitleDesc": "Titel Z–A", + "sortDurAsc": "Kürzeste zuerst", + "sortDurDesc": "Längste zuerst", + "groupByChannel": "Nach Kanal gruppieren" } diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index a33b2b4..228f33a 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -6,6 +6,8 @@ "confirm": "Confirm", "confirmTitle": "Are you sure?", "save": "Save", + "undo": "Undo", + "redo": "Redo", "loading": "Loading…", "language": "Language", "somethingWrong": "Something went wrong.", diff --git a/frontend/src/i18n/locales/en/playlists.json b/frontend/src/i18n/locales/en/playlists.json index 2f61a01..feb104a 100644 --- a/frontend/src/i18n/locales/en/playlists.json +++ b/frontend/src/i18n/locales/en/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.", "deleteOnYoutubeConfirm": "Delete on YouTube", "deleteHereOnly": "Here only", - "deleteYtFailed": "Couldn't delete on YouTube; removed here only." + "deleteYtFailed": "Couldn't delete on YouTube; removed here only.", + "sortLabel": "Sort", + "sortManual": "Custom order", + "sortTitleAsc": "Title A–Z", + "sortTitleDesc": "Title Z–A", + "sortDurAsc": "Shortest first", + "sortDurDesc": "Longest first", + "groupByChannel": "Group by channel" } diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index d155cd1..d010adc 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -6,6 +6,8 @@ "confirm": "Megerősítés", "confirmTitle": "Biztos vagy benne?", "save": "Mentés", + "undo": "Visszavonás", + "redo": "Újra", "loading": "Betöltés…", "language": "Nyelv", "somethingWrong": "Hiba történt.", diff --git a/frontend/src/i18n/locales/hu/playlists.json b/frontend/src/i18n/locales/hu/playlists.json index 233cb49..9fd8abd 100644 --- a/frontend/src/i18n/locales/hu/playlists.json +++ b/frontend/src/i18n/locales/hu/playlists.json @@ -34,5 +34,12 @@ "deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.", "deleteOnYoutubeConfirm": "Törlés a YouTube-on", "deleteHereOnly": "Csak itt", - "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva." + "deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva.", + "sortLabel": "Rendezés", + "sortManual": "Egyéni sorrend", + "sortTitleAsc": "Cím A–Z", + "sortTitleDesc": "Cím Z–A", + "sortDurAsc": "Legrövidebb elöl", + "sortDurDesc": "Leghosszabb elöl", + "groupByChannel": "Csoportosítás csatorna szerint" } diff --git a/frontend/src/lib/useUndoable.ts b/frontend/src/lib/useUndoable.ts new file mode 100644 index 0000000..89f86ff --- /dev/null +++ b/frontend/src/lib/useUndoable.ts @@ -0,0 +1,90 @@ +import { useCallback, useReducer, useRef } from "react"; + +// A generic, reusable undo/redo container for a single piece of state. +// +// It keeps a past/present/future snapshot stack: `set` records the current value into the +// past and clears the redo stack; `undo`/`redo` walk the stack. Every value change (set, +// undo, redo) calls `onApply(value)` — the side effect that makes the change real (e.g. +// persist to the server). `reset` installs a fresh value and clears history WITHOUT calling +// onApply (use it when the underlying data is reloaded from the source of truth). +// +// Not playlist-specific: any component with an undoable value (sort order, a draft, a set +// of filters…) can use it. State lives in a ref so the callbacks are stable and never read +// a stale snapshot; a reducer-based force-render keeps the view in sync. +export interface Undoable { + value: T; + set: (next: T) => void; + reset: (value: T) => void; + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +} + +interface History { + past: T[]; + present: T; + future: T[]; +} + +export function useUndoable( + initial: T, + opts?: { onApply?: (value: T) => void; limit?: number } +): Undoable { + const [, force] = useReducer((n: number) => n + 1, 0); + const ref = useRef>({ past: [], present: initial, future: [] }); + const onApplyRef = useRef(opts?.onApply); + onApplyRef.current = opts?.onApply; + const limit = opts?.limit ?? 100; + + const set = useCallback( + (next: T) => { + const s = ref.current; + if (Object.is(next, s.present)) return; + const past = [...s.past, s.present]; + if (past.length > limit) past.shift(); + ref.current = { past, present: next, future: [] }; + force(); + onApplyRef.current?.(next); + }, + [limit] + ); + + const undo = useCallback(() => { + const s = ref.current; + if (!s.past.length) return; + const prev = s.past[s.past.length - 1]; + ref.current = { + past: s.past.slice(0, -1), + present: prev, + future: [s.present, ...s.future], + }; + force(); + onApplyRef.current?.(prev); + }, []); + + const redo = useCallback(() => { + const s = ref.current; + if (!s.future.length) return; + const [next, ...rest] = s.future; + ref.current = { past: [...s.past, s.present], present: next, future: rest }; + force(); + onApplyRef.current?.(next); + }, []); + + const reset = useCallback((value: T) => { + ref.current = { past: [], present: value, future: [] }; + force(); + }, []); + + const h = ref.current; + return { + value: h.present, + set, + reset, + undo, + redo, + canUndo: h.past.length > 0, + canRedo: h.future.length > 0, + }; +} From 740a14af4b77a79fab31a0ba79c080127f2f255d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 22:13:32 +0200 Subject: [PATCH 21/28] feat(playlists): rail sorting, consolidated item sort, persist selection 1) Fix: the selected playlist is now persisted (localStorage) and scrolled into view, so F5 keeps it instead of jumping to the first one. 2) Consolidate the in-detail sort: one key select (Title/Duration/Channel) + an asc/desc direction toggle (was separate A-Z / Z-A / shortest / longest options); add Channel as a sort key. Direction also orders the channel groups when grouping. 3) Left rail sorting: by name / item count / total length, asc/desc, plus an 'unsynced first' toggle that floats playlists with unpushed edits to the top. Backend: list/summary now return total_duration_seconds (grouped sum). The rail also shows each playlist's total length. Sort prefs persist to localStorage. --- backend/app/routes/playlists.py | 42 +++- frontend/src/components/Playlists.tsx | 203 ++++++++++++++++---- frontend/src/i18n/locales/de/playlists.json | 16 +- frontend/src/i18n/locales/en/playlists.json | 16 +- frontend/src/i18n/locales/hu/playlists.json | 16 +- frontend/src/lib/api.ts | 1 + 6 files changed, 237 insertions(+), 57 deletions(-) diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index e7942f6..375a2fe 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -31,7 +31,13 @@ def _mark_dirty(pl: Playlist) -> None: pl.dirty = True -def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict: +def _summary( + db: Session, + pl: Playlist, + count: int, + total_duration: int = 0, + has_video: bool | None = None, +) -> dict: cover = db.scalar( select(Video.thumbnail_url) .join(PlaylistItem, PlaylistItem.video_id == Video.id) @@ -47,6 +53,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non "yt_playlist_id": pl.yt_playlist_id, "dirty": pl.dirty, "item_count": count, + "total_duration_seconds": total_duration, "cover_thumbnail": cover, } if has_video is not None: @@ -54,6 +61,18 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non return out +def _total_duration(db: Session, playlist_id: int) -> int: + return ( + db.scalar( + select(func.coalesce(func.sum(Video.duration_seconds), 0)) + .select_from(PlaylistItem) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id == playlist_id) + ) + or 0 + ) + + @router.get("") def list_playlists( contains: str | None = None, @@ -81,6 +100,17 @@ def list_playlists( .group_by(PlaylistItem.playlist_id) ).all() ) + durations = dict( + db.execute( + select( + PlaylistItem.playlist_id, + func.coalesce(func.sum(Video.duration_seconds), 0), + ) + .join(Video, Video.id == PlaylistItem.video_id) + .where(PlaylistItem.playlist_id.in_(ids)) + .group_by(PlaylistItem.playlist_id) + ).all() + ) member: set[int] = set() if contains: member = set( @@ -94,7 +124,13 @@ def list_playlists( .all() ) return [ - _summary(db, p, counts.get(p.id, 0), (p.id in member) if contains else None) + _summary( + db, + p, + counts.get(p.id, 0), + durations.get(p.id, 0), + (p.id in member) if contains else None, + ) for p in pls ] @@ -339,7 +375,7 @@ def rename_playlist( count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) ) - return _summary(db, pl, count or 0) + return _summary(db, pl, count or 0, _total_duration(db, pl.id)) @router.delete("/{playlist_id}") diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 0053e4e..6ccf771 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -17,10 +17,13 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { + ArrowDown, + ArrowUp, Check, GripVertical, ListPlus, Pencil, + Pin, Play, Plus, RefreshCw, @@ -37,29 +40,34 @@ import PlayerModal from "./PlayerModal"; import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; -type SortMode = "manual" | "title-asc" | "title-desc" | "dur-asc" | "dur-desc"; +type SortKey = "manual" | "title" | "duration" | "channel"; +type SortDir = "asc" | "desc"; -function comparator(mode: SortMode): ((a: Video, b: Video) => number) | null { - switch (mode) { - case "title-asc": - return (a, b) => (a.title ?? "").localeCompare(b.title ?? ""); - case "title-desc": - return (a, b) => (b.title ?? "").localeCompare(a.title ?? ""); - case "dur-asc": - return (a, b) => (a.duration_seconds ?? Infinity) - (b.duration_seconds ?? Infinity); - case "dur-desc": - return (a, b) => (b.duration_seconds ?? -Infinity) - (a.duration_seconds ?? -Infinity); - default: - return null; - } +function comparator(key: SortKey, dir: SortDir): ((a: Video, b: Video) => number) | null { + if (key === "manual") return null; + const sign = dir === "asc" ? 1 : -1; + if (key === "duration") + return (a, b) => { + const av = a.duration_seconds; + const bv = b.duration_seconds; + if (av == null && bv == null) return 0; + if (av == null) return 1; // nulls always last, both directions + if (bv == null) return -1; + return sign * (av - bv); + }; + return (a, b) => { + const av = (key === "channel" ? a.channel_title : a.title) ?? ""; + const bv = (key === "channel" ? b.channel_title : b.title) ?? ""; + return sign * av.localeCompare(bv); + }; } // Sort a playlist's items. When `group` is on, items are grouped by channel (groups ordered -// by channel name A–Z) and the chosen sort is applied within each group; with sort "manual" -// the within-group order is left as-is. Returns the same array reference when nothing would -// change, so it doesn't create a spurious undo entry. -function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { - const cmp = comparator(mode); +// by channel name in the chosen direction) and the chosen sort is applied within each group; +// with key "manual" the within-group order is left as-is. Returns the same array reference +// when nothing would change, so it doesn't create a spurious undo entry. +function sortItems(items: Video[], key: SortKey, dir: SortDir, group: boolean): Video[] { + const cmp = comparator(key, dir); if (!group) return cmp ? [...items].sort(cmp) : items; const groups = new Map(); for (const v of items) { @@ -68,7 +76,8 @@ function sortItems(items: Video[], mode: SortMode, group: boolean): Video[] { if (g) g.push(v); else groups.set(k, [v]); } - const keys = [...groups.keys()].sort((a, b) => a.localeCompare(b)); + const sign = dir === "asc" ? 1 : -1; + const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b)); const out: Video[] = []; for (const k of keys) { const g = groups.get(k)!; @@ -83,6 +92,9 @@ const idsKey = (items: Video[]) => .sort() .join(","); +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + function Row({ video, index, @@ -160,7 +172,29 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const [selectedId, setSelectedId] = useState(null); + // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). + const [selectedId, setSelectedId] = useState(() => { + const s = localStorage.getItem("siftlode.playlist"); + return s ? Number(s) : null; + }); + useEffect(() => { + if (selectedId != null) localStorage.setItem("siftlode.playlist", String(selectedId)); + }, [selectedId]); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + // How the left playlist rail is ordered (persisted). + const [plSort, setPlSort] = useState(() => { + try { + const s = localStorage.getItem("siftlode.plSort"); + if (s) return { ...PL_SORT_DEFAULT, ...JSON.parse(s) }; + } catch { + /* ignore */ + } + return PL_SORT_DEFAULT; + }); + useEffect(() => { + localStorage.setItem("siftlode.plSort", JSON.stringify(plSort)); + }, [plSort]); const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); @@ -175,7 +209,8 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { }, }); const items = order.value; - const [sortMode, setSortMode] = useState("manual"); + const [sortKey, setSortKey] = useState("manual"); + const [sortDir, setSortDir] = useState("asc"); const [groupBy, setGroupBy] = useState(false); // Tracks the membership (id set) currently loaded into `order`, so a refetch that only // re-confirms our own reorder doesn't wipe the undo history — we reset history only when @@ -225,20 +260,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { // Reset the sort controls when switching playlists (the order itself is per-playlist). useEffect(() => { - setSortMode("manual"); + setSortKey("manual"); + setSortDir("asc"); setGroupBy(false); }, [selectedId]); - function applySort(mode: SortMode, group: boolean) { - order.set(sortItems(items, mode, group)); + function applySort(key: SortKey, dir: SortDir, group: boolean) { + order.set(sortItems(items, key, dir, group)); } const undo = () => { - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.undo(); }; const redo = () => { - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.redo(); }; @@ -250,6 +286,35 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { // Watch later is a built-in playlist: show a localized name and no rename/delete. const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + // The left rail in the chosen order. "custom" keeps the server position order (Array.sort + // is stable); "dirty first" floats playlists with unpushed edits to the top. + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; // custom: server order + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + // Scroll the restored selection into view once after the rail first renders. + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips // a dirty mirror so it won't clobber unpushed edits); the chip just marks their origin. @@ -349,7 +414,7 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { const newIndex = items.findIndex((v) => v.id === over.id); if (oldIndex < 0 || newIndex < 0) return; // A manual drag breaks any active sort/grouping; reflect that in the controls. - setSortMode("manual"); + setSortKey("manual"); setGroupBy(false); order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists } @@ -432,13 +497,56 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
+ {playlists.length > 1 && ( +
+ + + +
+ )} {playlists.length === 0 && !listQuery.isLoading && (
{t("playlists.noneYet")}
)}
- {playlists.map((pl) => ( + {sortedPlaylists.map((pl) => (
{t("playlists.itemCount", { count: pl.item_count })} + {pl.total_duration_seconds > 0 && + ` · ${formatDuration(pl.total_duration_seconds)}`}
@@ -589,27 +699,42 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
{t("playlists.sortLabel")} +