@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.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,195 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { loadMetadata } from '../metadata.js';
4
+
5
+ /**
6
+ * Determine the output directory based on source directory and options.
7
+ *
8
+ * @param {string} sourceDir
9
+ * @param {object} [options]
10
+ * @param {string} [options.outputDir]
11
+ * @param {string} [options.outputSuffix]
12
+ * @param {boolean} [options.hasShorten=false]
13
+ * @returns {string}
14
+ */
15
+ export function resolveOutputDirectory(sourceDir, options = {}) {
16
+ const resolvedSource = path.resolve(sourceDir || process.cwd());
17
+ if (options.outputDir) {
18
+ return path.resolve(options.outputDir);
19
+ }
20
+ const suffix = options.outputSuffix !== undefined
21
+ ? options.outputSuffix
22
+ : (options.hasShorten ? ' (short)' : ' (edited)');
23
+ return path.join(path.dirname(resolvedSource), path.basename(resolvedSource) + suffix);
24
+ }
25
+
26
+ /**
27
+ * Resolve actions configuration for a specific entry.
28
+ *
29
+ * @param {object} params
30
+ * @param {string} params.songKey
31
+ * @param {string} params.filename
32
+ * @param {object} [params.rawEntry]
33
+ * @param {object} [params.rawMetadata]
34
+ * @param {object} [params.actionsFileConfig]
35
+ * @returns {Array<object>}
36
+ */
37
+ export function resolveEntryActions({ songKey, filename, rawEntry, rawMetadata, actionsFileConfig }) {
38
+ // If actions file is provided, check it first
39
+ if (actionsFileConfig) {
40
+ if (actionsFileConfig.songs && actionsFileConfig.songs[songKey]?.actions) {
41
+ return actionsFileConfig.songs[songKey].actions;
42
+ }
43
+ if (Array.isArray(actionsFileConfig.videos)) {
44
+ const match = actionsFileConfig.videos.find(v => v.filename === filename || v.songKey === songKey);
45
+ if (match?.actions) return match.actions;
46
+ }
47
+ if (Array.isArray(actionsFileConfig.defaultActions)) {
48
+ return actionsFileConfig.defaultActions;
49
+ }
50
+ if (Array.isArray(actionsFileConfig.actions)) {
51
+ return actionsFileConfig.actions;
52
+ }
53
+ }
54
+
55
+ // Next check entry specific actions
56
+ if (Array.isArray(rawEntry?.actions)) {
57
+ return rawEntry.actions;
58
+ }
59
+
60
+ // Check raw metadata songs[songKey].actions
61
+ if (rawMetadata?.songs && rawMetadata.songs[songKey]?.actions) {
62
+ return rawMetadata.songs[songKey].actions;
63
+ }
64
+
65
+ // Check raw metadata defaultActions
66
+ if (Array.isArray(rawMetadata?.defaultActions)) {
67
+ return rawMetadata.defaultActions;
68
+ }
69
+
70
+ return [];
71
+ }
72
+
73
+ /**
74
+ * Plan batch actions for a directory.
75
+ *
76
+ * @param {string} sourceDir
77
+ * @param {object} [options]
78
+ * @param {string} [options.metadataFile]
79
+ * @param {string} [options.actionsFile]
80
+ * @param {string} [options.outputDir]
81
+ * @param {string} [options.outputSuffix]
82
+ * @param {string} [options.only]
83
+ * @param {boolean} [options.force=false]
84
+ * @param {boolean} [options.dryRun=false]
85
+ * @returns {object}
86
+ */
87
+ export function planBatchActions(sourceDir, options = {}) {
88
+ const resolvedSource = path.resolve(sourceDir || process.cwd());
89
+ const metadataPath = options.metadataFile
90
+ ? path.resolve(options.metadataFile)
91
+ : path.join(resolvedSource, 'metadata.json');
92
+
93
+ let rawMetadata = {};
94
+ if (fs.existsSync(metadataPath)) {
95
+ try {
96
+ rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
97
+ } catch (err) {
98
+ throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
99
+ }
100
+ }
101
+
102
+ let actionsFileConfig = null;
103
+ if (options.actionsFile) {
104
+ const actionsPath = path.resolve(options.actionsFile);
105
+ if (!fs.existsSync(actionsPath)) {
106
+ throw new Error(`Actions file not found: ${actionsPath}`);
107
+ }
108
+ try {
109
+ actionsFileConfig = JSON.parse(fs.readFileSync(actionsPath, 'utf-8'));
110
+ } catch (err) {
111
+ throw new Error(`Failed to parse actions file (${actionsPath}): ${err.message}`);
112
+ }
113
+ }
114
+
115
+ const normalized = loadMetadata(resolvedSource, options.metadataFile);
116
+ const journeyConfig = {
117
+ enabled: true,
118
+ mode: 'before-current',
119
+ position: 'right',
120
+ displayDuration: 4,
121
+ sidebarDisplayDuration: undefined,
122
+ sidebarLabel: 'stage',
123
+ theme: 'dark',
124
+ style: 'badge',
125
+ maxCenterChars: 60,
126
+ maxVisibleRows: 8,
127
+ ...(rawMetadata.journey || {}),
128
+ ...(actionsFileConfig?.journey || {}),
129
+ };
130
+
131
+ const rawSongs = rawMetadata.songs || {};
132
+ let entries = normalized.videos.map((v, index) => {
133
+ const songKey = v.filename;
134
+ const rawEntry = rawSongs[songKey] || v;
135
+ const actions = resolveEntryActions({
136
+ songKey,
137
+ filename: v.filename,
138
+ rawEntry,
139
+ rawMetadata,
140
+ actionsFileConfig,
141
+ });
142
+
143
+ return {
144
+ index,
145
+ songKey,
146
+ filename: v.filename,
147
+ title: v.title,
148
+ sourcePath: v.filePath,
149
+ actions,
150
+ rawEntry,
151
+ };
152
+ });
153
+
154
+ if (options.only) {
155
+ const onlyTarget = String(options.only).trim();
156
+ entries = entries.filter(
157
+ e => e.songKey === onlyTarget || e.filename === onlyTarget || path.basename(e.filename, path.extname(e.filename)) === onlyTarget
158
+ );
159
+ if (entries.length === 0) {
160
+ throw new Error(`No video matching --only "${options.only}" was found.`);
161
+ }
162
+ }
163
+
164
+ const hasShorten = entries.some(e => e.actions.some(a => a.type === 'shorten'));
165
+ const outputDir = resolveOutputDirectory(resolvedSource, {
166
+ outputDir: options.outputDir,
167
+ outputSuffix: options.outputSuffix,
168
+ hasShorten,
169
+ });
170
+
171
+ if (fs.existsSync(outputDir) && !options.force && !options.outputDir) {
172
+ const files = fs.readdirSync(outputDir).filter(f => !f.startsWith('.'));
173
+ if (files.length > 0) {
174
+ throw new Error(
175
+ `Output directory already exists and is not empty: ${outputDir}. Use --force to overwrite or specify --output-dir.`
176
+ );
177
+ }
178
+ }
179
+
180
+ const plannedEntries = entries.map(e => ({
181
+ ...e,
182
+ targetPath: path.join(outputDir, e.filename),
183
+ }));
184
+
185
+ return {
186
+ sourceDir: resolvedSource,
187
+ outputDir,
188
+ hasShorten,
189
+ rawMetadata,
190
+ journeyConfig,
191
+ entries: plannedEntries,
192
+ dryRun: Boolean(options.dryRun),
193
+ force: Boolean(options.force),
194
+ };
195
+ }
@@ -0,0 +1,152 @@
1
+ import path from 'node:path';
2
+ import { planBatchActions } from '../batch/plan.js';
3
+ import { cloneMetadataForOutput } from '../batch/clone-metadata.js';
4
+ import { runActionsPipeline } from '../actions/index.js';
5
+
6
+ /**
7
+ * Apply actions pipeline across videos in a directory.
8
+ *
9
+ * @param {string} targetDir
10
+ * @param {object} [options]
11
+ * @param {string} [options.metadataFile]
12
+ * @param {string} [options.actionsFile]
13
+ * @param {string} [options.outputDir]
14
+ * @param {string} [options.outputSuffix]
15
+ * @param {string} [options.only]
16
+ * @param {boolean} [options.force=false]
17
+ * @param {boolean} [options.dryRun=false]
18
+ * @param {string} [options.pexelsKey]
19
+ * @param {string} [options.pixabayKey]
20
+ * @param {string} [options.cacheDir]
21
+ * @param {typeof fetch} [options.fetchFn]
22
+ * @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
23
+ * @returns {Promise<{
24
+ * sourceDir: string,
25
+ * outputDir: string,
26
+ * total: number,
27
+ * processed: Array<object>,
28
+ * skipped: Array<object>,
29
+ * failed: Array<object>,
30
+ * dryRun: boolean
31
+ * }>}
32
+ */
33
+ export async function batchApplyActions(targetDir, options = {}) {
34
+ const plan = planBatchActions(targetDir, options);
35
+ const onEvent = options.onEvent;
36
+
37
+ if (onEvent) {
38
+ onEvent({
39
+ type: 'actions_plan',
40
+ sourceDir: plan.sourceDir,
41
+ outputDir: plan.outputDir,
42
+ hasShorten: plan.hasShorten,
43
+ total: plan.entries.length,
44
+ entries: plan.entries,
45
+ dryRun: plan.dryRun,
46
+ });
47
+ }
48
+
49
+ const results = [];
50
+ const skipped = [];
51
+ const failed = [];
52
+
53
+ const context = {
54
+ sourceDir: plan.sourceDir,
55
+ outputDir: plan.outputDir,
56
+ rawMetadata: plan.rawMetadata,
57
+ journeyConfig: plan.journeyConfig,
58
+ dryRun: plan.dryRun,
59
+ force: plan.force,
60
+ pexelsKey: options.pexelsKey,
61
+ pixabayKey: options.pixabayKey,
62
+ cacheDir: options.cacheDir,
63
+ fetchFn: options.fetchFn,
64
+ onEvent,
65
+ onWarn: (msg) => {
66
+ if (onEvent) {
67
+ onEvent({ type: 'action_warn', message: msg });
68
+ }
69
+ },
70
+ };
71
+
72
+ for (let i = 0; i < plan.entries.length; i++) {
73
+ const entry = plan.entries[i];
74
+
75
+ if (onEvent) {
76
+ onEvent({
77
+ type: 'entry_start',
78
+ index: i,
79
+ total: plan.entries.length,
80
+ songKey: entry.songKey,
81
+ filename: entry.filename,
82
+ actionsCount: entry.actions.length,
83
+ });
84
+ }
85
+
86
+ try {
87
+ const res = await runActionsPipeline(entry, entry.actions, context);
88
+ if (res.skipped) {
89
+ skipped.push(res);
90
+ if (onEvent) {
91
+ onEvent({
92
+ type: 'entry_skipped',
93
+ songKey: entry.songKey,
94
+ filename: entry.filename,
95
+ reason: res.reason,
96
+ });
97
+ }
98
+ } else {
99
+ results.push(res);
100
+ if (onEvent) {
101
+ onEvent({
102
+ type: 'entry_success',
103
+ index: i,
104
+ total: plan.entries.length,
105
+ songKey: entry.songKey,
106
+ filename: entry.filename,
107
+ outputPath: res.outputPath,
108
+ actionsApplied: res.actionsApplied,
109
+ steps: res.steps,
110
+ });
111
+ }
112
+ }
113
+ } catch (err) {
114
+ failed.push({
115
+ songKey: entry.songKey,
116
+ filename: entry.filename,
117
+ error: err,
118
+ });
119
+ if (onEvent) {
120
+ onEvent({
121
+ type: 'entry_error',
122
+ index: i,
123
+ total: plan.entries.length,
124
+ songKey: entry.songKey,
125
+ filename: entry.filename,
126
+ error: err,
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ if (!plan.dryRun && results.length > 0) {
133
+ cloneMetadataForOutput(plan.rawMetadata, plan.outputDir, results);
134
+ if (onEvent) {
135
+ onEvent({
136
+ type: 'metadata_cloned',
137
+ outputDir: plan.outputDir,
138
+ outputPath: path.join(plan.outputDir, 'metadata.json'),
139
+ });
140
+ }
141
+ }
142
+
143
+ return {
144
+ sourceDir: plan.sourceDir,
145
+ outputDir: plan.outputDir,
146
+ total: plan.entries.length,
147
+ processed: results,
148
+ skipped,
149
+ failed,
150
+ dryRun: plan.dryRun,
151
+ };
152
+ }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ generateJourneySvg,
5
+ renderSvgToPng,
6
+ resolveJourneyTracks,
7
+ truncate,
8
+ } from '../actions/journey-overlay.js';
9
+
10
+ /**
11
+ * Preview journey overlay as a standalone PNG image.
12
+ *
13
+ * @param {string} songKey - Song key or filename to preview
14
+ * @param {object} [options]
15
+ * @param {string} [options.dir] - Target source folder containing metadata.json
16
+ * @param {string} [options.metadata] - Path to custom metadata.json
17
+ * @param {string} [options.centerText] - Override center text for preview
18
+ * @param {string} [options.output] - Output path for the PNG file
19
+ * @param {number} [options.width=1920]
20
+ * @param {number} [options.height=1080]
21
+ * @returns {Promise<{ outputPath: string, width: number, height: number, tracksCount: number }>}
22
+ */
23
+ export async function previewJourney(songKey, options = {}) {
24
+ const targetDir = path.resolve(options.dir || process.cwd());
25
+ const metadataPath = options.metadata
26
+ ? path.resolve(options.metadata)
27
+ : path.join(targetDir, 'metadata.json');
28
+
29
+ let rawMetadata = {};
30
+ if (fs.existsSync(metadataPath)) {
31
+ try {
32
+ rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
33
+ } catch (err) {
34
+ throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
35
+ }
36
+ }
37
+
38
+ const journeyConfig = {
39
+ enabled: true,
40
+ mode: 'before-current',
41
+ position: 'right',
42
+ displayDuration: 4,
43
+ sidebarDisplayDuration: undefined,
44
+ sidebarLabel: 'stage',
45
+ theme: 'dark',
46
+ style: 'badge',
47
+ maxCenterChars: 60,
48
+ maxVisibleRows: 8,
49
+ ...(rawMetadata.journey || {}),
50
+ };
51
+
52
+ const songData = rawMetadata.songs?.[songKey] || {};
53
+ const journeyAction = Array.isArray(songData.actions)
54
+ ? songData.actions.find(a => a.type === 'journey-overlay')
55
+ : null;
56
+
57
+ let centerText = options.centerText;
58
+ if (centerText === undefined) {
59
+ if (Array.isArray(journeyAction?.centerConcepts) && journeyAction.centerConcepts.length > 0) {
60
+ const idx = options.conceptIndex !== undefined ? parseInt(options.conceptIndex, 10) : 0;
61
+ centerText = journeyAction.centerConcepts[idx] || journeyAction.centerConcepts[0];
62
+ } else {
63
+ centerText = journeyAction?.centerText || '';
64
+ }
65
+ }
66
+
67
+ if (centerText && centerText.length > journeyConfig.maxCenterChars) {
68
+ centerText = truncate(centerText, journeyConfig.maxCenterChars);
69
+ }
70
+
71
+ const tracks = resolveJourneyTracks({
72
+ currentSongKey: songKey,
73
+ rawMetadata,
74
+ mode: journeyConfig.mode,
75
+ maxVisibleRows: journeyConfig.style === 'panel' ? journeyConfig.maxVisibleRows : null,
76
+ sidebarLabel: journeyConfig.sidebarLabel,
77
+ });
78
+
79
+ const width = options.width || 1920;
80
+ const height = options.height || 1080;
81
+
82
+ const svgContent = generateJourneySvg({
83
+ width,
84
+ height,
85
+ tracks,
86
+ centerText,
87
+ showSidebar: journeyConfig.enabled !== false,
88
+ theme: journeyConfig.theme,
89
+ position: journeyConfig.position,
90
+ style: journeyConfig.style,
91
+ stage: songData.journeyStage || songData.albumRole,
92
+ step: typeof songData.trackNumber === 'number' ? songData.trackNumber : (songData.playlistPosition !== undefined ? songData.playlistPosition + 1 : undefined),
93
+ totalSteps: rawMetadata.album?.trackCount || rawMetadata.album?.arc?.length || (tracks[0]?.totalTracks) || 11,
94
+ });
95
+
96
+ const pngBuffer = renderSvgToPng(svgContent, width, height);
97
+
98
+ const outputPath = options.output
99
+ ? path.resolve(options.output)
100
+ : path.join(targetDir, `journey-preview-${path.basename(songKey, path.extname(songKey))}.png`);
101
+
102
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
103
+ fs.writeFileSync(outputPath, pngBuffer);
104
+
105
+ return {
106
+ outputPath,
107
+ width,
108
+ height,
109
+ tracksCount: tracks.length,
110
+ };
111
+ }