From 2f661968166a08ab7e589aee3fd529da383704e7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 15:33:53 +0200 Subject: [PATCH] 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"),