diff --git a/backend/alembic/versions/0013_playlist_fingerprint.py b/backend/alembic/versions/0013_playlist_fingerprint.py new file mode 100644 index 0000000..7ec6dbc --- /dev/null +++ b/backend/alembic/versions/0013_playlist_fingerprint.py @@ -0,0 +1,64 @@ +"""record a synced-state fingerprint per playlist + +Revision ID: 0013_playlist_fingerprint +Revises: 0012_watch_later +Create Date: 2026-06-15 + +Adds playlists.synced_fingerprint — a SHA-256 of the playlist's name + ordered video ids +as last synced from / pushed to YouTube. `dirty` is then derived by comparing the current +fingerprint to this baseline, so manually restoring the original order clears dirty without +a YouTube read. Backfills the baseline for already-clean linked playlists (the current +state == the synced state for them); dirty ones are left NULL so they stay dirty until the +next real sync establishes a baseline. +""" +import hashlib +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0013_playlist_fingerprint" +down_revision: Union[str, None] = "0012_watch_later" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _fingerprint(name: str, ids: list[str]) -> str: + h = hashlib.sha256() + h.update((name or "").encode()) + h.update(b"\n") + h.update(",".join(ids).encode()) + return h.hexdigest() + + +def upgrade() -> None: + op.add_column( + "playlists", sa.Column("synced_fingerprint", sa.String(), nullable=True) + ) + bind = op.get_bind() + # Backfill clean, YouTube-linked playlists: their current state IS the synced state. + rows = bind.execute( + sa.text( + "SELECT id, name FROM playlists " + "WHERE yt_playlist_id IS NOT NULL AND dirty = false" + ) + ).fetchall() + for pid, name in rows: + ids = [ + r[0] + for r in bind.execute( + sa.text( + "SELECT video_id FROM playlist_items " + "WHERE playlist_id = :pid ORDER BY position" + ), + {"pid": pid}, + ).fetchall() + ] + bind.execute( + sa.text("UPDATE playlists SET synced_fingerprint = :fp WHERE id = :pid"), + {"fp": _fingerprint(name, ids), "pid": pid}, + ) + + +def downgrade() -> None: + op.drop_column("playlists", "synced_fingerprint") diff --git a/backend/app/models.py b/backend/app/models.py index 546e645..cf4d044 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -325,7 +325,11 @@ class Playlist(Base): String(16), default="local", server_default="local" ) yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) + # True when the current items/name differ from `synced_fingerprint` (unpushed edits). dirty: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + # SHA-256 of name + ordered video ids as last synced from / pushed to YouTube; the + # baseline `dirty` is derived against. NULL = no baseline yet (treated as dirty). + synced_fingerprint: Mapped[str | None] = mapped_column(String) position: Mapped[int] = mapped_column(Integer, default=0, server_default="0") created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 22b03a5..b923161 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -13,6 +13,7 @@ from app.routes.feed import _saved_expr, _serialize from app.sync.playlists import ( plan_push, push_playlist, + recompute_dirty, repull_playlist, sync_user_playlists, ) @@ -28,14 +29,6 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist: return pl -def _mark_dirty(pl: Playlist) -> None: - """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 - - def _summary( db: Session, pl: Playlist, @@ -399,7 +392,7 @@ def rename_playlist( raise HTTPException(status_code=400, detail="name cannot be empty") if name != pl.name: pl.name = name - _mark_dirty(pl) + recompute_dirty(db, pl) db.commit() count = db.scalar( select(func.count()).where(PlaylistItem.playlist_id == pl.id) @@ -463,7 +456,7 @@ def add_item( db.add( PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) ) - _mark_dirty(pl) + recompute_dirty(db, pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -483,7 +476,7 @@ def remove_item( ) if item is not None: db.delete(item) - _mark_dirty(pl) + recompute_dirty(db, pl) db.commit() return {"playlist_id": pl.id, "video_id": video_id} @@ -509,6 +502,6 @@ def reorder_items( if it is not None: it.position = pos pos += 1 - _mark_dirty(pl) + recompute_dirty(db, 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 9051920..764ad7d 100644 --- a/backend/app/sync/playlists.py +++ b/backend/app/sync/playlists.py @@ -5,6 +5,7 @@ the write-back direction (local edits -> YouTube) is a later phase. YouTube's special Watch Later / History playlists are not exposed by the Data API and are never synced. Videos in a playlist that aren't in our shared catalog yet are fetched and ingested (with a stub channel) so the mirror is faithful.""" +import hashlib import logging from datetime import datetime, timezone @@ -23,6 +24,29 @@ log = logging.getLogger("subfeed.sync") WRITE_UNIT_COST = 50 +def fingerprint(name: str, ids: list[str]) -> str: + """A stable checksum of a playlist's name + ordered video ids. Stored as the synced + baseline; the current fingerprint is compared to it to derive `dirty`.""" + h = hashlib.sha256() + h.update((name or "").encode()) + h.update(b"\n") + h.update(",".join(ids).encode()) + return h.hexdigest() + + +def current_fingerprint(db: Session, pl: Playlist) -> str: + return fingerprint(pl.name, _local_video_ids(db, pl)) + + +def recompute_dirty(db: Session, pl: Playlist) -> None: + """Set `dirty` by comparing the current state to the synced baseline. Unlinked lists + (and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state).""" + if not pl.yt_playlist_id: + pl.dirty = False + else: + pl.dirty = pl.synced_fingerprint != current_fingerprint(db, pl) + + 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 missing ones (and a stub channel for any unknown channel). Deleted/private videos that @@ -142,6 +166,8 @@ def sync_user_playlists(db: Session, user: User) -> dict: db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) for pos, vid in enumerate(ordered): db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.synced_fingerprint = fingerprint(pl.name, ordered) + pl.dirty = False db.commit() synced += 1 return {"synced": synced} @@ -236,7 +262,9 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: inserted += 1 except YouTubeError: failures.append(vid) - pl.dirty = False + if not failures: + pl.synced_fingerprint = fingerprint(pl.name, desired) + pl.dirty = False db.commit() return { "created": created, @@ -293,7 +321,9 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict: j = model.index(want) model.pop(j) model.insert(i, want) - pl.dirty = False + if not failures: + pl.synced_fingerprint = fingerprint(pl.name, desired) + pl.dirty = False db.commit() return { "created": created, @@ -333,6 +363,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict: db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) for pos, vid in enumerate(ordered): db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) + pl.synced_fingerprint = fingerprint(pl.name, ordered) pl.dirty = False db.commit() return {"items": len(ordered)}