From 121911a8663796c3a9de8c869bfc86d83f1ddd87 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 18:40:05 +0200 Subject: [PATCH 1/2] feat(progress): track per-user resume position server-side Add position_seconds (+progress_updated_at) to video_states so watch progress survives across devices and can drive a feed filter. New POST /api/videos/{id}/progress checkpoints the player position (clearing trivially -early and near-finished positions). Feed serialize exposes position_seconds and a show=in_progress filter lists started-but-unfinished videos. Un-marking 'watched' now keeps a stored position instead of deleting the row. --- .../alembic/versions/0010_watch_progress.py | 44 +++++++++++ backend/app/models.py | 8 ++ backend/app/routes/feed.py | 75 ++++++++++++++++++- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0010_watch_progress.py diff --git a/backend/alembic/versions/0010_watch_progress.py b/backend/alembic/versions/0010_watch_progress.py new file mode 100644 index 0000000..a18cc10 --- /dev/null +++ b/backend/alembic/versions/0010_watch_progress.py @@ -0,0 +1,44 @@ +"""watch progress: resume position on video_states + +Revision ID: 0010_watch_progress +Revises: 0009_quota_events +Create Date: 2026-06-14 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0010_watch_progress" +down_revision: Union[str, None] = "0009_quota_events" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "video_states", + sa.Column( + "position_seconds", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + op.add_column( + "video_states", + sa.Column("progress_updated_at", sa.DateTime(timezone=True), nullable=True), + ) + # Partial index to keep the "in progress" feed filter cheap (only started videos). + op.create_index( + "ix_video_states_in_progress", + "video_states", + ["user_id"], + postgresql_where=sa.text("position_seconds > 0"), + ) + + +def downgrade() -> None: + op.drop_index("ix_video_states_in_progress", table_name="video_states") + op.drop_column("video_states", "progress_updated_at") + op.drop_column("video_states", "position_seconds") diff --git a/backend/app/models.py b/backend/app/models.py index 243f769..46bb24c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -250,6 +250,14 @@ class VideoState(Base): # new | watched | saved | hidden status: Mapped[str] = mapped_column(String(12), default="new", server_default="new") watched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Resume position (seconds into the video) for the in-app player. Orthogonal to + # status: a partially-watched video keeps status "new" but a non-zero position, so + # it can render a progress bar and match the "in progress" feed filter. 0 = not + # started / finished (trivially-early and near-finished positions are not stored). + position_seconds: Mapped[int] = mapped_column( + Integer, default=0, server_default="0", nullable=False + ) + progress_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 2917244..6134e23 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -16,6 +16,12 @@ router = APIRouter(prefix="/api", tags=["feed"]) VALID_STATES = {"new", "watched", "saved", "hidden"} HIDDEN_LIVE = ("live", "upcoming") +# Resume-position thresholds (mirror the client): positions below this are "didn't +# really start" and within this of the end are "basically finished" — neither is worth +# storing, so we clear the position instead (the video shows no progress bar). +PROGRESS_MIN_SECONDS = 5 +FINISH_MARGIN_SECONDS = 10 + def _channel_url(channel_id: str, handle: str | None) -> str: if handle and handle.startswith("@"): @@ -38,6 +44,7 @@ def _serialize(row) -> dict: "is_short": row.is_short, "live_status": row.live_status, "status": row.status or "new", + "position_seconds": row.position_seconds or 0, "watch_url": f"https://www.youtube.com/watch?v={row.id}", } @@ -64,6 +71,7 @@ def _filtered_query( Returns the column-bearing select plus the watch-status expression for sorting.""" state = aliased(VideoState) status_expr = func.coalesce(state.status, "new") + position_expr = func.coalesce(state.position_seconds, 0) query = ( select( @@ -80,6 +88,7 @@ def _filtered_query( Video.is_short, Video.live_status, status_expr.label("status"), + position_expr.label("position_seconds"), ) .join(Channel, Channel.id == Video.channel_id) # Only channels this user is subscribed to (and hasn't hidden). @@ -158,6 +167,11 @@ def _filtered_query( if show == "unwatched": query = query.where(status_expr.notin_(("watched", "hidden"))) + elif show == "in_progress": + # Started but not finished: a stored resume position, not yet watched/hidden. + query = query.where( + and_(position_expr > 0, status_expr.notin_(("watched", "hidden"))) + ) elif show == "watched": query = query.where(status_expr == "watched") elif show == "saved": @@ -279,7 +293,13 @@ def set_video_state( if status == "new": if row is not None: - db.delete(row) + # Keep the row if it still holds a resume position (un-marking "watched" + # should restore the in-progress state, not wipe where the user left off). + if row.position_seconds: + row.status = "new" + row.watched_at = None + else: + db.delete(row) db.commit() return {"video_id": video_id, "status": "new"} @@ -292,6 +312,59 @@ def set_video_state( return {"video_id": video_id, "status": status} +@router.post("/videos/{video_id}/progress") +def set_video_progress( + video_id: str, + payload: dict, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Checkpoint the in-app player's resume position (called periodically while playing). + Stores a per-user position on the video_states row without touching watch status, so a + partially-watched video can render a progress bar and match the "in progress" filter. + Trivially-early and near-finished positions clear the position rather than store it.""" + try: + position = int(payload.get("position_seconds") or 0) + duration = int(payload.get("duration_seconds") or 0) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="position_seconds must be an integer") + if position < 0: + position = 0 + + if db.get(Video, video_id) is None: + raise HTTPException(status_code=404, detail="Unknown video") + + # Decide whether this position is worth keeping (else clear it). + near_end = duration > 0 and position > duration - FINISH_MARGIN_SECONDS + keep = position >= PROGRESS_MIN_SECONDS and not near_end + + row = db.execute( + select(VideoState).where( + VideoState.user_id == user.id, VideoState.video_id == video_id + ) + ).scalar_one_or_none() + + if not keep: + # Nothing meaningful to store: drop a status-less row entirely, otherwise just + # zero the position (a saved/hidden video keeps its status). + if row is not None: + if row.status == "new": + db.delete(row) + else: + row.position_seconds = 0 + row.progress_updated_at = datetime.now(timezone.utc) + db.commit() + return {"video_id": video_id, "position_seconds": 0} + + if row is None: + row = VideoState(user_id=user.id, video_id=video_id) + db.add(row) + row.position_seconds = position + row.progress_updated_at = datetime.now(timezone.utc) + db.commit() + return {"video_id": video_id, "position_seconds": position} + + @router.get("/videos/{video_id}") def get_video_detail( video_id: str, From 9e3f2fe1a2a97c95137a5752c0a811e8ff506029 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 18:40:12 +0200 Subject: [PATCH 2/2] feat(feed): resume progress bar, play/continue/restart, in-progress filter Video cards show a resume progress bar for started-but-unfinished videos and a hover overlay: Play on every card, Continue + Restart on in-progress ones. The in-app player now resumes from (and checkpoints to) the server position instead of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the feed on close so the card bar reflects the session. Sidebar gains an 'In progress' show filter. --- frontend/src/components/Feed.tsx | 20 +++++-- frontend/src/components/PlayerModal.tsx | 43 ++++++++------- frontend/src/components/Sidebar.tsx | 1 + frontend/src/components/VideoCard.tsx | 71 +++++++++++++++++++++++-- frontend/src/lib/api.ts | 9 ++++ 5 files changed, 114 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index b29955d..bbdd000 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -16,6 +16,9 @@ function matchesView(status: string, show: string): boolean { case "saved": return status === "saved"; case "unwatched": + case "in_progress": + // (in_progress is further narrowed server-side by resume position; here we only + // need to drop a card once it's optimistically marked watched/hidden.) return status !== "watched" && status !== "hidden"; default: return status !== "hidden"; // all @@ -36,9 +39,17 @@ export default function Feed({ onOpenWizard: () => void; }) { const [overrides, setOverrides] = useState>({}); - const [activeVideo, setActiveVideo] = useState