vidup 1.0.16 → 1.0.18

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.js CHANGED
@@ -1,274 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import yargs from 'yargs';
5
- import { hideBin } from 'yargs/helpers';
6
- import { l as log, s as stripVideoMetadataInDirectory, a as syncVideoInDirectory } from '../sync-CCKOz_Uf.js';
7
- import 'bunnycdn-stream';
8
- import 'hasha';
9
- import 'node:fs';
10
- import 'node:fs/promises';
11
- import 'node:url';
12
- import 'ora';
13
- import 'execa';
14
- import 'ffmpeg-static';
15
-
16
- const toZeroIfInfinity = (value) => Number.isFinite(value) ? value : 0;
17
- function parseNumber(milliseconds) {
18
- return {
19
- days: Math.trunc(milliseconds / 864e5),
20
- hours: Math.trunc(milliseconds / 36e5 % 24),
21
- minutes: Math.trunc(milliseconds / 6e4 % 60),
22
- seconds: Math.trunc(milliseconds / 1e3 % 60),
23
- milliseconds: Math.trunc(milliseconds % 1e3),
24
- microseconds: Math.trunc(toZeroIfInfinity(milliseconds * 1e3) % 1e3),
25
- nanoseconds: Math.trunc(toZeroIfInfinity(milliseconds * 1e6) % 1e3)
26
- };
27
- }
28
- function parseBigint(milliseconds) {
29
- return {
30
- days: milliseconds / 86400000n,
31
- hours: milliseconds / 3600000n % 24n,
32
- minutes: milliseconds / 60000n % 60n,
33
- seconds: milliseconds / 1000n % 60n,
34
- milliseconds: milliseconds % 1000n,
35
- microseconds: 0n,
36
- nanoseconds: 0n
37
- };
38
- }
39
- function parseMilliseconds(milliseconds) {
40
- switch (typeof milliseconds) {
41
- case "number": {
42
- if (Number.isFinite(milliseconds)) {
43
- return parseNumber(milliseconds);
44
- }
45
- break;
46
- }
47
- case "bigint": {
48
- return parseBigint(milliseconds);
49
- }
50
- }
51
- throw new TypeError("Expected a finite number or bigint");
52
- }
53
-
54
- const isZero = (value) => value === 0 || value === 0n;
55
- const pluralize = (word, count) => count === 1 || count === 1n ? word : `${word}s`;
56
- const SECOND_ROUNDING_EPSILON = 1e-7;
57
- const ONE_DAY_IN_MILLISECONDS = 24n * 60n * 60n * 1000n;
58
- function prettyMilliseconds(milliseconds, options) {
59
- const isBigInt = typeof milliseconds === "bigint";
60
- if (!isBigInt && !Number.isFinite(milliseconds)) {
61
- throw new TypeError("Expected a finite number or bigint");
62
- }
63
- options = { ...options };
64
- const sign = milliseconds < 0 ? "-" : "";
65
- milliseconds = milliseconds < 0 ? -milliseconds : milliseconds;
66
- if (options.colonNotation) {
67
- options.compact = false;
68
- options.formatSubMilliseconds = false;
69
- options.separateMilliseconds = false;
70
- options.verbose = false;
71
- }
72
- if (options.compact) {
73
- options.unitCount = 1;
74
- options.secondsDecimalDigits = 0;
75
- options.millisecondsDecimalDigits = 0;
76
- }
77
- let result = [];
78
- const floorDecimals = (value, decimalDigits) => {
79
- const flooredInterimValue = Math.floor(value * 10 ** decimalDigits + SECOND_ROUNDING_EPSILON);
80
- const flooredValue = Math.round(flooredInterimValue) / 10 ** decimalDigits;
81
- return flooredValue.toFixed(decimalDigits);
82
- };
83
- const add = (value, long, short, valueString) => {
84
- if ((result.length === 0 || !options.colonNotation) && isZero(value) && !(options.colonNotation && short === "m")) {
85
- return;
86
- }
87
- valueString ??= String(value);
88
- if (options.colonNotation) {
89
- const wholeDigits = valueString.includes(".") ? valueString.split(".")[0].length : valueString.length;
90
- const minLength = result.length > 0 ? 2 : 1;
91
- valueString = "0".repeat(Math.max(0, minLength - wholeDigits)) + valueString;
92
- } else {
93
- valueString += options.verbose ? " " + pluralize(long, value) : short;
94
- }
95
- result.push(valueString);
96
- };
97
- const parsed = parseMilliseconds(milliseconds);
98
- const days = BigInt(parsed.days);
99
- if (options.hideYearAndDays) {
100
- add(BigInt(days) * 24n + BigInt(parsed.hours), "hour", "h");
101
- } else {
102
- if (options.hideYear) {
103
- add(days, "day", "d");
104
- } else {
105
- add(days / 365n, "year", "y");
106
- add(days % 365n, "day", "d");
107
- }
108
- add(Number(parsed.hours), "hour", "h");
109
- }
110
- add(Number(parsed.minutes), "minute", "m");
111
- if (!options.hideSeconds) {
112
- if (options.separateMilliseconds || options.formatSubMilliseconds || !options.colonNotation && milliseconds < 1e3 && !options.subSecondsAsDecimals) {
113
- const seconds = Number(parsed.seconds);
114
- const milliseconds2 = Number(parsed.milliseconds);
115
- const microseconds = Number(parsed.microseconds);
116
- const nanoseconds = Number(parsed.nanoseconds);
117
- add(seconds, "second", "s");
118
- if (options.formatSubMilliseconds) {
119
- add(milliseconds2, "millisecond", "ms");
120
- add(microseconds, "microsecond", "\xB5s");
121
- add(nanoseconds, "nanosecond", "ns");
122
- } else {
123
- const millisecondsAndBelow = milliseconds2 + microseconds / 1e3 + nanoseconds / 1e6;
124
- const millisecondsDecimalDigits = typeof options.millisecondsDecimalDigits === "number" ? options.millisecondsDecimalDigits : 0;
125
- const roundedMilliseconds = millisecondsAndBelow >= 1 ? Math.round(millisecondsAndBelow) : Math.ceil(millisecondsAndBelow);
126
- const millisecondsString = millisecondsDecimalDigits ? millisecondsAndBelow.toFixed(millisecondsDecimalDigits) : roundedMilliseconds;
127
- add(
128
- Number.parseFloat(millisecondsString),
129
- "millisecond",
130
- "ms",
131
- millisecondsString
132
- );
133
- }
134
- } else {
135
- const seconds = (isBigInt ? Number(milliseconds % ONE_DAY_IN_MILLISECONDS) : milliseconds) / 1e3 % 60;
136
- const secondsDecimalDigits = typeof options.secondsDecimalDigits === "number" ? options.secondsDecimalDigits : 1;
137
- const secondsFixed = floorDecimals(seconds, secondsDecimalDigits);
138
- const secondsString = options.keepDecimalsOnWholeSeconds ? secondsFixed : secondsFixed.replace(/\.0+$/, "");
139
- add(Number.parseFloat(secondsString), "second", "s", secondsString);
140
- }
141
- }
142
- if (result.length === 0) {
143
- return sign + "0" + (options.verbose ? " milliseconds" : "ms");
144
- }
145
- const separator = options.colonNotation ? ":" : " ";
146
- if (typeof options.unitCount === "number") {
147
- result = result.slice(0, Math.max(options.unitCount, 1));
148
- }
149
- return sign + result.join(separator);
150
- }
151
-
152
- let homeDirectory;
153
- let currentUser;
154
- function untildify(pathWithTilde) {
155
- if (typeof pathWithTilde !== "string") {
156
- throw new TypeError(`Expected a string, got ${typeof pathWithTilde}`);
157
- }
158
- if (homeDirectory === void 0) {
159
- homeDirectory = os.homedir();
160
- }
161
- if (homeDirectory && /^~(?=$|\/|\\)/.test(pathWithTilde)) {
162
- return pathWithTilde.replace(/^~/, homeDirectory);
163
- }
164
- const userMatch = pathWithTilde.match(/^~([^/\\]+)(.*)/);
165
- if (userMatch) {
166
- if (currentUser === void 0) {
167
- currentUser = os.userInfo().username;
168
- }
169
- if (currentUser) {
170
- const username = userMatch[1];
171
- const rest = userMatch[2];
172
- if (username === currentUser) {
173
- return homeDirectory + rest;
174
- }
175
- }
176
- }
177
- return pathWithTilde;
178
- }
179
-
180
- var version = "1.0.16";
181
-
182
- const startTime = performance.now();
183
- const yargsInstance = yargs(hideBin(process.argv));
184
- await yargsInstance.scriptName("vidup").usage("$0 [command]", "Run a vidup command.").command(
185
- ["$0 <directory> [options]", "sync <directory> [options]"],
186
- "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.",
187
- (yargs2) => yargs2.positional("directory", {
188
- default: void 0,
189
- demandOption: true,
190
- describe: "The path to the local directory of video files to sync.",
191
- type: "string"
192
- }).option("service", {
193
- alias: "s",
194
- choices: ["bunny", "mux", "cloudflare"],
195
- demandOption: true,
196
- describe: "Streaming service / platform to sync local video files to. Only the Bunny.net streaming CDN is supported at this time.",
197
- type: "string"
198
- }).option("key", {
199
- default: void 0,
200
- demandOption: true,
201
- describe: "Streaming service API access key.",
202
- type: "string"
203
- }).option("library", {
204
- default: void 0,
205
- demandOption: true,
206
- describe: "Streaming service library ID.",
207
- type: "string"
208
- }).option("strip-metadata", {
209
- default: false,
210
- describe: "Remove all metadata from the video files before uploading them to the streaming service. Warning: This will modify local videos in-place.",
211
- type: "boolean"
212
- }).option("dry-run", {
213
- alias: "d",
214
- default: false,
215
- describe: "Perform a dry run without making any changes. Useful for testing and debugging. Pairs well with the `--json` command.",
216
- type: "boolean"
217
- }).option("json", {
218
- default: false,
219
- describe: "Output the sync report as JSON.",
220
- type: "boolean"
221
- }).option("verbose", {
222
- default: false,
223
- describe: "Enable verbose logging. All verbose logs and prefixed with their log level and are printed to `stderr` for ease of redirection.",
224
- type: "boolean"
225
- }).check((argv) => {
226
- if (argv.service === "bunny") {
227
- return argv;
228
- }
229
- throw new Error(
230
- `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`
231
- );
232
- }),
233
- async ({ directory, dryRun, json, key, library, service, stripMetadata, verbose }) => {
234
- log.verbose = verbose;
235
- const resolvedDirectory = path.resolve(untildify(directory));
236
- log.info("Starting video synchronization...");
237
- if (dryRun) {
238
- log.warn(`Dry run enabled, not making any changes`);
239
- }
240
- const stripReport = stripMetadata ? await stripVideoMetadataInDirectory(resolvedDirectory, {
241
- dryRun,
242
- verbose
243
- }) : [];
244
- const syncReport = await syncVideoInDirectory(resolvedDirectory, {
245
- credentials: {
246
- key,
247
- library
248
- },
249
- dryRun,
250
- service,
251
- verbose
252
- });
253
- if (json) {
254
- for (const entry of syncReport) {
255
- entry.stripMetadata = entry.localFile !== void 0 && stripReport.includes(entry.localFile);
256
- }
257
- process.stdout.write(JSON.stringify(syncReport, void 0, 2));
258
- process.stdout.write("\n");
259
- } else {
260
- for (const entry of stripReport) {
261
- process.stdout.write(`${dryRun ? "Found" : "Stripped"} metadata: "${entry}"
262
- `);
263
- }
264
- for (const entry of syncReport) {
265
- process.stdout.write(
266
- `${entry.action}: "${entry.localFile}" Remote GUID: "${entry.remoteId}"
267
- `
268
- );
269
- }
270
- }
271
- log.info(`Synchronized video in ${prettyMilliseconds(performance.now() - startTime)}`);
272
- process.exitCode = 0;
273
- }
274
- ).demandCommand(1).alias("h", "help").version(version).alias("v", "version").help().wrap(process.stdout.isTTY ? Math.min(120, yargsInstance.terminalWidth()) : 0).parse();
2
+ import e from"node:path";import t from"pretty-ms";import n from"untildify";import r from"yargs";import{hideBin as i}from"yargs/helpers";import{oraPromise as a}from"ora";import o from"picocolors";import{execa as s}from"execa";import c from"ffmpeg-static";import l from"node:fs/promises";import{BunnyCdnStream as u}from"bunnycdn-stream";import{hash as d}from"hasha";import{JSONFilePreset as f}from"lowdb/node";import{createReadStream as p}from"node:fs";var m=`1.0.18`;const h=process.versions.node!==void 0,g={verbose:!1,log(...e){if(!this.verbose)return;let t=o.gray(`[Log]`);h?console.warn(t,...e):console.log(t,...e)},logPrefixed(e,...t){this.info(o.blue(`[${e}]`),...t)},info(...e){if(!this.verbose)return;let t=o.green(`[Info]`);h?console.warn(t,...e):console.info(t,...e)},infoPrefixed(e,...t){this.info(o.blue(`[${e}]`),...t)},async infoSpin(e,t){return a(e,{prefixText:o.green(`[Info]`),stream:process.stderr,text:t})},warn(...e){console.warn(o.yellow(`[Warning]`),...e)},warnPrefixed(e,...t){this.warn(o.blue(`[${e}]`),...t)},error(...e){console.error(o.red(`[Error]`),...e)},errorPrefixed(e,...t){this.error(o.blue(`[${e}]`),...t)}};async function _(t){return(await l.readdir(t)).filter(t=>{let n=e.extname(t);return[`.mp4`,`.mov`,`.avi`,`.mkv`].includes(n)}).map(n=>e.join(t,n))}async function v(e){if(c===null)throw Error(`ffmpeg-static failed to locate ffmpeg binary`);try{let{stderr:t,stdout:n}=await s(c,[`-i`,e,`-f`,`ffmetadata`,`-`]);return n.includes(`creation_time`)||t.includes(`creation_time`)}catch(t){throw Error(`Failed to check metadata for: ${e}:\n${String(t)}`)}}async function y(t){if(c===null)throw Error(`ffmpeg-static failed to locate ffmpeg binary`);try{let n=e.extname(t).slice(1),r=`${t.slice(0,Math.max(0,t.lastIndexOf(`.`)))}.temp.${n}`;await s(c,[`-i`,t,`-map_metadata`,`-1`,`-c:v`,`copy`,`-c:a`,`copy`,r]),await l.rename(r,t)}catch(e){throw Error(`Failed to strip metadata from: ${t}:\n${String(e)}`)}}async function b(e,t={}){let{dryRun:n=!1,verbose:r=!1}=t;return x(await _(e),{dryRun:n,verbose:r})}async function x(e,t={}){let{dryRun:n=!1,verbose:r=!1}=t,i=g.verbose;g.verbose=r;let a=[];for(let t of e)await v(t)&&a.push(t);if(!n){g.info(`Found ${a.length} videos with metadata to strip`);for(let e of a)console.log(`Stripping metadata from: ${e}`),await y(e)}return g.verbose=i,a}async function S(t,n){let{credentials:r,dryRun:i=!1,service:a,verbose:o=!1}=n,s=g.verbose;g.verbose=o;let c=[],l=await _(t);g.info(`Found ${l.length} video files in directory "${t}"`);let m=await f(e.join(t,`.vidup-state.json`),{syncState:[]});m.data.syncState=m.data.syncState.filter(n=>l.includes(e.join(t,n.filename)));for(let t of l){let n=e.basename(t),r=await d(t,{algorithm:`sha1`}),i=m.data.syncState.find(e=>e.filename===n);i?i.localHash=r:m.data.syncState.push({filename:n,localHash:r,remoteHash:{}})}if(i||await m.write(),a!==`bunny`)throw Error(`Streaming service not yet implemented: ${a}`);let h=new u({apiKey:r.key,videoLibrary:r.library}),v=await h.listAllVideos();g.info(`Found ${v.length} video files on ${a} remote streaming service`);let y=v.filter(e=>{let t=m.data.syncState.find(t=>t.filename===e.title);return t!==void 0&&t.localHash===t.remoteHash[a]});for(let e of y)c.push({action:`None`,localFile:e.title,remoteId:e.guid});let b=m.data.syncState.filter(e=>!v.some(t=>t.title===e.filename));for(let e of b)c.push({action:`Create`,localFile:e.filename,remoteId:`Not yet uploaded (Dry run)`});let x=v.filter(e=>{let t=m.data.syncState.find(t=>t.filename===e.title);return t!==void 0&&t.localHash!==t.remoteHash[a]});for(let e of x)c.push({action:`Update`,localFile:e.title,remoteId:`Not yet uploaded (Dry run)`});let S=v.filter(e=>!m.data.syncState.some(t=>t.filename===e.title));for(let e of S)c.push({action:`Delete`,localFile:e.title,remoteId:e.guid});if(!i){m.data.lastRun=new Date,await m.write(),g.info(`Synchronizing...`);for(let[e,t]of S.entries()){if(e===0&&g.info(`Deleting ${S.length} remote videos...`),!(await g.infoSpin(h.deleteVideo(t.guid),`Deleting remote video ${e+1}/${S.length}: ${t.title}`)).success)throw Error(`Failed to delete remote video: ${t.title}`);m.data.lastUpdate=new Date,await m.write()}for(let[n,r]of b.entries()){n===0&&g.info(`Uploading ${b.length} new local videos...`);let i=p(e.join(t,r.filename)),o=await g.infoSpin(h.createAndUploadVideo(i,{title:r.filename}),`Uploading new video ${n+1}/${b.length}: ${r.filename}`);g.info(`Remote video ${n+1}/${b.length} created with GUID: ${o.guid}`);let s=m.data.syncState.find(e=>e.filename===r.filename);if(s===void 0)throw Error(`Failed to find state entry for: ${r.filename}`);s.remoteHash[a]=r.localHash,m.data.lastUpdate=new Date,await m.write();let l=c.find(e=>e.localFile===r.filename);if(l===void 0)throw Error(`Failed to find report entry for: ${r.filename}`);l.remoteId=o.guid}for(let[n,r]of x.entries()){n===0&&(g.info(`Updating ${x.length} remote videos...`),g.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!`));let i=m.data.syncState.find(e=>e.filename===r.title);if(i===void 0)throw Error(`Failed to find state entry for: ${r.title}`);if(!(await h.deleteVideo(r.guid)).success)throw Error(`Failed to delete remote video before updating: ${r.title}`);let o=p(e.join(t,i.filename)),s=await g.infoSpin(h.createAndUploadVideo(o,{title:i.filename}),`Updating remote video ${n+1}/${x.length}: ${r.title}`);g.info(`Updated remote video ${n+1}/${x.length} created with GUID: ${s.guid}`),i.remoteHash[a]=i.localHash,m.data.lastUpdate=new Date,await m.write();let l=c.find(e=>e.localFile===i.filename);if(l===void 0)throw Error(`Failed to find report entry for: ${i.filename}`);l.remoteId=s.guid}g.info(`All ${l.length} videos are now in sync!`)}return g.verbose=s,c}const C=performance.now(),w=r(i(process.argv));await w.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.`,e=>e.positional(`directory`,{default:void 0,demandOption:!0,describe:`The path to the local directory of video files to sync.`,type:`string`}).option(`service`,{alias:`s`,choices:[`bunny`,`mux`,`cloudflare`],demandOption:!0,describe:`Streaming service / platform to sync local video files to. Only the Bunny.net streaming CDN is supported at this time.`,type:`string`}).option(`key`,{default:void 0,demandOption:!0,describe:`Streaming service API access key.`,type:`string`}).option(`library`,{default:void 0,demandOption:!0,describe:`Streaming service library ID.`,type:`string`}).option(`strip-metadata`,{default:!1,describe:`Remove all metadata from the video files before uploading them to the streaming service. Warning: This will modify local videos in-place.`,type:`boolean`}).option(`dry-run`,{alias:`d`,default:!1,describe:"Perform a dry run without making any changes. Useful for testing and debugging. Pairs well with the `--json` command.",type:`boolean`}).option(`json`,{default:!1,describe:`Output the sync report as JSON.`,type:`boolean`}).option(`verbose`,{default:!1,describe:"Enable verbose logging. All verbose logs and prefixed with their log level and are printed to `stderr` for ease of redirection.",type:`boolean`}).check(e=>{if(e.service===`bunny`)return e;throw Error(`Sorry, vidup doesn't yet implement synchronization support the ${e.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`)}),async({directory:r,dryRun:i,json:a,key:o,library:s,service:c,stripMetadata:l,verbose:u})=>{g.verbose=u;let d=e.resolve(n(r));g.info(`Starting video synchronization...`),i&&g.warn(`Dry run enabled, not making any changes`);let f=l?await b(d,{dryRun:i,verbose:u}):[],p=await S(d,{credentials:{key:o,library:s},dryRun:i,service:c,verbose:u});if(a){for(let e of p)e.stripMetadata=e.localFile!==void 0&&f.includes(e.localFile);process.stdout.write(JSON.stringify(p,void 0,2)),process.stdout.write(`
3
+ `)}else{for(let e of f)process.stdout.write(`${i?`Found`:`Stripped`} metadata: "${e}"\n`);for(let e of p)process.stdout.write(`${e.action}: "${e.localFile}"\t Remote GUID: "${e.remoteId}"\n`)}g.info(`Synchronized video in ${t(performance.now()-C)}`),process.exitCode=0}).demandCommand(1).alias(`h`,`help`).version(m).alias(`v`,`version`).help().wrap(process.stdout.isTTY?Math.min(120,w.terminalWidth()):0).parse();export{};
@@ -1,47 +1,53 @@
1
+ //#region src/lib/strip.d.ts
1
2
  type StripOptions = {
2
- dryRun?: boolean;
3
- verbose?: boolean;
3
+ dryRun?: boolean;
4
+ verbose?: boolean;
4
5
  };
5
6
  /**
6
7
  * Strip metadata from video files in a directory
8
+ *
7
9
  * @returns List of file paths with metadata that were stripped
8
10
  */
9
11
  declare function stripVideoMetadataInDirectory(directory: string, options?: StripOptions): Promise<string[]>;
10
-
12
+ //#endregion
13
+ //#region src/lib/sync.d.ts
11
14
  type Service = 'bunny' | 'cloudflare' | 'mux';
12
15
  type SyncReport = Array<{
13
- action: 'Create' | 'Delete' | 'None' | 'Update';
14
- localFile?: string;
15
- remoteId: string;
16
- stripMetadata?: boolean;
16
+ action: 'Create' | 'Delete' | 'None' | 'Update';
17
+ localFile?: string;
18
+ remoteId: string;
19
+ stripMetadata?: boolean;
17
20
  }>;
18
21
  type SyncOptions = {
19
- credentials: {
20
- key: string;
21
- library: string;
22
- };
23
- dryRun?: boolean;
24
- service: Service;
25
- verbose?: boolean;
22
+ credentials: {
23
+ key: string;
24
+ library: string;
25
+ };
26
+ dryRun?: boolean;
27
+ service: Service;
28
+ verbose?: boolean;
26
29
  };
27
30
  /**
28
- * Synchronize a remote streaming service to mirror the contents of a local directory
29
- * @returns Array of sync report entries describing what was changed (or will change if a dry run)
31
+ * Synchronize a remote streaming service to mirror the contents of a local
32
+ * directory
33
+ *
34
+ * @returns Array of sync report entries describing what was changed (or will
35
+ * change if a dry run)
30
36
  */
31
37
  declare function syncVideoInDirectory(directory: string, options: SyncOptions): Promise<SyncReport>;
32
-
38
+ //#endregion
39
+ //#region src/lib/utilities/log.d.ts
33
40
  declare const log: {
34
- verbose: boolean;
35
- log(...data: unknown[]): void;
36
- logPrefixed(prefix: string, ...data: unknown[]): void;
37
- info(...data: unknown[]): void;
38
- infoPrefixed(prefix: string, ...data: unknown[]): void;
39
- infoSpin<T>(promise: Promise<T>, message: string): Promise<T>;
40
- warn(...data: unknown[]): void;
41
- warnPrefixed(prefix: string, ...data: unknown[]): void;
42
- error(...data: unknown[]): void;
43
- errorPrefixed(prefix: string, ...data: unknown[]): void;
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;
44
51
  };
45
-
46
- export { log, stripVideoMetadataInDirectory, syncVideoInDirectory };
47
- export type { Service, StripOptions, SyncOptions, SyncReport };
52
+ //#endregion
53
+ export { type Service, type StripOptions, type SyncOptions, type SyncReport, log, stripVideoMetadataInDirectory, syncVideoInDirectory };
package/dist/lib/index.js CHANGED
@@ -1,10 +1,261 @@
1
- export { l as log, s as stripVideoMetadataInDirectory, a as syncVideoInDirectory } from '../sync-CCKOz_Uf.js';
2
- import 'bunnycdn-stream';
3
- import 'hasha';
4
- import 'node:fs';
5
- import 'node:fs/promises';
6
- import 'node:path';
7
- import 'node:url';
8
- import 'ora';
9
- import 'execa';
10
- import 'ffmpeg-static';
1
+ import { oraPromise } from "ora";
2
+ import pc from "picocolors";
3
+ import { execa } from "execa";
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 };
@@ -8,8 +8,8 @@ import { oraPromise } from 'ora';
8
8
  import { execa } from 'execa';
9
9
  import ffmpegPath from 'ffmpeg-static';
10
10
 
11
- function getDefaultExportFromCjs (x) {
12
- return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
11
+ function getDefaultExportFromCjs(x) {
12
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
13
13
  }
14
14
 
15
15
  var picocolors = {exports: {}};
package/license.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024-2025 Eric Mika
3
+ Copyright (c) 2024-2026 Eric Mika
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vidup",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Synchronize a local directory of video files to remote streaming services.",
5
5
  "keywords": [
6
6
  "bunnycdn-stream",
@@ -23,6 +23,7 @@
23
23
  "email": "eric@ericmika.com",
24
24
  "url": "https://ericmika.com"
25
25
  },
26
+ "sideEffects": false,
26
27
  "type": "module",
27
28
  "exports": {
28
29
  ".": {
@@ -45,29 +46,40 @@
45
46
  "execa": "^9.6.1",
46
47
  "ffmpeg-static": "^5.3.0",
47
48
  "hasha": "^7.0.0",
48
- "ora": "^9.0.0",
49
- "yargs": "^17.7.2"
50
- },
51
- "devDependencies": {
52
- "@kitschpatrol/shared-config": "^5.10.0",
53
- "@types/node": "^20.19.25",
54
- "bumpp": "^10.3.2",
55
49
  "lowdb": "^7.0.1",
56
- "mdat-plugin-cli-help": "^1.0.4",
50
+ "ora": "^9.4.0",
57
51
  "picocolors": "^1.1.1",
58
- "pkgroll": "^2.21.4",
59
52
  "pretty-ms": "^9.3.0",
53
+ "untildify": "^6.0.0",
54
+ "yargs": "^18.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@arethetypeswrong/core": "^0.18.2",
58
+ "@kitschpatrol/shared-config": "^7.6.0",
59
+ "@types/node": "^20.19.41",
60
+ "bumpp": "^11.1.0",
61
+ "mdat-plugin-cli-help": "^3.0.0",
62
+ "publint": "^0.3.21",
63
+ "shx": "^0.4.0",
64
+ "tsdown": "^0.22.0",
60
65
  "typescript": "^5.9.3",
61
- "untildify": "^6.0.0"
66
+ "vitest": "^4.1.6"
62
67
  },
63
68
  "engines": {
64
69
  "node": ">=20.19.0"
65
70
  },
71
+ "devEngines": {
72
+ "runtime": {
73
+ "name": "node",
74
+ "version": ">=22.18.0"
75
+ }
76
+ },
66
77
  "scripts": {
67
- "build": "pkgroll --clean-dist --tsconfig tsconfig.build.json",
68
- "clean": "git rm -f pnpm-lock.yaml ; git clean -fdX",
78
+ "build": "tsdown",
79
+ "clean": "shx rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
69
80
  "fix": "ksc fix",
70
81
  "lint": "ksc lint",
71
- "release": "bumpp --commit 'Release: %s' && pnpm run build && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') && pnpm publish"
82
+ "release": "bumpp --commit 'Release: %s' && pnpm build && NPM_AUTH_TOKEN=$(op read 'op://Personal/npm/token') pnpm publish",
83
+ "test": "vitest run"
72
84
  }
73
85
  }
package/readme.md CHANGED
@@ -1,5 +1,3 @@
1
- <!--+ Warning: Content inside HTML comment blocks was generated by mdat and may be overwritten. +-->
2
-
3
1
  <!-- title -->
4
2
 
5
3
  # vidup
@@ -9,7 +7,8 @@
9
7
  <!-- badges -->
10
8
 
11
9
  [![NPM Package vidup](https://img.shields.io/npm/v/vidup.svg)](https://npmjs.com/package/vidup)
12
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/license/mit/)
11
+ [![CI](https://github.com/kitschpatrol/vidup/actions/workflows/ci.yml/badge.svg)](https://github.com/kitschpatrol/vidup/actions/workflows/ci.yml)
13
12
 
14
13
  <!-- /badges -->
15
14
 
@@ -46,7 +45,7 @@ Invoke directly:
46
45
  npx vidup
47
46
  ```
48
47
 
49
- Or, install locally to access the CLI commands in a single project or to import the provided APIs:
48
+ Or, install locally to access the CLI commands in a single project or to import the provided TypeScript APIs:
50
49
 
51
50
  ```sh
52
51
  npm install vidup
@@ -217,7 +216,7 @@ Video streaming providers have slow and clunky web UIs. I'd rather not maintain
217
216
 
218
217
  ## Maintainers
219
218
 
220
- [@kitschpatrol](https://github.com/kitschpatrol)
219
+ [kitschpatrol](https://github.com/kitschpatrol)
221
220
 
222
221
  ## Acknowledgments
223
222
 
@@ -227,10 +226,14 @@ Thanks to [@dan-online](https://github.com/dan-online) for his super-helpful [bu
227
226
 
228
227
  ## Contributing
229
228
 
230
- [Issues](https://github.com/kitschpatrol/vidup/issues) and pull requests are welcome.
229
+ [Issues](https://github.com/kitschpatrol/vidup/issues) are welcome and appreciated.
230
+
231
+ Please open an issue to discuss changes before submitting a pull request. Unsolicited PRs (especially AI-generated ones) are unlikely to be merged.
232
+
233
+ This repository uses [@kitschpatrol/shared-config](https://github.com/kitschpatrol/shared-config) (via its `ksc` CLI) for linting and formatting, plus [MDAT](https://github.com/kitschpatrol/mdat) for readme placeholder expansion.
231
234
 
232
235
  ## License
233
236
 
234
- [MIT](license.txt) © Eric Mika
237
+ [MIT](license.txt) © [Eric Mika](https://ericmika.com)
235
238
 
236
239
  <!-- /footer -->