A read from before a write no longer brings a row back: a write calls off the read on its way, what lands while one is out stays off the screen, and the last write out does the refresh. A failure puts back its own rows, an accepted reorder brings back none, and an Undo reads past the cache. A list the cache has dropped is read before a removal, so its Undo has places. undo-move refuses the same list twice, positions cannot repeat, and undo-move and the Plex key bodies refuse a body without their videos. Focus goes to the header's box when the last shown row or Save this order goes.
152 lines
5.8 KiB
TypeScript
152 lines
5.8 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;
|
|
updated_at: string; // ISO; when the list last CHANGED (rename, add/remove/move/reorder)
|
|
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" }),
|
|
// One request on purpose: add-then-remove from the client leaves a video in BOTH lists when the
|
|
// second call fails. The server does it in one transaction, and says which landed in the target
|
|
// and which it already held: the Undo needs to tell them apart.
|
|
moveManyToPlaylist: (
|
|
id: number,
|
|
videoIds: string[],
|
|
targetPlaylistId: number,
|
|
): Promise<{
|
|
ok: true;
|
|
from_playlist_id: number;
|
|
to_playlist_id: number;
|
|
moved: string[];
|
|
already_there: string[];
|
|
}> =>
|
|
req(`/api/playlists/${id}/items/move-bulk`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ video_ids: videoIds, target_playlist_id: targetPlaylistId }),
|
|
}),
|
|
// The Undo of a move, back into `id`. `positions` pairs with `moved` + `alreadyThere`.
|
|
undoPlaylistMove: (
|
|
id: number,
|
|
fromPlaylistId: number,
|
|
moved: string[],
|
|
alreadyThere: string[],
|
|
positions: number[],
|
|
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
|
req(`/api/playlists/${id}/items/undo-move`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
from_playlist_id: fromPlaylistId,
|
|
moved,
|
|
already_there: alreadyThere,
|
|
positions,
|
|
}),
|
|
}),
|
|
removeManyFromPlaylist: (
|
|
id: number,
|
|
videoIds: string[],
|
|
): Promise<{ ok: true; playlist_id: number; removed: string[] }> =>
|
|
req(`/api/playlists/${id}/items/remove-bulk`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ video_ids: videoIds }),
|
|
}),
|
|
// The Undo of a removal, in one request; an index in the stored order per video.
|
|
restoreToPlaylist: (
|
|
id: number,
|
|
videoIds: string[],
|
|
positions: number[],
|
|
): Promise<{ ok: true; playlist_id: number; added: number }> =>
|
|
req(`/api/playlists/${id}/items/restore`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ video_ids: videoIds, positions }),
|
|
}),
|
|
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" }),
|
|
};
|