@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.
- package/README.md +59 -1
- 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 +31 -0
- package/src/actions/remove-watermark.js +109 -0
- package/src/cli/remove-watermark.js +252 -0
- package/src/index.d.ts +147 -1
- package/src/index.js +42 -0
- 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,252 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
removeWatermark,
|
|
5
|
+
isWatermarkSupportedMedia,
|
|
6
|
+
defaultCleanOutputPath,
|
|
7
|
+
defaultCleanOutDir,
|
|
8
|
+
} from '../actions/remove-watermark.js';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_WATERMARK_PROVIDER,
|
|
11
|
+
listWatermarkProviders,
|
|
12
|
+
} from '../providers/watermark/index.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Batch / single-file watermark removal without metadata.json.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} input - File or directory path
|
|
18
|
+
* @param {object} [options]
|
|
19
|
+
* @param {string} [options.output] - Explicit output file (file input only)
|
|
20
|
+
* @param {string} [options.outDir] - Output directory (directory input, or optional for file)
|
|
21
|
+
* @param {string} [options.provider]
|
|
22
|
+
* @param {boolean} [options.overwrite=false]
|
|
23
|
+
* @param {boolean} [options.json=false]
|
|
24
|
+
* @param {number} [options.videoBitrateMbps]
|
|
25
|
+
* @param {boolean} [options.dryRun=false]
|
|
26
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
27
|
+
* @returns {Promise<{
|
|
28
|
+
* input: string,
|
|
29
|
+
* mode: 'file' | 'directory',
|
|
30
|
+
* provider: string,
|
|
31
|
+
* dryRun: boolean,
|
|
32
|
+
* processed: Array<object>,
|
|
33
|
+
* skipped: Array<object>,
|
|
34
|
+
* failed: Array<object>
|
|
35
|
+
* }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function batchRemoveWatermark(input, options = {}) {
|
|
38
|
+
const {
|
|
39
|
+
output,
|
|
40
|
+
outDir,
|
|
41
|
+
provider = DEFAULT_WATERMARK_PROVIDER,
|
|
42
|
+
overwrite = false,
|
|
43
|
+
json = false,
|
|
44
|
+
videoBitrateMbps,
|
|
45
|
+
dryRun = false,
|
|
46
|
+
onEvent,
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
const resolvedInput = path.resolve(input || '.');
|
|
50
|
+
if (!fs.existsSync(resolvedInput)) {
|
|
51
|
+
throw new Error(`Input path not found: ${resolvedInput}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stat = fs.statSync(resolvedInput);
|
|
55
|
+
const actionOpts = { provider, overwrite, json, videoBitrateMbps };
|
|
56
|
+
|
|
57
|
+
if (stat.isFile()) {
|
|
58
|
+
return removeSingleFile(resolvedInput, { output, outDir, dryRun, actionOpts, onEvent });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (stat.isDirectory()) {
|
|
62
|
+
return removeDirectory(resolvedInput, { outDir, dryRun, actionOpts, onEvent });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
throw new Error(`Unsupported input path type: ${resolvedInput}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} inputPath
|
|
70
|
+
* @param {object} ctx
|
|
71
|
+
*/
|
|
72
|
+
async function removeSingleFile(inputPath, ctx) {
|
|
73
|
+
const { output, outDir, dryRun, actionOpts, onEvent } = ctx;
|
|
74
|
+
let outputPath = output ? path.resolve(output) : null;
|
|
75
|
+
if (!outputPath && outDir) {
|
|
76
|
+
outputPath = path.join(path.resolve(outDir), path.basename(inputPath));
|
|
77
|
+
}
|
|
78
|
+
if (!outputPath) {
|
|
79
|
+
outputPath = defaultCleanOutputPath(inputPath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const processed = [];
|
|
83
|
+
const skipped = [];
|
|
84
|
+
const failed = [];
|
|
85
|
+
|
|
86
|
+
if (onEvent) {
|
|
87
|
+
onEvent({
|
|
88
|
+
type: 'watermark_plan',
|
|
89
|
+
mode: 'file',
|
|
90
|
+
input: inputPath,
|
|
91
|
+
total: 1,
|
|
92
|
+
provider: actionOpts.provider,
|
|
93
|
+
dryRun,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
if (onEvent) {
|
|
99
|
+
onEvent({ type: 'entry_start', index: 0, total: 1, filename: path.basename(inputPath), inputPath });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isWatermarkSupportedMedia(inputPath)) {
|
|
103
|
+
const skip = { filename: path.basename(inputPath), inputPath, reason: 'Unsupported media extension' };
|
|
104
|
+
skipped.push(skip);
|
|
105
|
+
if (onEvent) onEvent({ type: 'entry_skipped', ...skip });
|
|
106
|
+
} else if (fs.existsSync(outputPath) && !actionOpts.overwrite && !dryRun) {
|
|
107
|
+
const skip = {
|
|
108
|
+
filename: path.basename(inputPath),
|
|
109
|
+
inputPath,
|
|
110
|
+
outputPath,
|
|
111
|
+
reason: 'Output file already exists (use --overwrite / --force)',
|
|
112
|
+
};
|
|
113
|
+
skipped.push(skip);
|
|
114
|
+
if (onEvent) onEvent({ type: 'entry_skipped', ...skip });
|
|
115
|
+
} else {
|
|
116
|
+
const res = await removeWatermark({
|
|
117
|
+
inputPath,
|
|
118
|
+
outputPath,
|
|
119
|
+
action: actionOpts,
|
|
120
|
+
dryRun,
|
|
121
|
+
onStderr: (chunk) => {
|
|
122
|
+
if (onEvent) onEvent({ type: 'provider_stderr', chunk });
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const item = {
|
|
126
|
+
filename: path.basename(inputPath),
|
|
127
|
+
inputPath,
|
|
128
|
+
outputPath,
|
|
129
|
+
command: res.command,
|
|
130
|
+
provider: res.provider,
|
|
131
|
+
dryRun: res.dryRun,
|
|
132
|
+
};
|
|
133
|
+
processed.push(item);
|
|
134
|
+
if (onEvent) onEvent({ type: 'entry_complete', ...item });
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const fail = { filename: path.basename(inputPath), inputPath, error: err };
|
|
138
|
+
failed.push(fail);
|
|
139
|
+
if (onEvent) onEvent({ type: 'entry_failed', filename: fail.filename, inputPath, error: err });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
input: inputPath,
|
|
144
|
+
mode: 'file',
|
|
145
|
+
provider: actionOpts.provider,
|
|
146
|
+
dryRun,
|
|
147
|
+
processed,
|
|
148
|
+
skipped,
|
|
149
|
+
failed,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} inputDir
|
|
155
|
+
* @param {object} ctx
|
|
156
|
+
*/
|
|
157
|
+
async function removeDirectory(inputDir, ctx) {
|
|
158
|
+
const { outDir, dryRun, actionOpts, onEvent } = ctx;
|
|
159
|
+
const resolvedOutDir = outDir ? path.resolve(outDir) : defaultCleanOutDir(inputDir);
|
|
160
|
+
|
|
161
|
+
const entries = fs.readdirSync(inputDir)
|
|
162
|
+
.map((name) => path.join(inputDir, name))
|
|
163
|
+
.filter((p) => fs.statSync(p).isFile() && isWatermarkSupportedMedia(p))
|
|
164
|
+
.sort((a, b) => a.localeCompare(b));
|
|
165
|
+
|
|
166
|
+
if (onEvent) {
|
|
167
|
+
onEvent({
|
|
168
|
+
type: 'watermark_plan',
|
|
169
|
+
mode: 'directory',
|
|
170
|
+
input: inputDir,
|
|
171
|
+
outDir: resolvedOutDir,
|
|
172
|
+
total: entries.length,
|
|
173
|
+
provider: actionOpts.provider,
|
|
174
|
+
dryRun,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Directory batch: prefer provider --out-dir when not dry-run for a single spawn when overwrite/json only.
|
|
179
|
+
// We still process file-by-file so skip/exists and progress events stay consistent with apply-actions.
|
|
180
|
+
const processed = [];
|
|
181
|
+
const skipped = [];
|
|
182
|
+
const failed = [];
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < entries.length; i++) {
|
|
185
|
+
const inputPath = entries[i];
|
|
186
|
+
const outputPath = path.join(resolvedOutDir, path.basename(inputPath));
|
|
187
|
+
|
|
188
|
+
if (onEvent) {
|
|
189
|
+
onEvent({
|
|
190
|
+
type: 'entry_start',
|
|
191
|
+
index: i,
|
|
192
|
+
total: entries.length,
|
|
193
|
+
filename: path.basename(inputPath),
|
|
194
|
+
inputPath,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (fs.existsSync(outputPath) && !actionOpts.overwrite && !dryRun) {
|
|
200
|
+
const skip = {
|
|
201
|
+
filename: path.basename(inputPath),
|
|
202
|
+
inputPath,
|
|
203
|
+
outputPath,
|
|
204
|
+
reason: 'Output file already exists (use --overwrite / --force)',
|
|
205
|
+
};
|
|
206
|
+
skipped.push(skip);
|
|
207
|
+
if (onEvent) onEvent({ type: 'entry_skipped', ...skip });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const res = await removeWatermark({
|
|
212
|
+
inputPath,
|
|
213
|
+
outputPath,
|
|
214
|
+
action: actionOpts,
|
|
215
|
+
dryRun,
|
|
216
|
+
onStderr: (chunk) => {
|
|
217
|
+
if (onEvent) onEvent({ type: 'provider_stderr', chunk });
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
const item = {
|
|
221
|
+
filename: path.basename(inputPath),
|
|
222
|
+
inputPath,
|
|
223
|
+
outputPath,
|
|
224
|
+
command: res.command,
|
|
225
|
+
provider: res.provider,
|
|
226
|
+
dryRun: res.dryRun,
|
|
227
|
+
};
|
|
228
|
+
processed.push(item);
|
|
229
|
+
if (onEvent) onEvent({ type: 'entry_complete', ...item });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
const fail = { filename: path.basename(inputPath), inputPath, error: err };
|
|
232
|
+
failed.push(fail);
|
|
233
|
+
if (onEvent) onEvent({ type: 'entry_failed', filename: fail.filename, inputPath, error: err });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
input: inputDir,
|
|
239
|
+
mode: 'directory',
|
|
240
|
+
provider: actionOpts.provider,
|
|
241
|
+
outDir: resolvedOutDir,
|
|
242
|
+
dryRun,
|
|
243
|
+
processed,
|
|
244
|
+
skipped,
|
|
245
|
+
failed,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export {
|
|
250
|
+
listWatermarkProviders,
|
|
251
|
+
DEFAULT_WATERMARK_PROVIDER,
|
|
252
|
+
};
|
package/src/index.d.ts
CHANGED
|
@@ -193,6 +193,78 @@ export const QUOTA_PER_SWAP: number;
|
|
|
193
193
|
export const DEFAULT_SWAP_STATE_FILENAME: string;
|
|
194
194
|
export const SWAP_STATUSES: string[];
|
|
195
195
|
|
|
196
|
+
// ─── Access / Privacy ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
export type PrivacyStatus = 'private' | 'unlisted' | 'public';
|
|
199
|
+
|
|
200
|
+
export const QUOTA_PER_PRIVACY_UPDATE: number;
|
|
201
|
+
|
|
202
|
+
export function normalizePrivacyStatus(value: unknown): PrivacyStatus;
|
|
203
|
+
export function getVideosPrivacy(youtube: youtube_v3.Youtube, videoIds: string[]): Promise<Map<string, PrivacyStatus>>;
|
|
204
|
+
export function getPlaylistPrivacy(youtube: youtube_v3.Youtube, playlistId: string): Promise<PrivacyStatus>;
|
|
205
|
+
export function setVideoPrivacy(youtube: youtube_v3.Youtube, videoId: string, privacyStatus: PrivacyStatus): Promise<void>;
|
|
206
|
+
export function setPlaylistPrivacy(youtube: youtube_v3.Youtube, playlistId: string, privacyStatus: PrivacyStatus): Promise<void>;
|
|
207
|
+
|
|
208
|
+
export interface AccessVideoPlan {
|
|
209
|
+
videoId: string;
|
|
210
|
+
title?: string;
|
|
211
|
+
position?: number;
|
|
212
|
+
currentPrivacy: PrivacyStatus | null;
|
|
213
|
+
targetPrivacy: PrivacyStatus;
|
|
214
|
+
changed: boolean;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface SetPlaylistAccessOptions {
|
|
218
|
+
youtube?: youtube_v3.Youtube;
|
|
219
|
+
playlistId: string;
|
|
220
|
+
privacyStatus: PrivacyStatus;
|
|
221
|
+
videoIds?: string[];
|
|
222
|
+
videoSource?: 'playlist' | 'history';
|
|
223
|
+
albumDir?: string;
|
|
224
|
+
overviewHistoryPath?: string;
|
|
225
|
+
overviewKeys?: string[];
|
|
226
|
+
updateMetadata?: boolean;
|
|
227
|
+
metadataFile?: string;
|
|
228
|
+
dryRun?: boolean;
|
|
229
|
+
onEvent?: (event: { type: string; [key: string]: any }) => void;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface SetPlaylistAccessResult {
|
|
233
|
+
dryRun: boolean;
|
|
234
|
+
playlistId: string;
|
|
235
|
+
privacyStatus: PrivacyStatus;
|
|
236
|
+
videoSource: 'playlist' | 'history';
|
|
237
|
+
playlist: {
|
|
238
|
+
playlistId: string;
|
|
239
|
+
currentPrivacy: PrivacyStatus | null;
|
|
240
|
+
targetPrivacy: PrivacyStatus;
|
|
241
|
+
changed: boolean;
|
|
242
|
+
};
|
|
243
|
+
videos: AccessVideoPlan[];
|
|
244
|
+
changedCount: number;
|
|
245
|
+
estimatedQuota: number;
|
|
246
|
+
updated: {
|
|
247
|
+
playlist: boolean;
|
|
248
|
+
videos: Array<{ videoId: string; privacyStatus: PrivacyStatus }>;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function setPlaylistAccess(options: SetPlaylistAccessOptions): Promise<SetPlaylistAccessResult>;
|
|
253
|
+
|
|
254
|
+
export interface SetVideosAccessOptions {
|
|
255
|
+
youtube?: youtube_v3.Youtube;
|
|
256
|
+
videoIds: string[];
|
|
257
|
+
privacyStatus: PrivacyStatus;
|
|
258
|
+
dryRun?: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function setVideosAccess(options: SetVideosAccessOptions): Promise<{
|
|
262
|
+
dryRun: boolean;
|
|
263
|
+
videos: AccessVideoPlan[];
|
|
264
|
+
changedCount?: number;
|
|
265
|
+
updated?: string[];
|
|
266
|
+
}>;
|
|
267
|
+
|
|
196
268
|
// ─── Metadata & Title Updates ──────────────────────────────────────────────
|
|
197
269
|
|
|
198
270
|
export interface BatchUpdateMetadataOptions {
|
|
@@ -200,6 +272,11 @@ export interface BatchUpdateMetadataOptions {
|
|
|
200
272
|
titlePrefix?: string;
|
|
201
273
|
playlistPostfix?: string;
|
|
202
274
|
playlistTitle?: string;
|
|
275
|
+
replacePattern?: string;
|
|
276
|
+
replaceWith?: string;
|
|
277
|
+
replaceLiteral?: boolean;
|
|
278
|
+
replaceInSnippet?: boolean;
|
|
279
|
+
includePlaylistId?: string;
|
|
203
280
|
metadataFile?: string;
|
|
204
281
|
updateLocalMetadata?: boolean;
|
|
205
282
|
syncMetadata?: boolean;
|
|
@@ -260,6 +337,9 @@ export function updatePlaylistTitle(
|
|
|
260
337
|
newTitle: string
|
|
261
338
|
): Promise<{ playlistId: string; title: string; data: any }>;
|
|
262
339
|
|
|
340
|
+
export function applyTextReplace(text: string, pattern: string, replacement?: string, literal?: boolean): string;
|
|
341
|
+
export function deepReplaceInObject(value: unknown, pattern: string, replacement?: string, literal?: boolean): unknown;
|
|
342
|
+
|
|
263
343
|
export function batchUpdateMetadata(
|
|
264
344
|
targetFolder?: string,
|
|
265
345
|
options?: BatchUpdateMetadataOptions
|
|
@@ -500,7 +580,15 @@ export interface JourneyOverlayAction {
|
|
|
500
580
|
showSidebar?: boolean;
|
|
501
581
|
}
|
|
502
582
|
|
|
503
|
-
export
|
|
583
|
+
export interface RemoveWatermarkAction {
|
|
584
|
+
type: 'remove-watermark';
|
|
585
|
+
provider?: string;
|
|
586
|
+
overwrite?: boolean;
|
|
587
|
+
json?: boolean;
|
|
588
|
+
videoBitrateMbps?: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export type VideoAction = ShortenAction | ReplaceBackgroundAction | JourneyOverlayAction | RemoveWatermarkAction | { type: string; [key: string]: any };
|
|
504
592
|
|
|
505
593
|
export interface JourneyConfig {
|
|
506
594
|
enabled?: boolean;
|
|
@@ -637,6 +725,64 @@ export function applyJourneyOverlay(params: {
|
|
|
637
725
|
onWarn?: (msg: string) => void;
|
|
638
726
|
}): Promise<{ outputPath: string; duration?: number; dryRun?: boolean; command?: string }>;
|
|
639
727
|
|
|
728
|
+
export function isWatermarkSupportedMedia(filePath: string): boolean;
|
|
729
|
+
export function defaultCleanOutputPath(inputPath: string): string;
|
|
730
|
+
export function defaultCleanOutDir(inputDir: string): string;
|
|
731
|
+
|
|
732
|
+
export function removeWatermark(params: {
|
|
733
|
+
inputPath: string;
|
|
734
|
+
outputPath: string;
|
|
735
|
+
action?: Partial<RemoveWatermarkAction>;
|
|
736
|
+
dryRun?: boolean;
|
|
737
|
+
onWarn?: (msg: string) => void;
|
|
738
|
+
onStderr?: (chunk: string) => void;
|
|
739
|
+
}): Promise<{ outputPath: string; dryRun?: boolean; command: string; provider: string }>;
|
|
740
|
+
|
|
741
|
+
export function listWatermarkProviders(): Array<{ id: string; license: string; packageName: string }>;
|
|
742
|
+
export function getWatermarkProvider(providerId?: string): {
|
|
743
|
+
id: string;
|
|
744
|
+
license: string;
|
|
745
|
+
packageName: string;
|
|
746
|
+
remove: (options: object) => Promise<any>;
|
|
747
|
+
};
|
|
748
|
+
export function removeWatermarkWithProvider(options: {
|
|
749
|
+
inputPath: string;
|
|
750
|
+
outputPath?: string;
|
|
751
|
+
outDir?: string;
|
|
752
|
+
provider?: string;
|
|
753
|
+
overwrite?: boolean;
|
|
754
|
+
json?: boolean;
|
|
755
|
+
videoBitrateMbps?: number;
|
|
756
|
+
dryRun?: boolean;
|
|
757
|
+
onStderr?: (chunk: string) => void;
|
|
758
|
+
}): Promise<{ outputPath?: string; outDir?: string; dryRun?: boolean; command: string; exitCode?: number; stdout?: string }>;
|
|
759
|
+
|
|
760
|
+
export const DEFAULT_WATERMARK_PROVIDER: string;
|
|
761
|
+
export const PERMISSIVE_LICENSES: readonly string[];
|
|
762
|
+
|
|
763
|
+
export function batchRemoveWatermark(
|
|
764
|
+
input?: string,
|
|
765
|
+
options?: {
|
|
766
|
+
output?: string;
|
|
767
|
+
outDir?: string;
|
|
768
|
+
provider?: string;
|
|
769
|
+
overwrite?: boolean;
|
|
770
|
+
json?: boolean;
|
|
771
|
+
videoBitrateMbps?: number;
|
|
772
|
+
dryRun?: boolean;
|
|
773
|
+
onEvent?: (event: { type: string; [key: string]: any }) => void;
|
|
774
|
+
}
|
|
775
|
+
): Promise<{
|
|
776
|
+
input: string;
|
|
777
|
+
mode: 'file' | 'directory';
|
|
778
|
+
provider: string;
|
|
779
|
+
dryRun: boolean;
|
|
780
|
+
outDir?: string;
|
|
781
|
+
processed: Array<object>;
|
|
782
|
+
skipped: Array<object>;
|
|
783
|
+
failed: Array<object>;
|
|
784
|
+
}>;
|
|
785
|
+
|
|
640
786
|
export function runActionsPipeline(
|
|
641
787
|
entry: PlannedEntry | { songKey?: string; filename: string; sourcePath: string; targetPath?: string },
|
|
642
788
|
actions?: VideoAction[],
|
package/src/index.js
CHANGED
|
@@ -32,6 +32,8 @@ export {
|
|
|
32
32
|
updatePlaylistSnippet,
|
|
33
33
|
updatePlaylistTitle,
|
|
34
34
|
batchUpdateMetadata,
|
|
35
|
+
applyTextReplace,
|
|
36
|
+
deepReplaceInObject,
|
|
35
37
|
QUOTA_PER_VIDEO_UPDATE,
|
|
36
38
|
QUOTA_PER_PLAYLIST_UPDATE,
|
|
37
39
|
QUOTA_PER_LIST,
|
|
@@ -81,6 +83,10 @@ export {
|
|
|
81
83
|
generateJourneySvg,
|
|
82
84
|
renderSvgToPng,
|
|
83
85
|
resolveJourneyTracks,
|
|
86
|
+
removeWatermark,
|
|
87
|
+
isWatermarkSupportedMedia,
|
|
88
|
+
defaultCleanOutputPath,
|
|
89
|
+
defaultCleanOutDir,
|
|
84
90
|
runActionsPipeline,
|
|
85
91
|
} from './actions/index.js';
|
|
86
92
|
export {
|
|
@@ -97,5 +103,41 @@ export {
|
|
|
97
103
|
export {
|
|
98
104
|
previewJourney,
|
|
99
105
|
} from './cli/preview-journey.js';
|
|
106
|
+
export {
|
|
107
|
+
batchRemoveWatermark,
|
|
108
|
+
} from './cli/remove-watermark.js';
|
|
109
|
+
export {
|
|
110
|
+
getWatermarkProvider,
|
|
111
|
+
listWatermarkProviders,
|
|
112
|
+
removeWatermarkWithProvider,
|
|
113
|
+
DEFAULT_WATERMARK_PROVIDER,
|
|
114
|
+
PERMISSIVE_LICENSES,
|
|
115
|
+
} from './providers/watermark/index.js';
|
|
116
|
+
export {
|
|
117
|
+
detectOutdatedUploads,
|
|
118
|
+
} from './playlist/detect-outdated.js';
|
|
119
|
+
export {
|
|
120
|
+
listPlaylistItems,
|
|
121
|
+
reorderPlaylist,
|
|
122
|
+
playlistOrderMatches,
|
|
123
|
+
} from './playlist/reorder.js';
|
|
124
|
+
export {
|
|
125
|
+
resolveDesiredPlaylistOrder,
|
|
126
|
+
} from './playlist/order.js';
|
|
127
|
+
export {
|
|
128
|
+
syncPlaylist,
|
|
129
|
+
} from './playlist/sync.js';
|
|
130
|
+
export {
|
|
131
|
+
normalizePrivacyStatus,
|
|
132
|
+
getVideosPrivacy,
|
|
133
|
+
getPlaylistPrivacy,
|
|
134
|
+
setVideoPrivacy,
|
|
135
|
+
setPlaylistPrivacy,
|
|
136
|
+
QUOTA_PER_PRIVACY_UPDATE,
|
|
137
|
+
} from './access/privacy.js';
|
|
138
|
+
export {
|
|
139
|
+
setPlaylistAccess,
|
|
140
|
+
setVideosAccess,
|
|
141
|
+
} from './access/set-access.js';
|
|
100
142
|
|
|
101
143
|
|
|
@@ -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
|
+
}
|