feat(playlists): derive dirty from a synced-state fingerprint
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.
This commit is contained in:
parent
a1d7c408ec
commit
bc1eb62bfe
4 changed files with 106 additions and 14 deletions
|
|
@ -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