feat(playlists): derive dirty from a synced-state fingerprint

Store a SHA-256 of each playlist's name + ordered video ids as last synced from /
pushed to YouTube (migration 0013, backfilled for already-clean linked playlists).
'dirty' is now recomputed on every edit by comparing the current fingerprint to that
baseline instead of being stickily set true. So manually restoring the original order
(or undo back to it) clears dirty on its own — the Reset to YouTube button disappears
and no YouTube read is needed. Baseline is set on read-sync, repull (reset), and a
clean push; a missing baseline counts as dirty (unknown YT state). Verified the
migration's fingerprint matches the app's runtime fingerprint exactly.
This commit is contained in:
npeter83 2026-06-15 23:00:56 +02:00
parent a2ad555ef3
commit 6330ac3184
4 changed files with 106 additions and 14 deletions

View file

@ -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")

View file

@ -325,7 +325,11 @@ class Playlist(Base):
String(16), default="local", server_default="local" String(16), default="local", server_default="local"
) )
yt_playlist_id: Mapped[str | None] = mapped_column(String(64)) 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") 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") position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()

View file

@ -13,6 +13,7 @@ from app.routes.feed import _saved_expr, _serialize
from app.sync.playlists import ( from app.sync.playlists import (
plan_push, plan_push,
push_playlist, push_playlist,
recompute_dirty,
repull_playlist, repull_playlist,
sync_user_playlists, sync_user_playlists,
) )
@ -28,14 +29,6 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return pl 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( def _summary(
db: Session, db: Session,
pl: Playlist, pl: Playlist,
@ -399,7 +392,7 @@ def rename_playlist(
raise HTTPException(status_code=400, detail="name cannot be empty") raise HTTPException(status_code=400, detail="name cannot be empty")
if name != pl.name: if name != pl.name:
pl.name = name pl.name = name
_mark_dirty(pl) recompute_dirty(db, pl)
db.commit() db.commit()
count = db.scalar( count = db.scalar(
select(func.count()).where(PlaylistItem.playlist_id == pl.id) select(func.count()).where(PlaylistItem.playlist_id == pl.id)
@ -463,7 +456,7 @@ def add_item(
db.add( db.add(
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos) PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
) )
_mark_dirty(pl) recompute_dirty(db, pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "video_id": video_id} return {"playlist_id": pl.id, "video_id": video_id}
@ -483,7 +476,7 @@ def remove_item(
) )
if item is not None: if item is not None:
db.delete(item) db.delete(item)
_mark_dirty(pl) recompute_dirty(db, pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "video_id": video_id} return {"playlist_id": pl.id, "video_id": video_id}
@ -509,6 +502,6 @@ def reorder_items(
if it is not None: if it is not None:
it.position = pos it.position = pos
pos += 1 pos += 1
_mark_dirty(pl) recompute_dirty(db, pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "count": pos} return {"playlist_id": pl.id, "count": pos}

View file

@ -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 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 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.""" ingested (with a stub channel) so the mirror is faithful."""
import hashlib
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
@ -23,6 +24,29 @@ log = logging.getLogger("subfeed.sync")
WRITE_UNIT_COST = 50 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: 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 """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 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)) db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered): for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False
db.commit() db.commit()
synced += 1 synced += 1
return {"synced": synced} return {"synced": synced}
@ -236,6 +262,8 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
inserted += 1 inserted += 1
except YouTubeError: except YouTubeError:
failures.append(vid) failures.append(vid)
if not failures:
pl.synced_fingerprint = fingerprint(pl.name, desired)
pl.dirty = False pl.dirty = False
db.commit() db.commit()
return { return {
@ -293,6 +321,8 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
j = model.index(want) j = model.index(want)
model.pop(j) model.pop(j)
model.insert(i, want) model.insert(i, want)
if not failures:
pl.synced_fingerprint = fingerprint(pl.name, desired)
pl.dirty = False pl.dirty = False
db.commit() db.commit()
return { return {
@ -333,6 +363,7 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id)) db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
for pos, vid in enumerate(ordered): for pos, vid in enumerate(ordered):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos)) db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
pl.synced_fingerprint = fingerprint(pl.name, ordered)
pl.dirty = False pl.dirty = False
db.commit() db.commit()
return {"items": len(ordered)} return {"items": len(ordered)}