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