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.
This commit is contained in:
parent
d791d577a0
commit
6520f35d88
3 changed files with 126 additions and 1 deletions
44
backend/alembic/versions/0010_watch_progress.py
Normal file
44
backend/alembic/versions/0010_watch_progress.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue