@x12i/youtube-video-uploader-cli 1.3.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.
- package/README.md +76 -5
- package/bin/cli.js +344 -6
- package/package.json +5 -3
- package/src/access/privacy.js +113 -0
- package/src/access/set-access.js +247 -0
- package/src/actions/index.js +33 -0
- package/src/actions/journey-overlay.js +117 -61
- package/src/actions/remove-watermark.js +109 -0
- package/src/actions/replace-background.js +42 -4
- package/src/actions/shorten.js +49 -10
- package/src/batch/plan.js +4 -3
- package/src/cli/preview-journey.js +9 -4
- package/src/cli/remove-watermark.js +252 -0
- package/src/index.d.ts +156 -3
- package/src/index.js +42 -0
- package/src/media/compose.js +85 -12
- package/src/playlist/detect-outdated.js +50 -0
- package/src/playlist/order.js +57 -0
- package/src/playlist/reorder.js +90 -0
- package/src/playlist/sync.js +192 -0
- package/src/providers/watermark/index.js +113 -0
- package/src/providers/watermark/pilio.js +201 -0
- package/src/swap/steps.js +17 -12
- package/src/swap/swapSong.js +40 -2
- package/src/update.js +165 -25
|
@@ -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
|
-
*
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
|
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:
|
|
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:
|
|
164
|
+
requestBody: { id: newVideoId, status: { privacyStatus: newPrivacyStatus } },
|
|
160
165
|
});
|
|
161
166
|
}
|
package/src/swap/swapSong.js
CHANGED
|
@@ -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, {
|
|
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 });
|
package/src/update.js
CHANGED
|
@@ -4,11 +4,50 @@ import { google } from 'googleapis';
|
|
|
4
4
|
import { loadMetadata } from './metadata.js';
|
|
5
5
|
import { UploadState } from './state.js';
|
|
6
6
|
import { authenticate } from './auth.js';
|
|
7
|
+
import { listPlaylistItems } from './playlist/reorder.js';
|
|
7
8
|
|
|
8
9
|
export const QUOTA_PER_VIDEO_UPDATE = 50;
|
|
9
10
|
export const QUOTA_PER_PLAYLIST_UPDATE = 50;
|
|
10
11
|
export const QUOTA_PER_LIST = 1;
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Apply a find/replace to a string.
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @param {string} pattern
|
|
17
|
+
* @param {string} [replacement]
|
|
18
|
+
* @param {boolean} [literal=false]
|
|
19
|
+
*/
|
|
20
|
+
export function applyTextReplace(text, pattern, replacement = '', literal = false) {
|
|
21
|
+
if (!pattern || text == null) return text;
|
|
22
|
+
const value = String(text);
|
|
23
|
+
if (literal) {
|
|
24
|
+
return value.split(pattern).join(replacement);
|
|
25
|
+
}
|
|
26
|
+
return value.replace(new RegExp(pattern, 'g'), replacement);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deep-replace matching strings anywhere in a JSON-serializable object.
|
|
31
|
+
* @param {unknown} value
|
|
32
|
+
* @param {string} pattern
|
|
33
|
+
* @param {string} [replacement]
|
|
34
|
+
* @param {boolean} [literal=false]
|
|
35
|
+
*/
|
|
36
|
+
export function deepReplaceInObject(value, pattern, replacement = '', literal = false) {
|
|
37
|
+
if (typeof value === 'string') {
|
|
38
|
+
return applyTextReplace(value, pattern, replacement, literal);
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.map((item) => deepReplaceInObject(item, pattern, replacement, literal));
|
|
42
|
+
}
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
return Object.fromEntries(
|
|
45
|
+
Object.entries(value).map(([key, item]) => [key, deepReplaceInObject(item, pattern, replacement, literal)])
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
12
51
|
/**
|
|
13
52
|
* Update snippet fields of a YouTube video while preserving other metadata.
|
|
14
53
|
*
|
|
@@ -132,6 +171,9 @@ export async function updatePlaylistTitle(youtube, playlistId, newTitle) {
|
|
|
132
171
|
* @param {string} [options.playlistTitle] - Override playlist title
|
|
133
172
|
* @param {string} [options.replacePattern] - Regex or substring to find in titles
|
|
134
173
|
* @param {string} [options.replaceWith] - Replacement string
|
|
174
|
+
* @param {boolean} [options.replaceLiteral=false] - Treat replacePattern as a plain substring
|
|
175
|
+
* @param {boolean} [options.replaceInSnippet=false] - Also replace in descriptions/tags on YouTube
|
|
176
|
+
* @param {string} [options.includePlaylistId] - Include every video in this playlist (e.g. overview prefix videos)
|
|
135
177
|
* @param {string} [options.metadataFile] - Custom path to metadata.json
|
|
136
178
|
* @param {boolean} [options.updateLocalMetadata=true] - Update metadata.json on disk
|
|
137
179
|
* @param {boolean} [options.syncMetadata=false] - Push titles from metadata.json directly to YouTube
|
|
@@ -156,6 +198,14 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
156
198
|
const dryRun = Boolean(options.dryRun);
|
|
157
199
|
const onEvent = options.onEvent || (() => {});
|
|
158
200
|
const updateLocal = options.updateLocalMetadata !== false;
|
|
201
|
+
const replaceLiteral = Boolean(options.replaceLiteral);
|
|
202
|
+
const replaceInSnippet = Boolean(options.replaceInSnippet);
|
|
203
|
+
const includePlaylistId = options.includePlaylistId || null;
|
|
204
|
+
|
|
205
|
+
const replaceText = (text) => {
|
|
206
|
+
if (!options.replacePattern) return text;
|
|
207
|
+
return applyTextReplace(text, options.replacePattern, options.replaceWith || '', replaceLiteral);
|
|
208
|
+
};
|
|
159
209
|
|
|
160
210
|
const metadataPath = options.metadataFile
|
|
161
211
|
? path.resolve(options.metadataFile)
|
|
@@ -175,10 +225,30 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
175
225
|
const cachedPlaylist = state.getPlaylist(playlistKey);
|
|
176
226
|
const playlistId = options.singlePlaylistId || rawMetadata?.playlistId || rawMetadata?.playlist?.playlistId || cachedPlaylist?.playlistId || state.getPlaylist('gray_to_light')?.playlistId || null;
|
|
177
227
|
|
|
228
|
+
let youtube = null;
|
|
229
|
+
const needsYoutubeForPlan = Boolean(includePlaylistId && options.replacePattern);
|
|
230
|
+
if (needsYoutubeForPlan) {
|
|
231
|
+
const auth = await authenticate({
|
|
232
|
+
tokenFile: options.tokenFile,
|
|
233
|
+
clientSecretsFile: options.clientSecretsFile,
|
|
234
|
+
clientId: options.clientId,
|
|
235
|
+
clientSecret: options.clientSecret,
|
|
236
|
+
});
|
|
237
|
+
youtube = google.youtube({ version: 'v3', auth });
|
|
238
|
+
}
|
|
239
|
+
|
|
178
240
|
// Build playlist plan
|
|
179
241
|
let playlistPlan = null;
|
|
180
242
|
if (playlistId) {
|
|
181
243
|
let currentTitle = rawMetadata?.playlist?.youtube?.snippet?.title || rawMetadata?.playlist?.snippet?.title || '';
|
|
244
|
+
if (!currentTitle && youtube) {
|
|
245
|
+
try {
|
|
246
|
+
const { data } = await youtube.playlists.list({ part: ['snippet'], id: [playlistId] });
|
|
247
|
+
currentTitle = data.items?.[0]?.snippet?.title || '';
|
|
248
|
+
} catch {
|
|
249
|
+
// Non-fatal during planning
|
|
250
|
+
}
|
|
251
|
+
}
|
|
182
252
|
let newTitle = currentTitle;
|
|
183
253
|
|
|
184
254
|
if (options.syncMetadata) {
|
|
@@ -192,8 +262,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
192
262
|
}
|
|
193
263
|
|
|
194
264
|
if (options.replacePattern) {
|
|
195
|
-
|
|
196
|
-
newTitle = newTitle.replace(reg, options.replaceWith || '');
|
|
265
|
+
newTitle = replaceText(newTitle);
|
|
197
266
|
}
|
|
198
267
|
|
|
199
268
|
if (currentTitle || newTitle || options.playlistPostfix) {
|
|
@@ -207,6 +276,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
207
276
|
}
|
|
208
277
|
|
|
209
278
|
// Build video plans
|
|
279
|
+
/** @type {Array<{ filename: string, videoId: string, oldTitle: string, newTitle: string, changed: boolean, snippetUpdates?: object }>} */
|
|
210
280
|
const videoPlans = [];
|
|
211
281
|
const songsObj = rawMetadata?.songs || {};
|
|
212
282
|
const orderedKeys = Array.isArray(rawMetadata?.playlist?.orderedSongKeys)
|
|
@@ -233,6 +303,9 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
233
303
|
|
|
234
304
|
let currentTitle = songData.youtube?.snippet?.title || path.basename(filename, path.extname(filename));
|
|
235
305
|
let newTitle = currentTitle;
|
|
306
|
+
const snippet = songData.youtube?.snippet || {};
|
|
307
|
+
let newDescription = snippet.description || '';
|
|
308
|
+
let newTags = Array.isArray(snippet.tags) ? [...snippet.tags] : [];
|
|
236
309
|
|
|
237
310
|
if (options.syncMetadata) {
|
|
238
311
|
newTitle = currentTitle;
|
|
@@ -250,8 +323,20 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
250
323
|
}
|
|
251
324
|
|
|
252
325
|
if (options.replacePattern) {
|
|
253
|
-
|
|
254
|
-
|
|
326
|
+
newTitle = replaceText(newTitle);
|
|
327
|
+
if (replaceInSnippet) {
|
|
328
|
+
newDescription = replaceText(newDescription);
|
|
329
|
+
newTags = newTags.map((tag) => replaceText(tag));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const snippetUpdates = {};
|
|
334
|
+
if (newTitle !== currentTitle) snippetUpdates.title = newTitle;
|
|
335
|
+
if (replaceInSnippet && newDescription !== (snippet.description || '')) {
|
|
336
|
+
snippetUpdates.description = newDescription;
|
|
337
|
+
}
|
|
338
|
+
if (replaceInSnippet && JSON.stringify(newTags) !== JSON.stringify(snippet.tags || [])) {
|
|
339
|
+
snippetUpdates.tags = newTags;
|
|
255
340
|
}
|
|
256
341
|
|
|
257
342
|
videoPlans.push({
|
|
@@ -259,7 +344,8 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
259
344
|
videoId,
|
|
260
345
|
oldTitle: currentTitle,
|
|
261
346
|
newTitle,
|
|
262
|
-
changed: (
|
|
347
|
+
changed: Object.keys(snippetUpdates).length > 0,
|
|
348
|
+
snippetUpdates: Object.keys(snippetUpdates).length > 0 ? snippetUpdates : undefined,
|
|
263
349
|
});
|
|
264
350
|
}
|
|
265
351
|
|
|
@@ -278,8 +364,7 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
278
364
|
}
|
|
279
365
|
}
|
|
280
366
|
if (options.replacePattern) {
|
|
281
|
-
|
|
282
|
-
newTitle = newTitle.replace(reg, options.replaceWith || '');
|
|
367
|
+
newTitle = replaceText(newTitle);
|
|
283
368
|
}
|
|
284
369
|
videoPlans.push({
|
|
285
370
|
filename: upload.filename,
|
|
@@ -287,6 +372,41 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
287
372
|
oldTitle: currentTitle,
|
|
288
373
|
newTitle,
|
|
289
374
|
changed: (newTitle !== currentTitle),
|
|
375
|
+
snippetUpdates: newTitle !== currentTitle ? { title: newTitle } : undefined,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (includePlaylistId && youtube && options.replacePattern) {
|
|
382
|
+
const items = await listPlaylistItems(youtube, includePlaylistId);
|
|
383
|
+
const plannedIds = new Set(videoPlans.map((plan) => plan.videoId));
|
|
384
|
+
const missingIds = items.map((item) => item.videoId).filter((videoId) => videoId && !plannedIds.has(videoId));
|
|
385
|
+
for (let i = 0; i < missingIds.length; i += 50) {
|
|
386
|
+
const chunk = missingIds.slice(i, i + 50);
|
|
387
|
+
const { data } = await youtube.videos.list({ part: ['snippet'], id: chunk });
|
|
388
|
+
for (const item of data.items || []) {
|
|
389
|
+
const currentTitle = item.snippet?.title || '';
|
|
390
|
+
const newTitle = replaceText(currentTitle);
|
|
391
|
+
const snippetUpdates = {};
|
|
392
|
+
if (newTitle !== currentTitle) snippetUpdates.title = newTitle;
|
|
393
|
+
if (replaceInSnippet) {
|
|
394
|
+
const newDescription = replaceText(item.snippet?.description || '');
|
|
395
|
+
const newTags = (item.snippet?.tags || []).map((tag) => replaceText(tag));
|
|
396
|
+
if (newDescription !== (item.snippet?.description || '')) {
|
|
397
|
+
snippetUpdates.description = newDescription;
|
|
398
|
+
}
|
|
399
|
+
if (JSON.stringify(newTags) !== JSON.stringify(item.snippet?.tags || [])) {
|
|
400
|
+
snippetUpdates.tags = newTags;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
videoPlans.push({
|
|
404
|
+
filename: item.id,
|
|
405
|
+
videoId: item.id,
|
|
406
|
+
oldTitle: currentTitle,
|
|
407
|
+
newTitle,
|
|
408
|
+
changed: Object.keys(snippetUpdates).length > 0,
|
|
409
|
+
snippetUpdates: Object.keys(snippetUpdates).length > 0 ? snippetUpdates : undefined,
|
|
290
410
|
});
|
|
291
411
|
}
|
|
292
412
|
}
|
|
@@ -325,14 +445,15 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
325
445
|
}
|
|
326
446
|
|
|
327
447
|
// Authenticate
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
448
|
+
if (!youtube) {
|
|
449
|
+
const auth = await authenticate({
|
|
450
|
+
tokenFile: options.tokenFile,
|
|
451
|
+
clientSecretsFile: options.clientSecretsFile,
|
|
452
|
+
clientId: options.clientId,
|
|
453
|
+
clientSecret: options.clientSecret,
|
|
454
|
+
});
|
|
455
|
+
youtube = google.youtube({ version: 'v3', auth });
|
|
456
|
+
}
|
|
336
457
|
|
|
337
458
|
// Update Playlist
|
|
338
459
|
if (playlistPlan && playlistPlan.changed) {
|
|
@@ -351,7 +472,11 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
351
472
|
const v = changedVideos[i];
|
|
352
473
|
onEvent({ type: 'video_update_start', item: v, index: i, total: changedVideos.length });
|
|
353
474
|
try {
|
|
354
|
-
|
|
475
|
+
if (v.snippetUpdates && Object.keys(v.snippetUpdates).length > 0) {
|
|
476
|
+
await updateVideoSnippet(youtube, v.videoId, v.snippetUpdates);
|
|
477
|
+
} else {
|
|
478
|
+
await updateVideoTitle(youtube, v.videoId, v.newTitle);
|
|
479
|
+
}
|
|
355
480
|
onEvent({ type: 'video_update_success', item: v, index: i, total: changedVideos.length });
|
|
356
481
|
} catch (err) {
|
|
357
482
|
onEvent({ type: 'video_update_error', item: v, error: err });
|
|
@@ -362,17 +487,32 @@ export async function batchUpdateMetadata(targetFolder, options = {}) {
|
|
|
362
487
|
// Update metadata.json on disk if present and requested
|
|
363
488
|
if (updateLocal && rawMetadata && fs.existsSync(metadataPath)) {
|
|
364
489
|
try {
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
rawMetadata
|
|
368
|
-
|
|
369
|
-
|
|
490
|
+
if (options.replacePattern) {
|
|
491
|
+
rawMetadata = deepReplaceInObject(
|
|
492
|
+
rawMetadata,
|
|
493
|
+
options.replacePattern,
|
|
494
|
+
options.replaceWith || '',
|
|
495
|
+
replaceLiteral
|
|
496
|
+
);
|
|
497
|
+
} else {
|
|
498
|
+
if (playlistPlan && playlistPlan.changed) {
|
|
499
|
+
if (rawMetadata.playlist?.youtube?.snippet) {
|
|
500
|
+
rawMetadata.playlist.youtube.snippet.title = playlistPlan.newTitle;
|
|
501
|
+
} else if (rawMetadata.playlist?.snippet) {
|
|
502
|
+
rawMetadata.playlist.snippet.title = playlistPlan.newTitle;
|
|
503
|
+
}
|
|
370
504
|
}
|
|
371
|
-
}
|
|
372
505
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
506
|
+
for (const v of videoPlans) {
|
|
507
|
+
if (v.changed && rawMetadata.songs?.[v.filename]?.youtube?.snippet) {
|
|
508
|
+
rawMetadata.songs[v.filename].youtube.snippet.title = v.newTitle;
|
|
509
|
+
if (v.snippetUpdates?.description) {
|
|
510
|
+
rawMetadata.songs[v.filename].youtube.snippet.description = v.snippetUpdates.description;
|
|
511
|
+
}
|
|
512
|
+
if (v.snippetUpdates?.tags) {
|
|
513
|
+
rawMetadata.songs[v.filename].youtube.snippet.tags = v.snippetUpdates.tags;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
376
516
|
}
|
|
377
517
|
}
|
|
378
518
|
|