siftlode/backend/alembic/versions/0013_playlist_fingerprint.py

65 lines
2.1 KiB
Python
Raw Normal View History

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