@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 +26 -0
- package/bin/cli.js +116 -1
- package/package.json +1 -1
- package/src/auth.js +1 -1
- package/src/index.d.ts +73 -0
- package/src/index.js +9 -0
- package/src/swap/steps.js +161 -0
- package/src/swap/store.js +120 -0
- package/src/swap/swapSong.js +154 -0
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 {
|
|
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.
|
|
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
|
+
}
|