@x12i/youtube-video-uploader-cli 1.4.0 → 1.6.1

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.
@@ -0,0 +1,192 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { UploadState } from '../state.js';
4
+ import { swapSong } from '../swap/swapSong.js';
5
+ import { detectOutdatedUploads } from './detect-outdated.js';
6
+ import { resolveDesiredPlaylistOrder } from './order.js';
7
+ import {
8
+ listPlaylistItems,
9
+ playlistOrderMatches,
10
+ reorderPlaylist,
11
+ } from './reorder.js';
12
+
13
+ /**
14
+ * Sync a YouTube playlist with local files: push outdated uploads via swap,
15
+ * then realign playlist order. Keeps videos private by default.
16
+ *
17
+ * @param {object} options
18
+ * @param {import('googleapis').youtube_v3.Youtube} options.youtube
19
+ * @param {string} options.albumDir
20
+ * @param {string} options.playlistId
21
+ * @param {string} [options.overviewHistoryPath]
22
+ * @param {string[]} [options.overviewKeys]
23
+ * @param {string[]} [options.songKeys]
24
+ * @param {string[]} [options.onlyFilenames]
25
+ * @param {'preserve' | 'private' | 'public'} [options.privacyMode='private']
26
+ * @param {boolean} [options.dryRun=false]
27
+ * @param {boolean} [options.skipReorder=false]
28
+ * @param {boolean} [options.freshSwap=false]
29
+ * @param {string} [options.stateFile]
30
+ * @param {(event: object) => void} [options.onEvent]
31
+ * @returns {Promise<object>}
32
+ */
33
+ export async function syncPlaylist(options = {}) {
34
+ const {
35
+ youtube,
36
+ albumDir,
37
+ playlistId,
38
+ overviewHistoryPath,
39
+ overviewKeys = [],
40
+ songKeys,
41
+ onlyFilenames,
42
+ privacyMode = 'private',
43
+ dryRun = false,
44
+ skipReorder = false,
45
+ freshSwap = false,
46
+ stateFile,
47
+ onEvent = () => {},
48
+ } = options;
49
+
50
+ if (!albumDir || !playlistId) {
51
+ throw new Error('syncPlaylist requires albumDir and playlistId');
52
+ }
53
+
54
+ const resolvedAlbumDir = path.resolve(albumDir);
55
+ const albumState = new UploadState(resolvedAlbumDir, stateFile);
56
+
57
+ const outdated = detectOutdatedUploads(resolvedAlbumDir, albumState, { onlyFilenames });
58
+ const desiredOrder = resolveDesiredPlaylistOrder({
59
+ albumDir: resolvedAlbumDir,
60
+ albumState,
61
+ overviewHistoryPath,
62
+ overviewKeys,
63
+ songKeys,
64
+ });
65
+
66
+ let currentOrder = [];
67
+ if (!dryRun && youtube) {
68
+ const currentItems = await listPlaylistItems(youtube, playlistId);
69
+ currentOrder = currentItems.map((item) => item.videoId);
70
+ }
71
+
72
+ const plan = {
73
+ playlistId,
74
+ albumDir: resolvedAlbumDir,
75
+ privacyMode,
76
+ outdated,
77
+ desiredOrder,
78
+ currentOrder,
79
+ orderAligned: currentOrder.length
80
+ ? playlistOrderMatches(currentOrder, desiredOrder)
81
+ : null,
82
+ swaps: outdated.map((item) => ({
83
+ filename: item.filename,
84
+ oldVideoId: item.videoId,
85
+ modifiedAt: item.modifiedAt,
86
+ uploadedAt: item.uploadedAt,
87
+ })),
88
+ };
89
+
90
+ onEvent({ type: 'sync_plan', plan });
91
+
92
+ if (dryRun) {
93
+ return {
94
+ dryRun: true,
95
+ ...plan,
96
+ pushed: [],
97
+ reordered: false,
98
+ };
99
+ }
100
+
101
+ if (!youtube) {
102
+ throw new Error('syncPlaylist requires an authorized youtube client when dryRun is false');
103
+ }
104
+
105
+ const pushed = [];
106
+ const failed = [];
107
+
108
+ for (const item of outdated) {
109
+ onEvent({ type: 'sync_push_start', filename: item.filename, oldVideoId: item.videoId });
110
+ try {
111
+ const { record } = await swapSong({
112
+ youtube,
113
+ songKey: item.filename,
114
+ newVideoPath: item.filePath,
115
+ playlistId,
116
+ oldVideoId: item.videoId,
117
+ stateDir: resolvedAlbumDir,
118
+ fresh: freshSwap,
119
+ privacyMode,
120
+ onEvent: (event) => onEvent({ ...event, filename: item.filename }),
121
+ });
122
+
123
+ albumState.data.uploads[item.filename] = {
124
+ ...albumState.data.uploads[item.filename],
125
+ videoId: record.newVideoId,
126
+ uploadedAt: new Date().toISOString(),
127
+ playlistId,
128
+ previousVideoId: item.videoId,
129
+ };
130
+ albumState.save();
131
+
132
+ pushed.push({
133
+ filename: item.filename,
134
+ oldVideoId: item.videoId,
135
+ newVideoId: record.newVideoId,
136
+ url: `https://youtu.be/${record.newVideoId}`,
137
+ });
138
+ onEvent({ type: 'sync_push_success', filename: item.filename, record });
139
+ } catch (err) {
140
+ failed.push({ filename: item.filename, error: err });
141
+ onEvent({ type: 'sync_push_error', filename: item.filename, error: err });
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ const refreshedDesiredOrder = resolveDesiredPlaylistOrder({
147
+ albumDir: resolvedAlbumDir,
148
+ albumState,
149
+ overviewHistoryPath,
150
+ overviewKeys,
151
+ songKeys,
152
+ });
153
+
154
+ let reorderResult = { changed: 0, finalOrder: refreshedDesiredOrder };
155
+ if (!skipReorder) {
156
+ const latestItems = await listPlaylistItems(youtube, playlistId);
157
+ const latestOrder = latestItems.map((item) => item.videoId);
158
+ if (!playlistOrderMatches(latestOrder, refreshedDesiredOrder)) {
159
+ onEvent({ type: 'sync_reorder_start', desiredOrder: refreshedDesiredOrder });
160
+ reorderResult = await reorderPlaylist(youtube, playlistId, refreshedDesiredOrder);
161
+ onEvent({ type: 'sync_reorder_success', ...reorderResult });
162
+ }
163
+ }
164
+
165
+ if (overviewHistoryPath && overviewKeys.length > 0) {
166
+ const absOverviewHistory = path.resolve(overviewHistoryPath);
167
+ if (fs.existsSync(absOverviewHistory)) {
168
+ const overviewHistory = JSON.parse(fs.readFileSync(absOverviewHistory, 'utf-8'));
169
+ overviewHistory.playlistId = playlistId;
170
+ overviewHistory.playlistOrder = refreshedDesiredOrder;
171
+ overviewHistory.updatedAt = new Date().toISOString();
172
+ fs.writeFileSync(absOverviewHistory, JSON.stringify(overviewHistory, null, 2) + '\n');
173
+ }
174
+ }
175
+
176
+ albumState.data.playlistId = playlistId;
177
+ albumState.data.playlistOrder = refreshedDesiredOrder;
178
+ albumState.data.updatedAt = new Date().toISOString();
179
+ albumState.save();
180
+
181
+ return {
182
+ dryRun: false,
183
+ playlistId,
184
+ pushed,
185
+ failed,
186
+ outdated,
187
+ desiredOrder: refreshedDesiredOrder,
188
+ reordered: reorderResult.changed > 0,
189
+ reorderChanges: reorderResult.changed,
190
+ orderAligned: true,
191
+ };
192
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Watermark-remover provider registry.
3
+ *
4
+ * Only register tools with permissive open-source licenses:
5
+ * MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, or ISC.
6
+ *
7
+ * To add another MIT/Apache/BSD/ISC npm CLI later:
8
+ * 1. Create `src/providers/watermark/<id>.js` with a `run*Remove` entry point
9
+ * 2. Register it in PROVIDERS below
10
+ * 3. No pipeline changes required
11
+ */
12
+
13
+ import {
14
+ PILIO_PROVIDER_ID,
15
+ PILIO_LICENSE,
16
+ runPilioRemove,
17
+ formatPilioCommand,
18
+ buildPilioArgs,
19
+ resolvePilioBin,
20
+ } from './pilio.js';
21
+
22
+ /** @typedef {'MIT' | 'Apache-2.0' | 'BSD-2-Clause' | 'BSD-3-Clause' | 'ISC'} PermissiveLicense */
23
+
24
+ /**
25
+ * @typedef {object} WatermarkProvider
26
+ * @property {string} id
27
+ * @property {PermissiveLicense} license
28
+ * @property {string} packageName
29
+ * @property {(options: object) => Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>} remove
30
+ */
31
+
32
+ /** @type {Record<string, WatermarkProvider>} */
33
+ const PROVIDERS = {
34
+ [PILIO_PROVIDER_ID]: {
35
+ id: PILIO_PROVIDER_ID,
36
+ license: PILIO_LICENSE,
37
+ packageName: '@pilio/gemini-watermark-remover',
38
+ remove: runPilioRemove,
39
+ },
40
+ };
41
+
42
+ export const DEFAULT_WATERMARK_PROVIDER = PILIO_PROVIDER_ID;
43
+
44
+ export const PERMISSIVE_LICENSES = Object.freeze([
45
+ 'MIT',
46
+ 'Apache-2.0',
47
+ 'BSD-2-Clause',
48
+ 'BSD-3-Clause',
49
+ 'ISC',
50
+ ]);
51
+
52
+ /**
53
+ * List registered watermark providers.
54
+ * @returns {Array<{ id: string, license: string, packageName: string }>}
55
+ */
56
+ export function listWatermarkProviders() {
57
+ return Object.values(PROVIDERS).map(({ id, license, packageName }) => ({
58
+ id,
59
+ license,
60
+ packageName,
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * Resolve a watermark provider by id (default: pilio).
66
+ * @param {string} [providerId]
67
+ * @returns {WatermarkProvider}
68
+ */
69
+ export function getWatermarkProvider(providerId = DEFAULT_WATERMARK_PROVIDER) {
70
+ const id = String(providerId || DEFAULT_WATERMARK_PROVIDER).trim().toLowerCase();
71
+ const provider = PROVIDERS[id];
72
+ if (!provider) {
73
+ const known = Object.keys(PROVIDERS).join(', ');
74
+ throw new Error(
75
+ `Unknown watermark provider "${providerId}". Known providers: ${known}. ` +
76
+ `Only permissive-license tools (${PERMISSIVE_LICENSES.join(', ')}) may be registered.`
77
+ );
78
+ }
79
+ if (!PERMISSIVE_LICENSES.includes(provider.license)) {
80
+ throw new Error(
81
+ `Watermark provider "${provider.id}" has disallowed license "${provider.license}". ` +
82
+ `Allowed: ${PERMISSIVE_LICENSES.join(', ')}.`
83
+ );
84
+ }
85
+ return provider;
86
+ }
87
+
88
+ /**
89
+ * Remove a Gemini/Veo visible watermark via the selected provider.
90
+ *
91
+ * @param {object} options
92
+ * @param {string} options.inputPath
93
+ * @param {string} [options.outputPath]
94
+ * @param {string} [options.outDir]
95
+ * @param {string} [options.provider]
96
+ * @param {boolean} [options.overwrite]
97
+ * @param {boolean} [options.json]
98
+ * @param {number} [options.videoBitrateMbps]
99
+ * @param {boolean} [options.dryRun]
100
+ * @param {(chunk: string) => void} [options.onStderr]
101
+ */
102
+ export async function removeWatermarkWithProvider(options = {}) {
103
+ const provider = getWatermarkProvider(options.provider);
104
+ return provider.remove(options);
105
+ }
106
+
107
+ export {
108
+ runPilioRemove,
109
+ formatPilioCommand,
110
+ buildPilioArgs,
111
+ resolvePilioBin,
112
+ PILIO_PROVIDER_ID,
113
+ };
@@ -0,0 +1,201 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export const PILIO_PROVIDER_ID = 'pilio';
7
+ export const PILIO_LICENSE = 'MIT';
8
+ export const PILIO_PACKAGE = '@pilio/gemini-watermark-remover';
9
+
10
+ /**
11
+ * Locate the installed package root and gwr binary path.
12
+ * @returns {{ root: string, binPath: string }}
13
+ */
14
+ export function resolvePilioBin() {
15
+ let entryUrl;
16
+ try {
17
+ entryUrl = import.meta.resolve(PILIO_PACKAGE);
18
+ } catch (err) {
19
+ throw new Error(
20
+ `Watermark provider "${PILIO_PROVIDER_ID}" requires ${PILIO_PACKAGE} (license: ${PILIO_LICENSE}). ` +
21
+ `Install it with: npm install ${PILIO_PACKAGE} sharp\n` +
22
+ `Original error: ${err.message}`
23
+ );
24
+ }
25
+
26
+ const entryPath = fileURLToPath(entryUrl);
27
+ let dir = path.dirname(entryPath);
28
+ while (dir !== path.dirname(dir)) {
29
+ const pkgPath = path.join(dir, 'package.json');
30
+ if (fs.existsSync(pkgPath)) {
31
+ try {
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
33
+ if (pkg.name === PILIO_PACKAGE) {
34
+ const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.gwr;
35
+ if (!binRel) {
36
+ throw new Error(`${PILIO_PACKAGE} package.json is missing a "gwr" bin entry`);
37
+ }
38
+ const binPath = path.resolve(dir, binRel);
39
+ if (!fs.existsSync(binPath)) {
40
+ throw new Error(`gwr binary not found at ${binPath}`);
41
+ }
42
+ return { root: dir, binPath };
43
+ }
44
+ } catch (err) {
45
+ if (err.message.includes('gwr binary') || err.message.includes('bin entry')) {
46
+ throw err;
47
+ }
48
+ }
49
+ }
50
+ dir = path.dirname(dir);
51
+ }
52
+
53
+ throw new Error(
54
+ `Could not locate ${PILIO_PACKAGE} package root after resolving ${entryPath}. ` +
55
+ `Reinstall with: npm install ${PILIO_PACKAGE} sharp`
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Build the gwr argv for a remove operation.
61
+ *
62
+ * @param {object} options
63
+ * @param {string} options.inputPath
64
+ * @param {string} [options.outputPath]
65
+ * @param {string} [options.outDir]
66
+ * @param {boolean} [options.overwrite=false]
67
+ * @param {boolean} [options.json=false]
68
+ * @param {number} [options.videoBitrateMbps]
69
+ * @returns {string[]}
70
+ */
71
+ export function buildPilioArgs(options = {}) {
72
+ const {
73
+ inputPath,
74
+ outputPath,
75
+ outDir,
76
+ overwrite = false,
77
+ json = false,
78
+ videoBitrateMbps,
79
+ } = options;
80
+
81
+ if (!inputPath) {
82
+ throw new Error('inputPath is required for pilio watermark removal');
83
+ }
84
+
85
+ const args = ['remove', inputPath];
86
+
87
+ if (outDir) {
88
+ args.push('--out-dir', outDir);
89
+ } else if (outputPath) {
90
+ args.push('--output', outputPath);
91
+ } else {
92
+ throw new Error('Either outputPath or outDir is required for pilio watermark removal');
93
+ }
94
+
95
+ if (overwrite) {
96
+ args.push('--overwrite');
97
+ }
98
+ if (json) {
99
+ args.push('--json');
100
+ }
101
+ if (videoBitrateMbps !== undefined && videoBitrateMbps !== null && videoBitrateMbps !== '') {
102
+ const n = Number(videoBitrateMbps);
103
+ if (!Number.isFinite(n) || n <= 0) {
104
+ throw new Error(`Invalid videoBitrateMbps: ${videoBitrateMbps}`);
105
+ }
106
+ args.push('--video-bitrate-mbps', String(n));
107
+ }
108
+
109
+ return args;
110
+ }
111
+
112
+ /**
113
+ * Format a human-readable command string (for dry-run / logs).
114
+ *
115
+ * @param {string[]} args
116
+ * @param {string} [binPath='gwr']
117
+ * @returns {string}
118
+ */
119
+ export function formatPilioCommand(args, binPath = 'gwr') {
120
+ const quote = (s) => (/\s/.test(s) ? JSON.stringify(s) : s);
121
+ return [quote(binPath), ...args.map(quote)].join(' ');
122
+ }
123
+
124
+ /**
125
+ * Run the pilio gwr CLI.
126
+ *
127
+ * @param {object} options
128
+ * @param {string} options.inputPath
129
+ * @param {string} [options.outputPath]
130
+ * @param {string} [options.outDir]
131
+ * @param {boolean} [options.overwrite=false]
132
+ * @param {boolean} [options.json=false]
133
+ * @param {number} [options.videoBitrateMbps]
134
+ * @param {boolean} [options.dryRun=false]
135
+ * @param {(chunk: string) => void} [options.onStderr]
136
+ * @returns {Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>}
137
+ */
138
+ export async function runPilioRemove(options = {}) {
139
+ const { dryRun = false, onStderr } = options;
140
+ const { binPath } = resolvePilioBin();
141
+ const args = buildPilioArgs(options);
142
+ const command = formatPilioCommand(args, binPath);
143
+
144
+ if (dryRun) {
145
+ return {
146
+ outputPath: options.outputPath,
147
+ outDir: options.outDir,
148
+ dryRun: true,
149
+ command,
150
+ };
151
+ }
152
+
153
+ if (options.outputPath) {
154
+ fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true });
155
+ }
156
+ if (options.outDir) {
157
+ fs.mkdirSync(path.resolve(options.outDir), { recursive: true });
158
+ }
159
+
160
+ return new Promise((resolve, reject) => {
161
+ const child = spawn(process.execPath, [binPath, ...args], {
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ env: process.env,
164
+ });
165
+
166
+ let stdout = '';
167
+ let stderr = '';
168
+
169
+ child.stdout.on('data', (chunk) => {
170
+ stdout += chunk.toString();
171
+ });
172
+ child.stderr.on('data', (chunk) => {
173
+ const text = chunk.toString();
174
+ stderr += text;
175
+ if (onStderr) onStderr(text);
176
+ });
177
+
178
+ child.on('error', (err) => {
179
+ reject(new Error(
180
+ `Failed to execute watermark provider "${PILIO_PROVIDER_ID}" (${PILIO_PACKAGE}): ${err.message}`
181
+ ));
182
+ });
183
+
184
+ child.on('close', (code) => {
185
+ if (code !== 0) {
186
+ const detail = (stderr || stdout || '').trim() || `exit code ${code}`;
187
+ reject(new Error(
188
+ `Watermark provider "${PILIO_PROVIDER_ID}" failed for ${options.inputPath}: ${detail}`
189
+ ));
190
+ return;
191
+ }
192
+ resolve({
193
+ outputPath: options.outputPath,
194
+ outDir: options.outDir,
195
+ command,
196
+ exitCode: code,
197
+ stdout,
198
+ });
199
+ });
200
+ });
201
+ }
package/src/swap/steps.js CHANGED
@@ -8,10 +8,7 @@ import { uploadVideo } from '../uploader.js';
8
8
  */
9
9
 
10
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.
11
+ * Upload the new video for a playlist swap.
15
12
  *
16
13
  * @param {import('googleapis').youtube_v3.Youtube} youtube
17
14
  * @param {string} newVideoPath
@@ -19,6 +16,7 @@ import { uploadVideo } from '../uploader.js';
19
16
  * @param {string} [opts.oldVideoId] - Fetch old video's snippet to carry over
20
17
  * @param {object} [opts.meta] - Explicit metadata overrides
21
18
  * @param {number} [opts.retries=3]
19
+ * @param {'private' | 'unlisted' | 'public'} [opts.uploadPrivacy='unlisted']
22
20
  * @returns {Promise<{ videoId: string, url: string }>}
23
21
  */
24
22
  export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
@@ -26,12 +24,14 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
26
24
  throw new Error(`New video file not found: ${newVideoPath}`);
27
25
  }
28
26
 
27
+ const uploadPrivacy = opts.uploadPrivacy || opts.meta?.privacyStatus || 'unlisted';
28
+
29
29
  let meta = {
30
30
  title: opts.meta?.title,
31
31
  description: opts.meta?.description,
32
32
  tags: opts.meta?.tags,
33
33
  categoryId: opts.meta?.categoryId,
34
- privacyStatus: 'unlisted',
34
+ privacyStatus: uploadPrivacy,
35
35
  selfDeclaredMadeForKids: opts.meta?.selfDeclaredMadeForKids ?? false,
36
36
  };
37
37
 
@@ -49,7 +49,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
49
49
  description: meta.description ?? old.snippet?.description,
50
50
  tags: meta.tags || old.snippet?.tags || [],
51
51
  categoryId: meta.categoryId || old.snippet?.categoryId,
52
- privacyStatus: 'unlisted',
52
+ privacyStatus: uploadPrivacy,
53
53
  selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids
54
54
  ?? (old.status?.madeForKids !== undefined ? old.status.madeForKids : false),
55
55
  };
@@ -68,7 +68,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
68
68
  let lastErr;
69
69
  for (let attempt = 1; attempt <= retries; attempt++) {
70
70
  try {
71
- return await uploadVideo(youtube, newVideoPath, meta, 'unlisted');
71
+ return await uploadVideo(youtube, newVideoPath, meta, uploadPrivacy);
72
72
  } catch (err) {
73
73
  lastErr = err;
74
74
  if (attempt < retries) {
@@ -142,20 +142,25 @@ export async function removeOldItem(youtube, oldPlaylistItemId) {
142
142
  }
143
143
 
144
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.
145
+ * Step 5 — Finalize swap privacy on old and new videos.
147
146
  *
148
147
  * @param {import('googleapis').youtube_v3.Youtube} youtube
149
148
  * @param {string} oldVideoId
150
149
  * @param {string} newVideoId
150
+ * @param {object} [options]
151
+ * @param {'private' | 'unlisted' | 'public'} [options.newPrivacyStatus='public']
152
+ * @param {'private' | 'unlisted' | 'public'} [options.oldPrivacyStatus='unlisted']
151
153
  */
152
- export async function finalizeSwap(youtube, oldVideoId, newVideoId) {
154
+ export async function finalizeSwap(youtube, oldVideoId, newVideoId, options = {}) {
155
+ const newPrivacyStatus = options.newPrivacyStatus || 'public';
156
+ const oldPrivacyStatus = options.oldPrivacyStatus || 'unlisted';
157
+
153
158
  await youtube.videos.update({
154
159
  part: ['status'],
155
- requestBody: { id: oldVideoId, status: { privacyStatus: 'unlisted' } },
160
+ requestBody: { id: oldVideoId, status: { privacyStatus: oldPrivacyStatus } },
156
161
  });
157
162
  await youtube.videos.update({
158
163
  part: ['status'],
159
- requestBody: { id: newVideoId, status: { privacyStatus: 'public' } },
164
+ requestBody: { id: newVideoId, status: { privacyStatus: newPrivacyStatus } },
160
165
  });
161
166
  }
@@ -33,6 +33,7 @@ export const QUOTA_PER_SWAP = 150 + 1; // ~150 shared units + 1 upload-bucket un
33
33
  * @param {string} [params.stateDir] - Where the swap state file lives (default: cwd)
34
34
  * @param {string} [params.stateFile] - Custom state filename
35
35
  * @param {boolean} [params.fresh=false] - Reset any existing record for songKey
36
+ * @param {'preserve' | 'private' | 'public'} [params.privacyMode='public'] - Final privacy for new video; preserve reads old video status
36
37
  * @param {(event: { type: string, [key: string]: any }) => void} [params.onEvent]
37
38
  * @returns {Promise<{ record: any, dryRunPlan?: string[] }>}
38
39
  */
@@ -47,6 +48,7 @@ export async function swapSong(params) {
47
48
  stateDir,
48
49
  stateFile,
49
50
  fresh = false,
51
+ privacyMode = 'public',
50
52
  onEvent = () => {},
51
53
  } = params;
52
54
 
@@ -84,13 +86,46 @@ export async function swapSong(params) {
84
86
  }
85
87
 
86
88
  try {
89
+ let resolvedNewPrivacy = 'public';
90
+ let resolvedOldPrivacy = 'unlisted';
91
+ let uploadPrivacy = 'unlisted';
92
+
93
+ if (privacyMode === 'private') {
94
+ resolvedNewPrivacy = 'private';
95
+ resolvedOldPrivacy = 'unlisted';
96
+ uploadPrivacy = 'private';
97
+ } else if (privacyMode === 'preserve') {
98
+ try {
99
+ const res = await youtube.videos.list({
100
+ part: ['status'],
101
+ id: [oldVideoId],
102
+ });
103
+ const oldStatus = res.data.items?.[0]?.status?.privacyStatus;
104
+ if (oldStatus === 'private' || oldStatus === 'unlisted' || oldStatus === 'public') {
105
+ resolvedNewPrivacy = oldStatus;
106
+ uploadPrivacy = oldStatus;
107
+ } else {
108
+ resolvedNewPrivacy = 'private';
109
+ uploadPrivacy = 'private';
110
+ }
111
+ } catch {
112
+ resolvedNewPrivacy = 'private';
113
+ uploadPrivacy = 'private';
114
+ }
115
+ resolvedOldPrivacy = 'unlisted';
116
+ }
117
+
87
118
  // 1. UPLOAD
88
119
  await step('uploaded', async () => {
89
120
  const alreadyUploaded = store.getRecord(songKey).newVideoId;
90
121
  if (alreadyUploaded) {
91
122
  return { videoId: alreadyUploaded };
92
123
  }
93
- const up = await uploadNewVideo(youtube, newVideoPath, { oldVideoId, meta });
124
+ const up = await uploadNewVideo(youtube, newVideoPath, {
125
+ oldVideoId,
126
+ meta,
127
+ uploadPrivacy,
128
+ });
94
129
  onEvent({ type: 'swap_uploaded', songKey, newVideoId: up.videoId, url: up.url });
95
130
  return up;
96
131
  }, r => ({ newVideoId: r.videoId }));
@@ -128,7 +163,10 @@ export async function swapSong(params) {
128
163
 
129
164
  // 5. FINALIZE
130
165
  await step('finalized', async () => {
131
- await finalizeSwap(youtube, oldVideoId, newVideoId);
166
+ await finalizeSwap(youtube, oldVideoId, newVideoId, {
167
+ newPrivacyStatus: resolvedNewPrivacy,
168
+ oldPrivacyStatus: resolvedOldPrivacy,
169
+ });
132
170
  });
133
171
 
134
172
  onEvent({ type: 'swap_complete', songKey, oldVideoId, newVideoId, playlistId });