Files
siftlode/frontend/src/lib/api/playlists.ts
T
peter a0b725d779 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).
2026-08-28 04:20:29 +02:00

98 lines
3.9 KiB
TypeScript

// Playlists: local + YouTube-linked, item add/remove/reorder, the built-in Watch later shortcut,
// and the push-to-YouTube plan/apply flow. Only touches core's req.
import { req } from "./core";
import type { Video } from "./feed";
// Not exported: consumers compare `playlist.kind`/`.source` against string literals, they never
// name these types — keeping them local satisfies the no-unused-exports gate (knip).
type PlaylistKind = "user" | "watch_later";
type PlaylistSource = "local" | "youtube";
export interface Playlist {
id: number;
name: string;
kind: PlaylistKind;
source: PlaylistSource;
yt_playlist_id: string | null;
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
item_count: number;
total_duration_seconds: number;
cover_thumbnail: string | null;
has_video?: boolean; // only set when listed with ?contains=<video_id>
}
export interface PlaylistDetail {
id: number;
name: string;
kind: PlaylistKind;
source: PlaylistSource;
yt_playlist_id: string | null;
dirty: boolean;
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";
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;
rename: boolean;
units_estimate: number;
remaining_today: number;
affordable: boolean;
}
export interface PushResult {
created: 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;
}
export const playlistsApi = {
playlists: (containsVideoId?: string): Promise<Playlist[]> =>
req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
createPlaylist: (name: string): Promise<Playlist> =>
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
deletePlaylist: (id: number, onYoutube = false) =>
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
addToPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items`, {
method: "POST",
body: JSON.stringify({ video_id: videoId }),
}),
removeFromPlaylist: (id: number, videoId: string) =>
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
reorderPlaylist: (id: number, videoIds: string[]) =>
req(`/api/playlists/${id}/order`, {
method: "PUT",
body: JSON.stringify({ video_ids: videoIds }),
}),
// Bookmark shortcut for the built-in Watch later playlist.
watchLaterAdd: (videoId: string) =>
req("/api/playlists/watch-later", {
method: "POST",
body: JSON.stringify({ video_id: videoId }),
}),
watchLaterRemove: (videoId: string) =>
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
req("/api/playlists/sync-youtube", { method: "POST" }),
revertPlaylist: (id: number): Promise<{ items: number }> =>
req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
playlistPushPlan: (id: number): Promise<PushPlan> => req(`/api/playlists/${id}/push-plan`),
pushPlaylist: (id: number): Promise<PushResult> =>
req(`/api/playlists/${id}/push`, { method: "POST" }),
};