vidup 1.0.18 → 1.0.19
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-D5PZ18VU.js +251 -0
- package/package.json +19 -24
- package/readme.md +26 -8
- package/dist/sync-ChGtXZ_b.js +0 -543
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vidup",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"description": "Synchronize a local directory of video files to remote streaming services.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bunnycdn-stream",
|
|
@@ -43,43 +43,38 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@types/yargs": "^17.0.35",
|
|
45
45
|
"bunnycdn-stream": "^2.5.5",
|
|
46
|
-
"execa": "^
|
|
46
|
+
"execa": "^10.0.1",
|
|
47
47
|
"ffmpeg-static": "^5.3.0",
|
|
48
48
|
"hasha": "^7.0.0",
|
|
49
|
+
"lognow": "^0.8.4",
|
|
49
50
|
"lowdb": "^7.0.1",
|
|
50
|
-
"ora": "^9.4.
|
|
51
|
-
"
|
|
52
|
-
"pretty-ms": "^9.3.0",
|
|
51
|
+
"ora": "^9.4.1",
|
|
52
|
+
"pretty-ms": "^9.3.1",
|
|
53
53
|
"untildify": "^6.0.0",
|
|
54
|
-
"yargs": "^18.
|
|
54
|
+
"yargs": "^18.2.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@arethetypeswrong/core": "^0.18.
|
|
58
|
-
"@kitschpatrol/shared-config": "^
|
|
59
|
-
"@types/node": "
|
|
60
|
-
"
|
|
61
|
-
"
|
|
62
|
-
"
|
|
57
|
+
"@arethetypeswrong/core": "^0.18.5",
|
|
58
|
+
"@kitschpatrol/shared-config": "^8.9.0",
|
|
59
|
+
"@types/node": "~24.13.4",
|
|
60
|
+
"brewpub": "^0.3.0",
|
|
61
|
+
"bumpp": "^12.3.0",
|
|
62
|
+
"mdat-plugin-cli-help": "^3.2.0",
|
|
63
|
+
"publint": "^0.3.24",
|
|
63
64
|
"shx": "^0.4.0",
|
|
64
|
-
"tsdown": "^0.
|
|
65
|
-
"typescript": "^
|
|
66
|
-
"vitest": "^
|
|
65
|
+
"tsdown": "^0.23.0",
|
|
66
|
+
"typescript": "^6.0.3",
|
|
67
|
+
"vitest": "^5.0.1"
|
|
67
68
|
},
|
|
68
69
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
70
|
-
},
|
|
71
|
-
"devEngines": {
|
|
72
|
-
"runtime": {
|
|
73
|
-
"name": "node",
|
|
74
|
-
"version": ">=22.18.0"
|
|
75
|
-
}
|
|
70
|
+
"node": ">=24.16.0"
|
|
76
71
|
},
|
|
77
72
|
"scripts": {
|
|
78
73
|
"build": "tsdown",
|
|
79
|
-
"clean": "shx rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
|
|
74
|
+
"clean-deep": "shx rm -f pnpm-lock.yaml && git clean -fdX -e !.claude/",
|
|
80
75
|
"fix": "ksc fix",
|
|
81
76
|
"lint": "ksc lint",
|
|
82
|
-
"release": "bumpp --commit 'Release: %s' && pnpm build &&
|
|
77
|
+
"release": "bumpp --commit 'Release: %s' && pnpm build && pnpm publish --otp $(op item get npm --otp) && brewpub --tap kitschpatrol/tap",
|
|
83
78
|
"test": "vitest run"
|
|
84
79
|
}
|
|
85
80
|
}
|
package/readme.md
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
<!-- badges -->
|
|
8
8
|
|
|
9
|
-
[](https://npmjs.com/package/vidup)
|
|
10
|
-
[](https://opensource.org/license/mit
|
|
9
|
+
[](https://www.npmjs.com/package/vidup)
|
|
10
|
+
[](https://opensource.org/license/mit)
|
|
11
11
|
[](https://github.com/kitschpatrol/vidup/actions/workflows/ci.yml)
|
|
12
12
|
|
|
13
13
|
<!-- /badges -->
|
|
@@ -51,7 +51,13 @@ Or, install locally to access the CLI commands in a single project or to import
|
|
|
51
51
|
npm install vidup
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Or, install globally for access across your system:
|
|
54
|
+
Or, install globally for access across your system with either Homebrew or npm:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
brew install kitschpatrol/tap/vidup
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
_or_
|
|
55
61
|
|
|
56
62
|
```sh
|
|
57
63
|
npm install --global vidup
|
|
@@ -116,7 +122,7 @@ vidup sync <directory> [options]
|
|
|
116
122
|
| `--strip-metadata` | Remove all metadata from the video files before uploading them to the streaming service. Warning: This will modify local videos in-place. | `boolean` | `false` |
|
|
117
123
|
| `--dry-run`<br>`-d` | Perform a dry run without making any changes. Useful for testing and debugging. Pairs well with the `--json` command. | `boolean` | `false` |
|
|
118
124
|
| `--json` | Output the sync report as JSON. | `boolean` | `false` |
|
|
119
|
-
| `--verbose` | Enable verbose logging. All verbose logs
|
|
125
|
+
| `--verbose` | Enable verbose logging. All verbose logs are prefixed with their log level and are printed to `stderr` for ease of redirection. | `boolean` | `false` |
|
|
120
126
|
| `--help`<br>`-h` | Show help | `boolean` | |
|
|
121
127
|
| `--version`<br>`-v` | Show version number | `boolean` | |
|
|
122
128
|
|
|
@@ -165,7 +171,6 @@ async function syncVideoInDirectory(
|
|
|
165
171
|
}
|
|
166
172
|
dryRun?: boolean // Defaults to false
|
|
167
173
|
service: Service
|
|
168
|
-
verbose?: boolean // Defaults to false
|
|
169
174
|
},
|
|
170
175
|
): Promise<SyncReport>
|
|
171
176
|
```
|
|
@@ -173,11 +178,10 @@ async function syncVideoInDirectory(
|
|
|
173
178
|
A `stripVideoMetadataInDirectory` function is also exported, which will remove metadata from all videos in the target directory. It returns a list of video filepaths with metadata:
|
|
174
179
|
|
|
175
180
|
```ts
|
|
176
|
-
|
|
181
|
+
async function stripVideoMetadataInDirectory(
|
|
177
182
|
directory: string,
|
|
178
183
|
options: {
|
|
179
184
|
dryRun?: boolean // Defaults to false
|
|
180
|
-
verbose?: boolean // Defaults to false
|
|
181
185
|
},
|
|
182
186
|
): Promise<string[]>
|
|
183
187
|
```
|
|
@@ -198,6 +202,21 @@ const syncReport = await syncVideoInDirectory(process.cwd(), {
|
|
|
198
202
|
console.log(JSON.stringify(syncReport, undefined, 2))
|
|
199
203
|
```
|
|
200
204
|
|
|
205
|
+
#### Logging
|
|
206
|
+
|
|
207
|
+
Vidup logs progress at the `debug` level and problems at the `warn` and `error` levels via [lognow](https://github.com/kitschpatrol/lognow). By default, only warnings and errors are shown. To see everything, or to route the library's logs into your own logging system, inject a logger:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { setLogger } from 'vidup'
|
|
211
|
+
|
|
212
|
+
// Send all library logs to the console
|
|
213
|
+
setLogger(console)
|
|
214
|
+
|
|
215
|
+
// Or pass any LogLayer instance
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Call `setLogger()` with no argument to silence the library entirely.
|
|
219
|
+
|
|
201
220
|
## Background
|
|
202
221
|
|
|
203
222
|
### Motivation
|
|
@@ -206,7 +225,6 @@ Video streaming providers have slow and clunky web UIs. I'd rather not maintain
|
|
|
206
225
|
|
|
207
226
|
## The future
|
|
208
227
|
|
|
209
|
-
- [ ] Tests
|
|
210
228
|
- [ ] Add [Cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)
|
|
211
229
|
- [ ] Synchronize thumbnails and other metadata
|
|
212
230
|
- [ ] Remote collection / folder support
|
package/dist/sync-ChGtXZ_b.js
DELETED
|
@@ -1,543 +0,0 @@
|
|
|
1
|
-
import { BunnyCdnStream } from 'bunnycdn-stream';
|
|
2
|
-
import { hash } from 'hasha';
|
|
3
|
-
import { createReadStream } from 'node:fs';
|
|
4
|
-
import fs, { writeFile, rename, readFile } from 'node:fs/promises';
|
|
5
|
-
import path, { join, dirname, basename } from 'node:path';
|
|
6
|
-
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import { oraPromise } from 'ora';
|
|
8
|
-
import { execa } from 'execa';
|
|
9
|
-
import ffmpegPath from 'ffmpeg-static';
|
|
10
|
-
|
|
11
|
-
function getDefaultExportFromCjs(x) {
|
|
12
|
-
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
var picocolors = {exports: {}};
|
|
16
|
-
|
|
17
|
-
var hasRequiredPicocolors;
|
|
18
|
-
|
|
19
|
-
function requirePicocolors () {
|
|
20
|
-
if (hasRequiredPicocolors) return picocolors.exports;
|
|
21
|
-
hasRequiredPicocolors = 1;
|
|
22
|
-
let p = process || {}, argv = p.argv || [], env = p.env || {};
|
|
23
|
-
let isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
24
|
-
let formatter = (open, close, replace = open) => (input) => {
|
|
25
|
-
let string = "" + input, index = string.indexOf(close, open.length);
|
|
26
|
-
return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
|
|
27
|
-
};
|
|
28
|
-
let replaceClose = (string, close, replace, index) => {
|
|
29
|
-
let result = "", cursor = 0;
|
|
30
|
-
do {
|
|
31
|
-
result += string.substring(cursor, index) + replace;
|
|
32
|
-
cursor = index + close.length;
|
|
33
|
-
index = string.indexOf(close, cursor);
|
|
34
|
-
} while (~index);
|
|
35
|
-
return result + string.substring(cursor);
|
|
36
|
-
};
|
|
37
|
-
let createColors = (enabled = isColorSupported) => {
|
|
38
|
-
let f = enabled ? formatter : () => String;
|
|
39
|
-
return {
|
|
40
|
-
isColorSupported: enabled,
|
|
41
|
-
reset: f("\x1B[0m", "\x1B[0m"),
|
|
42
|
-
bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
43
|
-
dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
44
|
-
italic: f("\x1B[3m", "\x1B[23m"),
|
|
45
|
-
underline: f("\x1B[4m", "\x1B[24m"),
|
|
46
|
-
inverse: f("\x1B[7m", "\x1B[27m"),
|
|
47
|
-
hidden: f("\x1B[8m", "\x1B[28m"),
|
|
48
|
-
strikethrough: f("\x1B[9m", "\x1B[29m"),
|
|
49
|
-
black: f("\x1B[30m", "\x1B[39m"),
|
|
50
|
-
red: f("\x1B[31m", "\x1B[39m"),
|
|
51
|
-
green: f("\x1B[32m", "\x1B[39m"),
|
|
52
|
-
yellow: f("\x1B[33m", "\x1B[39m"),
|
|
53
|
-
blue: f("\x1B[34m", "\x1B[39m"),
|
|
54
|
-
magenta: f("\x1B[35m", "\x1B[39m"),
|
|
55
|
-
cyan: f("\x1B[36m", "\x1B[39m"),
|
|
56
|
-
white: f("\x1B[37m", "\x1B[39m"),
|
|
57
|
-
gray: f("\x1B[90m", "\x1B[39m"),
|
|
58
|
-
bgBlack: f("\x1B[40m", "\x1B[49m"),
|
|
59
|
-
bgRed: f("\x1B[41m", "\x1B[49m"),
|
|
60
|
-
bgGreen: f("\x1B[42m", "\x1B[49m"),
|
|
61
|
-
bgYellow: f("\x1B[43m", "\x1B[49m"),
|
|
62
|
-
bgBlue: f("\x1B[44m", "\x1B[49m"),
|
|
63
|
-
bgMagenta: f("\x1B[45m", "\x1B[49m"),
|
|
64
|
-
bgCyan: f("\x1B[46m", "\x1B[49m"),
|
|
65
|
-
bgWhite: f("\x1B[47m", "\x1B[49m"),
|
|
66
|
-
blackBright: f("\x1B[90m", "\x1B[39m"),
|
|
67
|
-
redBright: f("\x1B[91m", "\x1B[39m"),
|
|
68
|
-
greenBright: f("\x1B[92m", "\x1B[39m"),
|
|
69
|
-
yellowBright: f("\x1B[93m", "\x1B[39m"),
|
|
70
|
-
blueBright: f("\x1B[94m", "\x1B[39m"),
|
|
71
|
-
magentaBright: f("\x1B[95m", "\x1B[39m"),
|
|
72
|
-
cyanBright: f("\x1B[96m", "\x1B[39m"),
|
|
73
|
-
whiteBright: f("\x1B[97m", "\x1B[39m"),
|
|
74
|
-
bgBlackBright: f("\x1B[100m", "\x1B[49m"),
|
|
75
|
-
bgRedBright: f("\x1B[101m", "\x1B[49m"),
|
|
76
|
-
bgGreenBright: f("\x1B[102m", "\x1B[49m"),
|
|
77
|
-
bgYellowBright: f("\x1B[103m", "\x1B[49m"),
|
|
78
|
-
bgBlueBright: f("\x1B[104m", "\x1B[49m"),
|
|
79
|
-
bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
|
|
80
|
-
bgCyanBright: f("\x1B[106m", "\x1B[49m"),
|
|
81
|
-
bgWhiteBright: f("\x1B[107m", "\x1B[49m")
|
|
82
|
-
};
|
|
83
|
-
};
|
|
84
|
-
picocolors.exports = createColors();
|
|
85
|
-
picocolors.exports.createColors = createColors;
|
|
86
|
-
return picocolors.exports;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
var picocolorsExports = /*@__PURE__*/ requirePicocolors();
|
|
90
|
-
var pc = /*@__PURE__*/getDefaultExportFromCjs(picocolorsExports);
|
|
91
|
-
|
|
92
|
-
const isNode = process.versions.node !== void 0;
|
|
93
|
-
const log = {
|
|
94
|
-
verbose: false,
|
|
95
|
-
// Intended for temporary logging
|
|
96
|
-
log(...data) {
|
|
97
|
-
if (!this.verbose) return;
|
|
98
|
-
const levelPrefix = pc.gray("[Log]");
|
|
99
|
-
if (isNode) {
|
|
100
|
-
console.warn(levelPrefix, ...data);
|
|
101
|
-
} else {
|
|
102
|
-
console.log(levelPrefix, ...data);
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
logPrefixed(prefix, ...data) {
|
|
106
|
-
this.info(pc.blue(`[${prefix}]`), ...data);
|
|
107
|
-
},
|
|
108
|
-
info(...data) {
|
|
109
|
-
if (!this.verbose) return;
|
|
110
|
-
const levelPrefix = pc.green("[Info]");
|
|
111
|
-
if (isNode) {
|
|
112
|
-
console.warn(levelPrefix, ...data);
|
|
113
|
-
} else {
|
|
114
|
-
console.info(levelPrefix, ...data);
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
infoPrefixed(prefix, ...data) {
|
|
118
|
-
this.info(pc.blue(`[${prefix}]`), ...data);
|
|
119
|
-
},
|
|
120
|
-
async infoSpin(promise, message) {
|
|
121
|
-
return oraPromise(promise, {
|
|
122
|
-
prefixText: pc.green("[Info]"),
|
|
123
|
-
stream: process.stderr,
|
|
124
|
-
text: message
|
|
125
|
-
});
|
|
126
|
-
},
|
|
127
|
-
warn(...data) {
|
|
128
|
-
console.warn(pc.yellow("[Warning]"), ...data);
|
|
129
|
-
},
|
|
130
|
-
warnPrefixed(prefix, ...data) {
|
|
131
|
-
this.warn(pc.blue(`[${prefix}]`), ...data);
|
|
132
|
-
},
|
|
133
|
-
error(...data) {
|
|
134
|
-
console.error(pc.red("[Error]"), ...data);
|
|
135
|
-
},
|
|
136
|
-
errorPrefixed(prefix, ...data) {
|
|
137
|
-
this.error(pc.blue(`[${prefix}]`), ...data);
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
async function getVideosInDirectory(directory) {
|
|
142
|
-
const files = await fs.readdir(directory);
|
|
143
|
-
const videoFiles = files.filter((file) => {
|
|
144
|
-
const fileExtension = path.extname(file);
|
|
145
|
-
const videoExtensions = [".mp4", ".mov", ".avi", ".mkv"];
|
|
146
|
-
return videoExtensions.includes(fileExtension);
|
|
147
|
-
}).map((file) => path.join(directory, file));
|
|
148
|
-
return videoFiles;
|
|
149
|
-
}
|
|
150
|
-
async function videoHasMetadata(filename) {
|
|
151
|
-
if (ffmpegPath === null) {
|
|
152
|
-
throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
153
|
-
}
|
|
154
|
-
try {
|
|
155
|
-
const { stderr, stdout } = await execa(ffmpegPath, ["-i", filename, "-f", "ffmetadata", "-"]);
|
|
156
|
-
return stdout.includes("creation_time") || stderr.includes("creation_time");
|
|
157
|
-
} catch (error) {
|
|
158
|
-
throw new Error(`Failed to check metadata for: ${filename}:
|
|
159
|
-
${String(error)}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
async function stripVideoMetadata(filename) {
|
|
163
|
-
if (ffmpegPath === null) {
|
|
164
|
-
throw new Error("ffmpeg-static failed to locate ffmpeg binary");
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
const extension = path.extname(filename).slice(1);
|
|
168
|
-
const temporaryFilename = `${filename.slice(0, Math.max(0, filename.lastIndexOf(".")))}.temp.${extension}`;
|
|
169
|
-
await execa(ffmpegPath, [
|
|
170
|
-
"-i",
|
|
171
|
-
filename,
|
|
172
|
-
"-map_metadata",
|
|
173
|
-
"-1",
|
|
174
|
-
"-c:v",
|
|
175
|
-
"copy",
|
|
176
|
-
"-c:a",
|
|
177
|
-
"copy",
|
|
178
|
-
temporaryFilename
|
|
179
|
-
]);
|
|
180
|
-
await fs.rename(temporaryFilename, filename);
|
|
181
|
-
} catch (error) {
|
|
182
|
-
throw new Error(`Failed to strip metadata from: ${filename}:
|
|
183
|
-
${String(error)}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
async function stripVideoMetadataInDirectory(directory, options = {}) {
|
|
188
|
-
const { dryRun = false, verbose = false } = options;
|
|
189
|
-
const files = await getVideosInDirectory(directory);
|
|
190
|
-
return stripVideoMetadataInFiles(files, {
|
|
191
|
-
dryRun,
|
|
192
|
-
verbose
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
async function stripVideoMetadataInFiles(files, options = {}) {
|
|
196
|
-
const { dryRun = false, verbose = false } = options;
|
|
197
|
-
const initialVerbosity = log.verbose;
|
|
198
|
-
log.verbose = verbose;
|
|
199
|
-
const localVideosWithMetadata = [];
|
|
200
|
-
for (const videoFile of files) {
|
|
201
|
-
if (await videoHasMetadata(videoFile)) {
|
|
202
|
-
localVideosWithMetadata.push(videoFile);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (!dryRun) {
|
|
206
|
-
log.info(`Found ${localVideosWithMetadata.length} videos with metadata to strip`);
|
|
207
|
-
for (const videoFile of localVideosWithMetadata) {
|
|
208
|
-
console.log(`Stripping metadata from: ${videoFile}`);
|
|
209
|
-
await stripVideoMetadata(videoFile);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
log.verbose = initialVerbosity;
|
|
213
|
-
return localVideosWithMetadata;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function getTempFilename(file) {
|
|
217
|
-
const f = file instanceof URL ? fileURLToPath(file) : file.toString();
|
|
218
|
-
return join(dirname(f), `.${basename(f)}.tmp`);
|
|
219
|
-
}
|
|
220
|
-
async function retryAsyncOperation(fn, maxRetries, delayMs) {
|
|
221
|
-
for (let i = 0; i < maxRetries; i++) {
|
|
222
|
-
try {
|
|
223
|
-
return await fn();
|
|
224
|
-
} catch (error) {
|
|
225
|
-
if (i < maxRetries - 1) {
|
|
226
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
227
|
-
} else {
|
|
228
|
-
throw error;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
class Writer {
|
|
234
|
-
#filename;
|
|
235
|
-
#tempFilename;
|
|
236
|
-
#locked = false;
|
|
237
|
-
#prev = null;
|
|
238
|
-
#next = null;
|
|
239
|
-
#nextPromise = null;
|
|
240
|
-
#nextData = null;
|
|
241
|
-
// File is locked, add data for later
|
|
242
|
-
#add(data) {
|
|
243
|
-
this.#nextData = data;
|
|
244
|
-
this.#nextPromise ||= new Promise((resolve, reject) => {
|
|
245
|
-
this.#next = [resolve, reject];
|
|
246
|
-
});
|
|
247
|
-
return new Promise((resolve, reject) => {
|
|
248
|
-
this.#nextPromise?.then(resolve).catch(reject);
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
// File isn't locked, write data
|
|
252
|
-
async #write(data) {
|
|
253
|
-
this.#locked = true;
|
|
254
|
-
try {
|
|
255
|
-
await writeFile(this.#tempFilename, data, "utf-8");
|
|
256
|
-
await retryAsyncOperation(async () => {
|
|
257
|
-
await rename(this.#tempFilename, this.#filename);
|
|
258
|
-
}, 10, 100);
|
|
259
|
-
this.#prev?.[0]();
|
|
260
|
-
} catch (err) {
|
|
261
|
-
if (err instanceof Error) {
|
|
262
|
-
this.#prev?.[1](err);
|
|
263
|
-
}
|
|
264
|
-
throw err;
|
|
265
|
-
} finally {
|
|
266
|
-
this.#locked = false;
|
|
267
|
-
this.#prev = this.#next;
|
|
268
|
-
this.#next = this.#nextPromise = null;
|
|
269
|
-
if (this.#nextData !== null) {
|
|
270
|
-
const nextData = this.#nextData;
|
|
271
|
-
this.#nextData = null;
|
|
272
|
-
await this.write(nextData);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
constructor(filename) {
|
|
277
|
-
this.#filename = filename;
|
|
278
|
-
this.#tempFilename = getTempFilename(filename);
|
|
279
|
-
}
|
|
280
|
-
async write(data) {
|
|
281
|
-
return this.#locked ? this.#add(data) : this.#write(data);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
class TextFile {
|
|
286
|
-
#filename;
|
|
287
|
-
#writer;
|
|
288
|
-
constructor(filename) {
|
|
289
|
-
this.#filename = filename;
|
|
290
|
-
this.#writer = new Writer(filename);
|
|
291
|
-
}
|
|
292
|
-
async read() {
|
|
293
|
-
let data;
|
|
294
|
-
try {
|
|
295
|
-
data = await readFile(this.#filename, "utf-8");
|
|
296
|
-
} catch (e) {
|
|
297
|
-
if (e.code === "ENOENT") {
|
|
298
|
-
return null;
|
|
299
|
-
}
|
|
300
|
-
throw e;
|
|
301
|
-
}
|
|
302
|
-
return data;
|
|
303
|
-
}
|
|
304
|
-
write(str) {
|
|
305
|
-
return this.#writer.write(str);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
class DataFile {
|
|
310
|
-
#adapter;
|
|
311
|
-
#parse;
|
|
312
|
-
#stringify;
|
|
313
|
-
constructor(filename, { parse, stringify }) {
|
|
314
|
-
this.#adapter = new TextFile(filename);
|
|
315
|
-
this.#parse = parse;
|
|
316
|
-
this.#stringify = stringify;
|
|
317
|
-
}
|
|
318
|
-
async read() {
|
|
319
|
-
const data = await this.#adapter.read();
|
|
320
|
-
if (data === null) {
|
|
321
|
-
return null;
|
|
322
|
-
} else {
|
|
323
|
-
return this.#parse(data);
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
write(obj) {
|
|
327
|
-
return this.#adapter.write(this.#stringify(obj));
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
class JSONFile extends DataFile {
|
|
332
|
-
constructor(filename) {
|
|
333
|
-
super(filename, {
|
|
334
|
-
parse: JSON.parse,
|
|
335
|
-
stringify: (data) => JSON.stringify(data, null, 2)
|
|
336
|
-
});
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
class Memory {
|
|
341
|
-
#data = null;
|
|
342
|
-
read() {
|
|
343
|
-
return Promise.resolve(this.#data);
|
|
344
|
-
}
|
|
345
|
-
write(obj) {
|
|
346
|
-
this.#data = obj;
|
|
347
|
-
return Promise.resolve();
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function checkArgs(adapter, defaultData) {
|
|
352
|
-
if (adapter === void 0)
|
|
353
|
-
throw new Error("lowdb: missing adapter");
|
|
354
|
-
if (defaultData === void 0)
|
|
355
|
-
throw new Error("lowdb: missing default data");
|
|
356
|
-
}
|
|
357
|
-
class Low {
|
|
358
|
-
adapter;
|
|
359
|
-
data;
|
|
360
|
-
constructor(adapter, defaultData) {
|
|
361
|
-
checkArgs(adapter, defaultData);
|
|
362
|
-
this.adapter = adapter;
|
|
363
|
-
this.data = defaultData;
|
|
364
|
-
}
|
|
365
|
-
async read() {
|
|
366
|
-
const data = await this.adapter.read();
|
|
367
|
-
if (data)
|
|
368
|
-
this.data = data;
|
|
369
|
-
}
|
|
370
|
-
async write() {
|
|
371
|
-
if (this.data)
|
|
372
|
-
await this.adapter.write(this.data);
|
|
373
|
-
}
|
|
374
|
-
async update(fn) {
|
|
375
|
-
fn(this.data);
|
|
376
|
-
await this.write();
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async function JSONFilePreset(filename, defaultData) {
|
|
381
|
-
const adapter = process.env.NODE_ENV === "test" ? new Memory() : new JSONFile(filename);
|
|
382
|
-
const db = new Low(adapter, defaultData);
|
|
383
|
-
await db.read();
|
|
384
|
-
return db;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function syncVideoInDirectory(directory, options) {
|
|
388
|
-
const { credentials, dryRun = false, service, verbose = false } = options;
|
|
389
|
-
const initialVerbosity = log.verbose;
|
|
390
|
-
log.verbose = verbose;
|
|
391
|
-
const syncReport = [];
|
|
392
|
-
const videoFiles = await getVideosInDirectory(directory);
|
|
393
|
-
log.info(`Found ${videoFiles.length} video files in directory "${directory}"`);
|
|
394
|
-
const state = await JSONFilePreset(path.join(directory, ".vidup-state.json"), {
|
|
395
|
-
syncState: []
|
|
396
|
-
});
|
|
397
|
-
state.data.syncState = state.data.syncState.filter(
|
|
398
|
-
(entry) => videoFiles.includes(path.join(directory, entry.filename))
|
|
399
|
-
);
|
|
400
|
-
for (const videoFile of videoFiles) {
|
|
401
|
-
const filename = path.basename(videoFile);
|
|
402
|
-
const localHash = await hash(videoFile, { algorithm: "sha1" });
|
|
403
|
-
const existingEntry = state.data.syncState.find((entry) => entry.filename === filename);
|
|
404
|
-
if (existingEntry) {
|
|
405
|
-
existingEntry.localHash = localHash;
|
|
406
|
-
} else {
|
|
407
|
-
state.data.syncState.push({ filename, localHash, remoteHash: {} });
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
if (!dryRun) {
|
|
411
|
-
await state.write();
|
|
412
|
-
}
|
|
413
|
-
if (service !== "bunny") {
|
|
414
|
-
throw new Error(`Streaming service not yet implemented: ${service}`);
|
|
415
|
-
}
|
|
416
|
-
const stream = new BunnyCdnStream({
|
|
417
|
-
apiKey: credentials.key,
|
|
418
|
-
videoLibrary: credentials.library
|
|
419
|
-
});
|
|
420
|
-
const remoteVideos = await stream.listAllVideos();
|
|
421
|
-
log.info(`Found ${remoteVideos.length} video files on ${service} remote streaming service`);
|
|
422
|
-
const remoteVideosInGoodStanding = remoteVideos.filter((video) => {
|
|
423
|
-
const entry = state.data.syncState.find((entry2) => entry2.filename === video.title);
|
|
424
|
-
return entry !== void 0 && entry.localHash === entry.remoteHash[service];
|
|
425
|
-
});
|
|
426
|
-
for (const video of remoteVideosInGoodStanding) {
|
|
427
|
-
syncReport.push({
|
|
428
|
-
action: "None",
|
|
429
|
-
localFile: video.title,
|
|
430
|
-
remoteId: video.guid
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
const remoteVideosToCreate = state.data.syncState.filter(
|
|
434
|
-
(entry) => !remoteVideos.some((video) => video.title === entry.filename)
|
|
435
|
-
);
|
|
436
|
-
for (const video of remoteVideosToCreate) {
|
|
437
|
-
syncReport.push({
|
|
438
|
-
action: "Create",
|
|
439
|
-
localFile: video.filename,
|
|
440
|
-
remoteId: "Not yet uploaded (Dry run)"
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
const remoteVideosToUpdate = remoteVideos.filter((video) => {
|
|
444
|
-
const entry = state.data.syncState.find((entry2) => entry2.filename === video.title);
|
|
445
|
-
return entry !== void 0 && entry.localHash !== entry.remoteHash[service];
|
|
446
|
-
});
|
|
447
|
-
for (const video of remoteVideosToUpdate) {
|
|
448
|
-
syncReport.push({
|
|
449
|
-
action: "Update",
|
|
450
|
-
localFile: video.title,
|
|
451
|
-
remoteId: "Not yet uploaded (Dry run)"
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
const remoteVideosToDelete = remoteVideos.filter(
|
|
455
|
-
(video) => !state.data.syncState.some((entry) => entry.filename === video.title)
|
|
456
|
-
);
|
|
457
|
-
for (const video of remoteVideosToDelete) {
|
|
458
|
-
syncReport.push({
|
|
459
|
-
action: "Delete",
|
|
460
|
-
localFile: video.title,
|
|
461
|
-
remoteId: video.guid
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
if (!dryRun) {
|
|
465
|
-
state.data.lastRun = /* @__PURE__ */ new Date();
|
|
466
|
-
await state.write();
|
|
467
|
-
log.info(`Synchronizing...`);
|
|
468
|
-
for (const [index, remoteVideo] of remoteVideosToDelete.entries()) {
|
|
469
|
-
if (index === 0) log.info(`Deleting ${remoteVideosToDelete.length} remote videos...`);
|
|
470
|
-
const deleteResponse = await log.infoSpin(
|
|
471
|
-
stream.deleteVideo(remoteVideo.guid),
|
|
472
|
-
`Deleting remote video ${index + 1}/${remoteVideosToDelete.length}: ${remoteVideo.title}`
|
|
473
|
-
);
|
|
474
|
-
if (!deleteResponse.success) {
|
|
475
|
-
throw new Error(`Failed to delete remote video: ${remoteVideo.title}`);
|
|
476
|
-
}
|
|
477
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
478
|
-
await state.write();
|
|
479
|
-
}
|
|
480
|
-
for (const [index, localVideo] of remoteVideosToCreate.entries()) {
|
|
481
|
-
if (index === 0) log.info(`Uploading ${remoteVideosToCreate.length} new local videos...`);
|
|
482
|
-
const videoFile = createReadStream(path.join(directory, localVideo.filename));
|
|
483
|
-
const response = await log.infoSpin(
|
|
484
|
-
stream.createAndUploadVideo(videoFile, { title: localVideo.filename }),
|
|
485
|
-
`Uploading new video ${index + 1}/${remoteVideosToCreate.length}: ${localVideo.filename}`
|
|
486
|
-
);
|
|
487
|
-
log.info(
|
|
488
|
-
`Remote video ${index + 1}/${remoteVideosToCreate.length} created with GUID: ${response.guid}`
|
|
489
|
-
);
|
|
490
|
-
const stateEntry = state.data.syncState.find(
|
|
491
|
-
(entry) => entry.filename === localVideo.filename
|
|
492
|
-
);
|
|
493
|
-
if (stateEntry === void 0) {
|
|
494
|
-
throw new Error(`Failed to find state entry for: ${localVideo.filename}`);
|
|
495
|
-
}
|
|
496
|
-
stateEntry.remoteHash[service] = localVideo.localHash;
|
|
497
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
498
|
-
await state.write();
|
|
499
|
-
const reportEntry = syncReport.find((entry) => entry.localFile === localVideo.filename);
|
|
500
|
-
if (reportEntry === void 0) {
|
|
501
|
-
throw new Error(`Failed to find report entry for: ${localVideo.filename}`);
|
|
502
|
-
}
|
|
503
|
-
reportEntry.remoteId = response.guid;
|
|
504
|
-
}
|
|
505
|
-
for (const [index, remoteVideo] of remoteVideosToUpdate.entries()) {
|
|
506
|
-
if (index === 0) {
|
|
507
|
-
log.info(`Updating ${remoteVideosToUpdate.length} remote videos...`);
|
|
508
|
-
log.warn(
|
|
509
|
-
"Bunny does not currently support updating videos in-place, so vidup will delete and re-uploading instead... this will change the video's GUID!"
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
const stateEntry = state.data.syncState.find((entry) => entry.filename === remoteVideo.title);
|
|
513
|
-
if (stateEntry === void 0) {
|
|
514
|
-
throw new Error(`Failed to find state entry for: ${remoteVideo.title}`);
|
|
515
|
-
}
|
|
516
|
-
const deleteResponse = await stream.deleteVideo(remoteVideo.guid);
|
|
517
|
-
if (!deleteResponse.success) {
|
|
518
|
-
throw new Error(`Failed to delete remote video before updating: ${remoteVideo.title}`);
|
|
519
|
-
}
|
|
520
|
-
const videoFile = createReadStream(path.join(directory, stateEntry.filename));
|
|
521
|
-
const createResponse = await log.infoSpin(
|
|
522
|
-
stream.createAndUploadVideo(videoFile, { title: stateEntry.filename }),
|
|
523
|
-
`Updating remote video ${index + 1}/${remoteVideosToUpdate.length}: ${remoteVideo.title}`
|
|
524
|
-
);
|
|
525
|
-
log.info(
|
|
526
|
-
`Updated remote video ${index + 1}/${remoteVideosToUpdate.length} created with GUID: ${createResponse.guid}`
|
|
527
|
-
);
|
|
528
|
-
stateEntry.remoteHash[service] = stateEntry.localHash;
|
|
529
|
-
state.data.lastUpdate = /* @__PURE__ */ new Date();
|
|
530
|
-
await state.write();
|
|
531
|
-
const reportEntry = syncReport.find((entry) => entry.localFile === stateEntry.filename);
|
|
532
|
-
if (reportEntry === void 0) {
|
|
533
|
-
throw new Error(`Failed to find report entry for: ${stateEntry.filename}`);
|
|
534
|
-
}
|
|
535
|
-
reportEntry.remoteId = createResponse.guid;
|
|
536
|
-
}
|
|
537
|
-
log.info(`All ${videoFiles.length} videos are now in sync!`);
|
|
538
|
-
}
|
|
539
|
-
log.verbose = initialVerbosity;
|
|
540
|
-
return syncReport;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
export { syncVideoInDirectory as a, log as l, stripVideoMetadataInDirectory as s };
|