@x12i/youtube-video-uploader-cli 1.1.0 → 1.3.0

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,161 @@
1
+ import fs from 'node:fs';
2
+ import { uploadVideo } from '../uploader.js';
3
+
4
+ /**
5
+ * The five individual steps of a playlist video swap. Each is a plain
6
+ * function over the googleapis youtube client so they can be composed and
7
+ * tested independently.
8
+ */
9
+
10
+ /**
11
+ * Step 1 — Upload the new video as unlisted (never straight to public).
12
+ *
13
+ * Metadata (title/description/tags/category) is carried over from the old
14
+ * video's snippet when available so the replacement stays continuous.
15
+ *
16
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
17
+ * @param {string} newVideoPath
18
+ * @param {object} [opts]
19
+ * @param {string} [opts.oldVideoId] - Fetch old video's snippet to carry over
20
+ * @param {object} [opts.meta] - Explicit metadata overrides
21
+ * @param {number} [opts.retries=3]
22
+ * @returns {Promise<{ videoId: string, url: string }>}
23
+ */
24
+ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
25
+ if (!fs.existsSync(newVideoPath)) {
26
+ throw new Error(`New video file not found: ${newVideoPath}`);
27
+ }
28
+
29
+ let meta = {
30
+ title: opts.meta?.title,
31
+ description: opts.meta?.description,
32
+ tags: opts.meta?.tags,
33
+ categoryId: opts.meta?.categoryId,
34
+ privacyStatus: 'unlisted',
35
+ selfDeclaredMadeForKids: opts.meta?.selfDeclaredMadeForKids ?? false,
36
+ };
37
+
38
+ // Carry over metadata from the old video where not explicitly overridden
39
+ if (opts.oldVideoId) {
40
+ try {
41
+ const res = await youtube.videos.list({
42
+ part: ['snippet', 'status'],
43
+ id: [opts.oldVideoId],
44
+ });
45
+ const old = res.data.items?.[0];
46
+ if (old) {
47
+ meta = {
48
+ title: meta.title || old.snippet?.title,
49
+ description: meta.description ?? old.snippet?.description,
50
+ tags: meta.tags || old.snippet?.tags || [],
51
+ categoryId: meta.categoryId || old.snippet?.categoryId,
52
+ privacyStatus: 'unlisted',
53
+ selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids
54
+ ?? (old.status?.madeForKids !== undefined ? old.status.madeForKids : false),
55
+ };
56
+ }
57
+ } catch {
58
+ // Metadata carry-over is best-effort; explicit meta or defaults still apply
59
+ }
60
+ }
61
+
62
+ if (!meta.title) {
63
+ throw new Error('Cannot upload swap video without a title: provide meta.title or a reachable oldVideoId');
64
+ }
65
+
66
+ // Uploads are the most failure-prone step — retry with backoff
67
+ const retries = opts.retries ?? 3;
68
+ let lastErr;
69
+ for (let attempt = 1; attempt <= retries; attempt++) {
70
+ try {
71
+ return await uploadVideo(youtube, newVideoPath, meta, 'unlisted');
72
+ } catch (err) {
73
+ lastErr = err;
74
+ if (attempt < retries) {
75
+ await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
76
+ }
77
+ }
78
+ }
79
+ throw lastErr;
80
+ }
81
+
82
+ /**
83
+ * Step 2 — Locate the old video's playlistItem id and position.
84
+ *
85
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
86
+ * @param {string} playlistId
87
+ * @param {string} oldVideoId
88
+ * @returns {Promise<{ playlistItemId: string, position: number } | null>} Null when the video is not in the playlist
89
+ */
90
+ export async function findPlaylistItem(youtube, playlistId, oldVideoId) {
91
+ let pageToken;
92
+ do {
93
+ const { data } = await youtube.playlistItems.list({
94
+ part: ['snippet'],
95
+ playlistId,
96
+ maxResults: 50,
97
+ pageToken,
98
+ });
99
+ const match = (data.items || []).find(
100
+ item => item.snippet?.resourceId?.videoId === oldVideoId
101
+ );
102
+ if (match) {
103
+ return { playlistItemId: match.id, position: match.snippet.position };
104
+ }
105
+ pageToken = data.nextPageToken;
106
+ } while (pageToken);
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * Step 3 — Insert the new video into the playlist at the old item's position.
112
+ * The old item shifts down one slot temporarily; it is removed in step 4.
113
+ *
114
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
115
+ * @param {string} playlistId
116
+ * @param {string} newVideoId
117
+ * @param {number} position
118
+ * @returns {Promise<{ newPlaylistItemId: string }>}
119
+ */
120
+ export async function insertAtPosition(youtube, playlistId, newVideoId, position) {
121
+ const res = await youtube.playlistItems.insert({
122
+ part: ['snippet'],
123
+ requestBody: {
124
+ snippet: {
125
+ playlistId,
126
+ position,
127
+ resourceId: { kind: 'youtube#video', videoId: newVideoId },
128
+ },
129
+ },
130
+ });
131
+ return { newPlaylistItemId: res.data.id };
132
+ }
133
+
134
+ /**
135
+ * Step 4 — Remove the old video's playlist item.
136
+ *
137
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
138
+ * @param {string} oldPlaylistItemId
139
+ */
140
+ export async function removeOldItem(youtube, oldPlaylistItemId) {
141
+ await youtube.playlistItems.delete({ id: oldPlaylistItemId });
142
+ }
143
+
144
+ /**
145
+ * Step 5 — Finalize: unlist the old video (never delete), then publish the
146
+ * new one. Ordered so any earlier failure leaves the old video untouched.
147
+ *
148
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
149
+ * @param {string} oldVideoId
150
+ * @param {string} newVideoId
151
+ */
152
+ export async function finalizeSwap(youtube, oldVideoId, newVideoId) {
153
+ await youtube.videos.update({
154
+ part: ['status'],
155
+ requestBody: { id: oldVideoId, status: { privacyStatus: 'unlisted' } },
156
+ });
157
+ await youtube.videos.update({
158
+ part: ['status'],
159
+ requestBody: { id: newVideoId, status: { privacyStatus: 'public' } },
160
+ });
161
+ }
@@ -0,0 +1,120 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_SWAP_STATE_FILENAME = '.swap-history.json';
5
+
6
+ export const SWAP_STATUSES = [
7
+ 'pending',
8
+ 'uploaded',
9
+ 'located',
10
+ 'inserted',
11
+ 'removed',
12
+ 'finalized',
13
+ 'failed',
14
+ ];
15
+
16
+ /**
17
+ * State manager for playlist video swaps. Persists a SwapRecord per song key
18
+ * so an interrupted swap can be safely resumed without re-uploading or
19
+ * double-inserting playlist items.
20
+ *
21
+ * @typedef {Object} SwapRecord
22
+ * @property {string} songKey - Stable key identifying this swap (song/file identifier)
23
+ * @property {string} oldVideoId
24
+ * @property {string} playlistId
25
+ * @property {string} [newVideoPath]
26
+ * @property {string} [newVideoId]
27
+ * @property {string} [oldPlaylistItemId]
28
+ * @property {number} [oldPosition]
29
+ * @property {string} [newPlaylistItemId]
30
+ * @property {string} status
31
+ * @property {string} [lastError]
32
+ * @property {string} [updatedAt]
33
+ */
34
+ export class SwapStore {
35
+ /**
36
+ * @param {string} targetFolder
37
+ * @param {string} [stateFilename]
38
+ */
39
+ constructor(targetFolder, stateFilename = DEFAULT_SWAP_STATE_FILENAME) {
40
+ this.targetFolder = path.resolve(targetFolder || process.cwd());
41
+ this.statePath = path.join(this.targetFolder, stateFilename);
42
+ this.data = this._load();
43
+ }
44
+
45
+ _load() {
46
+ if (fs.existsSync(this.statePath)) {
47
+ try {
48
+ const content = fs.readFileSync(this.statePath, 'utf-8');
49
+ const parsed = JSON.parse(content);
50
+ return { ...parsed, swaps: parsed.swaps || {} };
51
+ } catch {
52
+ return { swaps: {} };
53
+ }
54
+ }
55
+ return { swaps: {} };
56
+ }
57
+
58
+ save() {
59
+ try {
60
+ fs.writeFileSync(this.statePath, JSON.stringify(this.data, null, 2), 'utf-8');
61
+ } catch (err) {
62
+ console.warn(`Warning: Could not save swap state to ${this.statePath}: ${err.message}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * @param {string} songKey
68
+ * @returns {SwapRecord | null}
69
+ */
70
+ getRecord(songKey) {
71
+ return this.data.swaps[songKey] || null;
72
+ }
73
+
74
+ /**
75
+ * Create or reset a pending swap record.
76
+ * @param {object} record
77
+ * @returns {SwapRecord}
78
+ */
79
+ initRecord(record) {
80
+ /** @type {SwapRecord} */
81
+ const full = {
82
+ status: 'pending',
83
+ updatedAt: new Date().toISOString(),
84
+ ...record,
85
+ };
86
+ this.data.swaps[full.songKey] = full;
87
+ this.save();
88
+ return full;
89
+ }
90
+
91
+ /**
92
+ * Merge fields into an existing record and persist immediately.
93
+ * @param {string} songKey
94
+ * @param {Partial<SwapRecord>} fields
95
+ * @returns {SwapRecord}
96
+ */
97
+ updateRecord(songKey, fields) {
98
+ if (!this.data.swaps[songKey]) {
99
+ throw new Error(`No swap record exists for key: ${songKey}`);
100
+ }
101
+ this.data.swaps[songKey] = {
102
+ ...this.data.swaps[songKey],
103
+ ...fields,
104
+ songKey,
105
+ updatedAt: new Date().toISOString(),
106
+ };
107
+ this.save();
108
+ return this.data.swaps[songKey];
109
+ }
110
+
111
+ /**
112
+ * List all records with the given status (or all records).
113
+ * @param {string} [status]
114
+ * @returns {SwapRecord[]}
115
+ */
116
+ listRecords(status) {
117
+ const all = Object.values(this.data.swaps);
118
+ return status ? all.filter(r => r.status === status) : all;
119
+ }
120
+ }
@@ -0,0 +1,154 @@
1
+ import { SwapStore } from './store.js';
2
+ import {
3
+ uploadNewVideo,
4
+ findPlaylistItem,
5
+ insertAtPosition,
6
+ removeOldItem,
7
+ finalizeSwap,
8
+ } from './steps.js';
9
+
10
+ /** Shared-pool quota cost of one full swap (list + insert + delete + 2 updates). */
11
+ export const QUOTA_PER_SWAP = 150 + 1; // ~150 shared units + 1 upload-bucket unit
12
+
13
+ /**
14
+ * Swap state machine:
15
+ *
16
+ * 1. UPLOAD — upload the new video (videos.insert), unlisted for now
17
+ * 2. LOCATE — find the old video's playlistItem id + position
18
+ * 3. INSERT — add the new video at the old item's position
19
+ * 4. REMOVE — delete the old playlistItem
20
+ * 5. FINALIZE — old video → unlisted, new video → public
21
+ *
22
+ * Every step persists state before moving on, so a crash or API error at any
23
+ * point can be safely resumed by re-running swapSong — completed steps are
24
+ * skipped, the old video is never deleted, and the upload is never repeated.
25
+ *
26
+ * @param {object} params
27
+ * @param {import('googleapis').youtube_v3.Youtube} params.youtube - Authorized youtube client
28
+ * @param {string} params.songKey - Stable key for this swap (song/file identifier)
29
+ * @param {string} params.newVideoPath - Path to the replacement video file
30
+ * @param {string} params.playlistId
31
+ * @param {string} params.oldVideoId
32
+ * @param {object} [params.meta] - Explicit new-video metadata overrides
33
+ * @param {string} [params.stateDir] - Where the swap state file lives (default: cwd)
34
+ * @param {string} [params.stateFile] - Custom state filename
35
+ * @param {boolean} [params.fresh=false] - Reset any existing record for songKey
36
+ * @param {(event: { type: string, [key: string]: any }) => void} [params.onEvent]
37
+ * @returns {Promise<{ record: any, dryRunPlan?: string[] }>}
38
+ */
39
+ export async function swapSong(params) {
40
+ const {
41
+ youtube,
42
+ songKey,
43
+ newVideoPath,
44
+ playlistId,
45
+ oldVideoId,
46
+ meta,
47
+ stateDir,
48
+ stateFile,
49
+ fresh = false,
50
+ onEvent = () => {},
51
+ } = params;
52
+
53
+ if (!songKey || !newVideoPath || !playlistId || !oldVideoId) {
54
+ throw new Error('swapSong requires songKey, newVideoPath, playlistId and oldVideoId');
55
+ }
56
+
57
+ const store = new SwapStore(stateDir || process.cwd(), stateFile);
58
+ const existing = store.getRecord(songKey);
59
+
60
+ if (existing && !fresh && ['pending', 'uploaded', 'located', 'inserted', 'removed'].includes(existing.status)) {
61
+ onEvent({ type: 'swap_resume', songKey, status: existing.status });
62
+ }
63
+
64
+ const record = (existing && !fresh)
65
+ ? { ...existing, playlistId, oldVideoId, newVideoPath }
66
+ : store.initRecord({ songKey, playlistId, oldVideoId, newVideoPath, status: 'pending' });
67
+ if (existing && !fresh) {
68
+ // persist any refreshed params above
69
+ store.updateRecord(songKey, { playlistId, oldVideoId, newVideoPath });
70
+ }
71
+
72
+ /**
73
+ * Run one step, guarding against re-doing completed work.
74
+ * @template T
75
+ * @param {string} doneStatus - Status that marks this step complete
76
+ * @param {() => Promise<T>} fn
77
+ * @param {(result: T) => Partial<Record<string, any>>} [fieldsFromResult]
78
+ */
79
+ async function step(doneStatus, fn, fieldsFromResult) {
80
+ if (stepIndex(record.status) >= stepIndex(doneStatus)) return;
81
+ const result = await fn();
82
+ store.updateRecord(songKey, { status: doneStatus, lastError: undefined, ...(fieldsFromResult ? fieldsFromResult(result) : {}) });
83
+ onEvent({ type: 'swap_step', songKey, status: doneStatus });
84
+ }
85
+
86
+ try {
87
+ // 1. UPLOAD
88
+ await step('uploaded', async () => {
89
+ const alreadyUploaded = store.getRecord(songKey).newVideoId;
90
+ if (alreadyUploaded) {
91
+ return { videoId: alreadyUploaded };
92
+ }
93
+ const up = await uploadNewVideo(youtube, newVideoPath, { oldVideoId, meta });
94
+ onEvent({ type: 'swap_uploaded', songKey, newVideoId: up.videoId, url: up.url });
95
+ return up;
96
+ }, r => ({ newVideoId: r.videoId }));
97
+ const newVideoId = store.getRecord(songKey).newVideoId;
98
+
99
+ // 2. LOCATE
100
+ await step('located', async () => {
101
+ const found = await findPlaylistItem(youtube, playlistId, oldVideoId);
102
+ if (!found) {
103
+ const err = new Error(
104
+ `Old video ${oldVideoId} not found in playlist ${playlistId} — ` +
105
+ `it may have already been removed, or the video ID is wrong.`
106
+ );
107
+ err.needsManualReview = true;
108
+ throw err;
109
+ }
110
+ return found;
111
+ }, r => ({ oldPlaylistItemId: r.playlistItemId, oldPosition: r.position }));
112
+ const current = store.getRecord(songKey);
113
+
114
+ // 3. INSERT (guard: if a previous attempt already inserted the new video
115
+ // but crashed before persisting, reuse that item instead of duplicating it)
116
+ await step('inserted', async () => {
117
+ const alreadyInserted = await findPlaylistItem(youtube, playlistId, newVideoId);
118
+ if (alreadyInserted) {
119
+ return { newPlaylistItemId: alreadyInserted.playlistItemId };
120
+ }
121
+ return await insertAtPosition(youtube, playlistId, newVideoId, current.oldPosition);
122
+ }, r => ({ newPlaylistItemId: r.newPlaylistItemId }));
123
+
124
+ // 4. REMOVE
125
+ await step('removed', async () => {
126
+ await removeOldItem(youtube, current.oldPlaylistItemId);
127
+ });
128
+
129
+ // 5. FINALIZE
130
+ await step('finalized', async () => {
131
+ await finalizeSwap(youtube, oldVideoId, newVideoId);
132
+ });
133
+
134
+ onEvent({ type: 'swap_complete', songKey, oldVideoId, newVideoId, playlistId });
135
+ return { record: store.getRecord(songKey) };
136
+ } catch (err) {
137
+ const updated = store.updateRecord(songKey, { status: 'failed', lastError: err.message });
138
+ onEvent({ type: 'swap_error', songKey, error: err, needsManualReview: Boolean(err.needsManualReview) });
139
+ const wrapped = new Error(`Swap failed for ${songKey} (status persisted, safe to re-run): ${err.message}`);
140
+ wrapped.cause = err;
141
+ wrapped.record = updated;
142
+ throw wrapped;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Rank a status by how far through the state machine it is.
148
+ * @param {string} status
149
+ * @returns {number}
150
+ */
151
+ function stepIndex(status) {
152
+ const order = ['pending', 'failed', 'uploaded', 'located', 'inserted', 'removed', 'finalized'];
153
+ return order.indexOf(status);
154
+ }