Merge feature/playlist-fingerprint-dirty: fingerprint-derived dirty state

This commit is contained in:
npeter83 2026-06-15 23:00:56 +02:00
commit 7a547d40fe
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,7 +262,9 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
inserted += 1 inserted += 1
except YouTubeError: except YouTubeError:
failures.append(vid) failures.append(vid)
pl.dirty = False if not failures:
pl.synced_fingerprint = fingerprint(pl.name, desired)
pl.dirty = False
db.commit() db.commit()
return { return {
"created": created, "created": created,
@ -293,7 +321,9 @@ 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)
pl.dirty = False if not failures:
pl.synced_fingerprint = fingerprint(pl.name, desired)
pl.dirty = False
db.commit() db.commit()
return { return {
"created": created, "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)) 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)}