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.
64 lines
2.1 KiB
Python
64 lines
2.1 KiB
Python
"""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")
|