@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,113 @@
1
+ import { VALID_PRIVACY_STATUSES } from '../metadata.js';
2
+
3
+ /**
4
+ * @typedef {'private' | 'unlisted' | 'public'} PrivacyStatus
5
+ */
6
+
7
+ /**
8
+ * Normalize and validate a privacy status string.
9
+ * @param {string} value
10
+ * @returns {PrivacyStatus}
11
+ */
12
+ export function normalizePrivacyStatus(value) {
13
+ const status = String(value || '').toLowerCase().trim();
14
+ if (!VALID_PRIVACY_STATUSES.has(status)) {
15
+ throw new Error(`Invalid privacy status: ${value}. Use private, unlisted, or public.`);
16
+ }
17
+ return /** @type {PrivacyStatus} */ (status);
18
+ }
19
+
20
+ /**
21
+ * Fetch privacy status for videos (batch up to 50 ids per call).
22
+ *
23
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
24
+ * @param {string[]} videoIds
25
+ * @returns {Promise<Map<string, PrivacyStatus>>}
26
+ */
27
+ export async function getVideosPrivacy(youtube, videoIds) {
28
+ const map = new Map();
29
+ const ids = [...new Set(videoIds.filter(Boolean))];
30
+ for (let i = 0; i < ids.length; i += 50) {
31
+ const chunk = ids.slice(i, i + 50);
32
+ const { data } = await youtube.videos.list({
33
+ part: ['status', 'snippet'],
34
+ id: chunk,
35
+ });
36
+ for (const item of data.items || []) {
37
+ map.set(item.id, /** @type {PrivacyStatus} */ (item.status?.privacyStatus || 'private'));
38
+ }
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /**
44
+ * Fetch playlist privacy status.
45
+ *
46
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
47
+ * @param {string} playlistId
48
+ * @returns {Promise<PrivacyStatus>}
49
+ */
50
+ export async function getPlaylistPrivacy(youtube, playlistId) {
51
+ const { data } = await youtube.playlists.list({
52
+ part: ['status'],
53
+ id: [playlistId],
54
+ });
55
+ const item = data.items?.[0];
56
+ if (!item) {
57
+ throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
58
+ }
59
+ return /** @type {PrivacyStatus} */ (item.status?.privacyStatus || 'private');
60
+ }
61
+
62
+ /**
63
+ * Update a single video's privacy status.
64
+ *
65
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
66
+ * @param {string} videoId
67
+ * @param {PrivacyStatus} privacyStatus
68
+ * @returns {Promise<{ videoId: string, privacyStatus: PrivacyStatus }>}
69
+ */
70
+ export async function setVideoPrivacy(youtube, videoId, privacyStatus) {
71
+ const status = normalizePrivacyStatus(privacyStatus);
72
+ await youtube.videos.update({
73
+ part: ['status'],
74
+ requestBody: {
75
+ id: videoId,
76
+ status: { privacyStatus: status },
77
+ },
78
+ });
79
+ return { videoId, privacyStatus: status };
80
+ }
81
+
82
+ /**
83
+ * Update a playlist's privacy status.
84
+ *
85
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
86
+ * @param {string} playlistId
87
+ * @param {PrivacyStatus} privacyStatus
88
+ * @returns {Promise<{ playlistId: string, privacyStatus: PrivacyStatus }>}
89
+ */
90
+ export async function setPlaylistPrivacy(youtube, playlistId, privacyStatus) {
91
+ const status = normalizePrivacyStatus(privacyStatus);
92
+
93
+ const { data } = await youtube.playlists.list({
94
+ part: ['snippet', 'status'],
95
+ id: [playlistId],
96
+ });
97
+ const existing = data.items?.[0];
98
+ if (!existing?.snippet) {
99
+ throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
100
+ }
101
+
102
+ await youtube.playlists.update({
103
+ part: ['snippet', 'status'],
104
+ requestBody: {
105
+ id: playlistId,
106
+ snippet: existing.snippet,
107
+ status: { privacyStatus: status },
108
+ },
109
+ });
110
+ return { playlistId, privacyStatus: status };
111
+ }
112
+
113
+ export const QUOTA_PER_PRIVACY_UPDATE = 50;
@@ -0,0 +1,247 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { UploadState } from '../state.js';
4
+ import { listPlaylistItems } from '../playlist/reorder.js';
5
+ import { resolveDesiredPlaylistOrder } from '../playlist/order.js';
6
+ import {
7
+ getVideosPrivacy,
8
+ getPlaylistPrivacy,
9
+ setVideoPrivacy,
10
+ setPlaylistPrivacy,
11
+ normalizePrivacyStatus,
12
+ QUOTA_PER_PRIVACY_UPDATE,
13
+ } from './privacy.js';
14
+
15
+ /**
16
+ * Set privacy/access for a playlist and its member videos.
17
+ *
18
+ * @param {object} options
19
+ * @param {import('googleapis').youtube_v3.Youtube} [options.youtube]
20
+ * @param {string} options.playlistId
21
+ * @param {'private' | 'unlisted' | 'public'} options.privacyStatus
22
+ * @param {string[]} [options.videoIds] - Explicit video IDs (skips playlist listing)
23
+ * @param {'playlist' | 'history'} [options.videoSource='playlist'] - Resolve videos from YouTube playlist or upload history
24
+ * @param {string} [options.albumDir]
25
+ * @param {string} [options.overviewHistoryPath]
26
+ * @param {string[]} [options.overviewKeys]
27
+ * @param {boolean} [options.updateMetadata=false]
28
+ * @param {string} [options.metadataFile]
29
+ * @param {boolean} [options.dryRun=false]
30
+ * @param {(event: object) => void} [options.onEvent]
31
+ * @returns {Promise<object>}
32
+ */
33
+ export async function setPlaylistAccess(options = {}) {
34
+ const {
35
+ youtube,
36
+ playlistId,
37
+ privacyStatus: rawPrivacy,
38
+ videoSource = 'playlist',
39
+ albumDir,
40
+ overviewHistoryPath,
41
+ overviewKeys = [],
42
+ videoIds: explicitVideoIds,
43
+ updateMetadata = false,
44
+ metadataFile,
45
+ dryRun = false,
46
+ onEvent = () => {},
47
+ } = options;
48
+
49
+ if (!playlistId) {
50
+ throw new Error('setPlaylistAccess requires playlistId');
51
+ }
52
+
53
+ const privacyStatus = normalizePrivacyStatus(rawPrivacy);
54
+
55
+ /** @type {Array<{ videoId: string, title?: string, position?: number }>} */
56
+ let targets = [];
57
+
58
+ if (Array.isArray(explicitVideoIds) && explicitVideoIds.length > 0) {
59
+ targets = explicitVideoIds.map((videoId, index) => ({ videoId, position: index }));
60
+ } else if (videoSource === 'playlist') {
61
+ if (!youtube && !dryRun) {
62
+ throw new Error('videoSource "playlist" requires a YouTube client');
63
+ }
64
+ if (youtube) {
65
+ const items = await listPlaylistItems(youtube, playlistId);
66
+ targets = items.map((item) => ({
67
+ videoId: item.videoId,
68
+ title: item.title,
69
+ position: item.position,
70
+ }));
71
+ }
72
+ } else if (videoSource === 'history') {
73
+ if (!albumDir) {
74
+ throw new Error('videoSource "history" requires albumDir');
75
+ }
76
+ const albumState = new UploadState(albumDir);
77
+ const videoIds = resolveDesiredPlaylistOrder({
78
+ albumDir,
79
+ albumState,
80
+ overviewHistoryPath,
81
+ overviewKeys,
82
+ });
83
+ targets = videoIds.map((videoId) => ({ videoId }));
84
+ } else {
85
+ throw new Error(`Unsupported videoSource: ${videoSource}`);
86
+ }
87
+
88
+ if (targets.length === 0 && dryRun && videoSource === 'playlist') {
89
+ // Dry-run without youtube: cannot list playlist — caller should use history
90
+ throw new Error('Dry-run with videoSource "playlist" requires YouTube auth to list playlist items');
91
+ }
92
+
93
+ let currentPlaylistPrivacy = null;
94
+ let currentVideoPrivacy = new Map();
95
+
96
+ if (youtube) {
97
+ currentPlaylistPrivacy = await getPlaylistPrivacy(youtube, playlistId);
98
+ currentVideoPrivacy = await getVideosPrivacy(youtube, targets.map((t) => t.videoId));
99
+ }
100
+
101
+ const videoPlans = targets.map((target) => {
102
+ const current = currentVideoPrivacy.get(target.videoId) || null;
103
+ return {
104
+ ...target,
105
+ currentPrivacy: current,
106
+ targetPrivacy: privacyStatus,
107
+ changed: current !== null ? current !== privacyStatus : true,
108
+ };
109
+ });
110
+
111
+ const playlistChanged = currentPlaylistPrivacy !== null
112
+ ? currentPlaylistPrivacy !== privacyStatus
113
+ : true;
114
+
115
+ const changedVideos = videoPlans.filter((v) => v.changed);
116
+ const estimatedQuota = (playlistChanged ? QUOTA_PER_PRIVACY_UPDATE : 0)
117
+ + (changedVideos.length * QUOTA_PER_PRIVACY_UPDATE);
118
+
119
+ const plan = {
120
+ playlistId,
121
+ privacyStatus,
122
+ videoSource,
123
+ playlist: {
124
+ playlistId,
125
+ currentPrivacy: currentPlaylistPrivacy,
126
+ targetPrivacy: privacyStatus,
127
+ changed: playlistChanged,
128
+ },
129
+ videos: videoPlans,
130
+ changedCount: changedVideos.length + (playlistChanged ? 1 : 0),
131
+ estimatedQuota,
132
+ };
133
+
134
+ onEvent({ type: 'access_plan', plan });
135
+
136
+ if (dryRun) {
137
+ return { dryRun: true, ...plan, updated: { playlist: false, videos: [] } };
138
+ }
139
+
140
+ if (!youtube) {
141
+ throw new Error('setPlaylistAccess requires a YouTube client when dryRun is false');
142
+ }
143
+
144
+ const updated = { playlist: false, videos: [] };
145
+
146
+ if (playlistChanged) {
147
+ onEvent({ type: 'access_playlist_start', playlistId, privacyStatus });
148
+ await setPlaylistPrivacy(youtube, playlistId, privacyStatus);
149
+ updated.playlist = true;
150
+ onEvent({ type: 'access_playlist_success', playlistId, privacyStatus });
151
+ }
152
+
153
+ for (let i = 0; i < changedVideos.length; i += 1) {
154
+ const video = changedVideos[i];
155
+ onEvent({
156
+ type: 'access_video_start',
157
+ videoId: video.videoId,
158
+ title: video.title,
159
+ index: i,
160
+ total: changedVideos.length,
161
+ });
162
+ await setVideoPrivacy(youtube, video.videoId, privacyStatus);
163
+ updated.videos.push({ videoId: video.videoId, privacyStatus });
164
+ onEvent({ type: 'access_video_success', videoId: video.videoId, privacyStatus });
165
+ }
166
+
167
+ if (updateMetadata && albumDir) {
168
+ const metadataPath = metadataFile
169
+ ? path.resolve(metadataFile)
170
+ : path.join(path.resolve(albumDir), 'metadata.json');
171
+ if (fs.existsSync(metadataPath)) {
172
+ try {
173
+ const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
174
+ if (raw.uploadPolicy) {
175
+ raw.uploadPolicy.initialVideoPrivacyStatus = privacyStatus;
176
+ raw.uploadPolicy.initialPlaylistPrivacyStatus = privacyStatus;
177
+ }
178
+ if (raw.playlist?.youtube?.status) {
179
+ raw.playlist.youtube.status.privacyStatus = privacyStatus;
180
+ } else if (raw.playlist?.status) {
181
+ raw.playlist.status.privacyStatus = privacyStatus;
182
+ }
183
+ if (raw.songs && typeof raw.songs === 'object') {
184
+ for (const song of Object.values(raw.songs)) {
185
+ if (song?.youtube?.status) {
186
+ song.youtube.status.privacyStatus = privacyStatus;
187
+ }
188
+ }
189
+ }
190
+ fs.writeFileSync(metadataPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
191
+ onEvent({ type: 'access_metadata_saved', metadataPath });
192
+ } catch (err) {
193
+ onEvent({ type: 'access_metadata_error', error: err });
194
+ }
195
+ }
196
+ }
197
+
198
+ return {
199
+ dryRun: false,
200
+ ...plan,
201
+ updated,
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Set privacy for explicit video IDs (no playlist update).
207
+ *
208
+ * @param {object} options
209
+ * @param {import('googleapis').youtube_v3.Youtube} options.youtube
210
+ * @param {string[]} options.videoIds
211
+ * @param {'private' | 'unlisted' | 'public'} options.privacyStatus
212
+ * @param {boolean} [options.dryRun=false]
213
+ * @returns {Promise<object>}
214
+ */
215
+ export async function setVideosAccess(options = {}) {
216
+ const { youtube, videoIds = [], privacyStatus: rawPrivacy, dryRun = false } = options;
217
+ const privacyStatus = normalizePrivacyStatus(rawPrivacy);
218
+ const ids = [...new Set(videoIds.filter(Boolean))];
219
+
220
+ if (ids.length === 0) {
221
+ throw new Error('setVideosAccess requires at least one videoId');
222
+ }
223
+
224
+ const current = youtube ? await getVideosPrivacy(youtube, ids) : new Map();
225
+ const plans = ids.map((videoId) => ({
226
+ videoId,
227
+ currentPrivacy: current.get(videoId) || null,
228
+ targetPrivacy: privacyStatus,
229
+ changed: current.get(videoId) !== privacyStatus,
230
+ }));
231
+
232
+ if (dryRun) {
233
+ return { dryRun: true, videos: plans, changedCount: plans.filter((p) => p.changed).length };
234
+ }
235
+
236
+ if (!youtube) {
237
+ throw new Error('setVideosAccess requires a YouTube client');
238
+ }
239
+
240
+ const updated = [];
241
+ for (const plan of plans.filter((p) => p.changed)) {
242
+ await setVideoPrivacy(youtube, plan.videoId, privacyStatus);
243
+ updated.push(plan.videoId);
244
+ }
245
+
246
+ return { dryRun: false, videos: plans, updated };
247
+ }
@@ -9,6 +9,12 @@ import {
9
9
  renderSvgToPng,
10
10
  resolveJourneyTracks,
11
11
  } from './journey-overlay.js';
12
+ import {
13
+ removeWatermark,
14
+ isWatermarkSupportedMedia,
15
+ defaultCleanOutputPath,
16
+ defaultCleanOutDir,
17
+ } from './remove-watermark.js';
12
18
 
13
19
  export {
14
20
  shortenVideo,
@@ -20,6 +26,10 @@ export {
20
26
  generateJourneySvg,
21
27
  renderSvgToPng,
22
28
  resolveJourneyTracks,
29
+ removeWatermark,
30
+ isWatermarkSupportedMedia,
31
+ defaultCleanOutputPath,
32
+ defaultCleanOutDir,
23
33
  };
24
34
 
25
35
  /**
@@ -144,6 +154,15 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
144
154
  onWarn,
145
155
  });
146
156
  steps.push(`[${i + 1}/${actions.length}] ${res.command}`);
157
+ } else if (action.type === 'remove-watermark') {
158
+ const res = await removeWatermark({
159
+ inputPath: currentInput,
160
+ outputPath: stepOutput,
161
+ action,
162
+ dryRun: true,
163
+ onWarn,
164
+ });
165
+ steps.push(`[${i + 1}/${actions.length}] ${res.command}`);
147
166
  } else {
148
167
  throw new Error(`Unknown action type: "${action.type}" on entry ${songKey}`);
149
168
  }
@@ -232,6 +251,18 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
232
251
  dryRun: false,
233
252
  onWarn,
234
253
  });
254
+ } else if (action.type === 'remove-watermark') {
255
+ await removeWatermark({
256
+ inputPath: currentInput,
257
+ outputPath: stepOutput,
258
+ action: {
259
+ ...action,
260
+ // Pipeline targets are always freshly planned; allow overwrite of step outputs.
261
+ overwrite: action.overwrite !== undefined ? action.overwrite : true,
262
+ },
263
+ dryRun: false,
264
+ onWarn,
265
+ });
235
266
  } else {
236
267
  throw new Error(`Unknown action type: "${action.type}" on entry ${songKey}`);
237
268
  }
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ DEFAULT_WATERMARK_PROVIDER,
5
+ removeWatermarkWithProvider,
6
+ } from '../providers/watermark/index.js';
7
+
8
+ const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.tif', '.tiff', '.bmp']);
9
+ const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.mkv', '.m4v']);
10
+
11
+ /**
12
+ * @param {string} filePath
13
+ * @returns {boolean}
14
+ */
15
+ export function isWatermarkSupportedMedia(filePath) {
16
+ const ext = path.extname(filePath || '').toLowerCase();
17
+ return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
18
+ }
19
+
20
+ /**
21
+ * Default cleaned output path next to the input (stem-clean.ext).
22
+ * @param {string} inputPath
23
+ * @returns {string}
24
+ */
25
+ export function defaultCleanOutputPath(inputPath) {
26
+ const dir = path.dirname(inputPath);
27
+ const ext = path.extname(inputPath);
28
+ const stem = path.basename(inputPath, ext);
29
+ return path.join(dir, `${stem}-clean${ext}`);
30
+ }
31
+
32
+ /**
33
+ * Default out-dir for batch cleaning a folder (sibling `basename-clean`).
34
+ * @param {string} inputDir
35
+ * @returns {string}
36
+ */
37
+ export function defaultCleanOutDir(inputDir) {
38
+ const resolved = path.resolve(inputDir);
39
+ return `${resolved}-clean`;
40
+ }
41
+
42
+ /**
43
+ * Remove Gemini/Veo visible watermarks from a single media file.
44
+ *
45
+ * Matches other action executors: dryRun returns `{ command }` without writing.
46
+ *
47
+ * @param {object} params
48
+ * @param {string} params.inputPath
49
+ * @param {string} params.outputPath
50
+ * @param {object} [params.action]
51
+ * @param {string} [params.action.provider]
52
+ * @param {boolean} [params.action.overwrite]
53
+ * @param {boolean} [params.action.json]
54
+ * @param {number} [params.action.videoBitrateMbps]
55
+ * @param {boolean} [params.dryRun=false]
56
+ * @param {(msg: string) => void} [params.onWarn]
57
+ * @param {(chunk: string) => void} [params.onStderr]
58
+ * @returns {Promise<{ outputPath: string, dryRun?: boolean, command: string, provider: string }>}
59
+ */
60
+ export async function removeWatermark(params = {}) {
61
+ const {
62
+ inputPath,
63
+ outputPath,
64
+ action = {},
65
+ dryRun = false,
66
+ onWarn,
67
+ onStderr,
68
+ } = params;
69
+
70
+ if (!inputPath) {
71
+ throw new Error('inputPath is required for remove-watermark');
72
+ }
73
+ if (!outputPath) {
74
+ throw new Error('outputPath is required for remove-watermark');
75
+ }
76
+
77
+ const provider = action.provider || DEFAULT_WATERMARK_PROVIDER;
78
+ const overwrite = action.overwrite !== undefined ? Boolean(action.overwrite) : true;
79
+ const json = Boolean(action.json);
80
+ const videoBitrateMbps = action.videoBitrateMbps;
81
+
82
+ if (!dryRun && !fs.existsSync(inputPath)) {
83
+ throw new Error(`Input file not found: ${inputPath}`);
84
+ }
85
+
86
+ if (!isWatermarkSupportedMedia(inputPath) && onWarn) {
87
+ onWarn(`File extension may be unsupported by watermark remover: ${path.extname(inputPath)}`);
88
+ }
89
+
90
+ const result = await removeWatermarkWithProvider({
91
+ inputPath,
92
+ outputPath,
93
+ provider,
94
+ overwrite,
95
+ json,
96
+ videoBitrateMbps,
97
+ dryRun,
98
+ onStderr,
99
+ });
100
+
101
+ return {
102
+ outputPath,
103
+ dryRun: result.dryRun,
104
+ command: result.command.startsWith('Remove watermark')
105
+ ? result.command
106
+ : `Remove watermark (${provider}): ${result.command}`,
107
+ provider,
108
+ };
109
+ }