feat(e10): make "move a video to another list" one atomic request
The one-step move S2 needs has no honest client-side shape: "add to the target, then
remove from the source" leaves the video in BOTH lists whenever the second call fails,
and the reverse order loses it entirely. So it is one endpoint in one transaction —
POST /api/playlists/{id}/items/{video_id}/move — which re-parents the existing item row
(so `added_at` survives), recomputes `dirty` on both sides, and is idempotent when the
target already holds the video.
Also publishes `updated_at` on the playlist summary, and bumps it explicitly for the
edits that only write `playlist_items` (add / remove / reorder / move). UpdatedAtMixin's
`onupdate` fires on an UPDATE of the playlist row, so without `_touch` the rail's coming
"recently changed" sort would have seen renames and nothing else.
This commit is contained in:
@@ -77,6 +77,11 @@ function localizeDetail(d: unknown): string {
|
||||
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
|
||||
if (o?.code === "url_blocked")
|
||||
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
|
||||
// E10 S2: the one-step "move to another list". Both reasons mean the same thing to a reader —
|
||||
// the list moved under you — but they are different enough to be worth naming separately, and an
|
||||
// unknown reason falls back to the plain failure rather than printing a key.
|
||||
if (o?.code === "playlist_move")
|
||||
return t(`playlists.moveErrors.${o.reason}`, t("playlists.moveFailed"));
|
||||
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
|
||||
// limit, and the copy says so.
|
||||
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface Playlist {
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -74,6 +75,17 @@ export const playlistsApi = {
|
||||
}),
|
||||
removeFromPlaylist: (id: number, videoId: string) =>
|
||||
req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
|
||||
// One request on purpose: add-then-remove from the client leaves the video in BOTH lists when
|
||||
// the second call fails. The server does it in one transaction.
|
||||
moveToPlaylist: (
|
||||
id: number,
|
||||
videoId: string,
|
||||
targetPlaylistId: number,
|
||||
): Promise<{ ok: true; from_playlist_id: number; to_playlist_id: number; video_id: string }> =>
|
||||
req(`/api/playlists/${id}/items/${videoId}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_playlist_id: targetPlaylistId }),
|
||||
}),
|
||||
reorderPlaylist: (id: number, videoIds: string[]) =>
|
||||
req(`/api/playlists/${id}/order`, {
|
||||
method: "PUT",
|
||||
|
||||
Reference in New Issue
Block a user