feat(playlists): two-way (3-way) merge YouTube sync + mandatory preview (S7)
"Sync to YouTube" was a one-directional destructive push (local wins, deletes from YouTube every item not in the local list), so items added on YouTube were lost and a dirty pull was skipped entirely. Replace it with a real 3-way merge: each item is classified against the last-synced baseline as a local-add, a YouTube-add, or a genuine delete, so an add or delete on either side survives. - Persist the ordered baseline id set (new playlists.synced_video_ids, migration 0075); the fingerprint hash alone couldn't drive a merge. NULL baseline (fresh export / pre-S7 link) unions losslessly on the first sync and records one. - Pure merge_plan(baseline, local, youtube) -> add/remove per side + merged order (local order, YouTube-only additions appended). merge_playlist applies the local side directly and the YouTube side through the failure breaker; the baseline only advances on full success so a partial failure re-converges. - plan_merge/push-plan now report what each side gains and loses; the confirm dialog shows the two-way plan before applying (pushPlanMerge, HU+EN). - Tests: 10 new (pure classification incl. delete-vs-stale-copy; DB apply proving a YouTube-add and a Siftlode-add both survive on both sides, a genuine delete propagates without collateral loss, no-baseline never deletes, create path).
This commit is contained in:
@@ -14,8 +14,8 @@ from app.db import get_db
|
||||
from app.feedquery import feed_columns, serialize
|
||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||
from app.sync.playlists import (
|
||||
plan_push,
|
||||
push_playlist,
|
||||
merge_playlist,
|
||||
plan_merge,
|
||||
recompute_dirty,
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
@@ -329,13 +329,13 @@ def push_plan(
|
||||
user: User = Depends(require_write_scope),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
||||
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page).
|
||||
Write scope is required (require_write_scope) — syncing is a YouTube write."""
|
||||
"""Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) +
|
||||
the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live
|
||||
YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write."""
|
||||
pl = _pushable(db, user, playlist_id)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
plan = plan_push(db, user, pl)
|
||||
plan = plan_merge(db, user, pl)
|
||||
except YouTubeError:
|
||||
raise HTTPException(status_code=422, detail="Couldn't read the playlist on YouTube.")
|
||||
plan["remaining_today"] = quota.remaining_today(db)
|
||||
@@ -349,14 +349,14 @@ def push(
|
||||
user: User = Depends(require_write_scope),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
||||
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
||||
big playlist can't strand half-pushed. Write scope required (require_write_scope)."""
|
||||
"""Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so
|
||||
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||
pl = _pushable(db, user, playlist_id)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||
# plan and the push, halving read-quota and closing the second read→write TOCTOU
|
||||
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||
live = None
|
||||
if pl.yt_playlist_id:
|
||||
@@ -365,13 +365,13 @@ def push(
|
||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||
)
|
||||
plan = plan_push(db, user, pl, live=live)
|
||||
plan = plan_merge(db, user, pl, live=live)
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Not enough YouTube quota left today for this sync.",
|
||||
)
|
||||
return push_playlist(db, user, pl, live=live)
|
||||
return merge_playlist(db, user, pl, live=live)
|
||||
except YouTubeError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=422, detail="YouTube playlist sync failed.")
|
||||
|
||||
Reference in New Issue
Block a user