@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,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 type VideoAction = ShortenAction | ReplaceBackgroundAction | JourneyOverlayAction | { type: string; [key: string]: any };
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
+ }