@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
package/src/media/compose.js
CHANGED
|
@@ -3,6 +3,7 @@ import ffmpegStatic from 'ffmpeg-static';
|
|
|
3
3
|
import ffprobeStatic from 'ffprobe-static';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
6
7
|
|
|
7
8
|
// Configure ffmpeg and ffprobe paths if available
|
|
8
9
|
if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
|
|
@@ -12,6 +13,47 @@ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
|
|
|
12
13
|
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const ASS_FILTER_UNSAFE = /['\[\]:;\\,]/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a safe path to pass to ffmpeg's `ass=` filter.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} assPath
|
|
22
|
+
* @returns {{ filterPath: string | null, cleanupPath?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveAssFilterPath(assPath) {
|
|
25
|
+
if (!assPath || !fs.existsSync(assPath)) {
|
|
26
|
+
return { filterPath: null };
|
|
27
|
+
}
|
|
28
|
+
if (!ASS_FILTER_UNSAFE.test(assPath)) {
|
|
29
|
+
return { filterPath: assPath };
|
|
30
|
+
}
|
|
31
|
+
const cleanupPath = path.join(
|
|
32
|
+
os.tmpdir(),
|
|
33
|
+
`compose-ass-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ass`
|
|
34
|
+
);
|
|
35
|
+
try {
|
|
36
|
+
fs.copyFileSync(assPath, cleanupPath);
|
|
37
|
+
return { filterPath: cleanupPath, cleanupPath };
|
|
38
|
+
} catch {
|
|
39
|
+
return { filterPath: assPath };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Quote and escape special characters in path for ffmpeg filter syntax.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} filePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function escapeForFfmpeg(filePath) {
|
|
50
|
+
const escaped = String(filePath)
|
|
51
|
+
.replaceAll('\\', '\\\\')
|
|
52
|
+
.replaceAll(':', '\\:')
|
|
53
|
+
.replaceAll("'", "\\'");
|
|
54
|
+
return `'${escaped}'`;
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
/**
|
|
16
58
|
* Get the duration of an audio or video file in seconds using ffprobe.
|
|
17
59
|
*
|
|
@@ -72,6 +114,7 @@ export async function composeVideo(options) {
|
|
|
72
114
|
targetHeight = 1080,
|
|
73
115
|
fps = 30,
|
|
74
116
|
dryRun = false,
|
|
117
|
+
assPath = null,
|
|
75
118
|
onProgress,
|
|
76
119
|
} = options;
|
|
77
120
|
|
|
@@ -98,12 +141,31 @@ export async function composeVideo(options) {
|
|
|
98
141
|
}
|
|
99
142
|
|
|
100
143
|
const isImage = bgDuration === 0 || mode === 'ken-burns';
|
|
101
|
-
const
|
|
144
|
+
const baseScaleFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight},format=yuv420p`;
|
|
145
|
+
|
|
146
|
+
let assCleanupPath = null;
|
|
147
|
+
let scaleCropFilter = baseScaleFilter;
|
|
148
|
+
if (assPath && fs.existsSync(assPath)) {
|
|
149
|
+
const resolved = resolveAssFilterPath(assPath);
|
|
150
|
+
if (resolved.filterPath) {
|
|
151
|
+
assCleanupPath = resolved.cleanupPath || null;
|
|
152
|
+
scaleCropFilter = `${baseScaleFilter},ass=${escapeForFfmpeg(resolved.filterPath)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
if (assCleanupPath && fs.existsSync(assCleanupPath)) {
|
|
158
|
+
try { fs.unlinkSync(assCleanupPath); } catch {}
|
|
159
|
+
assCleanupPath = null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
102
162
|
|
|
103
163
|
if (dryRun) {
|
|
164
|
+
cleanup();
|
|
165
|
+
const subsNote = assPath ? ` + subtitles (${path.basename(assPath)})` : '';
|
|
104
166
|
const planDescription = isImage
|
|
105
|
-
? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
|
|
106
|
-
: `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
|
|
167
|
+
? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
|
|
168
|
+
: `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
|
|
107
169
|
|
|
108
170
|
return {
|
|
109
171
|
outputPath,
|
|
@@ -116,15 +178,22 @@ export async function composeVideo(options) {
|
|
|
116
178
|
return new Promise((resolve, reject) => {
|
|
117
179
|
const command = ffmpeg();
|
|
118
180
|
|
|
181
|
+
const audioOpts = [
|
|
182
|
+
'-c:a', 'aac',
|
|
183
|
+
'-b:a', '320k',
|
|
184
|
+
'-ar', '48000',
|
|
185
|
+
];
|
|
186
|
+
|
|
119
187
|
if (isImage) {
|
|
120
188
|
if (mode === 'ken-burns') {
|
|
121
189
|
const totalFrames = Math.max(30, Math.ceil(audioDuration * fps));
|
|
190
|
+
const kenBurnsFilter = `scale=8000:-1,zoompan=z='min(zoom+0.0008,1.25)':d=${totalFrames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${targetWidth}x${targetHeight}:fps=${fps},${scaleCropFilter}`;
|
|
122
191
|
command
|
|
123
192
|
.input(backgroundPath)
|
|
124
193
|
.inputOptions(['-loop', '1'])
|
|
125
194
|
.input(audioPath)
|
|
126
195
|
.complexFilter([
|
|
127
|
-
|
|
196
|
+
`${kenBurnsFilter}[v]`,
|
|
128
197
|
])
|
|
129
198
|
.outputOptions([
|
|
130
199
|
'-map', '[v]',
|
|
@@ -132,8 +201,7 @@ export async function composeVideo(options) {
|
|
|
132
201
|
'-c:v', 'libx264',
|
|
133
202
|
'-preset', 'veryfast',
|
|
134
203
|
'-crf', '20',
|
|
135
|
-
|
|
136
|
-
'-b:a', '192k',
|
|
204
|
+
...audioOpts,
|
|
137
205
|
'-shortest',
|
|
138
206
|
`-t`, `${audioDuration}`,
|
|
139
207
|
'-pix_fmt', 'yuv420p',
|
|
@@ -147,11 +215,12 @@ export async function composeVideo(options) {
|
|
|
147
215
|
.input(audioPath)
|
|
148
216
|
.videoFilters([scaleCropFilter])
|
|
149
217
|
.outputOptions([
|
|
218
|
+
'-map', '0:v',
|
|
219
|
+
'-map', '1:a',
|
|
150
220
|
'-c:v', 'libx264',
|
|
151
221
|
'-preset', 'veryfast',
|
|
152
222
|
'-crf', '20',
|
|
153
|
-
|
|
154
|
-
'-b:a', '192k',
|
|
223
|
+
...audioOpts,
|
|
155
224
|
'-shortest',
|
|
156
225
|
`-t`, `${audioDuration}`,
|
|
157
226
|
`-r`, `${fps}`,
|
|
@@ -169,11 +238,12 @@ export async function composeVideo(options) {
|
|
|
169
238
|
.input(audioPath)
|
|
170
239
|
.videoFilters([scaleCropFilter])
|
|
171
240
|
.outputOptions([
|
|
241
|
+
'-map', '0:v',
|
|
242
|
+
'-map', '1:a',
|
|
172
243
|
'-c:v', 'libx264',
|
|
173
244
|
'-preset', 'veryfast',
|
|
174
245
|
'-crf', '20',
|
|
175
|
-
|
|
176
|
-
'-b:a', '192k',
|
|
246
|
+
...audioOpts,
|
|
177
247
|
'-shortest',
|
|
178
248
|
`-t`, `${audioDuration}`,
|
|
179
249
|
`-r`, `${fps}`,
|
|
@@ -187,11 +257,12 @@ export async function composeVideo(options) {
|
|
|
187
257
|
.input(audioPath)
|
|
188
258
|
.videoFilters([scaleCropFilter])
|
|
189
259
|
.outputOptions([
|
|
260
|
+
'-map', '0:v',
|
|
261
|
+
'-map', '1:a',
|
|
190
262
|
'-c:v', 'libx264',
|
|
191
263
|
'-preset', 'veryfast',
|
|
192
264
|
'-crf', '20',
|
|
193
|
-
|
|
194
|
-
'-b:a', '192k',
|
|
265
|
+
...audioOpts,
|
|
195
266
|
'-shortest',
|
|
196
267
|
`-t`, `${audioDuration}`,
|
|
197
268
|
`-r`, `${fps}`,
|
|
@@ -208,12 +279,14 @@ export async function composeVideo(options) {
|
|
|
208
279
|
command
|
|
209
280
|
.output(outputPath)
|
|
210
281
|
.on('end', () => {
|
|
282
|
+
cleanup();
|
|
211
283
|
resolve({
|
|
212
284
|
outputPath,
|
|
213
285
|
duration: audioDuration,
|
|
214
286
|
});
|
|
215
287
|
})
|
|
216
288
|
.on('error', (err) => {
|
|
289
|
+
cleanup();
|
|
217
290
|
// Clean up partial output if any
|
|
218
291
|
if (fs.existsSync(outputPath)) {
|
|
219
292
|
try {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find local MP4 files that changed after their last YouTube upload timestamp.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} targetDir
|
|
8
|
+
* @param {import('../state.js').UploadState} state
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {number} [options.toleranceMs=1000] - Ignore sub-second clock skew
|
|
11
|
+
* @param {string[]} [options.onlyFilenames] - Restrict to these basenames
|
|
12
|
+
* @returns {Array<{ filename: string, filePath: string, videoId: string, uploadedAt: string, modifiedAt: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export function detectOutdatedUploads(targetDir, state, options = {}) {
|
|
15
|
+
const toleranceMs = options.toleranceMs ?? 1000;
|
|
16
|
+
const only = options.onlyFilenames
|
|
17
|
+
? new Set(options.onlyFilenames.map((f) => path.basename(f)))
|
|
18
|
+
: null;
|
|
19
|
+
|
|
20
|
+
const outdated = [];
|
|
21
|
+
const uploads = state.data.uploads || {};
|
|
22
|
+
|
|
23
|
+
for (const [filename, record] of Object.entries(uploads)) {
|
|
24
|
+
if (only && !only.has(filename)) continue;
|
|
25
|
+
if (!record?.videoId || !record?.uploadedAt) continue;
|
|
26
|
+
|
|
27
|
+
const filePath = path.join(targetDir, filename);
|
|
28
|
+
if (!fs.existsSync(filePath)) continue;
|
|
29
|
+
|
|
30
|
+
const stat = fs.statSync(filePath);
|
|
31
|
+
if (!stat.isFile() || stat.size === 0) continue;
|
|
32
|
+
|
|
33
|
+
const modifiedMs = stat.mtimeMs;
|
|
34
|
+
const uploadedMs = new Date(record.uploadedAt).getTime();
|
|
35
|
+
if (Number.isNaN(uploadedMs)) continue;
|
|
36
|
+
|
|
37
|
+
if (modifiedMs > uploadedMs + toleranceMs) {
|
|
38
|
+
outdated.push({
|
|
39
|
+
filename,
|
|
40
|
+
filePath,
|
|
41
|
+
videoId: record.videoId,
|
|
42
|
+
uploadedAt: record.uploadedAt,
|
|
43
|
+
modifiedAt: new Date(modifiedMs).toISOString(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
outdated.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
49
|
+
return outdated;
|
|
50
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadMetadata } from '../metadata.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve desired playlist video IDs from upload histories + metadata song order.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {string} options.albumDir
|
|
10
|
+
* @param {import('../state.js').UploadState} options.albumState
|
|
11
|
+
* @param {string} [options.overviewHistoryPath]
|
|
12
|
+
* @param {string[]} [options.overviewKeys]
|
|
13
|
+
* @param {string[]} [options.songKeys]
|
|
14
|
+
* @returns {string[]}
|
|
15
|
+
*/
|
|
16
|
+
export function resolveDesiredPlaylistOrder(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
albumDir,
|
|
19
|
+
albumState,
|
|
20
|
+
overviewHistoryPath,
|
|
21
|
+
overviewKeys = [],
|
|
22
|
+
songKeys,
|
|
23
|
+
} = options;
|
|
24
|
+
|
|
25
|
+
const ids = [];
|
|
26
|
+
|
|
27
|
+
if (overviewHistoryPath && overviewKeys.length > 0) {
|
|
28
|
+
const abs = path.resolve(overviewHistoryPath);
|
|
29
|
+
if (!fs.existsSync(abs)) {
|
|
30
|
+
throw new Error(`Overview upload history not found: ${abs}`);
|
|
31
|
+
}
|
|
32
|
+
const overviewHistory = JSON.parse(fs.readFileSync(abs, 'utf-8'));
|
|
33
|
+
for (const key of overviewKeys) {
|
|
34
|
+
const rec = overviewHistory.uploads?.[key];
|
|
35
|
+
if (!rec?.videoId) {
|
|
36
|
+
throw new Error(`Overview upload history missing videoId for ${key}`);
|
|
37
|
+
}
|
|
38
|
+
ids.push(rec.videoId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let orderedSongs = songKeys;
|
|
43
|
+
if (!orderedSongs || orderedSongs.length === 0) {
|
|
44
|
+
const metadata = loadMetadata(albumDir);
|
|
45
|
+
orderedSongs = metadata.videos.map((v) => v.filename);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const filename of orderedSongs) {
|
|
49
|
+
const rec = albumState.getUpload(filename);
|
|
50
|
+
if (!rec?.videoId) {
|
|
51
|
+
throw new Error(`Album upload history missing videoId for ${filename}`);
|
|
52
|
+
}
|
|
53
|
+
ids.push(rec.videoId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return ids;
|
|
57
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List all playlist items with snippet data.
|
|
3
|
+
*
|
|
4
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
5
|
+
* @param {string} playlistId
|
|
6
|
+
* @returns {Promise<Array<{ id: string, videoId: string, position: number, title: string }>>}
|
|
7
|
+
*/
|
|
8
|
+
export async function listPlaylistItems(youtube, playlistId) {
|
|
9
|
+
const items = [];
|
|
10
|
+
let pageToken;
|
|
11
|
+
do {
|
|
12
|
+
const { data } = await youtube.playlistItems.list({
|
|
13
|
+
part: ['snippet'],
|
|
14
|
+
playlistId,
|
|
15
|
+
maxResults: 50,
|
|
16
|
+
pageToken,
|
|
17
|
+
});
|
|
18
|
+
for (const item of data.items || []) {
|
|
19
|
+
items.push({
|
|
20
|
+
id: item.id,
|
|
21
|
+
videoId: item.snippet?.resourceId?.videoId,
|
|
22
|
+
position: item.snippet?.position ?? 0,
|
|
23
|
+
title: item.snippet?.title || '',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
pageToken = data.nextPageToken;
|
|
27
|
+
} while (pageToken);
|
|
28
|
+
|
|
29
|
+
items.sort((a, b) => a.position - b.position);
|
|
30
|
+
return items;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reorder a playlist to match an explicit video ID sequence.
|
|
35
|
+
* Updates positions from end to start to avoid index drift.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
38
|
+
* @param {string} playlistId
|
|
39
|
+
* @param {string[]} desiredVideoIds
|
|
40
|
+
* @returns {Promise<{ changed: number, finalOrder: string[] }>}
|
|
41
|
+
*/
|
|
42
|
+
export async function reorderPlaylist(youtube, playlistId, desiredVideoIds) {
|
|
43
|
+
if (!desiredVideoIds.length) {
|
|
44
|
+
throw new Error('desiredVideoIds must not be empty');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const items = await listPlaylistItems(youtube, playlistId);
|
|
48
|
+
const byVideo = new Map(items.map((item) => [item.videoId, item]));
|
|
49
|
+
|
|
50
|
+
for (const videoId of desiredVideoIds) {
|
|
51
|
+
if (!byVideo.has(videoId)) {
|
|
52
|
+
throw new Error(`Video ${videoId} is not in playlist ${playlistId}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let changed = 0;
|
|
57
|
+
for (let pos = desiredVideoIds.length - 1; pos >= 0; pos -= 1) {
|
|
58
|
+
const videoId = desiredVideoIds[pos];
|
|
59
|
+
const item = byVideo.get(videoId);
|
|
60
|
+
if (item.position === pos) continue;
|
|
61
|
+
|
|
62
|
+
await youtube.playlistItems.update({
|
|
63
|
+
part: ['snippet'],
|
|
64
|
+
requestBody: {
|
|
65
|
+
id: item.id,
|
|
66
|
+
snippet: {
|
|
67
|
+
playlistId,
|
|
68
|
+
resourceId: { kind: 'youtube#video', videoId },
|
|
69
|
+
position: pos,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
item.position = pos;
|
|
74
|
+
changed += 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { changed, finalOrder: desiredVideoIds };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compare current playlist order to desired video IDs.
|
|
82
|
+
*
|
|
83
|
+
* @param {string[]} currentVideoIds
|
|
84
|
+
* @param {string[]} desiredVideoIds
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function playlistOrderMatches(currentVideoIds, desiredVideoIds) {
|
|
88
|
+
if (currentVideoIds.length !== desiredVideoIds.length) return false;
|
|
89
|
+
return currentVideoIds.every((id, i) => id === desiredVideoIds[i]);
|
|
90
|
+
}
|
|
@@ -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
|
+
};
|