@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.
- package/README.md +362 -25
- package/bin/cli.js +735 -2
- package/package.json +7 -1
- package/src/actions/index.js +274 -0
- package/src/actions/journey-overlay.js +567 -0
- package/src/actions/replace-background.js +154 -0
- package/src/actions/shorten.js +271 -0
- package/src/auth.js +1 -1
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +194 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +106 -0
- package/src/index.d.ts +596 -1
- package/src/index.js +91 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +227 -0
- package/src/media/download.js +151 -0
- package/src/media/resolve.js +476 -0
- package/src/media/select.js +176 -0
- package/src/providers/index.js +235 -0
- package/src/providers/pexels.js +171 -0
- package/src/providers/pixabay.js +184 -0
- package/src/state.js +11 -0
- package/src/swap/steps.js +161 -0
- package/src/swap/store.js +120 -0
- package/src/swap/swapSong.js +154 -0
- package/src/update.js +392 -0
- package/src/uploader.js +20 -0
|
@@ -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
|
+
}
|