"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).
222 lines
8.2 KiB
Python
222 lines
8.2 KiB
Python
"""S7 — two-way (3-way) YouTube playlist merge.
|
|
|
|
The load-bearing property: an item added (or deleted) on EITHER side survives the sync — the old
|
|
push was one-directional (local wins, deletes everything not local from YouTube), so YouTube-side
|
|
adds were lost. Pure-merge classification runs with no DB; the apply path uses the `db` lane with a
|
|
fake YouTube client so both the local rows and the simulated YouTube state can be asserted.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.models import Channel, Playlist, PlaylistItem, Video
|
|
from app.sync import playlists as pl_mod
|
|
from app.sync.playlists import merge_plan, merge_playlist, parse_baseline
|
|
|
|
# --- pure merge classification (no DB) --------------------------------------------------------
|
|
|
|
|
|
class TestMergePlan:
|
|
def test_no_baseline_unions_losslessly(self):
|
|
# First-ever merge: can't tell an add from a delete, so keep everything, delete nothing.
|
|
mp = merge_plan(None, local=["a", "b"], youtube=["b", "c"])
|
|
assert mp.add_to_yt == ["a"] # local-only pushed out
|
|
assert mp.add_to_local == ["c"] # youtube-only pulled in
|
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
|
assert mp.final == ["a", "b", "c"] # local order, youtube-only appended
|
|
assert mp.baseline_known is False
|
|
|
|
def test_adds_on_both_sides_both_survive(self):
|
|
# baseline b,c ; local added 'a' ; youtube added 'd' — nothing deleted.
|
|
mp = merge_plan(["b", "c"], local=["a", "b", "c"], youtube=["b", "c", "d"])
|
|
assert mp.add_to_yt == ["a"]
|
|
assert mp.add_to_local == ["d"]
|
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
|
assert mp.final == ["a", "b", "c", "d"]
|
|
|
|
def test_genuine_local_delete_propagates_to_youtube(self):
|
|
# baseline a,b,c ; local removed 'b' ; youtube unchanged -> remove b from youtube.
|
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "b", "c"])
|
|
assert mp.remove_from_yt == ["b"]
|
|
assert mp.add_to_yt == [] and mp.add_to_local == []
|
|
assert mp.final == ["a", "c"]
|
|
|
|
def test_genuine_youtube_delete_propagates_to_local(self):
|
|
# baseline a,b,c ; youtube removed 'b' ; local unchanged -> remove b from local.
|
|
mp = merge_plan(["a", "b", "c"], local=["a", "b", "c"], youtube=["a", "c"])
|
|
assert mp.remove_from_local == ["b"]
|
|
assert mp.final == ["a", "c"]
|
|
|
|
def test_delete_on_both_sides_is_gone_not_a_conflict(self):
|
|
mp = merge_plan(["a", "b", "c"], local=["a", "c"], youtube=["a", "c"])
|
|
assert mp.remove_from_yt == [] and mp.remove_from_local == []
|
|
assert mp.final == ["a", "c"]
|
|
|
|
def test_a_local_delete_beats_a_stale_youtube_copy(self):
|
|
# 'b' was synced, deleted locally, still present on youtube -> it must be removed there,
|
|
# NOT resurrected into local as a youtube-add.
|
|
mp = merge_plan(["a", "b"], local=["a"], youtube=["a", "b"])
|
|
assert mp.add_to_local == []
|
|
assert mp.remove_from_yt == ["b"]
|
|
assert "b" not in mp.final
|
|
|
|
|
|
# --- apply path (db lane + fake YouTube) ------------------------------------------------------
|
|
|
|
|
|
class FakeYouTube:
|
|
"""Enough of YouTubeClient for merge_playlist: an in-memory playlist of (item_id, video_id)
|
|
and the write ops that mutate it. Every catalog video is pre-seeded so _ensure_videos never
|
|
needs get_videos."""
|
|
|
|
def __init__(self, *, title: str, items: list[str]):
|
|
self.title = title
|
|
self._seq = 0
|
|
self.items = [(self._new_id(), v) for v in items]
|
|
|
|
def _new_id(self) -> str:
|
|
self._seq += 1
|
|
return f"it{self._seq}"
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def create_playlist(self, title, description="", privacy="private"):
|
|
self.title = title
|
|
return "PLnew"
|
|
|
|
def get_playlist_snippet(self, pid):
|
|
return {"title": self.title}
|
|
|
|
def iter_playlist_items_with_ids(self, pid):
|
|
return [{"item_id": iid, "video_id": v} for iid, v in self.items]
|
|
|
|
def update_playlist_title(self, pid, title):
|
|
self.title = title
|
|
|
|
def add_playlist_item(self, pid, vid, position=None):
|
|
iid = self._new_id()
|
|
self.items.append((iid, vid))
|
|
return iid
|
|
|
|
def delete_playlist_item(self, item_id):
|
|
self.items = [(iid, v) for iid, v in self.items if iid != item_id]
|
|
|
|
def move_playlist_item(self, item_id, pid, vid, position):
|
|
row = next((r for r in self.items if r[0] == item_id), None)
|
|
if row is not None:
|
|
self.items.remove(row)
|
|
self.items.insert(position, row)
|
|
|
|
@property
|
|
def video_ids(self) -> list[str]:
|
|
return [v for _, v in self.items]
|
|
|
|
|
|
def _seed_videos(db, ids):
|
|
db.add(Channel(id="UCtest", title="Test"))
|
|
now = datetime.now(timezone.utc)
|
|
for v in ids:
|
|
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
|
db.commit()
|
|
|
|
|
|
def _linked_playlist(db, user, *, local_ids, baseline):
|
|
pl = Playlist(
|
|
user_id=user.id,
|
|
name="Mix",
|
|
source="local",
|
|
yt_playlist_id="PLyt",
|
|
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
|
|
)
|
|
db.add(pl)
|
|
db.flush()
|
|
for pos, v in enumerate(local_ids):
|
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
|
db.commit()
|
|
return pl
|
|
|
|
|
|
def _local_ids(db, pl):
|
|
return pl_mod._local_video_ids(db, pl)
|
|
|
|
|
|
@pytest.fixture
|
|
def _fake_yt(monkeypatch):
|
|
holder = {}
|
|
|
|
def install(fake):
|
|
holder["fake"] = fake
|
|
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda db, user: fake)
|
|
return fake
|
|
|
|
return install
|
|
|
|
|
|
def test_adds_on_both_sides_survive_on_both_sides(db, make_user, _fake_yt):
|
|
user = make_user()
|
|
_seed_videos(db, ["a", "b", "c", "d"])
|
|
# baseline b,c ; local added 'a' ; youtube added 'd'
|
|
pl = _linked_playlist(db, user, local_ids=["a", "b", "c"], baseline=["b", "c"])
|
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c", "d"]))
|
|
|
|
r = merge_playlist(db, user, pl)
|
|
|
|
assert r["failures"] == []
|
|
assert set(_local_ids(db, pl)) == {"a", "b", "c", "d"}, "youtube-add 'd' pulled into Siftlode"
|
|
assert set(fake.video_ids) == {"a", "b", "c", "d"}, "local-add 'a' pushed to YouTube"
|
|
assert r["inserted"] == 1 and r["pulled"] == 1
|
|
# Baseline advanced to the merged set so a re-sync is a no-op.
|
|
assert set(parse_baseline(pl)) == {"a", "b", "c", "d"}
|
|
|
|
|
|
def test_a_genuine_delete_propagates_without_collateral_loss(db, make_user, _fake_yt):
|
|
user = make_user()
|
|
_seed_videos(db, ["a", "b", "c", "d"])
|
|
# baseline a,b,c ; local removed 'b' ; youtube added 'd' (a real add, must NOT be collateral)
|
|
pl = _linked_playlist(db, user, local_ids=["a", "c"], baseline=["a", "b", "c"])
|
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["a", "b", "c", "d"]))
|
|
|
|
r = merge_playlist(db, user, pl)
|
|
|
|
assert r["failures"] == []
|
|
assert set(fake.video_ids) == {"a", "c", "d"}, "'b' removed on YouTube, 'd' kept"
|
|
assert set(_local_ids(db, pl)) == {"a", "c", "d"}, "'d' pulled in, 'b' stays deleted"
|
|
assert r["deleted"] == 1
|
|
|
|
|
|
def test_first_merge_with_no_baseline_never_deletes(db, make_user, _fake_yt):
|
|
user = make_user()
|
|
_seed_videos(db, ["a", "b", "c"])
|
|
pl = _linked_playlist(db, user, local_ids=["a", "b"], baseline=None)
|
|
fake = _fake_yt(FakeYouTube(title="Mix", items=["b", "c"]))
|
|
|
|
r = merge_playlist(db, user, pl)
|
|
|
|
assert r["deleted"] == 0 and r["removed_local"] == 0
|
|
assert set(_local_ids(db, pl)) == {"a", "b", "c"}
|
|
assert set(fake.video_ids) == {"a", "b", "c"}
|
|
assert parse_baseline(pl) is not None, "a baseline is recorded after the first union"
|
|
|
|
|
|
def test_create_path_appends_and_records_baseline(db, make_user, _fake_yt):
|
|
user = make_user()
|
|
_seed_videos(db, ["a", "b"])
|
|
pl = Playlist(user_id=user.id, name="Fresh", source="local")
|
|
db.add(pl)
|
|
db.flush()
|
|
for pos, v in enumerate(["a", "b"]):
|
|
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
|
db.commit()
|
|
_fake_yt(FakeYouTube(title="Fresh", items=[]))
|
|
|
|
r = merge_playlist(db, user, pl)
|
|
|
|
assert r["created"] == 1 and r["inserted"] == 2
|
|
assert pl.yt_playlist_id # created and linked
|
|
assert parse_baseline(pl) == ["a", "b"]
|