vidup 1.0.18 → 1.0.20
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/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +85 -2
- package/dist/lib/index.d.ts +14 -19
- package/dist/lib/index.js +3 -261
- package/dist/lib-Ddx3eQbF.js +251 -0
- package/package.json +20 -24
- package/readme.md +53 -16
- package/dist/sync-ChGtXZ_b.js +0 -543
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
package/dist/bin/cli.js
CHANGED
|
@@ -1,3 +1,86 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import { i as version, n as stripVideoMetadataInDirectory, r as setLogger, t as syncVideoInDirectory } from "../lib-Ddx3eQbF.js";
|
|
3
|
+
import { log, setDefaultLogOptions } from "lognow";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import prettyMilliseconds from "pretty-ms";
|
|
6
|
+
import untildify from "untildify";
|
|
7
|
+
import yargs from "yargs";
|
|
8
|
+
import { hideBin } from "yargs/helpers";
|
|
9
|
+
|
|
10
|
+
//#region src/bin/cli.ts
|
|
11
|
+
setLogger(log);
|
|
12
|
+
const startTime = performance.now();
|
|
13
|
+
const yargsInstance = yargs(hideBin(process.argv));
|
|
14
|
+
await yargsInstance.scriptName("vidup").usage("$0 [command]", "Run a vidup command.").command(["$0 <directory> [options]", "sync <directory> [options]"], "Synchronize a remote video streaming service to mirror the contents of a local directory. Warning: This command will irrevocably delete remote videos that are not present in the local directory.", (builder) => builder.positional("directory", {
|
|
15
|
+
default: void 0,
|
|
16
|
+
demandOption: true,
|
|
17
|
+
describe: "The path to the local directory of video files to sync.",
|
|
18
|
+
type: "string"
|
|
19
|
+
}).option("service", {
|
|
20
|
+
alias: "s",
|
|
21
|
+
choices: [
|
|
22
|
+
"bunny",
|
|
23
|
+
"mux",
|
|
24
|
+
"cloudflare"
|
|
25
|
+
],
|
|
26
|
+
demandOption: true,
|
|
27
|
+
describe: "Streaming service / platform to sync local video files to. Only the Bunny.net streaming CDN is supported at this time.",
|
|
28
|
+
type: "string"
|
|
29
|
+
}).option("key", {
|
|
30
|
+
default: void 0,
|
|
31
|
+
demandOption: true,
|
|
32
|
+
describe: "Streaming service API access key.",
|
|
33
|
+
type: "string"
|
|
34
|
+
}).option("library", {
|
|
35
|
+
default: void 0,
|
|
36
|
+
demandOption: true,
|
|
37
|
+
describe: "Streaming service library ID.",
|
|
38
|
+
type: "string"
|
|
39
|
+
}).option("strip-metadata", {
|
|
40
|
+
default: false,
|
|
41
|
+
describe: "Remove all metadata from the video files before uploading them to the streaming service. Warning: This will modify local videos in-place.",
|
|
42
|
+
type: "boolean"
|
|
43
|
+
}).option("dry-run", {
|
|
44
|
+
alias: "d",
|
|
45
|
+
default: false,
|
|
46
|
+
describe: "Perform a dry run without making any changes. Useful for testing and debugging. Pairs well with the `--json` command.",
|
|
47
|
+
type: "boolean"
|
|
48
|
+
}).option("json", {
|
|
49
|
+
default: false,
|
|
50
|
+
describe: "Output the sync report as JSON.",
|
|
51
|
+
type: "boolean"
|
|
52
|
+
}).option("verbose", {
|
|
53
|
+
default: false,
|
|
54
|
+
describe: "Enable verbose logging. All verbose logs are prefixed with their log level and are printed to `stderr` for ease of redirection.",
|
|
55
|
+
type: "boolean"
|
|
56
|
+
}).check((argv) => {
|
|
57
|
+
if (argv.service === "bunny") return argv;
|
|
58
|
+
throw new Error(`Sorry, vidup doesn't yet implement synchronization support the ${argv.service} streaming service. Only Bunny.net is supported at the moment. If you'd like to see support for another service, please open an issue or send a PR on GitHub: https://github.com/kitschpatrol/vidup/issues`);
|
|
59
|
+
}), async ({ directory, dryRun, json, key, library, service, stripMetadata, verbose }) => {
|
|
60
|
+
setDefaultLogOptions({ verbose });
|
|
61
|
+
const resolvedDirectory = path.resolve(untildify(directory));
|
|
62
|
+
log.debug("Starting video synchronization...");
|
|
63
|
+
if (dryRun) log.warn(`Dry run enabled, not making any changes`);
|
|
64
|
+
const stripReport = stripMetadata ? await stripVideoMetadataInDirectory(resolvedDirectory, { dryRun }) : [];
|
|
65
|
+
const syncReport = await syncVideoInDirectory(resolvedDirectory, {
|
|
66
|
+
credentials: {
|
|
67
|
+
key,
|
|
68
|
+
library
|
|
69
|
+
},
|
|
70
|
+
dryRun,
|
|
71
|
+
service
|
|
72
|
+
});
|
|
73
|
+
if (json) {
|
|
74
|
+
for (const entry of syncReport) entry.stripMetadata = entry.localFile !== void 0 && stripReport.includes(entry.localFile);
|
|
75
|
+
process.stdout.write(JSON.stringify(syncReport, void 0, 2));
|
|
76
|
+
process.stdout.write("\n");
|
|
77
|
+
} else {
|
|
78
|
+
for (const entry of stripReport) process.stdout.write(`${dryRun ? "Found" : "Stripped"} metadata: "${entry}"\n`);
|
|
79
|
+
for (const entry of syncReport) process.stdout.write(`${entry.action}: "${entry.localFile}"\t Remote GUID: "${entry.remoteId}"\n`);
|
|
80
|
+
}
|
|
81
|
+
log.debug(`Synchronized video in ${prettyMilliseconds(performance.now() - startTime)}`);
|
|
82
|
+
process.exitCode = 0;
|
|
83
|
+
}).demandCommand(1).alias("h", "help").version(version).alias("v", "version").help().wrap(process.stdout.isTTY ? Math.min(120, yargsInstance.terminalWidth()) : 0).parse();
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
export { };
|
package/dist/lib/index.d.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
|
+
import { ILogBasic, ILogLayer } from "lognow";
|
|
2
|
+
//#region src/lib/log.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Set the logger instance for the library. Library consumers can call this to
|
|
5
|
+
* inject their own logger. Calling it with no argument silences the library.
|
|
6
|
+
*
|
|
7
|
+
* @param logger - Accepts either a LogLayer instance or a Console- or
|
|
8
|
+
* Stream-like log target
|
|
9
|
+
*/
|
|
10
|
+
export declare function setLogger(logger?: ILogBasic | ILogLayer<unknown>): void;
|
|
11
|
+
//#endregion
|
|
1
12
|
//#region src/lib/strip.d.ts
|
|
2
13
|
type StripOptions = {
|
|
3
14
|
dryRun?: boolean;
|
|
4
|
-
verbose?: boolean;
|
|
5
15
|
};
|
|
6
16
|
/**
|
|
7
17
|
* Strip metadata from video files in a directory
|
|
8
18
|
*
|
|
9
19
|
* @returns List of file paths with metadata that were stripped
|
|
10
20
|
*/
|
|
11
|
-
declare function stripVideoMetadataInDirectory(directory: string, options?: StripOptions): Promise<string[]>;
|
|
21
|
+
export declare function stripVideoMetadataInDirectory(directory: string, options?: StripOptions): Promise<string[]>;
|
|
12
22
|
//#endregion
|
|
13
23
|
//#region src/lib/sync.d.ts
|
|
14
24
|
type Service = 'bunny' | 'cloudflare' | 'mux';
|
|
@@ -25,7 +35,6 @@ type SyncOptions = {
|
|
|
25
35
|
};
|
|
26
36
|
dryRun?: boolean;
|
|
27
37
|
service: Service;
|
|
28
|
-
verbose?: boolean;
|
|
29
38
|
};
|
|
30
39
|
/**
|
|
31
40
|
* Synchronize a remote streaming service to mirror the contents of a local
|
|
@@ -34,20 +43,6 @@ type SyncOptions = {
|
|
|
34
43
|
* @returns Array of sync report entries describing what was changed (or will
|
|
35
44
|
* change if a dry run)
|
|
36
45
|
*/
|
|
37
|
-
declare function syncVideoInDirectory(directory: string, options: SyncOptions): Promise<SyncReport>;
|
|
38
|
-
//#endregion
|
|
39
|
-
//#region src/lib/utilities/log.d.ts
|
|
40
|
-
declare const log: {
|
|
41
|
-
verbose: boolean;
|
|
42
|
-
log(...data: unknown[]): void;
|
|
43
|
-
logPrefixed(prefix: string, ...data: unknown[]): void;
|
|
44
|
-
info(...data: unknown[]): void;
|
|
45
|
-
infoPrefixed(prefix: string, ...data: unknown[]): void;
|
|
46
|
-
infoSpin<T>(promise: Promise<T>, message: string): Promise<T>;
|
|
47
|
-
warn(...data: unknown[]): void;
|
|
48
|
-
warnPrefixed(prefix: string, ...data: unknown[]): void;
|
|
49
|
-
error(...data: unknown[]): void;
|
|
50
|
-
errorPrefixed(prefix: string, ...data: unknown[]): void;
|
|
51
|
-
};
|
|
46
|
+
export declare function syncVideoInDirectory(directory: string, options: SyncOptions): Promise<SyncReport>;
|
|
52
47
|
//#endregion
|
|
53
|
-
export {
|
|
48
|
+
export type { Service, StripOptions, SyncOptions, SyncReport };
|
package/dist/lib/index.js
CHANGED
|
@@ -1,261 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import ffmpegPath from "ffmpeg-static";
|
|
5
|
-
import fs from "node:fs/promises";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { BunnyCdnStream } from "bunnycdn-stream";
|
|
8
|
-
import { hash } from "hasha";
|
|
9
|
-
import { JSONFilePreset } from "lowdb/node";
|
|
10
|
-
import { createReadStream } from "node:fs";
|
|
11
|
-
//#region src/lib/utilities/log.ts
|
|
12
|
-
const isNode = process.versions.node !== void 0;
|
|
13
|
-
const log = {
|
|
14
|
-
verbose: false,
|
|
15
|
-
log(...data) {
|
|
16
|
-
if (!this.verbose) return;
|
|
17
|
-
const levelPrefix = pc.gray("[Log]");
|
|
18
|
-
if (isNode) console.warn(levelPrefix, ...data);
|
|
19
|
-
else console.log(levelPrefix, ...data);
|
|
20
|
-
},
|
|
21
|
-
logPrefixed(prefix, ...data) {
|
|
22
|
-
this.info(pc.blue(`[${prefix}]`), ...data);
|
|
23
|
-
},
|
|
24
|
-
info(...data) {
|
|
25
|
-
if (!this.verbose) return;
|
|
26
|
-
const levelPrefix = pc.green("[Info]");
|
|
27
|
-
if (isNode) console.warn(levelPrefix, ...data);
|
|
28
|
-
else console.info(levelPrefix, ...data);
|
|
29
|
-
},
|
|
30
|
-
infoPrefixed(prefix, ...data) {
|
|
31
|
-
this.info(pc.blue(`[${prefix}]`), ...data);
|
|
32
|
-
},
|
|
33
|
-
async infoSpin(promise, message) {
|
|
34
|
-
return oraPromise(promise, {
|
|
35
|
-
prefixText: pc.green("[Info]"),
|
|
36
|
-
stream: process.stderr,
|
|
37
|
-
text: message
|
|
38
|
-
});
|
|
39
|
-
},
|
|
40
|
-
warn(...data) {
|
|
41
|
-
console.warn(pc.yellow("[Warning]"), ...data);
|
|
42
|
-
},
|
|
43
|
-
warnPrefixed(prefix, ...data) {
|
|
44
|
-
this.warn(pc.blue(`[${prefix}]`), ...data);
|
|
45
|
-
},
|
|
46
|
-
error(...data) {
|
|
47
|
-
console.error(pc.red("[Error]"), ...data);
|
|
48
|
-
},
|
|
49
|
-
errorPrefixed(prefix, ...data) {
|
|
50
|
-
this.error(pc.blue(`[${prefix}]`), ...data);
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
//#endregion
|
|
54
|
-
//#region src/lib/utilities/video.ts
|
|
55
|
-
/**
|
|
56
|
-
* Bla
|
|
57
|
-
*/
|
|
58
|
-
async function getVideosInDirectory(directory) {
|
|
59
|
-
return (await fs.readdir(directory)).filter((file) => {
|
|
60
|
-
const fileExtension = path.extname(file);
|
|
61
|
-
return [
|
|
62
|
-
".mp4",
|
|
63
|
-
".mov",
|
|
64
|
-
".avi",
|
|
65
|
-
".mkv"
|
|
66
|
-
].includes(fileExtension);
|
|
67
|
-
}).map((file) => path.join(directory, file));
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Bla
|
|
71
|
-
*/
|
|
72
|
-
async function videoHasMetadata(filename) {
|
|
73
|
-
if (ffmpegPath === null) throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
74
|
-
try {
|
|
75
|
-
const { stderr, stdout } = await execa(ffmpegPath, [
|
|
76
|
-
"-i",
|
|
77
|
-
filename,
|
|
78
|
-
"-f",
|
|
79
|
-
"ffmetadata",
|
|
80
|
-
"-"
|
|
81
|
-
]);
|
|
82
|
-
return stdout.includes("creation_time") || stderr.includes("creation_time");
|
|
83
|
-
} catch (error) {
|
|
84
|
-
throw new Error(`Failed to check metadata for: ${filename}:\n${String(error)}`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Bla
|
|
89
|
-
*/
|
|
90
|
-
async function stripVideoMetadata(filename) {
|
|
91
|
-
if (ffmpegPath === null) throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
92
|
-
try {
|
|
93
|
-
const extension = path.extname(filename).slice(1);
|
|
94
|
-
const temporaryFilename = `${filename.slice(0, Math.max(0, filename.lastIndexOf(".")))}.temp.${extension}`;
|
|
95
|
-
await execa(ffmpegPath, [
|
|
96
|
-
"-i",
|
|
97
|
-
filename,
|
|
98
|
-
"-map_metadata",
|
|
99
|
-
"-1",
|
|
100
|
-
"-c:v",
|
|
101
|
-
"copy",
|
|
102
|
-
"-c:a",
|
|
103
|
-
"copy",
|
|
104
|
-
temporaryFilename
|
|
105
|
-
]);
|
|
106
|
-
await fs.rename(temporaryFilename, filename);
|
|
107
|
-
} catch (error) {
|
|
108
|
-
throw new Error(`Failed to strip metadata from: ${filename}:\n${String(error)}`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
//#endregion
|
|
112
|
-
//#region src/lib/strip.ts
|
|
113
|
-
/**
|
|
114
|
-
* Strip metadata from video files in a directory
|
|
115
|
-
*
|
|
116
|
-
* @returns List of file paths with metadata that were stripped
|
|
117
|
-
*/
|
|
118
|
-
async function stripVideoMetadataInDirectory(directory, options = {}) {
|
|
119
|
-
const { dryRun = false, verbose = false } = options;
|
|
120
|
-
return stripVideoMetadataInFiles(await getVideosInDirectory(directory), {
|
|
121
|
-
dryRun,
|
|
122
|
-
verbose
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Strip metadata from multiple video files
|
|
127
|
-
*
|
|
128
|
-
* @returns List of file paths with metadata that were stripped
|
|
129
|
-
*/
|
|
130
|
-
async function stripVideoMetadataInFiles(files, options = {}) {
|
|
131
|
-
const { dryRun = false, verbose = false } = options;
|
|
132
|
-
const initialVerbosity = log.verbose;
|
|
133
|
-
log.verbose = verbose;
|
|
134
|
-
const localVideosWithMetadata = [];
|
|
135
|
-
for (const videoFile of files) if (await videoHasMetadata(videoFile)) localVideosWithMetadata.push(videoFile);
|
|
136
|
-
if (!dryRun) {
|
|
137
|
-
log.info(`Found ${localVideosWithMetadata.length} videos with metadata to strip`);
|
|
138
|
-
for (const videoFile of localVideosWithMetadata) {
|
|
139
|
-
console.log(`Stripping metadata from: ${videoFile}`);
|
|
140
|
-
await stripVideoMetadata(videoFile);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
log.verbose = initialVerbosity;
|
|
144
|
-
return localVideosWithMetadata;
|
|
145
|
-
}
|
|
146
|
-
//#endregion
|
|
147
|
-
//#region src/lib/sync.ts
|
|
148
|
-
/**
|
|
149
|
-
* Synchronize a remote streaming service to mirror the contents of a local
|
|
150
|
-
* directory
|
|
151
|
-
*
|
|
152
|
-
* @returns Array of sync report entries describing what was changed (or will
|
|
153
|
-
* change if a dry run)
|
|
154
|
-
*/
|
|
155
|
-
async function syncVideoInDirectory(directory, options) {
|
|
156
|
-
const { credentials, dryRun = false, service, verbose = false } = options;
|
|
157
|
-
const initialVerbosity = log.verbose;
|
|
158
|
-
log.verbose = verbose;
|
|
159
|
-
const syncReport = [];
|
|
160
|
-
const videoFiles = await getVideosInDirectory(directory);
|
|
161
|
-
log.info(`Found ${videoFiles.length} video files in directory "${directory}"`);
|
|
162
|
-
const state = await JSONFilePreset(path.join(directory, ".vidup-state.json"), { syncState: [] });
|
|
163
|
-
state.data.syncState = state.data.syncState.filter((entry) => videoFiles.includes(path.join(directory, entry.filename)));
|
|
164
|
-
for (const videoFile of videoFiles) {
|
|
165
|
-
const filename = path.basename(videoFile);
|
|
166
|
-
const localHash = await hash(videoFile, { algorithm: "sha1" });
|
|
167
|
-
const existingEntry = state.data.syncState.find((entry) => entry.filename === filename);
|
|
168
|
-
if (existingEntry) existingEntry.localHash = localHash;
|
|
169
|
-
else state.data.syncState.push({
|
|
170
|
-
filename,
|
|
171
|
-
localHash,
|
|
172
|
-
remoteHash: {}
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
if (!dryRun) await state.write();
|
|
176
|
-
if (service !== "bunny") throw new Error(`Streaming service not yet implemented: ${service}`);
|
|
177
|
-
const stream = new BunnyCdnStream({
|
|
178
|
-
apiKey: credentials.key,
|
|
179
|
-
videoLibrary: credentials.library
|
|
180
|
-
});
|
|
181
|
-
const remoteVideos = await stream.listAllVideos();
|
|
182
|
-
log.info(`Found ${remoteVideos.length} video files on ${service} remote streaming service`);
|
|
183
|
-
const remoteVideosInGoodStanding = remoteVideos.filter((video) => {
|
|
184
|
-
const entry = state.data.syncState.find((entry) => entry.filename === video.title);
|
|
185
|
-
return entry !== void 0 && entry.localHash === entry.remoteHash[service];
|
|
186
|
-
});
|
|
187
|
-
for (const video of remoteVideosInGoodStanding) syncReport.push({
|
|
188
|
-
action: "None",
|
|
189
|
-
localFile: video.title,
|
|
190
|
-
remoteId: video.guid
|
|
191
|
-
});
|
|
192
|
-
const remoteVideosToCreate = state.data.syncState.filter((entry) => !remoteVideos.some((video) => video.title === entry.filename));
|
|
193
|
-
for (const video of remoteVideosToCreate) syncReport.push({
|
|
194
|
-
action: "Create",
|
|
195
|
-
localFile: video.filename,
|
|
196
|
-
remoteId: "Not yet uploaded (Dry run)"
|
|
197
|
-
});
|
|
198
|
-
const remoteVideosToUpdate = remoteVideos.filter((video) => {
|
|
199
|
-
const entry = state.data.syncState.find((entry) => entry.filename === video.title);
|
|
200
|
-
return entry !== void 0 && entry.localHash !== entry.remoteHash[service];
|
|
201
|
-
});
|
|
202
|
-
for (const video of remoteVideosToUpdate) syncReport.push({
|
|
203
|
-
action: "Update",
|
|
204
|
-
localFile: video.title,
|
|
205
|
-
remoteId: "Not yet uploaded (Dry run)"
|
|
206
|
-
});
|
|
207
|
-
const remoteVideosToDelete = remoteVideos.filter((video) => !state.data.syncState.some((entry) => entry.filename === video.title));
|
|
208
|
-
for (const video of remoteVideosToDelete) syncReport.push({
|
|
209
|
-
action: "Delete",
|
|
210
|
-
localFile: video.title,
|
|
211
|
-
remoteId: video.guid
|
|
212
|
-
});
|
|
213
|
-
if (!dryRun) {
|
|
214
|
-
state.data.lastRun = /* @__PURE__ */ new Date();
|
|
215
|
-
await state.write();
|
|
216
|
-
log.info(`Synchronizing...`);
|
|
217
|
-
for (const [index, remoteVideo] of remoteVideosToDelete.entries()) {
|
|
218
|
-
if (index === 0) log.info(`Deleting ${remoteVideosToDelete.length} remote videos...`);
|
|
219
|
-
if (!(await log.infoSpin(stream.deleteVideo(remoteVideo.guid), `Deleting remote video ${index + 1}/${remoteVideosToDelete.length}: ${remoteVideo.title}`)).success) throw new Error(`Failed to delete remote video: ${remoteVideo.title}`);
|
|
220
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
221
|
-
await state.write();
|
|
222
|
-
}
|
|
223
|
-
for (const [index, localVideo] of remoteVideosToCreate.entries()) {
|
|
224
|
-
if (index === 0) log.info(`Uploading ${remoteVideosToCreate.length} new local videos...`);
|
|
225
|
-
const videoFile = createReadStream(path.join(directory, localVideo.filename));
|
|
226
|
-
const response = await log.infoSpin(stream.createAndUploadVideo(videoFile, { title: localVideo.filename }), `Uploading new video ${index + 1}/${remoteVideosToCreate.length}: ${localVideo.filename}`);
|
|
227
|
-
log.info(`Remote video ${index + 1}/${remoteVideosToCreate.length} created with GUID: ${response.guid}`);
|
|
228
|
-
const stateEntry = state.data.syncState.find((entry) => entry.filename === localVideo.filename);
|
|
229
|
-
if (stateEntry === void 0) throw new Error(`Failed to find state entry for: ${localVideo.filename}`);
|
|
230
|
-
stateEntry.remoteHash[service] = localVideo.localHash;
|
|
231
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
232
|
-
await state.write();
|
|
233
|
-
const reportEntry = syncReport.find((entry) => entry.localFile === localVideo.filename);
|
|
234
|
-
if (reportEntry === void 0) throw new Error(`Failed to find report entry for: ${localVideo.filename}`);
|
|
235
|
-
reportEntry.remoteId = response.guid;
|
|
236
|
-
}
|
|
237
|
-
for (const [index, remoteVideo] of remoteVideosToUpdate.entries()) {
|
|
238
|
-
if (index === 0) {
|
|
239
|
-
log.info(`Updating ${remoteVideosToUpdate.length} remote videos...`);
|
|
240
|
-
log.warn("Bunny does not currently support updating videos in-place, so vidup will delete and re-uploading instead... this will change the video's GUID!");
|
|
241
|
-
}
|
|
242
|
-
const stateEntry = state.data.syncState.find((entry) => entry.filename === remoteVideo.title);
|
|
243
|
-
if (stateEntry === void 0) throw new Error(`Failed to find state entry for: ${remoteVideo.title}`);
|
|
244
|
-
if (!(await stream.deleteVideo(remoteVideo.guid)).success) throw new Error(`Failed to delete remote video before updating: ${remoteVideo.title}`);
|
|
245
|
-
const videoFile = createReadStream(path.join(directory, stateEntry.filename));
|
|
246
|
-
const createResponse = await log.infoSpin(stream.createAndUploadVideo(videoFile, { title: stateEntry.filename }), `Updating remote video ${index + 1}/${remoteVideosToUpdate.length}: ${remoteVideo.title}`);
|
|
247
|
-
log.info(`Updated remote video ${index + 1}/${remoteVideosToUpdate.length} created with GUID: ${createResponse.guid}`);
|
|
248
|
-
stateEntry.remoteHash[service] = stateEntry.localHash;
|
|
249
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
250
|
-
await state.write();
|
|
251
|
-
const reportEntry = syncReport.find((entry) => entry.localFile === stateEntry.filename);
|
|
252
|
-
if (reportEntry === void 0) throw new Error(`Failed to find report entry for: ${stateEntry.filename}`);
|
|
253
|
-
reportEntry.remoteId = createResponse.guid;
|
|
254
|
-
}
|
|
255
|
-
log.info(`All ${videoFiles.length} videos are now in sync!`);
|
|
256
|
-
}
|
|
257
|
-
log.verbose = initialVerbosity;
|
|
258
|
-
return syncReport;
|
|
259
|
-
}
|
|
260
|
-
//#endregion
|
|
261
|
-
export { log, stripVideoMetadataInDirectory, syncVideoInDirectory };
|
|
1
|
+
import { n as stripVideoMetadataInDirectory, r as setLogger, t as syncVideoInDirectory } from "../lib-Ddx3eQbF.js";
|
|
2
|
+
|
|
3
|
+
export { setLogger, stripVideoMetadataInDirectory, syncVideoInDirectory };
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createLogger, injectionHelper } from "lognow";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execa } from "execa";
|
|
4
|
+
import ffmpegPath from "ffmpeg-static";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import { BunnyCdnStream } from "bunnycdn-stream";
|
|
7
|
+
import { hash } from "hasha";
|
|
8
|
+
import { JSONFilePreset } from "lowdb/node";
|
|
9
|
+
import { createReadStream } from "node:fs";
|
|
10
|
+
import { oraPromise } from "ora";
|
|
11
|
+
|
|
12
|
+
//#region package.json
|
|
13
|
+
var name = "vidup";
|
|
14
|
+
var version = "1.0.20";
|
|
15
|
+
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/lib/log.ts
|
|
18
|
+
/**
|
|
19
|
+
* The default logger instance for the library.
|
|
20
|
+
*/
|
|
21
|
+
let log$1 = createLogger(name);
|
|
22
|
+
/**
|
|
23
|
+
* Set the logger instance for the library. Library consumers can call this to
|
|
24
|
+
* inject their own logger. Calling it with no argument silences the library.
|
|
25
|
+
*
|
|
26
|
+
* @param logger - Accepts either a LogLayer instance or a Console- or
|
|
27
|
+
* Stream-like log target
|
|
28
|
+
*/
|
|
29
|
+
function setLogger(logger) {
|
|
30
|
+
log$1 = injectionHelper(logger);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/lib/utilities/video.ts
|
|
35
|
+
/**
|
|
36
|
+
* List the video files directly inside a directory. Matches by extension (.mp4,
|
|
37
|
+
* .mov, .avi, .mkv) and does not recurse into subdirectories.
|
|
38
|
+
*
|
|
39
|
+
* @returns Absolute paths to the matching files
|
|
40
|
+
*/
|
|
41
|
+
async function getVideosInDirectory(directory) {
|
|
42
|
+
return (await fs.readdir(directory)).filter((file) => {
|
|
43
|
+
const fileExtension = path.extname(file);
|
|
44
|
+
return [
|
|
45
|
+
".mp4",
|
|
46
|
+
".mov",
|
|
47
|
+
".avi",
|
|
48
|
+
".mkv"
|
|
49
|
+
].includes(fileExtension);
|
|
50
|
+
}).map((file) => path.join(directory, file));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Check whether a video file carries a `creation_time` metadata tag by
|
|
54
|
+
* exporting its metadata with ffmpeg. Throws if the ffmpeg binary is
|
|
55
|
+
* unavailable or the ffmpeg run fails.
|
|
56
|
+
*/
|
|
57
|
+
async function videoHasMetadata(filename) {
|
|
58
|
+
if (ffmpegPath === null) throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
59
|
+
try {
|
|
60
|
+
const { stderr, stdout } = await execa(ffmpegPath, [
|
|
61
|
+
"-i",
|
|
62
|
+
filename,
|
|
63
|
+
"-f",
|
|
64
|
+
"ffmetadata",
|
|
65
|
+
"-"
|
|
66
|
+
]);
|
|
67
|
+
return stdout.includes("creation_time") || stderr.includes("creation_time");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`Failed to check metadata for: ${filename}:\n${String(error)}`, { cause: error });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Remove all metadata from a video file in place. Rewrites the file with ffmpeg
|
|
74
|
+
* using stream copy (no re-encoding) into a temporary sibling file, then
|
|
75
|
+
* replaces the original. Throws if the ffmpeg binary is unavailable or the
|
|
76
|
+
* ffmpeg run or rename fails.
|
|
77
|
+
*/
|
|
78
|
+
async function stripVideoMetadata(filename) {
|
|
79
|
+
if (ffmpegPath === null) throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
80
|
+
try {
|
|
81
|
+
const extension = path.extname(filename).slice(1);
|
|
82
|
+
const temporaryFilename = `${filename.slice(0, Math.max(0, filename.lastIndexOf(".")))}.temp.${extension}`;
|
|
83
|
+
await execa(ffmpegPath, [
|
|
84
|
+
"-i",
|
|
85
|
+
filename,
|
|
86
|
+
"-map_metadata",
|
|
87
|
+
"-1",
|
|
88
|
+
"-c:v",
|
|
89
|
+
"copy",
|
|
90
|
+
"-c:a",
|
|
91
|
+
"copy",
|
|
92
|
+
temporaryFilename
|
|
93
|
+
]);
|
|
94
|
+
await fs.rename(temporaryFilename, filename);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`Failed to strip metadata from: ${filename}:\n${String(error)}`, { cause: error });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/lib/strip.ts
|
|
102
|
+
/**
|
|
103
|
+
* Strip metadata from video files in a directory
|
|
104
|
+
*
|
|
105
|
+
* @returns List of file paths with metadata that were stripped
|
|
106
|
+
*/
|
|
107
|
+
async function stripVideoMetadataInDirectory(directory, options = {}) {
|
|
108
|
+
return stripVideoMetadataInFiles(await getVideosInDirectory(directory), options);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Strip metadata from multiple video files
|
|
112
|
+
*
|
|
113
|
+
* @returns List of file paths with metadata that were stripped
|
|
114
|
+
*/
|
|
115
|
+
async function stripVideoMetadataInFiles(files, options = {}) {
|
|
116
|
+
const { dryRun = false } = options;
|
|
117
|
+
const localVideosWithMetadata = [];
|
|
118
|
+
for (const videoFile of files) if (await videoHasMetadata(videoFile)) localVideosWithMetadata.push(videoFile);
|
|
119
|
+
if (!dryRun) {
|
|
120
|
+
log$1.debug(`Found ${localVideosWithMetadata.length} videos with metadata to strip`);
|
|
121
|
+
for (const videoFile of localVideosWithMetadata) {
|
|
122
|
+
log$1.debug(`Stripping metadata from: ${videoFile}`);
|
|
123
|
+
await stripVideoMetadata(videoFile);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return localVideosWithMetadata;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/lib/sync.ts
|
|
131
|
+
/**
|
|
132
|
+
* Synchronize a remote streaming service to mirror the contents of a local
|
|
133
|
+
* directory
|
|
134
|
+
*
|
|
135
|
+
* @returns Array of sync report entries describing what was changed (or will
|
|
136
|
+
* change if a dry run)
|
|
137
|
+
*/
|
|
138
|
+
async function syncVideoInDirectory(directory, options) {
|
|
139
|
+
const { credentials, dryRun = false, service } = options;
|
|
140
|
+
const syncReport = [];
|
|
141
|
+
const videoFiles = await getVideosInDirectory(directory);
|
|
142
|
+
log$1.debug(`Found ${videoFiles.length} video files in directory "${directory}"`);
|
|
143
|
+
const state = await JSONFilePreset(path.join(directory, ".vidup-state.json"), { syncState: [] });
|
|
144
|
+
state.data.syncState = state.data.syncState.filter((entry) => videoFiles.includes(path.join(directory, entry.filename)));
|
|
145
|
+
for (const videoFile of videoFiles) {
|
|
146
|
+
const filename = path.basename(videoFile);
|
|
147
|
+
const localHash = await hash(videoFile, { algorithm: "sha1" });
|
|
148
|
+
const existingEntry = state.data.syncState.find((entry) => entry.filename === filename);
|
|
149
|
+
if (existingEntry) existingEntry.localHash = localHash;
|
|
150
|
+
else state.data.syncState.push({
|
|
151
|
+
filename,
|
|
152
|
+
localHash,
|
|
153
|
+
remoteHash: {}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (!dryRun) await state.write();
|
|
157
|
+
if (service !== "bunny") throw new Error(`Streaming service not yet implemented: ${service}`);
|
|
158
|
+
const stream = new BunnyCdnStream({
|
|
159
|
+
apiKey: credentials.key,
|
|
160
|
+
videoLibrary: credentials.library
|
|
161
|
+
});
|
|
162
|
+
const remoteVideos = await stream.listAllVideos();
|
|
163
|
+
log$1.debug(`Found ${remoteVideos.length} video files on ${service} remote streaming service`);
|
|
164
|
+
const remoteVideosInGoodStanding = remoteVideos.filter((video) => {
|
|
165
|
+
const entry = state.data.syncState.find((entry) => entry.filename === video.title);
|
|
166
|
+
return entry !== void 0 && entry.localHash === entry.remoteHash[service];
|
|
167
|
+
});
|
|
168
|
+
for (const video of remoteVideosInGoodStanding) syncReport.push({
|
|
169
|
+
action: "None",
|
|
170
|
+
localFile: video.title,
|
|
171
|
+
remoteId: video.guid
|
|
172
|
+
});
|
|
173
|
+
const remoteVideosToCreate = state.data.syncState.filter((entry) => remoteVideos.every((video) => video.title !== entry.filename));
|
|
174
|
+
for (const video of remoteVideosToCreate) syncReport.push({
|
|
175
|
+
action: "Create",
|
|
176
|
+
localFile: video.filename,
|
|
177
|
+
remoteId: "Not yet uploaded (Dry run)"
|
|
178
|
+
});
|
|
179
|
+
const remoteVideosToUpdate = remoteVideos.filter((video) => {
|
|
180
|
+
const entry = state.data.syncState.find((entry) => entry.filename === video.title);
|
|
181
|
+
return entry !== void 0 && entry.localHash !== entry.remoteHash[service];
|
|
182
|
+
});
|
|
183
|
+
for (const video of remoteVideosToUpdate) syncReport.push({
|
|
184
|
+
action: "Update",
|
|
185
|
+
localFile: video.title,
|
|
186
|
+
remoteId: "Not yet uploaded (Dry run)"
|
|
187
|
+
});
|
|
188
|
+
const remoteVideosToDelete = remoteVideos.filter((video) => state.data.syncState.every((entry) => entry.filename !== video.title));
|
|
189
|
+
for (const video of remoteVideosToDelete) syncReport.push({
|
|
190
|
+
action: "Delete",
|
|
191
|
+
localFile: video.title,
|
|
192
|
+
remoteId: video.guid
|
|
193
|
+
});
|
|
194
|
+
if (!dryRun) {
|
|
195
|
+
state.data.lastRun = /* @__PURE__ */ new Date();
|
|
196
|
+
await state.write();
|
|
197
|
+
log$1.debug(`Synchronizing...`);
|
|
198
|
+
for (const [index, remoteVideo] of remoteVideosToDelete.entries()) {
|
|
199
|
+
if (index === 0) log$1.debug(`Deleting ${remoteVideosToDelete.length} remote videos...`);
|
|
200
|
+
if (!(await withSpinner(stream.deleteVideo(remoteVideo.guid), `Deleting remote video ${index + 1}/${remoteVideosToDelete.length}: ${remoteVideo.title}`)).success) throw new Error(`Failed to delete remote video: ${remoteVideo.title}`);
|
|
201
|
+
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
202
|
+
await state.write();
|
|
203
|
+
}
|
|
204
|
+
for (const [index, localVideo] of remoteVideosToCreate.entries()) {
|
|
205
|
+
if (index === 0) log$1.debug(`Uploading ${remoteVideosToCreate.length} new local videos...`);
|
|
206
|
+
const videoFile = createReadStream(path.join(directory, localVideo.filename));
|
|
207
|
+
const response = await withSpinner(stream.createAndUploadVideo(videoFile, { title: localVideo.filename }), `Uploading new video ${index + 1}/${remoteVideosToCreate.length}: ${localVideo.filename}`);
|
|
208
|
+
log$1.debug(`Remote video ${index + 1}/${remoteVideosToCreate.length} created with GUID: ${response.guid}`);
|
|
209
|
+
const stateEntry = state.data.syncState.find((entry) => entry.filename === localVideo.filename);
|
|
210
|
+
if (stateEntry === void 0) throw new Error(`Failed to find state entry for: ${localVideo.filename}`);
|
|
211
|
+
stateEntry.remoteHash[service] = localVideo.localHash;
|
|
212
|
+
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
213
|
+
await state.write();
|
|
214
|
+
const reportEntry = syncReport.find((entry) => entry.localFile === localVideo.filename);
|
|
215
|
+
if (reportEntry === void 0) throw new Error(`Failed to find report entry for: ${localVideo.filename}`);
|
|
216
|
+
reportEntry.remoteId = response.guid;
|
|
217
|
+
}
|
|
218
|
+
for (const [index, remoteVideo] of remoteVideosToUpdate.entries()) {
|
|
219
|
+
if (index === 0) {
|
|
220
|
+
log$1.debug(`Updating ${remoteVideosToUpdate.length} remote videos...`);
|
|
221
|
+
log$1.warn("Bunny does not currently support updating videos in-place, so vidup will delete and re-uploading instead... this will change the video's GUID!");
|
|
222
|
+
}
|
|
223
|
+
const stateEntry = state.data.syncState.find((entry) => entry.filename === remoteVideo.title);
|
|
224
|
+
if (stateEntry === void 0) throw new Error(`Failed to find state entry for: ${remoteVideo.title}`);
|
|
225
|
+
if (!(await stream.deleteVideo(remoteVideo.guid)).success) throw new Error(`Failed to delete remote video before updating: ${remoteVideo.title}`);
|
|
226
|
+
const videoFile = createReadStream(path.join(directory, stateEntry.filename));
|
|
227
|
+
const creationResponse = await withSpinner(stream.createAndUploadVideo(videoFile, { title: stateEntry.filename }), `Updating remote video ${index + 1}/${remoteVideosToUpdate.length}: ${remoteVideo.title}`);
|
|
228
|
+
log$1.debug(`Updated remote video ${index + 1}/${remoteVideosToUpdate.length} created with GUID: ${creationResponse.guid}`);
|
|
229
|
+
stateEntry.remoteHash[service] = stateEntry.localHash;
|
|
230
|
+
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
231
|
+
await state.write();
|
|
232
|
+
const reportEntry = syncReport.find((entry) => entry.localFile === stateEntry.filename);
|
|
233
|
+
if (reportEntry === void 0) throw new Error(`Failed to find report entry for: ${stateEntry.filename}`);
|
|
234
|
+
reportEntry.remoteId = creationResponse.guid;
|
|
235
|
+
}
|
|
236
|
+
log$1.debug(`All ${videoFiles.length} videos are now in sync!`);
|
|
237
|
+
}
|
|
238
|
+
return syncReport;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Show a spinner on stderr while a long-running remote operation completes
|
|
242
|
+
*/
|
|
243
|
+
async function withSpinner(promise, text) {
|
|
244
|
+
return oraPromise(promise, {
|
|
245
|
+
stream: process.stderr,
|
|
246
|
+
text
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
export { version as i, stripVideoMetadataInDirectory as n, setLogger as r, syncVideoInDirectory as t };
|