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:
2026-08-28 04:20:29 +02:00
parent bc33b2ed16
commit a0b725d779
9 changed files with 474 additions and 100 deletions
+16 -8
View File
@@ -362,7 +362,15 @@ export default function Playlists() {
setPushing(true);
try {
const plan = await api.playlistPushPlan(selectedId);
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
const nothingToDo =
plan.action === "update" &&
!plan.add_yt &&
!plan.add_local &&
!plan.remove_yt &&
!plan.remove_local &&
!plan.to_reorder &&
!plan.rename;
if (nothingToDo) {
notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
return;
}
@@ -376,21 +384,21 @@ export default function Playlists() {
});
return;
}
let message =
const message =
plan.action === "create"
? t("playlists.pushPlanCreate", {
count: plan.to_insert,
count: plan.add_yt,
units: plan.units_estimate,
left: plan.remaining_today,
})
: t("playlists.pushPlanUpdate", {
insert: plan.to_insert,
del: plan.to_delete,
reorder: plan.to_reorder,
: t("playlists.pushPlanMerge", {
addYt: plan.add_yt,
addLocal: plan.add_local,
removeYt: plan.remove_yt,
removeLocal: plan.remove_local,
units: plan.units_estimate,
left: plan.remaining_today,
});
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
const ok = await confirm({
title: t("playlists.pushTitle"),
message,
+1 -2
View File
@@ -35,8 +35,7 @@
"pushTitle": "Sync to YouTube",
"pushConfirm": "Sync",
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
"pushPlanMerge": "Two-way sync — on YouTube: +{{addYt}} / −{{removeYt}}; here: +{{addLocal}} / −{{removeLocal}}. (~{{units}} quota units; {{left}} left today.)",
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
"pushUpToDate": "“{{name}}” is already in sync with YouTube.",
"pushDone": "“{{name}}” synced to YouTube ✓",
+1 -2
View File
@@ -35,8 +35,7 @@
"pushTitle": "Szinkron YouTube-ra",
"pushConfirm": "Szinkron",
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
"pushPlanMerge": "Kétirányú szinkron — YouTube-on: +{{addYt}} / −{{removeYt}}; itt: +{{addLocal}} / −{{removeLocal}}. (~{{units}} kvótaegység; ma még {{left}} maradt.)",
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
"pushUpToDate": "A(z) „{{name}}” már szinkronban van a YouTube-bal.",
"pushDone": "„{{name}}” szinkronizálva a YouTube-ra ✓",
+11 -5
View File
@@ -31,12 +31,16 @@ export interface PlaylistDetail {
items: Video[];
}
// Two-way sync plan: what each side gains/loses under the 3-way merge (neither side wins
// wholesale). Local edits are free; only the YouTube writes cost quota.
export interface PushPlan {
action: "create" | "update";
to_insert: number;
to_delete: number;
add_yt: number; // local-adds to push out to YouTube
add_local: number; // YouTube-adds to pull into Siftlode
remove_yt: number; // genuine local deletes propagated to YouTube
remove_local: number; // genuine YouTube deletes propagated to Siftlode
to_reorder: number;
yt_extra: number; // items on YouTube that the push would remove (divergence)
rename: boolean;
units_estimate: number;
remaining_today: number;
affordable: boolean;
@@ -44,9 +48,11 @@ export interface PushPlan {
export interface PushResult {
created: number;
inserted: number;
deleted: number;
inserted: number; // added on YouTube
deleted: number; // removed on YouTube
reordered: number;
pulled: number; // added into Siftlode from YouTube
removed_local: number; // removed from Siftlode (deleted on YouTube)
failures: string[];
yt_playlist_id: string | null;
}