@x12i/youtube-video-uploader-cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -144,6 +144,32 @@ npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_
144
144
 
145
145
  ---
146
146
 
147
+ ## šŸ” Replacing a Video in a Playlist (`swap`)
148
+
149
+ Re-uploads a song (e.g. a new version with lyrics subtitles) and swaps it into a playlist in place of the old video. The old video is **unlisted, never deleted**, so existing direct links keep working.
150
+
151
+ ```bash
152
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID
153
+ yt-uploader swap ./song-v2.mp4 --playlist PLxxxx --old-video VIDEOID --dry-run
154
+ ```
155
+
156
+ The swap runs as a resumable five-step state machine (state persisted to `.swap-history.json` after every step):
157
+
158
+ 1. **UPLOAD** — upload the new video *unlisted* (metadata carried over from the old video)
159
+ 2. **LOCATE** — find the old video's playlist item + position
160
+ 3. **INSERT** — insert the new video at the old position
161
+ 4. **REMOVE** — remove the old playlist item
162
+ 5. **FINALIZE** — old video → unlisted, new video → public
163
+
164
+ If any step fails, re-run the same command — completed steps are skipped automatically (no duplicate uploads or playlist entries). A missing old video aborts with a "needs manual review" flag instead of guessing.
165
+
166
+ **Quota cost:** ~150 shared-pool units + 1 upload-bucket unit per swap (well within the 10,000/day default pool).
167
+
168
+ **Notes:**
169
+ - OAuth scopes are now `youtube.upload` + `youtube.force-ssl` (narrower than before) — existing `token.json` files need a one-time re-consent.
170
+ - The target playlist must use **Manual** ordering for position-preserving inserts (set in YouTube Studio).
171
+ - Videos from an unverified Google Cloud project are forced private until the project passes YouTube's API audit.
172
+
147
173
  ## šŸ› ļø Programmatic Node.js API
148
174
 
