Merge feature/playlist-fingerprint-dirty: fingerprint-derived dirty state
This commit is contained in:
commit
7a547d40fe
4 changed files with 106 additions and 14 deletions
64
backend/alembic/versions/0013_playlist_fingerprint.py
Normal file
64
backend/alembic/versions/0013_playlist_fingerprint.py
Normal 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")
|
||||
|
|
@ -325,7 +325,11 @@ class Playlist(Base):
|
|||
String(16), default="local", server_default="local"
|
||||
)
|
||||
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")
|
||||
# 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")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from app.routes.feed import _saved_expr, _serialize
|
|||
from app.sync.playlists import (
|
||||
plan_push,
|
||||
push_playlist,
|
||||
recompute_dirty,
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
)
|
||||
|
|
@ -28,14 +29,6 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
|||
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(
|
||||
db: Session,
|
||||
pl: Playlist,
|
||||
|
|
@ -399,7 +392,7 @@ def rename_playlist(
|
|||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
if name != pl.name:
|
||||
pl.name = name
|
||||
_mark_dirty(pl)
|
||||
recompute_dirty(db, pl)
|
||||
db.commit()
|
||||
count = db.scalar(
|
||||
select(func.count()).where(PlaylistItem.playlist_id == pl.id)
|
||||
|
|
@ -463,7 +456,7 @@ def add_item(
|
|||
db.add(
|
||||
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
||||
)
|
||||
_mark_dirty(pl)
|
||||
recompute_dirty(db, pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
|
@ -483,7 +476,7 @@ def remove_item(
|
|||
)
|
||||
if item is not None:
|
||||
db.delete(item)
|
||||
_mark_dirty(pl)
|
||||
recompute_dirty(db, pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
|
|
@ -509,6 +502,6 @@ def reorder_items(
|
|||
if it is not None:
|
||||
it.position = pos
|
||||
pos += 1
|
||||
_mark_dirty(pl)
|
||||
recompute_dirty(db, pl)
|
||||
db.commit()
|
||||
return {"playlist_id": pl.id, "count": pos}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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."""
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
|
@ -23,6 +24,29 @@ log = logging.getLogger("subfeed.sync")
|
|||
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:
|
||||
"""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
|
||||
|
|
@ -142,6 +166,8 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
|||
db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == pl.id))
|
||||
for pos, vid in enumerate(ordered):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||
pl.dirty = False
|
||||
db.commit()
|
||||
synced += 1
|
||||
return {"synced": synced}
|
||||
|
|
@ -236,7 +262,9 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
|||
inserted += 1
|
||||
except YouTubeError:
|
||||
failures.append(vid)
|
||||
pl.dirty = False
|
||||
if not failures:
|
||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
||||
pl.dirty = False
|
||||
db.commit()
|
||||
return {
|
||||
"created": created,
|
||||
|
|
@ -293,7 +321,9 @@ def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
|||
j = model.index(want)
|
||||
model.pop(j)
|
||||
model.insert(i, want)
|
||||
pl.dirty = False
|
||||
if not failures:
|
||||
pl.synced_fingerprint = fingerprint(pl.name, desired)
|
||||
pl.dirty = False
|
||||
db.commit()
|
||||
return {
|
||||
"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))
|
||||
for pos, vid in enumerate(ordered):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=pos))
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ordered)
|
||||
pl.dirty = False
|
||||
db.commit()
|
||||
return {"items": len(ordered)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue