@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,192 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { UploadState } from '../state.js';
|
|
4
|
+
import { swapSong } from '../swap/swapSong.js';
|
|
5
|
+
import { detectOutdatedUploads } from './detect-outdated.js';
|
|
6
|
+
import { resolveDesiredPlaylistOrder } from './order.js';
|
|
7
|
+
import {
|
|
8
|
+
listPlaylistItems,
|
|
9
|
+
playlistOrderMatches,
|
|
10
|
+
reorderPlaylist,
|
|
11
|
+
} from './reorder.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sync a YouTube playlist with local files: push outdated uploads via swap,
|
|
15
|
+
* then realign playlist order. Keeps videos private by default.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {import('googleapis').youtube_v3.Youtube} options.youtube
|
|
19
|
+
* @param {string} options.albumDir
|
|
20
|
+
* @param {string} options.playlistId
|
|
21
|
+
* @param {string} [options.overviewHistoryPath]
|
|
22
|
+
* @param {string[]} [options.overviewKeys]
|
|
23
|
+
* @param {string[]} [options.songKeys]
|
|
24
|
+
* @param {string[]} [options.onlyFilenames]
|
|
25
|
+
* @param {'preserve' | 'private' | 'public'} [options.privacyMode='private']
|
|
26
|
+
* @param {boolean} [options.dryRun=false]
|
|
27
|
+
* @param {boolean} [options.skipReorder=false]
|
|
28
|
+
* @param {boolean} [options.freshSwap=false]
|
|
29
|
+
* @param {string} [options.stateFile]
|
|
30
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
31
|
+
* @returns {Promise<object>}
|
|
32
|
+
*/
|
|
33
|
+
export async function syncPlaylist(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
youtube,
|
|
36
|
+
albumDir,
|
|
37
|
+
playlistId,
|
|
38
|
+
overviewHistoryPath,
|
|
39
|
+
overviewKeys = [],
|
|
40
|
+
songKeys,
|
|
41
|
+
onlyFilenames,
|
|
42
|
+
privacyMode = 'private',
|
|
43
|
+
dryRun = false,
|
|
44
|
+
skipReorder = false,
|
|
45
|
+
freshSwap = false,
|
|
46
|
+
stateFile,
|
|
47
|
+
onEvent = () => {},
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
if (!albumDir || !playlistId) {
|
|
51
|
+
throw new Error('syncPlaylist requires albumDir and playlistId');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const resolvedAlbumDir = path.resolve(albumDir);
|
|
55
|
+
const albumState = new UploadState(resolvedAlbumDir, stateFile);
|
|
56
|
+
|
|
57
|
+
const outdated = detectOutdatedUploads(resolvedAlbumDir, albumState, { onlyFilenames });
|
|
58
|
+
const desiredOrder = resolveDesiredPlaylistOrder({
|
|
59
|
+
albumDir: resolvedAlbumDir,
|
|
60
|
+
albumState,
|
|
61
|
+
overviewHistoryPath,
|
|
62
|
+
overviewKeys,
|
|
63
|
+
songKeys,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
let currentOrder = [];
|
|
67
|
+
if (!dryRun && youtube) {
|
|
68
|
+
const currentItems = await listPlaylistItems(youtube, playlistId);
|
|
69
|
+
currentOrder = currentItems.map((item) => item.videoId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const plan = {
|
|
73
|
+
playlistId,
|
|
74
|
+
albumDir: resolvedAlbumDir,
|
|
75
|
+
privacyMode,
|
|
76
|
+
outdated,
|
|
77
|
+
desiredOrder,
|
|
78
|
+
currentOrder,
|
|
79
|
+
orderAligned: currentOrder.length
|
|
80
|
+
? playlistOrderMatches(currentOrder, desiredOrder)
|
|
81
|
+
: null,
|
|
82
|
+
swaps: outdated.map((item) => ({
|
|
83
|
+
filename: item.filename,
|
|
84
|
+
oldVideoId: item.videoId,
|
|
85
|
+
modifiedAt: item.modifiedAt,
|
|
86
|
+
uploadedAt: item.uploadedAt,
|
|
87
|
+
})),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
onEvent({ type: 'sync_plan', plan });
|
|
91
|
+
|
|
92
|
+
if (dryRun) {
|
|
93
|
+
return {
|
|
94
|
+
dryRun: true,
|
|
95
|
+
...plan,
|
|
96
|
+
pushed: [],
|
|
97
|
+
reordered: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!youtube) {
|
|
102
|
+
throw new Error('syncPlaylist requires an authorized youtube client when dryRun is false');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pushed = [];
|
|
106
|
+
const failed = [];
|
|
107
|
+
|
|
108
|
+
for (const item of outdated) {
|
|
109
|
+
onEvent({ type: 'sync_push_start', filename: item.filename, oldVideoId: item.videoId });
|
|
110
|
+
try {
|
|
111
|
+
const { record } = await swapSong({
|
|
112
|
+
youtube,
|
|
113
|
+
songKey: item.filename,
|
|
114
|
+
newVideoPath: item.filePath,
|
|
115
|
+
playlistId,
|
|
116
|
+
oldVideoId: item.videoId,
|
|
117
|
+
stateDir: resolvedAlbumDir,
|
|
118
|
+
fresh: freshSwap,
|
|
119
|
+
privacyMode,
|
|
120
|
+
onEvent: (event) => onEvent({ ...event, filename: item.filename }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
albumState.data.uploads[item.filename] = {
|
|
124
|
+
...albumState.data.uploads[item.filename],
|
|
125
|
+
videoId: record.newVideoId,
|
|
126
|
+
uploadedAt: new Date().toISOString(),
|
|
127
|
+
playlistId,
|
|
128
|
+
previousVideoId: item.videoId,
|
|
129
|
+
};
|
|
130
|
+
albumState.save();
|
|
131
|
+
|
|
132
|
+
pushed.push({
|
|
133
|
+
filename: item.filename,
|
|
134
|
+
oldVideoId: item.videoId,
|
|
135
|
+
newVideoId: record.newVideoId,
|
|
136
|
+
url: `https://youtu.be/${record.newVideoId}`,
|
|
137
|
+
});
|
|
138
|
+
onEvent({ type: 'sync_push_success', filename: item.filename, record });
|
|
139
|
+
} catch (err) {
|
|
140
|
+
failed.push({ filename: item.filename, error: err });
|
|
141
|
+
onEvent({ type: 'sync_push_error', filename: item.filename, error: err });
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const refreshedDesiredOrder = resolveDesiredPlaylistOrder({
|
|
147
|
+
albumDir: resolvedAlbumDir,
|
|
148
|
+
albumState,
|
|
149
|
+
overviewHistoryPath,
|
|
150
|
+
overviewKeys,
|
|
151
|
+
songKeys,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let reorderResult = { changed: 0, finalOrder: refreshedDesiredOrder };
|
|
155
|
+
if (!skipReorder) {
|
|
156
|
+
const latestItems = await listPlaylistItems(youtube, playlistId);
|
|
157
|
+
const latestOrder = latestItems.map((item) => item.videoId);
|
|
158
|
+
if (!playlistOrderMatches(latestOrder, refreshedDesiredOrder)) {
|
|
159
|
+
onEvent({ type: 'sync_reorder_start', desiredOrder: refreshedDesiredOrder });
|
|
160
|
+
reorderResult = await reorderPlaylist(youtube, playlistId, refreshedDesiredOrder);
|
|
161
|
+
onEvent({ type: 'sync_reorder_success', ...reorderResult });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (overviewHistoryPath && overviewKeys.length > 0) {
|
|
166
|
+
const absOverviewHistory = path.resolve(overviewHistoryPath);
|
|
167
|
+
if (fs.existsSync(absOverviewHistory)) {
|
|
168
|
+
const overviewHistory = JSON.parse(fs.readFileSync(absOverviewHistory, 'utf-8'));
|
|
169
|
+
overviewHistory.playlistId = playlistId;
|
|
170
|
+
overviewHistory.playlistOrder = refreshedDesiredOrder;
|
|
171
|
+
overviewHistory.updatedAt = new Date().toISOString();
|
|
172
|
+
fs.writeFileSync(absOverviewHistory, JSON.stringify(overviewHistory, null, 2) + '\n');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
albumState.data.playlistId = playlistId;
|
|
177
|
+
albumState.data.playlistOrder = refreshedDesiredOrder;
|
|
178
|
+
albumState.data.updatedAt = new Date().toISOString();
|
|
179
|
+
albumState.save();
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
dryRun: false,
|
|
183
|
+
playlistId,
|
|
184
|
+
pushed,
|
|
185
|
+
failed,
|
|
186
|
+
outdated,
|
|
187
|
+
desiredOrder: refreshedDesiredOrder,
|
|
188
|
+
reordered: reorderResult.changed > 0,
|
|
189
|
+
reorderChanges: reorderResult.changed,
|
|
190
|
+
orderAligned: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watermark-remover provider registry.
|
|
3
|
+
*
|
|
4
|
+
* Only register tools with permissive open-source licenses:
|
|
5
|
+
* MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, or ISC.
|
|
6
|
+
*
|
|
7
|
+
* To add another MIT/Apache/BSD/ISC npm CLI later:
|
|
8
|
+
* 1. Create `src/providers/watermark/<id>.js` with a `run*Remove` entry point
|
|
9
|
+
* 2. Register it in PROVIDERS below
|
|
10
|
+
* 3. No pipeline changes required
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
PILIO_PROVIDER_ID,
|
|
15
|
+
PILIO_LICENSE,
|
|
16
|
+
runPilioRemove,
|
|
17
|
+
formatPilioCommand,
|
|
18
|
+
buildPilioArgs,
|
|
19
|
+
resolvePilioBin,
|
|
20
|
+
} from './pilio.js';
|
|
21
|
+
|
|
22
|
+
/** @typedef {'MIT' | 'Apache-2.0' | 'BSD-2-Clause' | 'BSD-3-Clause' | 'ISC'} PermissiveLicense */
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} WatermarkProvider
|
|
26
|
+
* @property {string} id
|
|
27
|
+
* @property {PermissiveLicense} license
|
|
28
|
+
* @property {string} packageName
|
|
29
|
+
* @property {(options: object) => Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>} remove
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @type {Record<string, WatermarkProvider>} */
|
|
33
|
+
const PROVIDERS = {
|
|
34
|
+
[PILIO_PROVIDER_ID]: {
|
|
35
|
+
id: PILIO_PROVIDER_ID,
|
|
36
|
+
license: PILIO_LICENSE,
|
|
37
|
+
packageName: '@pilio/gemini-watermark-remover',
|
|
38
|
+
remove: runPilioRemove,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const DEFAULT_WATERMARK_PROVIDER = PILIO_PROVIDER_ID;
|
|
43
|
+
|
|
44
|
+
export const PERMISSIVE_LICENSES = Object.freeze([
|
|
45
|
+
'MIT',
|
|
46
|
+
'Apache-2.0',
|
|
47
|
+
'BSD-2-Clause',
|
|
48
|
+
'BSD-3-Clause',
|
|
49
|
+
'ISC',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* List registered watermark providers.
|
|
54
|
+
* @returns {Array<{ id: string, license: string, packageName: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export function listWatermarkProviders() {
|
|
57
|
+
return Object.values(PROVIDERS).map(({ id, license, packageName }) => ({
|
|
58
|
+
id,
|
|
59
|
+
license,
|
|
60
|
+
packageName,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a watermark provider by id (default: pilio).
|
|
66
|
+
* @param {string} [providerId]
|
|
67
|
+
* @returns {WatermarkProvider}
|
|
68
|
+
*/
|
|
69
|
+
export function getWatermarkProvider(providerId = DEFAULT_WATERMARK_PROVIDER) {
|
|
70
|
+
const id = String(providerId || DEFAULT_WATERMARK_PROVIDER).trim().toLowerCase();
|
|
71
|
+
const provider = PROVIDERS[id];
|
|
72
|
+
if (!provider) {
|
|
73
|
+
const known = Object.keys(PROVIDERS).join(', ');
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Unknown watermark provider "${providerId}". Known providers: ${known}. ` +
|
|
76
|
+
`Only permissive-license tools (${PERMISSIVE_LICENSES.join(', ')}) may be registered.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!PERMISSIVE_LICENSES.includes(provider.license)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Watermark provider "${provider.id}" has disallowed license "${provider.license}". ` +
|
|
82
|
+
`Allowed: ${PERMISSIVE_LICENSES.join(', ')}.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return provider;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Remove a Gemini/Veo visible watermark via the selected provider.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} options
|
|
92
|
+
* @param {string} options.inputPath
|
|
93
|
+
* @param {string} [options.outputPath]
|
|
94
|
+
* @param {string} [options.outDir]
|
|
95
|
+
* @param {string} [options.provider]
|
|
96
|
+
* @param {boolean} [options.overwrite]
|
|
97
|
+
* @param {boolean} [options.json]
|
|
98
|
+
* @param {number} [options.videoBitrateMbps]
|
|
99
|
+
* @param {boolean} [options.dryRun]
|
|
100
|
+
* @param {(chunk: string) => void} [options.onStderr]
|
|
101
|
+
*/
|
|
102
|
+
export async function removeWatermarkWithProvider(options = {}) {
|
|
103
|
+
const provider = getWatermarkProvider(options.provider);
|
|
104
|
+
return provider.remove(options);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
runPilioRemove,
|
|
109
|
+
formatPilioCommand,
|
|
110
|
+
buildPilioArgs,
|
|
111
|
+
resolvePilioBin,
|
|
112
|
+
PILIO_PROVIDER_ID,
|
|
113
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const PILIO_PROVIDER_ID = 'pilio';
|
|
7
|
+
export const PILIO_LICENSE = 'MIT';
|
|
8
|
+
export const PILIO_PACKAGE = '@pilio/gemini-watermark-remover';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Locate the installed package root and gwr binary path.
|
|
12
|
+
* @returns {{ root: string, binPath: string }}
|
|
13
|
+
*/
|
|
14
|
+
export function resolvePilioBin() {
|
|
15
|
+
let entryUrl;
|
|
16
|
+
try {
|
|
17
|
+
entryUrl = import.meta.resolve(PILIO_PACKAGE);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`Watermark provider "${PILIO_PROVIDER_ID}" requires ${PILIO_PACKAGE} (license: ${PILIO_LICENSE}). ` +
|
|
21
|
+
`Install it with: npm install ${PILIO_PACKAGE} sharp\n` +
|
|
22
|
+
`Original error: ${err.message}`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const entryPath = fileURLToPath(entryUrl);
|
|
27
|
+
let dir = path.dirname(entryPath);
|
|
28
|
+
while (dir !== path.dirname(dir)) {
|
|
29
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
30
|
+
if (fs.existsSync(pkgPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
33
|
+
if (pkg.name === PILIO_PACKAGE) {
|
|
34
|
+
const binRel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.gwr;
|
|
35
|
+
if (!binRel) {
|
|
36
|
+
throw new Error(`${PILIO_PACKAGE} package.json is missing a "gwr" bin entry`);
|
|
37
|
+
}
|
|
38
|
+
const binPath = path.resolve(dir, binRel);
|
|
39
|
+
if (!fs.existsSync(binPath)) {
|
|
40
|
+
throw new Error(`gwr binary not found at ${binPath}`);
|
|
41
|
+
}
|
|
42
|
+
return { root: dir, binPath };
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.message.includes('gwr binary') || err.message.includes('bin entry')) {
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
dir = path.dirname(dir);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Could not locate ${PILIO_PACKAGE} package root after resolving ${entryPath}. ` +
|
|
55
|
+
`Reinstall with: npm install ${PILIO_PACKAGE} sharp`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the gwr argv for a remove operation.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} options
|
|
63
|
+
* @param {string} options.inputPath
|
|
64
|
+
* @param {string} [options.outputPath]
|
|
65
|
+
* @param {string} [options.outDir]
|
|
66
|
+
* @param {boolean} [options.overwrite=false]
|
|
67
|
+
* @param {boolean} [options.json=false]
|
|
68
|
+
* @param {number} [options.videoBitrateMbps]
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function buildPilioArgs(options = {}) {
|
|
72
|
+
const {
|
|
73
|
+
inputPath,
|
|
74
|
+
outputPath,
|
|
75
|
+
outDir,
|
|
76
|
+
overwrite = false,
|
|
77
|
+
json = false,
|
|
78
|
+
videoBitrateMbps,
|
|
79
|
+
} = options;
|
|
80
|
+
|
|
81
|
+
if (!inputPath) {
|
|
82
|
+
throw new Error('inputPath is required for pilio watermark removal');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const args = ['remove', inputPath];
|
|
86
|
+
|
|
87
|
+
if (outDir) {
|
|
88
|
+
args.push('--out-dir', outDir);
|
|
89
|
+
} else if (outputPath) {
|
|
90
|
+
args.push('--output', outputPath);
|
|
91
|
+
} else {
|
|
92
|
+
throw new Error('Either outputPath or outDir is required for pilio watermark removal');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (overwrite) {
|
|
96
|
+
args.push('--overwrite');
|
|
97
|
+
}
|
|
98
|
+
if (json) {
|
|
99
|
+
args.push('--json');
|
|
100
|
+
}
|
|
101
|
+
if (videoBitrateMbps !== undefined && videoBitrateMbps !== null && videoBitrateMbps !== '') {
|
|
102
|
+
const n = Number(videoBitrateMbps);
|
|
103
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
104
|
+
throw new Error(`Invalid videoBitrateMbps: ${videoBitrateMbps}`);
|
|
105
|
+
}
|
|
106
|
+
args.push('--video-bitrate-mbps', String(n));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return args;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Format a human-readable command string (for dry-run / logs).
|
|
114
|
+
*
|
|
115
|
+
* @param {string[]} args
|
|
116
|
+
* @param {string} [binPath='gwr']
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function formatPilioCommand(args, binPath = 'gwr') {
|
|
120
|
+
const quote = (s) => (/\s/.test(s) ? JSON.stringify(s) : s);
|
|
121
|
+
return [quote(binPath), ...args.map(quote)].join(' ');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run the pilio gwr CLI.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} options
|
|
128
|
+
* @param {string} options.inputPath
|
|
129
|
+
* @param {string} [options.outputPath]
|
|
130
|
+
* @param {string} [options.outDir]
|
|
131
|
+
* @param {boolean} [options.overwrite=false]
|
|
132
|
+
* @param {boolean} [options.json=false]
|
|
133
|
+
* @param {number} [options.videoBitrateMbps]
|
|
134
|
+
* @param {boolean} [options.dryRun=false]
|
|
135
|
+
* @param {(chunk: string) => void} [options.onStderr]
|
|
136
|
+
* @returns {Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>}
|
|
137
|
+
*/
|
|
138
|
+
export async function runPilioRemove(options = {}) {
|
|
139
|
+
const { dryRun = false, onStderr } = options;
|
|
140
|
+
const { binPath } = resolvePilioBin();
|
|
141
|
+
const args = buildPilioArgs(options);
|
|
142
|
+
const command = formatPilioCommand(args, binPath);
|
|
143
|
+
|
|
144
|
+
if (dryRun) {
|
|
145
|
+
return {
|
|
146
|
+
outputPath: options.outputPath,
|
|
147
|
+
outDir: options.outDir,
|
|
148
|
+
dryRun: true,
|
|
149
|
+
command,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (options.outputPath) {
|
|
154
|
+
fs.mkdirSync(path.dirname(path.resolve(options.outputPath)), { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
if (options.outDir) {
|
|
157
|
+
fs.mkdirSync(path.resolve(options.outDir), { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
const child = spawn(process.execPath, [binPath, ...args], {
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
env: process.env,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let stdout = '';
|
|
167
|
+
let stderr = '';
|
|
168
|
+
|
|
169
|
+
child.stdout.on('data', (chunk) => {
|
|
170
|
+
stdout += chunk.toString();
|
|
171
|
+
});
|
|
172
|
+
child.stderr.on('data', (chunk) => {
|
|
173
|
+
const text = chunk.toString();
|
|
174
|
+
stderr += text;
|
|
175
|
+
if (onStderr) onStderr(text);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
child.on('error', (err) => {
|
|
179
|
+
reject(new Error(
|
|
180
|
+
`Failed to execute watermark provider "${PILIO_PROVIDER_ID}" (${PILIO_PACKAGE}): ${err.message}`
|
|
181
|
+
));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
child.on('close', (code) => {
|
|
185
|
+
if (code !== 0) {
|
|
186
|
+
const detail = (stderr || stdout || '').trim() || `exit code ${code}`;
|
|
187
|
+
reject(new Error(
|
|
188
|
+
`Watermark provider "${PILIO_PROVIDER_ID}" failed for ${options.inputPath}: ${detail}`
|
|
189
|
+
));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
resolve({
|
|
193
|
+
outputPath: options.outputPath,
|
|
194
|
+
outDir: options.outDir,
|
|
195
|
+
command,
|
|
196
|
+
exitCode: code,
|
|
197
|
+
stdout,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
package/src/swap/steps.js
CHANGED
|
@@ -8,10 +8,7 @@ import { uploadVideo } from '../uploader.js';
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Metadata (title/description/tags/category) is carried over from the old
|
|
14
|
-
* video's snippet when available so the replacement stays continuous.
|
|
11
|
+
* Upload the new video for a playlist swap.
|
|
15
12
|
*
|
|
16
13
|
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
17
14
|
* @param {string} newVideoPath
|
|
@@ -19,6 +16,7 @@ import { uploadVideo } from '../uploader.js';
|
|
|
19
16
|
* @param {string} [opts.oldVideoId] - Fetch old video's snippet to carry over
|
|
20
17
|
* @param {object} [opts.meta] - Explicit metadata overrides
|
|
21
18
|
* @param {number} [opts.retries=3]
|
|
19
|
+
* @param {'private' | 'unlisted' | 'public'} [opts.uploadPrivacy='unlisted']
|
|
22
20
|
* @returns {Promise<{ videoId: string, url: string }>}
|
|
23
21
|
*/
|
|
24
22
|
export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
|
|
@@ -26,12 +24,14 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
|
|
|
26
24
|
throw new Error(`New video file not found: ${newVideoPath}`);
|
|
27
25
|
}
|
|
28
26
|
|
|
27
|
+
const uploadPrivacy = opts.uploadPrivacy || opts.meta?.privacyStatus || 'unlisted';
|
|
28
|
+
|
|
29
29
|
let meta = {
|
|
30
30
|
title: opts.meta?.title,
|
|
31
31
|
description: opts.meta?.description,
|
|
32
32
|
tags: opts.meta?.tags,
|
|
33
33
|
categoryId: opts.meta?.categoryId,
|
|
34
|
-
privacyStatus:
|
|
34
|
+
privacyStatus: uploadPrivacy,
|
|
35
35
|
selfDeclaredMadeForKids: opts.meta?.selfDeclaredMadeForKids ?? false,
|
|
36
36
|
};
|
|
37
37
|
|
|
@@ -49,7 +49,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
|
|
|
49
49
|
description: meta.description ?? old.snippet?.description,
|
|
50
50
|
tags: meta.tags || old.snippet?.tags || [],
|
|
51
51
|
categoryId: meta.categoryId || old.snippet?.categoryId,
|
|
52
|
-
privacyStatus:
|
|
52
|
+
privacyStatus: uploadPrivacy,
|
|
53
53
|
selfDeclaredMadeForKids: meta.selfDeclaredMadeForKids
|
|
54
54
|
?? (old.status?.madeForKids !== undefined ? old.status.madeForKids : false),
|
|
55
55
|
};
|
|
@@ -68,7 +68,7 @@ export async function uploadNewVideo(youtube, newVideoPath, opts = {}) {
|
|
|
68
68
|
let lastErr;
|
|
69
69
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
70
70
|
try {
|
|
71
|
-
return await uploadVideo(youtube, newVideoPath, meta,
|
|
71
|
+
return await uploadVideo(youtube, newVideoPath, meta, uploadPrivacy);
|
|
72
72
|
} catch (err) {
|
|
73
73
|
lastErr = err;
|
|
74
74
|
if (attempt < retries) {
|
|
@@ -142,20 +142,25 @@ export async function removeOldItem(youtube, oldPlaylistItemId) {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
|
-
* Step 5 — Finalize
|
|
146
|
-
* new one. Ordered so any earlier failure leaves the old video untouched.
|
|
145
|
+
* Step 5 — Finalize swap privacy on old and new videos.
|
|
147
146
|
*
|
|
148
147
|
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
149
148
|
* @param {string} oldVideoId
|
|
150
149
|
* @param {string} newVideoId
|
|
150
|
+
* @param {object} [options]
|
|
151
|
+
* @param {'private' | 'unlisted' | 'public'} [options.newPrivacyStatus='public']
|
|
152
|
+
* @param {'private' | 'unlisted' | 'public'} [options.oldPrivacyStatus='unlisted']
|
|
151
153
|
*/
|
|
152
|
-
export async function finalizeSwap(youtube, oldVideoId, newVideoId) {
|
|
154
|
+
export async function finalizeSwap(youtube, oldVideoId, newVideoId, options = {}) {
|
|
155
|
+
const newPrivacyStatus = options.newPrivacyStatus || 'public';
|
|
156
|
+
const oldPrivacyStatus = options.oldPrivacyStatus || 'unlisted';
|
|
157
|
+
|
|
153
158
|
await youtube.videos.update({
|
|
154
159
|
part: ['status'],
|
|
155
|
-
requestBody: { id: oldVideoId, status: { privacyStatus:
|
|
160
|
+
requestBody: { id: oldVideoId, status: { privacyStatus: oldPrivacyStatus } },
|
|
156
161
|
});
|
|
157
162
|
await youtube.videos.update({
|
|
158
163
|
part: ['status'],
|
|
159
|
-
requestBody: { id: newVideoId, status: { privacyStatus:
|
|
164
|
+
requestBody: { id: newVideoId, status: { privacyStatus: newPrivacyStatus } },
|
|
160
165
|
});
|
|
161
166
|
}
|
package/src/swap/swapSong.js
CHANGED
|
@@ -33,6 +33,7 @@ export const QUOTA_PER_SWAP = 150 + 1; // ~150 shared units + 1 upload-bucket un
|
|
|
33
33
|
* @param {string} [params.stateDir] - Where the swap state file lives (default: cwd)
|
|
34
34
|
* @param {string} [params.stateFile] - Custom state filename
|
|
35
35
|
* @param {boolean} [params.fresh=false] - Reset any existing record for songKey
|
|
36
|
+
* @param {'preserve' | 'private' | 'public'} [params.privacyMode='public'] - Final privacy for new video; preserve reads old video status
|
|
36
37
|
* @param {(event: { type: string, [key: string]: any }) => void} [params.onEvent]
|
|
37
38
|
* @returns {Promise<{ record: any, dryRunPlan?: string[] }>}
|
|
38
39
|
*/
|
|
@@ -47,6 +48,7 @@ export async function swapSong(params) {
|
|
|
47
48
|
stateDir,
|
|
48
49
|
stateFile,
|
|
49
50
|
fresh = false,
|
|
51
|
+
privacyMode = 'public',
|
|
50
52
|
onEvent = () => {},
|
|
51
53
|
} = params;
|
|
52
54
|
|
|
@@ -84,13 +86,46 @@ export async function swapSong(params) {
|
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
try {
|
|
89
|
+
let resolvedNewPrivacy = 'public';
|
|
90
|
+
let resolvedOldPrivacy = 'unlisted';
|
|
91
|
+
let uploadPrivacy = 'unlisted';
|
|
92
|
+
|
|
93
|
+
if (privacyMode === 'private') {
|
|
94
|
+
resolvedNewPrivacy = 'private';
|
|
95
|
+
resolvedOldPrivacy = 'unlisted';
|
|
96
|
+
uploadPrivacy = 'private';
|
|
97
|
+
} else if (privacyMode === 'preserve') {
|
|
98
|
+
try {
|
|
99
|
+
const res = await youtube.videos.list({
|
|
100
|
+
part: ['status'],
|
|
101
|
+
id: [oldVideoId],
|
|
102
|
+
});
|
|
103
|
+
const oldStatus = res.data.items?.[0]?.status?.privacyStatus;
|
|
104
|
+
if (oldStatus === 'private' || oldStatus === 'unlisted' || oldStatus === 'public') {
|
|
105
|
+
resolvedNewPrivacy = oldStatus;
|
|
106
|
+
uploadPrivacy = oldStatus;
|
|
107
|
+
} else {
|
|
108
|
+
resolvedNewPrivacy = 'private';
|
|
109
|
+
uploadPrivacy = 'private';
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
resolvedNewPrivacy = 'private';
|
|
113
|
+
uploadPrivacy = 'private';
|
|
114
|
+
}
|
|
115
|
+
resolvedOldPrivacy = 'unlisted';
|
|
116
|
+
}
|
|
117
|
+
|
|
87
118
|
// 1. UPLOAD
|
|
88
119
|
await step('uploaded', async () => {
|
|
89
120
|
const alreadyUploaded = store.getRecord(songKey).newVideoId;
|
|
90
121
|
if (alreadyUploaded) {
|
|
91
122
|
return { videoId: alreadyUploaded };
|
|
92
123
|
}
|
|
93
|
-
const up = await uploadNewVideo(youtube, newVideoPath, {
|
|
124
|
+
const up = await uploadNewVideo(youtube, newVideoPath, {
|
|
125
|
+
oldVideoId,
|
|
126
|
+
meta,
|
|
127
|
+
uploadPrivacy,
|
|
128
|
+
});
|
|
94
129
|
onEvent({ type: 'swap_uploaded', songKey, newVideoId: up.videoId, url: up.url });
|
|
95
130
|
return up;
|
|
96
131
|
}, r => ({ newVideoId: r.videoId }));
|
|
@@ -128,7 +163,10 @@ export async function swapSong(params) {
|
|
|
128
163
|
|
|
129
164
|
// 5. FINALIZE
|
|
130
165
|
await step('finalized', async () => {
|
|
131
|
-
await finalizeSwap(youtube, oldVideoId, newVideoId
|
|
166
|
+
await finalizeSwap(youtube, oldVideoId, newVideoId, {
|
|
167
|
+
newPrivacyStatus: resolvedNewPrivacy,
|
|
168
|
+
oldPrivacyStatus: resolvedOldPrivacy,
|
|
169
|
+
});
|
|
132
170
|
});
|
|
133
171
|
|
|
134
172
|
onEvent({ type: 'swap_complete', songKey, oldVideoId, newVideoId, playlistId });
|