149
175
  ```javascript
package/bin/cli.js CHANGED
@@ -4,7 +4,9 @@ import { Command } from 'commander';
4
4
  import path from 'node:path';
5
5
  import fs from 'node:fs';
6
6
  import pc from 'picocolors';
7
- import { batchUpload, DAILY_DEFAULT_QUOTA } from '../src/index.js';
7
+ import { google } from 'googleapis';
8
+ import { batchUpload, DAILY_DEFAULT_QUOTA, swapSong, SwapStore, QUOTA_PER_SWAP } from '../src/index.js';
9
+ import { authenticate } from '../src/auth.js';
8
10
 
9
11
  // Read package.json for version
10
12
  const pkgPath = new URL('../package.json', import.meta.url);
@@ -183,4 +185,117 @@ program
183
185
  }
184
186
  });
185
187
 
188
+ program
189
+ .command('swap')
190
+ .description('Replace a song\'s video in a YouTube playlist: upload the new video, swap the playlist entry in place, unlist (never delete) the old video')
191
+ .argument('<videoFile>', 'Path to the new MP4 video file')
192
+ .requiredOption('--playlist <id>', 'Playlist ID containing the old video')
193
+ .requiredOption('--old-video <videoId>', 'Video ID of the old video to replace')
194
+ .option('-k, --key <songKey>', 'Stable song key for swap state tracking (default: video file basename)')
195
+ .option('--title <title>', 'Override the new video title (default: carried over from the old video)')
196
+ .option('--state-file <file>', 'Swap state filename (default: .swap-history.json in target dir)')
197
+ .option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in cwd)')
198
+ .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
199
+ .option('--client-id <id>', 'Google OAuth Client ID')
200
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
201
+ .option('--fresh', 'Discard any previous swap state for this key and start over', false)
202
+ .option('-n, --dry-run', 'Print the swap plan without any API calls', false)
203
+ .option('-q, --quiet', 'Minimal output mode', false)
204
+ .action(async (videoFile, options) => {
205
+ const isQuiet = options.quiet;
206
+ const videoPath = path.resolve(videoFile);
207
+ const songKey = options.key || path.basename(videoPath);
208
+ const stateDir = process.cwd();
209
+
210
+ if (!isQuiet) {
211
+ console.log();
212
+ console.log(pc.bold(pc.red(`šŸ” Playlist Video Swap`)) + ` ${pc.dim(`v${pkg.version}`)}`);
213
+ console.log(pc.dim(`──────────────────────────────────────────────────────────`));
214
+ console.log(`${pc.bold('šŸŽ¬ New Video :')} ${pc.white(videoPath)}`);
215
+ console.log(`${pc.bold('šŸ—‘ļø Old Video ID :')} ${pc.white(options.oldVideo)}`);
216
+ console.log(`${pc.bold('šŸ“‘ Playlist ID :')} ${pc.white(options.playlist)}`);
217
+ console.log(`${pc.bold('šŸ”‘ Swap Key :')} ${pc.white(songKey)}`);
218
+ console.log(pc.dim(`Estimated quota : ~${QUOTA_PER_SWAP - 1} shared units + 1 upload`));
219
+ console.log();
220
+ }
221
+
222
+ if (!fs.existsSync(videoPath)) {
223
+ console.error(pc.red(`\nāŒ Error: Video file not found: ${videoPath}\n`));
224
+ process.exit(1);
225
+ }
226
+
227
+ if (options.dryRun) {
228
+ if (!isQuiet) {
229
+ console.log(pc.yellow(`šŸ” DRY RUN — the following steps would run:`));
230
+ console.log(` 1. UPLOAD — videos.insert (unlisted), metadata carried over from old video`);
231
+ console.log(` 2. LOCATE — playlistItems.list to find old item id + position`);
232
+ console.log(` 3. INSERT — playlistItems.insert at the old position`);
233
+ console.log(` 4. REMOVE — playlistItems.delete for the old item`);
234
+ console.log(` 5. FINALIZE — old video → unlisted, new video → public`);
235
+ console.log();
236
+ console.log(pc.yellow(`✨ Dry run complete. No API calls were made.`));
237
+ console.log();
238
+ }
239
+ process.exit(0);
240
+ }
241
+
242
+ try {
243
+ const tokenFile = options.tokenFile
244
+ ? path.resolve(options.tokenFile)
245
+ : path.join(stateDir, 'token.json');
246
+
247
+ const auth = await authenticate({
248
+ tokenFile,
249
+ clientSecretsFile: options.clientSecretsFile,
250
+ clientId: options.clientId,
251
+ clientSecret: options.clientSecret,
252
+ });
253
+ const youtube = google.youtube({ version: 'v3', auth });
254
+
255
+ const store = new SwapStore(stateDir, options.stateFile);
256
+ const existing = store.getRecord(songKey);
257
+ if (existing && !options.fresh && !isQuiet) {
258
+ console.log(pc.cyan(`ā†©ļø Resuming swap in progress (last status: ${existing.status}${existing.lastError ? ` — ${existing.lastError}` : ''})`));
259
+ console.log();
260
+ }
261
+
262
+ const { record } = await swapSong({
263
+ youtube,
264
+ songKey,
265
+ newVideoPath: videoPath,
266
+ playlistId: options.playlist,
267
+ oldVideoId: options.oldVideo,
268
+ meta: options.title ? { title: options.title } : undefined,
269
+ stateDir,
270
+ stateFile: options.stateFile,
271
+ fresh: options.fresh,
272
+ onEvent: (event) => {
273
+ if (isQuiet) return;
274
+ if (event.type === 'swap_step') {
275
+ console.log(pc.green(` āœ… ${event.status.toUpperCase()}`));
276
+ } else if (event.type === 'swap_uploaded') {
277
+ console.log(pc.green(` ā¬†ļø Uploaded new video: `) + pc.underline(pc.blue(event.url)));
278
+ } else if (event.type === 'swap_error') {
279
+ console.error(pc.red(` āŒ Failed at swap step: ${event.error.message}`));
280
+ if (event.needsManualReview) {
281
+ console.error(pc.yellow(` āš ļø Needs manual review — the old video was not found in the playlist.`));
282
+ }
283
+ }
284
+ },
285
+ });
286
+
287
+ if (!isQuiet) {
288
+ console.log();
289
+ console.log(pc.green(pc.bold(`šŸŽ‰ Swap complete!`)));
290
+ console.log(` • Playlist entry replaced: ${pc.bold(options.playlist)} (position preserved)`);
291
+ console.log(` • New video is public : ${pc.underline(pc.blue(`https://youtu.be/${record.newVideoId}`))}`);
292
+ console.log(` • Old video unlisted : ${pc.dim(`https://youtu.be/${options.oldVideo} (direct links still work)`)}`);
293
+ console.log();
294
+ }
295
+ } catch (err) {
296
+ console.error(pc.red(`\nāŒ Error: ${err.message}\n`));
297
+ process.exit(1);
298
+ }
299
+ });
300
+
186
301
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/youtube-video-uploader-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, and smart upload-state resume.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@ import pc from 'picocolors';
8
8
 
9
9
  export const YOUTUBE_SCOPES = [
10
10
  'https://www.googleapis.com/auth/youtube.upload',
11
- 'https://www.googleapis.com/auth/youtube',
11
+ 'https://www.googleapis.com/auth/youtube.force-ssl',
12
12
  ];
13
13
 
14
14
  export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/oauth2callback';
package/src/index.d.ts CHANGED
@@ -81,3 +81,76 @@ export const DAILY_DEFAULT_QUOTA: number;
81
81
  export const DEFAULT_CATEGORY_ID: string;
82
82
  export const DEFAULT_PRIVACY_STATUS: string;
83
83
  export const VALID_PRIVACY_STATUSES: Set<string>;
84
+
85
+ // ─── Playlist video swap ───────────────────────────────────────────────────
86
+
87
+ export type SwapStatus =
88
+ | 'pending'
89
+ | 'uploaded'
90
+ | 'located'
91
+ | 'inserted'
92
+ | 'removed'
93
+ | 'finalized'
94
+ | 'failed';
95
+
96
+ export interface SwapRecord {
97
+ songKey: string;
98
+ oldVideoId: string;
99
+ playlistId: string;
100
+ newVideoPath?: string;
101
+ newVideoId?: string;
102
+ oldPlaylistItemId?: string;
103
+ oldPosition?: number;
104
+ newPlaylistItemId?: string;
105
+ status: SwapStatus;
106
+ lastError?: string;
107
+ updatedAt?: string;
108
+ }
109
+
110
+ export class SwapStore {
111
+ constructor(targetFolder?: string, stateFilename?: string);
112
+ getRecord(songKey: string): SwapRecord | null;
113
+ initRecord(record: { songKey: string; playlistId: string; oldVideoId: string; newVideoPath?: string }): SwapRecord;
114
+ updateRecord(songKey: string, fields: Partial<SwapRecord>): SwapRecord;
115
+ listRecords(status?: SwapStatus): SwapRecord[];
116
+ }
117
+
118
+ export function uploadNewVideo(
119
+ youtube: youtube_v3.Youtube,
120
+ newVideoPath: string,
121
+ opts?: { oldVideoId?: string; meta?: VideoMetadata; retries?: number }
122
+ ): Promise<{ videoId: string; url: string }>;
123
+
124
+ export function findPlaylistItem(
125
+ youtube: youtube_v3.Youtube,
126
+ playlistId: string,
127
+ oldVideoId: string
128
+ ): Promise<{ playlistItemId: string; position: number } | null>;
129
+
130
+ export function insertAtPosition(
131
+ youtube: youtube_v3.Youtube,
132
+ playlistId: string,
133
+ newVideoId: string,
134
+ position: number
135
+ ): Promise<{ newPlaylistItemId: string }>;
136
+
137
+ export function removeOldItem(youtube: youtube_v3.Youtube, oldPlaylistItemId: string): Promise<void>;
138
+
139
+ export function finalizeSwap(youtube: youtube_v3.Youtube, oldVideoId: string, newVideoId: string): Promise<void>;
140
+
141
+ export function swapSong(params: {
142
+ youtube: youtube_v3.Youtube;
143
+ songKey: string;
144
+ newVideoPath: string;
145
+ playlistId: string;
146
+ oldVideoId: string;
147
+ meta?: VideoMetadata;
148
+ stateDir?: string;
149
+ stateFile?: string;
150
+ fresh?: boolean;
151
+ onEvent?: (event: { type: string; [key: string]: any }) => void;
152
+ }): Promise<{ record: SwapRecord }>;
153
+
154
+ export const QUOTA_PER_SWAP: number;
155
+ export const DEFAULT_SWAP_STATE_FILENAME: string;
156
+ export const SWAP_STATUSES: string[];
package/src/index.js CHANGED
@@ -9,3 +9,12 @@ export {
9
9
  QUOTA_PER_PLAYLIST_ITEM,
10
10
  DAILY_DEFAULT_QUOTA,
11
11
  } from './uploader.js';
12
+ export { SwapStore, DEFAULT_SWAP_STATE_FILENAME, SWAP_STATUSES } from './swap/store.js';
13
+ export {
14
+ uploadNewVideo,
15
+ findPlaylistItem,
16
+ insertAtPosition,
17
+ removeOldItem,
18
+ finalizeSwap,
19
+ } from './swap/steps.js';
20
+ export { swapSong, QUOTA_PER_SWAP } from './swap/swapSong.js';
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import { uploadVideo } from '../uploader.js';
3
+
4
+ /**
5
+ * The five individual steps of a playlist video swap. Each is a plain
6
+ * function over the googleapis youtube client so they can be composed and
7
+ * tested independently.
8
+ */
9
+
10
+ /**
11
+ * Step 1 — Upload the new video as unlisted (never straight to public).
12
+ *
13
+ * Metadata (title/description/tags/category) is carried over from the old
14
+ * video's snippet when available so the replacement stays continuous.
15
+ *
16
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
17
+ * @param {string} newVideoPath
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.oldVideoId] - Fetch old video's snippet to carry over
20
+ * @param {object} [opts.meta] - Explicit metadata overrides
21
+ * @param {number} [opts.retries=3]
22
+ * @returns {Promise<{ videoId: string, url: string }>}
23
+ */
24
+ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
25
+ if (!fs.existsSync(newVideoPath)) {
26
+ throw new Error(`New video file not found: ${newVideoPath}`);
27
+ }
28
+
29
+ let meta = {
30
+ title: opts.meta?.title,
31
+ description: opts.meta?.description,
32
+ tags: opts.meta?.tags,
33
+ categoryId: opts.meta?.categoryId,
34
+ privacyStatus: 'unlisted',
35
+ selfDeclaredMadeForKids: opts.meta?.selfDeclaredMadeForKids ?? false,
36
+ };
37
+
38
+ // Carry over metadata from the old video where not explicitly overridden
39
+ if (opts.oldVideoId) {
40
+ try {
41
+ const res = await youtube.videos.list({
42
+ part: ['snippet', 'status'],
43
+ id: [opts.oldVideoId],
44
+ });
45
+ const old = res.data.items?.[0];
46
+ if (old) {
47
+ meta = {
48
+ title: meta.title || old.snippet?.title,
49
+ description: meta.description ?? old.snippet?.description,
50
+ tags: meta.tags || old.snippet?.tags || [],
51
+ categoryId: meta.categoryId || old.snippet?.categoryId,
52
+ privacyStatus: 'unlisted',
53
+ selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids
54
+ ?? (old.status?.madeForKids !== undefined ? old.status.madeForKids : false),
55
+ };
56
+ }
57
+ } catch {
58
+ // Metadata carry-over is best-effort; explicit meta or defaults still apply
59
+ }
60
+ }
61
+
62
+ if (!meta.title) {
63
+ throw new Error('Cannot upload swap video without a title: provide meta.title or a reachable oldVideoId');
64
+ }
65
+
66
+ // Uploads are the most failure-prone step — retry with backoff
67
+ const retries = opts.retries ?? 3;
68
+ let lastErr;
69
+ for (let attempt = 1; attempt <= retries; attempt++) {
70
+ try {
71
+ return await uploadVideo(youtube, newVideoPath, meta, 'unlisted');
72
+ } catch (err) {
73
+ lastErr = err;
74
+ if (attempt < retries) {
75
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
76
+ }
77
+ }
78
+ }
79
+ throw lastErr;
80
+ }
81
+
82
+ /**
83
+ * Step 2 — Locate the old video's playlistItem id and position.
84
+ *
85
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
86
+ * @param {string} playlistId
87
+ * @param {string} oldVideoId
88
+ * @returns {Promise<{ playlistItemId: string, position: number } | null>} Null when the video is not in the playlist
89
+ */
90
+ export async function findPlaylistItem(youtube, playlistId, oldVideoId) {
91
+ let pageToken;
92
+ do {
93
+ const { data } = await youtube.playlistItems.list({
94
+ part: ['snippet'],
95
+ playlistId,
96
+ maxResults: 50,
97
+ pageToken,
98
+ });
99
+ const match = (data.items || []).find(
100
+ item => item.snippet?.resourceId?.videoId === oldVideoId
101
+ );
102
+ if (match) {
103
+ return { playlistItemId: match.id, position: match.snippet.position };
104
+ }
105
+ pageToken = data.nextPageToken;
106
+ } while (pageToken);
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Step 3 — Insert the new video into the playlist at the old item's position.
112
+ * The old item shifts down one slot temporarily; it is removed in step 4.
113
+ *
114
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
115
+ * @param {string} playlistId
116
+ * @param {string} newVideoId
117
+ * @param {number} position
118
+ * @returns {Promise<{ newPlaylistItemId: string }>}
119
+ */
120
+ export async function insertAtPosition(youtube, playlistId, newVideoId, position) {
121
+ const res = await youtube.playlistItems.insert({
122
+ part: ['snippet'],
123
+ requestBody: {
124
+ snippet: {
125
+ playlistId,
126
+ position,
127
+ resourceId: { kind: 'youtube#video', videoId: newVideoId },
128
+ },
129
+ },
130
+ });
131
+ return { newPlaylistItemId: res.data.id };
132
+ }
133
+
134
+ /**
135
+ * Step 4 — Remove the old video's playlist item.
136
+ *
137
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
138
+ * @param {string} oldPlaylistItemId
139
+ */
140
+ export async function removeOldItem(youtube, oldPlaylistItemId) {
141
+ await youtube.playlistItems.delete({ id: oldPlaylistItemId });
142
+ }
143
+
144
+ /**
145
+ * Step 5 — Finalize: unlist the old video (never delete), then publish the
146
+ * new one. Ordered so any earlier failure leaves the old video untouched.
147
+ *
148
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
149
+ * @param {string} oldVideoId
150
+ * @param {string} newVideoId
151
+ */
152
+ export async function finalizeSwap(youtube, oldVideoId, newVideoId) {
153
+ await youtube.videos.update({
154
+ part: ['status'],
155
+ requestBody: { id: oldVideoId, status: { privacyStatus: 'unlisted' } },
156
+ });
157
+ await youtube.videos.update({
158
+ part: ['status'],
159
+ requestBody: { id: newVideoId, status: { privacyStatus: 'public' } },
160
+ });
161
+ }
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_SWAP_STATE_FILENAME = '.swap-history.json';
5
+
6
+ export const SWAP_STATUSES = [
7
+ 'pending',
8
+ 'uploaded',
9
+ 'located',
10
+ 'inserted',
11
+ 'removed',
12
+ 'finalized',
13
+ 'failed',
14
+ ];
15
+
16
+ /**
17
+ * State manager for playlist video swaps. Persists a SwapRecord per song key
18
+ * so an interrupted swap can be safely resumed without re-uploading or
19
+ * double-inserting playlist items.
20
+ *
21
+ * @typedef {Object} SwapRecord
22
+ * @property {string} songKey - Stable key identifying this swap (song/file identifier)
23
+ * @property {string} oldVideoId
24
+ * @property {string} playlistId
25
+ * @property {string} [newVideoPath]
26
+ * @property {string} [newVideoId]
27
+ * @property {string} [oldPlaylistItemId]
28
+ * @property {number} [oldPosition]
29
+ * @property {string} [newPlaylistItemId]
30
+ * @property {string} status
31
+ * @property {string} [lastError]
32
+ * @property {string} [updatedAt]
33
+ */
34
+ export class SwapStore {
35
+ /**
36
+ * @param {string} targetFolder
37
+ * @param {string} [stateFilename]
38
+ */
39
+ constructor(targetFolder, stateFilename = DEFAULT_SWAP_STATE_FILENAME) {
40
+ this.targetFolder = path.resolve(targetFolder || process.cwd());
41
+ this.statePath = path.join(this.targetFolder, stateFilename);
42
+ this.data = this._load();
43
+ }
44
+
45
+ _load() {
46
+ if (fs.existsSync(this.statePath)) {
47
+ try {
48
+ const content = fs.readFileSync(this.statePath, 'utf-8');
49
+ const parsed = JSON.parse(content);
50
+ return { ...parsed, swaps: parsed.swaps || {} };
51
+ } catch {
52
+ return { swaps: {} };
53
+ }
54
+ }
55
+ return { swaps: {} };
56
+ }
57
+
58
+ save() {
59
+ try {
60
+ fs.writeFileSync(this.statePath, JSON.stringify(this.data, null, 2), 'utf-8');
61
+ } catch (err) {
62
+ console.warn(`Warning: Could not save swap state to ${this.statePath}: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {string} songKey
68
+ * @returns {SwapRecord | null}
69
+ */
70
+ getRecord(songKey) {
71
+ return this.data.swaps[songKey] || null;
72
+ }
73
+
74
+ /**
75
+ * Create or reset a pending swap record.
76
+ * @param {object} record
77
+ * @returns {SwapRecord}
78
+ */
79
+ initRecord(record) {
80
+ /** @type {SwapRecord} */
81
+ const full = {
82
+ status: 'pending',
83
+ updatedAt: new Date().toISOString(),
84
+ ...record,
85
+ };
86
+ this.data.swaps[full.songKey] = full;
87
+ this.save();
88
+ return full;
89
+ }
90
+
91
+ /**
92
+ * Merge fields into an existing record and persist immediately.
93
+ * @param {string} songKey
94
+ * @param {Partial<SwapRecord>} fields
95
+ * @returns {SwapRecord}
96
+ */
97
+ updateRecord(songKey, fields) {
98
+ if (!this.data.swaps[songKey]) {
99
+ throw new Error(`No swap record exists for key: ${songKey}`);
100
+ }
101
+ this.data.swaps[songKey] = {
102
+ ...this.data.swaps[songKey],
103
+ ...fields,
104
+ songKey,
105
+ updatedAt: new Date().toISOString(),
106
+ };
107
+ this.save();
108
+ return this.data.swaps[songKey];
109
+ }
110
+
111
+ /**
112
+ * List all records with the given status (or all records).
113
+ * @param {string} [status]
114
+ * @returns {SwapRecord[]}
115
+ */
116
+ listRecords(status) {
117
+ const all = Object.values(this.data.swaps);
118
+ return status ? all.filter(r => r.status === status) : all;
119
+ }
120
+ }
@@ -0,0 +1,154 @@
1
+ import { SwapStore } from './store.js';
2
+ import {
3
+ uploadNewVideo,
4
+ findPlaylistItem,
5
+ insertAtPosition,
6
+ removeOldItem,
7
+ finalizeSwap,
8
+ } from './steps.js';
9
+
10
+ /** Shared-pool quota cost of one full swap (list + insert + delete + 2 updates). */
11
+ export const QUOTA_PER_SWAP = 150 + 1; // ~150 shared units + 1 upload-bucket unit
12
+
13
+ /**
14
+ * Swap state machine:
15
+ *
16
+ * 1. UPLOAD — upload the new video (videos.insert), unlisted for now
17
+ * 2. LOCATE — find the old video's playlistItem id + position
18
+ * 3. INSERT — add the new video at the old item's position
19
+ * 4. REMOVE — delete the old playlistItem
20
+ * 5. FINALIZE — old video → unlisted, new video → public
21
+ *
22
+ * Every step persists state before moving on, so a crash or API error at any
23
+ * point can be safely resumed by re-running swapSong — completed steps are
24
+ * skipped, the old video is never deleted, and the upload is never repeated.
25
+ *
26
+ * @param {object} params
27
+ * @param {import('googleapis').youtube_v3.Youtube} params.youtube - Authorized youtube client
28
+ * @param {string} params.songKey - Stable key for this swap (song/file identifier)
29
+ * @param {string} params.newVideoPath - Path to the replacement video file
30
+ * @param {string} params.playlistId
31
+ * @param {string} params.oldVideoId
32
+ * @param {object} [params.meta] - Explicit new-video metadata overrides
33
+ * @param {string} [params.stateDir] - Where the swap state file lives (default: cwd)
34
+ * @param {string} [params.stateFile] - Custom state filename
35
+ * @param {boolean} [params.fresh=false] - Reset any existing record for songKey
36
+ * @param {(event: { type: string, [key: string]: any }) => void} [params.onEvent]
37
+ * @returns {Promise<{ record: any, dryRunPlan?: string[] }>}
38
+ */
39
+ export async function swapSong(params) {
40
+ const {
41
+ youtube,
42
+ songKey,
43
+ newVideoPath,
44
+ playlistId,
45
+ oldVideoId,
46
+ meta,
47
+ stateDir,
48
+ stateFile,
49
+ fresh = false,
50
+ onEvent = () => {},
51
+ } = params;
52
+
53
+ if (!songKey || !newVideoPath || !playlistId || !oldVideoId) {
54
+ throw new Error('swapSong requires songKey, newVideoPath, playlistId and oldVideoId');
55
+ }
56
+
57
+ const store = new SwapStore(stateDir || process.cwd(), stateFile);
58
+ const existing = store.getRecord(songKey);
59
+
60
+ if (existing && !fresh && ['pending', 'uploaded', 'located', 'inserted', 'removed'].includes(existing.status)) {
61
+ onEvent({ type: 'swap_resume', songKey, status: existing.status });
62
+ }
63
+
64
+ const record = (existing && !fresh)
65
+ ? { ...existing, playlistId, oldVideoId, newVideoPath }
66
+ : store.initRecord({ songKey, playlistId, oldVideoId, newVideoPath, status: 'pending' });
67
+ if (existing && !fresh) {
68
+ // persist any refreshed params above
69
+ store.updateRecord(songKey, { playlistId, oldVideoId, newVideoPath });
70
+ }
71
+
72
+ /**
73
+ * Run one step, guarding against re-doing completed work.
74
+ * @template T
75
+ * @param {string} doneStatus - Status that marks this step complete
76
+ * @param {() => Promise<T>} fn
77
+ * @param {(result: T) => Partial<Record<string, any>>} [fieldsFromResult]
78
+ */
79
+ async function step(doneStatus, fn, fieldsFromResult) {
80
+ if (stepIndex(record.status) >= stepIndex(doneStatus)) return;
81
+ const result = await fn();
82
+ store.updateRecord(songKey, { status: doneStatus, lastError: undefined, ...(fieldsFromResult ? fieldsFromResult(result) : {}) });
83
+ onEvent({ type: 'swap_step', songKey, status: doneStatus });
84
+ }
85
+
86
+ try {
87
+ // 1. UPLOAD
88
+ await step('uploaded', async () => {
89
+ const alreadyUploaded = store.getRecord(songKey).newVideoId;
90
+ if (alreadyUploaded) {
91
+ return { videoId: alreadyUploaded };
92
+ }
93
+ const up = await uploadNewVideo(youtube, newVideoPath, { oldVideoId, meta });
94
+ onEvent({ type: 'swap_uploaded', songKey, newVideoId: up.videoId, url: up.url });
95
+ return up;
96
+ }, r => ({ newVideoId: r.videoId }));
97
+ const newVideoId = store.getRecord(songKey).newVideoId;
98
+
99
+ // 2. LOCATE
100
+ await step('located', async () => {
101
+ const found = await findPlaylistItem(youtube, playlistId, oldVideoId);
102
+ if (!found) {
103
+ const err = new Error(
104
+ `Old video ${oldVideoId} not found in playlist ${playlistId} — ` +
105
+ `it may have already been removed, or the video ID is wrong.`
106
+ );
107
+ err.needsManualReview = true;
108
+ throw err;
109
+ }
110
+ return found;
111
+ }, r => ({ oldPlaylistItemId: r.playlistItemId, oldPosition: r.position }));
112
+ const current = store.getRecord(songKey);
113
+
114
+ // 3. INSERT (guard: if a previous attempt already inserted the new video
115
+ // but crashed before persisting, reuse that item instead of duplicating it)
116
+ await step('inserted', async () => {
117
+ const alreadyInserted = await findPlaylistItem(youtube, playlistId, newVideoId);
118
+ if (alreadyInserted) {
119
+ return { newPlaylistItemId: alreadyInserted.playlistItemId };
120
+ }
121
+ return await insertAtPosition(youtube, playlistId, newVideoId, current.oldPosition);
122
+ }, r => ({ newPlaylistItemId: r.newPlaylistItemId }));
123
+
124
+ // 4. REMOVE
125
+ await step('removed', async () => {
126
+ await removeOldItem(youtube, current.oldPlaylistItemId);
127
+ });
128
+
129
+ // 5. FINALIZE
130
+ await step('finalized', async () => {
131
+ await finalizeSwap(youtube, oldVideoId, newVideoId);
132
+ });
133
+
134
+ onEvent({ type: 'swap_complete', songKey, oldVideoId, newVideoId, playlistId });
135
+ return { record: store.getRecord(songKey) };
136
+ } catch (err) {
137
+ const updated = store.updateRecord(songKey, { status: 'failed', lastError: err.message });
138
+ onEvent({ type: 'swap_error', songKey, error: err, needsManualReview: Boolean(err.needsManualReview) });
139
+ const wrapped = new Error(`Swap failed for ${songKey} (status persisted, safe to re-run): ${err.message}`);
140
+ wrapped.cause = err;
141
+ wrapped.record = updated;
142
+ throw wrapped;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Rank a status by how far through the state machine it is.
148
+ * @param {string} status
149
+ * @returns {number}
150
+ */
151
+ function stepIndex(status) {
152
+ const order = ['pending', 'failed', 'uploaded', 'located', 'inserted', 'removed', 'finalized'];
153
+ return order.indexOf(status);
154
+ }