@viralspin/mcp 0.4.0 → 0.5.2
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/LICENSE +21 -21
- package/README.md +79 -79
- package/dist/cli.js +11 -1
- package/dist/client.js +4 -4
- package/dist/index.js +7 -0
- package/dist/local-render/cwd.js +29 -0
- package/dist/local-render/media-fix.js +71 -0
- package/dist/local-render/render.js +25 -3
- package/dist/local-render/tokens.js +4 -0
- package/dist/local-render/watch.js +49 -13
- package/dist/organize-watch.js +699 -0
- package/dist/organizer/edit.js +148 -0
- package/dist/organizer/images.js +231 -0
- package/dist/organizer/plan.js +204 -0
- package/dist/organizer/scan.js +331 -0
- package/dist/organizer/topics.js +204 -0
- package/dist/tools/organize.js +171 -0
- package/package.json +11 -2
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.embFile = exports.THUMB_PORT = void 0;
|
|
7
|
+
exports.warmFolderPicker = warmFolderPicker;
|
|
8
|
+
exports.pickFolder = pickFolder;
|
|
9
|
+
exports.clipFile = clipFile;
|
|
10
|
+
exports.startThumbServer = startThumbServer;
|
|
11
|
+
exports.resultOf = resultOf;
|
|
12
|
+
exports.organizeTick = organizeTick;
|
|
13
|
+
exports.organizeLoop = organizeLoop;
|
|
14
|
+
exports.organizeDelay = organizeDelay;
|
|
15
|
+
/**
|
|
16
|
+
* THE CONNECTOR'S ORGANIZER: while `viralspin-mcp watch` runs, a "Sort a folder" asked from the web app (My Story ▸ Organized) is done HERE, on the creator's own computer.
|
|
17
|
+
* It scans the folder (read-only, nothing moved), reports progress and the bucket names + counts, and serves the small thumbnails to the web page over loopback
|
|
18
|
+
* (127.0.0.1, only to ViralSpin's own origins), so the animation can show the creator's real pictures without any of them being uploaded. Adding the folder to My Story
|
|
19
|
+
* (the opt-in upload of thumbnails + tags) happens only when the web app asks for it.
|
|
20
|
+
*/
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const http_1 = __importDefault(require("http"));
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
const organize_js_1 = require("./tools/organize.js");
|
|
27
|
+
const plan_js_1 = require("./organizer/plan.js");
|
|
28
|
+
const scan_js_1 = require("./organizer/scan.js");
|
|
29
|
+
const edit_js_1 = require("./organizer/edit.js");
|
|
30
|
+
const topics_js_1 = require("./organizer/topics.js");
|
|
31
|
+
const images_js_1 = require("./organizer/images.js");
|
|
32
|
+
exports.THUMB_PORT = 47831;
|
|
33
|
+
const ORIGIN_OK = /^(https:\/\/([\w-]+\.)?viralspin\.ai|http:\/\/localhost:\d+|http:\/\/127\.0\.0\.1:\d+)$/;
|
|
34
|
+
/**
|
|
35
|
+
* Windows: ONE PowerShell kept running with the forms library already loaded, so "Choose a folder…" opens at once instead of after a cold start; it
|
|
36
|
+
* reads "pick" lines and answers each with the chosen path ("" = cancelled). The window is Windows' own folder picker (the Explorer dialog with
|
|
37
|
+
* folders only and a "Sort this folder" button, via IFileOpenDialog + FOS_PICKFOLDERS), forced to the front: a background process can not normally take the foreground, which left the
|
|
38
|
+
* old dialog hidden behind the browser and looking like it took forever.
|
|
39
|
+
*/
|
|
40
|
+
const WIN_PICKER = String.raw `
|
|
41
|
+
[Console]::OutputEncoding=[Text.Encoding]::UTF8
|
|
42
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
43
|
+
Add-Type -TypeDefinition @'
|
|
44
|
+
using System;
|
|
45
|
+
using System.Runtime.InteropServices;
|
|
46
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class VsFileOpenDialogRCW {}
|
|
47
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
48
|
+
interface IVsShellItem {
|
|
49
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
50
|
+
void GetParent(out IVsShellItem p);
|
|
51
|
+
void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
52
|
+
void GetAttributes(uint m, out uint a);
|
|
53
|
+
void Compare(IVsShellItem i, uint h, out int o);
|
|
54
|
+
}
|
|
55
|
+
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
56
|
+
interface IVsFileOpenDialog {
|
|
57
|
+
[PreserveSig] int Show(IntPtr parent);
|
|
58
|
+
void SetFileTypes(uint c, IntPtr f); void SetFileTypeIndex(uint i); void GetFileTypeIndex(out uint i);
|
|
59
|
+
void Advise(IntPtr e, out uint c); void Unadvise(uint c);
|
|
60
|
+
void SetOptions(uint fos); void GetOptions(out uint fos);
|
|
61
|
+
void SetDefaultFolder(IVsShellItem i); void SetFolder(IVsShellItem i); void GetFolder(out IVsShellItem i); void GetCurrentSelection(out IVsShellItem i);
|
|
62
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
|
|
63
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
64
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
65
|
+
void GetResult(out IVsShellItem i);
|
|
66
|
+
void AddPlace(IVsShellItem i, int fdap); void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
|
|
67
|
+
void Close(int hr); void SetClientGuid(ref Guid g); void ClearClientData(); void SetFilter(IntPtr f);
|
|
68
|
+
void GetResults(out IntPtr e); void GetSelectedItems(out IntPtr e);
|
|
69
|
+
}
|
|
70
|
+
public static class VsPick {
|
|
71
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
72
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, int f, int e);
|
|
73
|
+
public static string Pick(IntPtr owner, string title) {
|
|
74
|
+
IVsFileOpenDialog d = (IVsFileOpenDialog)new VsFileOpenDialogRCW();
|
|
75
|
+
uint o; d.GetOptions(out o);
|
|
76
|
+
d.SetOptions(o | 0x20 | 0x40);
|
|
77
|
+
d.SetTitle(title); d.SetOkButtonLabel("Sort this folder");
|
|
78
|
+
if (d.Show(owner) != 0) return "";
|
|
79
|
+
IVsShellItem r; d.GetResult(out r);
|
|
80
|
+
string p; r.GetDisplayName(0x80058000, out p);
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
'@
|
|
85
|
+
[Console]::Out.WriteLine("ready"); [Console]::Out.Flush()
|
|
86
|
+
while ($true) {
|
|
87
|
+
$line = [Console]::In.ReadLine(); if ($line -eq $null) { break }
|
|
88
|
+
$o = New-Object System.Windows.Forms.Form
|
|
89
|
+
$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.StartPosition = 'Manual'
|
|
90
|
+
$o.Location = New-Object System.Drawing.Point(-3000, -3000); $o.Size = New-Object System.Drawing.Size(1, 1)
|
|
91
|
+
$o.Show(); [VsPick]::keybd_event(0x12, 0, 0, 0); [VsPick]::keybd_event(0x12, 0, 2, 0); [void][VsPick]::SetForegroundWindow($o.Handle); $o.Activate()
|
|
92
|
+
$r = ''; try { $r = [VsPick]::Pick($o.Handle, 'Choose a folder of pictures and clips to sort') } catch { $r = '' }
|
|
93
|
+
$o.Close(); $o.Dispose(); [Console]::Out.WriteLine('PICKED:' + $r); [Console]::Out.Flush()
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
let winPicker = null;
|
|
97
|
+
function winPickerProc() {
|
|
98
|
+
if (winPicker && winPicker.child.exitCode === null)
|
|
99
|
+
return winPicker;
|
|
100
|
+
const file = path_1.default.join(os_1.default.tmpdir(), "viralspin-folder-picker.ps1");
|
|
101
|
+
fs_1.default.writeFileSync(file, WIN_PICKER);
|
|
102
|
+
const child = (0, child_process_1.spawn)("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", file], { windowsHide: true });
|
|
103
|
+
const me = { child, waiting: [] };
|
|
104
|
+
let buf = "";
|
|
105
|
+
child.stdout.on("data", (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) {
|
|
106
|
+
const l = buf.slice(0, i).trim();
|
|
107
|
+
buf = buf.slice(i + 1);
|
|
108
|
+
if (l.startsWith("PICKED:"))
|
|
109
|
+
me.waiting.shift()?.(l.slice(7));
|
|
110
|
+
} });
|
|
111
|
+
child.on("exit", () => { me.waiting.splice(0).forEach((f) => f("")); if (winPicker === me)
|
|
112
|
+
winPicker = null; });
|
|
113
|
+
child.on("error", () => { me.waiting.splice(0).forEach((f) => f("")); });
|
|
114
|
+
winPicker = me;
|
|
115
|
+
return me;
|
|
116
|
+
}
|
|
117
|
+
/** Start the Windows picker ahead of time, so the first click is fast too. */
|
|
118
|
+
function warmFolderPicker() { if (process.platform === "win32") {
|
|
119
|
+
try {
|
|
120
|
+
winPickerProc();
|
|
121
|
+
}
|
|
122
|
+
catch { /* the first click starts it */ }
|
|
123
|
+
} }
|
|
124
|
+
/** The computer's own "choose a folder" window. Resolves to the path, or "" when cancelled. A web page cannot learn a folder's real path, so the connector asks the OS. */
|
|
125
|
+
function pickFolder() {
|
|
126
|
+
const done = (resolve) => (err, out) => resolve(err ? "" : String(out).trim());
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
if (process.platform === "win32") {
|
|
129
|
+
const p = winPickerProc();
|
|
130
|
+
p.waiting.push(resolve);
|
|
131
|
+
p.child.stdin?.write("pick\n");
|
|
132
|
+
}
|
|
133
|
+
else if (process.platform === "darwin") {
|
|
134
|
+
(0, child_process_1.execFile)("osascript", ["-e", 'POSIX path of (choose folder with prompt "Choose a folder of pictures and clips to sort")'], { timeout: 300000 }, done(resolve));
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
(0, child_process_1.execFile)("zenity", ["--file-selection", "--directory", "--title=Choose a folder to sort"], { timeout: 300000 }, done(resolve));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** The sort running on this computer right now: its previews in the order they were made (the page builds its live pile from these). */
|
|
141
|
+
const live = { job: "", thumbs: [], at: new Map(), cards: [] };
|
|
142
|
+
const clipJobs = new Map();
|
|
143
|
+
/**
|
|
144
|
+
* A plan, parsed once and kept while its file is unchanged. A big sort's plan is tens of MB; the replay asks for a thousand thumbnails at once,
|
|
145
|
+
* and parsing the plan for each one held every thumbnail up for minutes (the page drew colour tiles where the pictures should be).
|
|
146
|
+
*/
|
|
147
|
+
const planCache = new Map();
|
|
148
|
+
function readPlan(planId) {
|
|
149
|
+
const f = (0, organize_js_1.planFile)(planId);
|
|
150
|
+
const st = fs_1.default.statSync(f);
|
|
151
|
+
const hit = planCache.get(planId);
|
|
152
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
153
|
+
return hit.plan;
|
|
154
|
+
const plan = JSON.parse(fs_1.default.readFileSync(f, "utf8"));
|
|
155
|
+
planCache.set(planId, { mtimeMs: st.mtimeMs, size: st.size, plan });
|
|
156
|
+
if (planCache.size > 4)
|
|
157
|
+
planCache.delete(planCache.keys().next().value);
|
|
158
|
+
return plan;
|
|
159
|
+
}
|
|
160
|
+
/** The playable clip for a plan item (cached): the Live Photo's paired clip, or the first 10 s of a video, as H.264 + AAC, at most 1080 px tall. */
|
|
161
|
+
function clipFile(planId, i) {
|
|
162
|
+
const key = `${planId}:${i}`;
|
|
163
|
+
if (!clipJobs.has(key))
|
|
164
|
+
clipJobs.set(key, (async () => {
|
|
165
|
+
const plan = readPlan(planId);
|
|
166
|
+
const it = plan.items?.[i];
|
|
167
|
+
const src = it?.live ? path_1.default.join(plan.root, it.live) : it?.kind === "video" ? path_1.default.join(plan.root, it.rel) : null;
|
|
168
|
+
if (!src || !fs_1.default.existsSync(src))
|
|
169
|
+
return null;
|
|
170
|
+
const dir = path_1.default.join(organize_js_1.STATE_DIR, `${planId}-clips`);
|
|
171
|
+
const out = path_1.default.join(dir, `${i}.mp4`);
|
|
172
|
+
if (fs_1.default.existsSync(out) && fs_1.default.statSync(out).size > 0)
|
|
173
|
+
return out;
|
|
174
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
175
|
+
const ok = await new Promise((done) => {
|
|
176
|
+
(0, child_process_1.execFile)((0, images_js_1.ffmpegPath)(), ["-v", "error", "-y", "-i", src, "-t", "10", "-vf", "scale=-2:'min(1080,ih)'", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", out], { timeout: 120000 }, (e) => done(!e && fs_1.default.existsSync(out)));
|
|
177
|
+
});
|
|
178
|
+
if (!ok) {
|
|
179
|
+
clipJobs.delete(key);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
})());
|
|
184
|
+
return clipJobs.get(key);
|
|
185
|
+
}
|
|
186
|
+
/** Loopback server for thumbnails: GET /thumb/<planId>/<index>.jpg. Only the app's own origins get a CORS grant; plan ids are random. */
|
|
187
|
+
function startThumbServer(log) {
|
|
188
|
+
const server = http_1.default.createServer((req, res) => {
|
|
189
|
+
const origin = String(req.headers.origin || "");
|
|
190
|
+
// Vary on EVERY answer, granted or not: a plain <img> (no Origin) cached without it was handed by the browser to the 3D space's CORS
|
|
191
|
+
// request for the same preview, which then failed, and every card in it stayed a colour block
|
|
192
|
+
res.setHeader("Vary", "Origin");
|
|
193
|
+
if (ORIGIN_OK.test(origin)) {
|
|
194
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
195
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
196
|
+
}
|
|
197
|
+
if (req.method === "OPTIONS") {
|
|
198
|
+
res.statusCode = 204;
|
|
199
|
+
return res.end();
|
|
200
|
+
}
|
|
201
|
+
const route = (req.url || "").split("?")[0];
|
|
202
|
+
// the running sort's previews: /live/<job> -> { count }, /live/<job>/<n>.jpg -> the n-th preview made
|
|
203
|
+
// the cards so far, in the order they were placed: /live/<job>/cards/<from> -> { rows: [[preview n, card], ...] }
|
|
204
|
+
const lc = /^\/live\/([\w-]{4,60})\/cards\/(\d{1,6})$/.exec(route);
|
|
205
|
+
if (lc) {
|
|
206
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
207
|
+
res.statusCode = 403;
|
|
208
|
+
return res.end();
|
|
209
|
+
}
|
|
210
|
+
res.setHeader("Content-Type", "application/json");
|
|
211
|
+
return res.end(JSON.stringify({ rows: lc[1] === live.job ? live.cards.slice(Number(lc[2]), Number(lc[2]) + 500) : [] }));
|
|
212
|
+
}
|
|
213
|
+
const lm = /^\/live\/([\w-]{4,60})(?:\/(\d{1,6})\.jpg)?$/.exec(route);
|
|
214
|
+
if (lm) {
|
|
215
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
216
|
+
res.statusCode = 403;
|
|
217
|
+
return res.end();
|
|
218
|
+
}
|
|
219
|
+
if (lm[1] !== live.job) {
|
|
220
|
+
res.setHeader("Content-Type", "application/json");
|
|
221
|
+
return res.end(JSON.stringify({ count: 0 }));
|
|
222
|
+
}
|
|
223
|
+
if (lm[2] === undefined) {
|
|
224
|
+
res.setHeader("Content-Type", "application/json");
|
|
225
|
+
return res.end(JSON.stringify({ count: live.thumbs.length, cards: live.cards.length }));
|
|
226
|
+
}
|
|
227
|
+
const f = live.thumbs[Number(lm[2])];
|
|
228
|
+
if (!f || !fs_1.default.existsSync(f)) {
|
|
229
|
+
res.statusCode = 404;
|
|
230
|
+
return res.end();
|
|
231
|
+
}
|
|
232
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
233
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
234
|
+
return void fs_1.default.createReadStream(f).pipe(res);
|
|
235
|
+
}
|
|
236
|
+
if (route === "/ping") {
|
|
237
|
+
res.setHeader("Content-Type", "application/json");
|
|
238
|
+
return res.end(JSON.stringify({ ok: true, items: 1 }));
|
|
239
|
+
}
|
|
240
|
+
// a sorted folder's pictures, for browsing and using them on this computer without uploading anything: one row per picture with a preview
|
|
241
|
+
// open the sorted folder (/reveal/<plan>) or one file of it, selected (/reveal/<plan>/<i>), in Explorer or Finder. Sorting moves nothing,
|
|
242
|
+
// so a sort's folders are not on disk: this shows each file where it really is. Only paths in the plan, and only for a ViralSpin page (POST
|
|
243
|
+
// with an allowed Origin), so no other site can open windows on this computer
|
|
244
|
+
const rv = /^\/reveal\/([\w-]{4,40})(?:\/(\d{1,6}))?$/.exec(route);
|
|
245
|
+
if (rv) {
|
|
246
|
+
if (req.method !== "POST" || !ORIGIN_OK.test(origin)) {
|
|
247
|
+
res.statusCode = 403;
|
|
248
|
+
return res.end();
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const plan = readPlan(rv[1]);
|
|
252
|
+
const it = rv[2] !== undefined ? plan.items?.[Number(rv[2])] : null;
|
|
253
|
+
const file = it ? path_1.default.join(plan.root, it.rel) : null;
|
|
254
|
+
const sel = file && fs_1.default.existsSync(file) ? file : null, dir = sel ? null : file && fs_1.default.existsSync(path_1.default.dirname(file)) ? path_1.default.dirname(file) : plan.root;
|
|
255
|
+
if (!sel && !fs_1.default.existsSync(dir)) {
|
|
256
|
+
res.statusCode = 404;
|
|
257
|
+
return res.end();
|
|
258
|
+
}
|
|
259
|
+
// explorer wants /select,"path" exactly: Node's own quoting of the whole argument breaks it on a path with spaces
|
|
260
|
+
const [cmd, args] = process.platform === "win32" ? ["explorer.exe", sel ? [`/select,"${sel}"`] : [`"${dir}"`]]
|
|
261
|
+
: process.platform === "darwin" ? ["open", sel ? ["-R", sel] : [dir]] : ["xdg-open", [sel ? path_1.default.dirname(sel) : dir]];
|
|
262
|
+
(0, child_process_1.spawn)(cmd, args, { detached: true, stdio: "ignore", windowsVerbatimArguments: process.platform === "win32" }).on("error", () => undefined).unref();
|
|
263
|
+
res.setHeader("Content-Type", "application/json");
|
|
264
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
res.statusCode = 404;
|
|
268
|
+
return res.end();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
// a file served with Range, so a <video> starts at once and seeks without reading the whole file
|
|
272
|
+
const ranged = (file) => {
|
|
273
|
+
const size = fs_1.default.statSync(file).size;
|
|
274
|
+
const m = /bytes=(\d*)-(\d*)/.exec(String(req.headers.range || ""));
|
|
275
|
+
res.setHeader("Content-Type", "video/mp4");
|
|
276
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
277
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
278
|
+
if (m && size > 0) {
|
|
279
|
+
const start = m[1] ? Math.min(Number(m[1]), size - 1) : Math.max(0, size - Number(m[2] || 0)), end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
|
280
|
+
res.statusCode = 206;
|
|
281
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
|
|
282
|
+
res.setHeader("Content-Length", String(end - start + 1));
|
|
283
|
+
fs_1.default.createReadStream(file, { start, end }).pipe(res);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
res.setHeader("Content-Length", String(size));
|
|
287
|
+
fs_1.default.createReadStream(file).pipe(res);
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
// a video (or a Live Photo's clip) as it is on disk, for playing it in full at once: nothing converted. A browser that can't decode it (HEVC
|
|
291
|
+
// without hardware support) says so, and the page falls back to /clip
|
|
292
|
+
const vm = /^\/video\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
293
|
+
if (vm) {
|
|
294
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
295
|
+
res.statusCode = 403;
|
|
296
|
+
return res.end();
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const plan = readPlan(vm[1]);
|
|
300
|
+
const it = plan.items?.[Number(vm[2])];
|
|
301
|
+
const src = it?.live ? path_1.default.join(plan.root, it.live) : it?.kind === "video" ? path_1.default.join(plan.root, it.rel) : null;
|
|
302
|
+
if (!src || !fs_1.default.existsSync(src)) {
|
|
303
|
+
res.statusCode = 404;
|
|
304
|
+
return res.end();
|
|
305
|
+
}
|
|
306
|
+
return ranged(src);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
res.statusCode = 404;
|
|
310
|
+
return res.end();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// a Live Photo's clip, or the first seconds of a video, converted once to H.264 (iPhone clips are HEVC, which most browsers on Windows
|
|
314
|
+
// can't play) and kept next to the previews; served with Range so the reel picker can show and seek it
|
|
315
|
+
const cm = /^\/clip\/([\w-]{4,40})\/(\d{1,6})\.mp4$/.exec(route);
|
|
316
|
+
if (cm) {
|
|
317
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
318
|
+
res.statusCode = 403;
|
|
319
|
+
return res.end();
|
|
320
|
+
}
|
|
321
|
+
void clipFile(cm[1], Number(cm[2])).then((file) => {
|
|
322
|
+
if (!file) {
|
|
323
|
+
res.statusCode = 404;
|
|
324
|
+
return res.end();
|
|
325
|
+
}
|
|
326
|
+
ranged(file);
|
|
327
|
+
}).catch(() => { res.statusCode = 500; res.end(); });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// search a sort by meaning ("tennis skirt"): the words against every picture's kept embedding, best first; nothing is read again
|
|
331
|
+
const sm = /^\/plan\/([\w-]{4,40})\/search$/.exec(route);
|
|
332
|
+
if (sm) {
|
|
333
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
334
|
+
res.statusCode = 403;
|
|
335
|
+
return res.end();
|
|
336
|
+
}
|
|
337
|
+
const q = (new URL(req.url || "", "http://x").searchParams.get("q") || "").trim().slice(0, 80);
|
|
338
|
+
res.setHeader("Content-Type", "application/json");
|
|
339
|
+
void searchPlan(sm[1], q).then((r) => res.end(JSON.stringify(r))).catch((e) => res.end(JSON.stringify({ q, items: [], error: e.message })));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const pm = /^\/plan\/([\w-]{4,40})\/items$/.exec(route);
|
|
343
|
+
if (pm) {
|
|
344
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
345
|
+
res.statusCode = 403;
|
|
346
|
+
return res.end();
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const plan = readPlan(pm[1]);
|
|
350
|
+
const where = new Map();
|
|
351
|
+
for (const b of plan.buckets)
|
|
352
|
+
if (b.kind !== "duplicates")
|
|
353
|
+
for (const i of b.items)
|
|
354
|
+
where.set(i, b);
|
|
355
|
+
const rows = [...where.entries()].filter(([i]) => plan.items[i]?.thumb).map(([i, b]) => {
|
|
356
|
+
const it = plan.items[i];
|
|
357
|
+
return { i, bucket: b.name, media: b.media ?? null, key: (0, organize_js_1.bucketKey)(b), tags: it.tags ?? [], month: it.month ?? null, live: !!it.live, kind: it.kind, file: path_1.default.basename(it.rel), dir: (path_1.default.dirname(it.rel) === "." ? "" : path_1.default.dirname(it.rel)).split(path_1.default.sep).join("/"), clip: !!it.live || it.kind === "video" };
|
|
358
|
+
});
|
|
359
|
+
res.setHeader("Content-Type", "application/json");
|
|
360
|
+
return res.end(JSON.stringify({ plan: plan.id, root: path_1.default.basename(plan.root), items: rows }));
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
res.statusCode = 404;
|
|
364
|
+
return res.end();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (route === "/pick-folder") { // only ViralSpin's own pages may ask for the window (a page with no allowed Origin gets nothing)
|
|
368
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
369
|
+
res.statusCode = 403;
|
|
370
|
+
return res.end();
|
|
371
|
+
}
|
|
372
|
+
void pickFolder().then((p) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ path: p })); });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// the picture itself at screen size (up to PREVIEW_W px wide), made once from the original and kept next to the previews; for a clip, a frame
|
|
376
|
+
// about a second in (what the viewer shows before it plays). 404 for a file that is gone, and the page falls back to the small preview
|
|
377
|
+
const pv = /^\/photo\/([\w-]{4,40})\/(\d{1,6})\.jpg$/.exec(route);
|
|
378
|
+
if (pv) {
|
|
379
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
380
|
+
res.statusCode = 403;
|
|
381
|
+
return res.end();
|
|
382
|
+
}
|
|
383
|
+
void (async () => {
|
|
384
|
+
try {
|
|
385
|
+
const plan = readPlan(pv[1]);
|
|
386
|
+
const it = plan.items?.[Number(pv[2])];
|
|
387
|
+
const src = it && (it.kind === "image" || it.kind === "video") ? path_1.default.join(plan.root, it.rel) : null;
|
|
388
|
+
if (!src || !fs_1.default.existsSync(src)) {
|
|
389
|
+
res.statusCode = 404;
|
|
390
|
+
return res.end();
|
|
391
|
+
}
|
|
392
|
+
const dir = path_1.default.join(organize_js_1.STATE_DIR, `${pv[1]}-photos2400`), out = path_1.default.join(dir, `${pv[2]}.jpg`); // a new folder: the old one holds 1600 px copies
|
|
393
|
+
if (!(fs_1.default.existsSync(out) && fs_1.default.statSync(out).size > 0)) {
|
|
394
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
395
|
+
if (!(await (0, images_js_1.makePreview)(src, out, it.kind))) {
|
|
396
|
+
res.statusCode = 404;
|
|
397
|
+
return res.end();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
401
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
402
|
+
fs_1.default.createReadStream(out).pipe(res);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
res.statusCode = 404;
|
|
406
|
+
res.end();
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// one file's details for the viewer: name, folder, size, dates, pixel size, a clip's length (GET /meta/<plan>/<i>)
|
|
412
|
+
const mm = /^\/meta\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
413
|
+
if (mm) {
|
|
414
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
415
|
+
res.statusCode = 403;
|
|
416
|
+
return res.end();
|
|
417
|
+
}
|
|
418
|
+
void (async () => {
|
|
419
|
+
try {
|
|
420
|
+
const plan = readPlan(mm[1]);
|
|
421
|
+
const it = plan.items?.[Number(mm[2])];
|
|
422
|
+
const src = it ? path_1.default.join(plan.root, it.rel) : null;
|
|
423
|
+
if (!it || !src || !fs_1.default.existsSync(src)) {
|
|
424
|
+
res.statusCode = 404;
|
|
425
|
+
return res.end();
|
|
426
|
+
}
|
|
427
|
+
const st = fs_1.default.statSync(src), px = await (0, images_js_1.fileMeta)(src, it.kind);
|
|
428
|
+
res.setHeader("Content-Type", "application/json");
|
|
429
|
+
res.end(JSON.stringify({ file: path_1.default.basename(it.rel), dir: path_1.default.dirname(it.rel) === "." ? "" : path_1.default.dirname(it.rel).split(path_1.default.sep).join("/"), root: plan.root, kind: it.kind, live: !!it.live, size: st.size, modified: st.mtime.toISOString(), month: it.month ?? null, tags: it.tags ?? [], ...px }));
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
res.statusCode = 404;
|
|
433
|
+
res.end();
|
|
434
|
+
}
|
|
435
|
+
})();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// the original file itself, for zooming in to its real pixels: only formats every browser can show (a HEIC or RAW gets 404 and the viewer
|
|
439
|
+
// stays on /photo); served from this computer to this computer, nothing is uploaded
|
|
440
|
+
const om = /^\/original\/([\w-]{4,40})\/(\d{1,6})$/.exec(route);
|
|
441
|
+
if (om) {
|
|
442
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
443
|
+
res.statusCode = 403;
|
|
444
|
+
return res.end();
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const plan = readPlan(om[1]);
|
|
448
|
+
const it = plan.items?.[Number(om[2])];
|
|
449
|
+
const src = it && it.kind === "image" ? path_1.default.join(plan.root, it.rel) : null;
|
|
450
|
+
const type = src ? { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" }[path_1.default.extname(src).toLowerCase()] : undefined;
|
|
451
|
+
if (!src || !type || !fs_1.default.existsSync(src)) {
|
|
452
|
+
res.statusCode = 404;
|
|
453
|
+
return res.end();
|
|
454
|
+
}
|
|
455
|
+
res.setHeader("Content-Type", type);
|
|
456
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
457
|
+
return void fs_1.default.createReadStream(src).pipe(res);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
res.statusCode = 404;
|
|
461
|
+
return res.end();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const m = /^\/thumb\/([\w-]{4,40})\/(\d{1,6})\.jpg$/.exec((req.url || "").split("?")[0]);
|
|
465
|
+
if (!m) {
|
|
466
|
+
res.statusCode = 404;
|
|
467
|
+
return res.end();
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const plan = readPlan(m[1]);
|
|
471
|
+
const t = plan.items?.[Number(m[2])]?.thumb;
|
|
472
|
+
if (!t || !fs_1.default.existsSync(t)) {
|
|
473
|
+
res.statusCode = 404;
|
|
474
|
+
return res.end();
|
|
475
|
+
}
|
|
476
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
477
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
478
|
+
fs_1.default.createReadStream(t).pipe(res);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
res.statusCode = 404;
|
|
482
|
+
res.end();
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
return new Promise((resolve, reject) => {
|
|
486
|
+
let port = exports.THUMB_PORT;
|
|
487
|
+
const tryListen = () => server.listen(port, "127.0.0.1");
|
|
488
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < exports.THUMB_PORT + 5) {
|
|
489
|
+
port++;
|
|
490
|
+
tryListen();
|
|
491
|
+
}
|
|
492
|
+
else
|
|
493
|
+
reject(e); });
|
|
494
|
+
server.on("listening", () => { warmFolderPicker(); log(`[organize] thumbnails are served to ViralSpin on 127.0.0.1:${port} (this computer only)`); resolve(port); });
|
|
495
|
+
tryListen();
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** The pictures of a sort that best match some words, best first: the closest and anything nearly as close (up to 200). */
|
|
499
|
+
async function searchPlan(planId, q) {
|
|
500
|
+
if (!q)
|
|
501
|
+
return { q, items: [] };
|
|
502
|
+
const plan = readPlan(planId);
|
|
503
|
+
const ef = (0, exports.embFile)(planId);
|
|
504
|
+
if (!fs_1.default.existsSync(ef))
|
|
505
|
+
return { q, items: [], error: "This sort has no picture fingerprints yet. Make one change to it first (add a label), then search." };
|
|
506
|
+
// a word the vocabulary knows keeps its descriptive prompt ("tennis" -> "a person playing tennis or a tennis court")
|
|
507
|
+
const p = (0, topics_js_1.labelSet)([q]).prompts[0] ?? q;
|
|
508
|
+
const T = await (0, topics_js_1.textEmbeddings)({ prompts: [p], names: [q], groups: [q], custom: new Set([q]) });
|
|
509
|
+
if (!T)
|
|
510
|
+
return { q, items: [], error: "The picture model is not available on this computer." };
|
|
511
|
+
const t = T[0], d = t.length;
|
|
512
|
+
const raw = fs_1.default.readFileSync(ef);
|
|
513
|
+
const E = new Float32Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 4));
|
|
514
|
+
const where = new Map();
|
|
515
|
+
for (const b of plan.buckets)
|
|
516
|
+
if (b.kind !== "duplicates")
|
|
517
|
+
for (const i of b.items)
|
|
518
|
+
where.set(i, b);
|
|
519
|
+
const scored = [];
|
|
520
|
+
plan.items.forEach((it, i) => {
|
|
521
|
+
if (!it.thumb || it.dupOf !== undefined || (i + 1) * d > E.length || !where.has(i))
|
|
522
|
+
return;
|
|
523
|
+
let s = 0;
|
|
524
|
+
for (let k = 0; k < d; k++)
|
|
525
|
+
s += E[i * d + k] * t[k];
|
|
526
|
+
if (s)
|
|
527
|
+
scored.push({ i, s });
|
|
528
|
+
});
|
|
529
|
+
scored.sort((a, b) => b.s - a.s);
|
|
530
|
+
const top = scored[0]?.s ?? 0;
|
|
531
|
+
const keep = scored.filter((x, k) => k < 12 || (x.s >= top - 0.035 && x.s >= 0.22)).slice(0, 200);
|
|
532
|
+
return { q, items: keep.map(({ i, s }) => { const it = plan.items[i], b = where.get(i); return { i, score: Math.round(s * 1000) / 1000, bucket: b.name, media: b.media ?? null, month: it.month ?? null, file: path_1.default.basename(it.rel), clip: !!it.live || it.kind === "video" }; }) };
|
|
533
|
+
}
|
|
534
|
+
/** What the web page gets back for a plan: totals, the move preview and each bucket with a sample of previews. */
|
|
535
|
+
function resultOf(plan, port) {
|
|
536
|
+
return {
|
|
537
|
+
totals: { ...plan.totals, live: plan.items.filter((it) => it.live).length }, by: plan.by, port, thumbs: true, root: path_1.default.basename(plan.root), labels: (0, edit_js_1.labelsAfter)(plan.labels ?? [], plan.ops ?? []),
|
|
538
|
+
preview: { moves: plan.moves.length, into: path_1.default.join(plan.root, "_Organized"), sample: plan.moves.slice(0, 8).map((m) => ({ from: path_1.default.relative(plan.root, m.from), to: path_1.default.relative(plan.root, m.to) })) },
|
|
539
|
+
buckets: plan.buckets.map((b) => ({ name: b.name, kind: b.kind, media: b.media ?? null, count: b.items.length, sample: b.items.filter((i) => plan.items[i].thumb).slice(0, 40) })),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const embFile = (id) => (0, organize_js_1.planFile)(id).replace(/\.json$/, ".emb");
|
|
543
|
+
exports.embFile = embFile;
|
|
544
|
+
async function runJob(d, base, job, port) {
|
|
545
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${job.id}`, b);
|
|
546
|
+
fs_1.default.mkdirSync(organize_js_1.STATE_DIR, { recursive: true });
|
|
547
|
+
const tmp = Date.now().toString(36);
|
|
548
|
+
let last = 0;
|
|
549
|
+
// ONE PROGRESS WRITE AT A TIME, at most every 2 s. They were fired every 0.9 s without waiting, so when the database slowed they piled up
|
|
550
|
+
// into a stream of 504s on the same row and made it slower still (2026-10-31).
|
|
551
|
+
let inFlight = false;
|
|
552
|
+
try {
|
|
553
|
+
if (!(await (0, images_js_1.canReadPictures)()))
|
|
554
|
+
throw new Error("This computer can't read pictures yet (no image library or ffmpeg). Reinstall the ViralSpin connector.");
|
|
555
|
+
live.job = job.id;
|
|
556
|
+
live.thumbs = [];
|
|
557
|
+
live.at = new Map();
|
|
558
|
+
live.cards = [];
|
|
559
|
+
const plan = await (0, scan_js_1.organizeScan)(job.folder, {
|
|
560
|
+
onThumb: (f) => { live.at.set(Number(path_1.default.basename(f, ".jpg")), live.thumbs.length); live.thumbs.push(f); },
|
|
561
|
+
onLabel: (i, card) => { const n = live.at.get(i); if (n !== undefined)
|
|
562
|
+
live.cards.push([n, card]); }, by: job.by, vision: job.vision, labels: job.labels ?? [], embFile: path_1.default.join(organize_js_1.STATE_DIR, `${tmp}.emb`), thumbs: path_1.default.join(organize_js_1.STATE_DIR, `${tmp}-thumbs`), onProgress: (p) => {
|
|
563
|
+
if (p.phase !== "done" && (inFlight || Date.now() - last < 2000))
|
|
564
|
+
return;
|
|
565
|
+
last = Date.now();
|
|
566
|
+
inFlight = true;
|
|
567
|
+
void patch({ phase: p.phase, done: p.done ?? 0, total: p.total ?? 0, eta_ms: p.etaMs ?? 0, ...(p.phase === "found" ? { files_found: p.total ?? 0 } : {}) }).catch(() => { }).finally(() => { inFlight = false; });
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
fs_1.default.writeFileSync((0, organize_js_1.planFile)(plan.id), JSON.stringify(plan));
|
|
571
|
+
try {
|
|
572
|
+
fs_1.default.renameSync(path_1.default.join(organize_js_1.STATE_DIR, `${tmp}.emb`), (0, exports.embFile)(plan.id));
|
|
573
|
+
}
|
|
574
|
+
catch { /* sorted by folder or month: no embeddings */ }
|
|
575
|
+
const result = resultOf(plan, port);
|
|
576
|
+
await patch({ status: "done", plan_id: plan.id, result });
|
|
577
|
+
d.log(`[organize] sorted ${plan.totals.files} file(s) from ${job.folder} into ${plan.buckets.length} bucket(s)`);
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
await patch({ status: "failed", error: e.message }).catch(() => { });
|
|
581
|
+
d.log(`[organize] ${job.folder} failed: ${e.message}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/** One pass: take a pending job, or do the My Story upload a done job asked for. Returns whether it did anything. */
|
|
585
|
+
async function organizeTick(d, base, port) {
|
|
586
|
+
const { jobs } = await d.req("GET", `${base}/v1/organize/jobs`);
|
|
587
|
+
const pending = jobs.find((j) => j.status === "pending");
|
|
588
|
+
if (pending) {
|
|
589
|
+
try {
|
|
590
|
+
await d.req("PATCH", `${base}/v1/organize/jobs/${pending.id}`, { claim: true });
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
return false;
|
|
594
|
+
} // another computer took it
|
|
595
|
+
d.log(`[organize] sorting ${pending.folder}`);
|
|
596
|
+
await runJob(d, base, pending, port);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
const edit = jobs.find((j) => j.status === "done" && j.plan_id && Array.isArray(j.pending_ops) && j.pending_ops.length);
|
|
600
|
+
if (edit) {
|
|
601
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${edit.id}`, b);
|
|
602
|
+
try {
|
|
603
|
+
let last = 0;
|
|
604
|
+
const { plan, note } = await (0, edit_js_1.applyOps)((0, organize_js_1.planFile)(edit.plan_id), (0, exports.embFile)(edit.plan_id), edit.pending_ops, (done, total, etaMs) => {
|
|
605
|
+
if (Date.now() - last < 3000 && done < total)
|
|
606
|
+
return;
|
|
607
|
+
last = Date.now();
|
|
608
|
+
void patch({ phase: "relabel", done, total, eta_ms: etaMs }).catch(() => { });
|
|
609
|
+
});
|
|
610
|
+
await patch({ result: resultOf(plan, port), pending_ops: [], ops_note: note, plan_id: plan.id });
|
|
611
|
+
d.log(`[organize] ${note}`);
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
await patch({ pending_ops: [], ops_note: e.message }).catch(() => { });
|
|
615
|
+
d.log(`[organize] change failed: ${e.message}`);
|
|
616
|
+
}
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
const story = jobs.find((j) => j.status === "done" && j.want_story && !j.story_status && j.plan_id);
|
|
620
|
+
if (story) {
|
|
621
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${story.id}`, b);
|
|
622
|
+
await patch({ story_status: "running" });
|
|
623
|
+
try {
|
|
624
|
+
let last = 0;
|
|
625
|
+
await (0, organize_js_1.addPlanToStory)((0, organize_js_1.planFile)(story.plan_id), { post: (ep, b) => d.req("POST", base + ep, b) }, (done, total) => {
|
|
626
|
+
if (Date.now() - last < 2000 && done < total)
|
|
627
|
+
return;
|
|
628
|
+
last = Date.now();
|
|
629
|
+
void patch({ phase: "story", done, total }).catch(() => { });
|
|
630
|
+
}, story.story_buckets);
|
|
631
|
+
await patch({ story_status: "done" });
|
|
632
|
+
d.log(`[organize] added ${story.folder} to My Story`);
|
|
633
|
+
}
|
|
634
|
+
catch (e) {
|
|
635
|
+
await patch({ story_status: "failed", error: e.message }).catch(() => { });
|
|
636
|
+
d.log(`[organize] My Story upload failed: ${e.message}`);
|
|
637
|
+
}
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
const move = jobs.find((j) => j.status === "done" && j.plan_id && ((j.want_apply && !j.apply_status) || (j.want_undo && j.apply_status === "applied")));
|
|
641
|
+
if (move) {
|
|
642
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${move.id}`, b);
|
|
643
|
+
const undo = move.want_undo && move.apply_status === "applied";
|
|
644
|
+
await patch({ apply_status: "running" });
|
|
645
|
+
try {
|
|
646
|
+
const plan = JSON.parse(fs_1.default.readFileSync((0, organize_js_1.planFile)(move.plan_id), "utf8"));
|
|
647
|
+
if (undo) {
|
|
648
|
+
const r = (0, plan_js_1.undoMoves)(path_1.default.join(plan.root, "_Organized", `.viralspin-undo-${plan.id}.json`));
|
|
649
|
+
await patch({ apply_status: "undone", apply_result: { restored: r.restored, left: r.left } });
|
|
650
|
+
d.log(`[organize] put back ${r.restored} file(s) in ${move.folder}`);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
const r = (0, plan_js_1.applyMoves)(plan.root, plan.moves, plan.id);
|
|
654
|
+
await patch({ apply_status: "applied", apply_result: { moved: r.moved, skipped: r.skipped } });
|
|
655
|
+
d.log(`[organize] moved ${r.moved} file(s) in ${move.folder} (undo is one click in the app)`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
await patch({ apply_status: "failed", error: e.message }).catch(() => { });
|
|
660
|
+
d.log(`[organize] move failed: ${e.message}`);
|
|
661
|
+
}
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
/** Runs beside the render watch loop until aborted. Asks every 4 s (the web app is waiting on it), a little slower after errors. */
|
|
667
|
+
async function organizeLoop(d, base, signal) {
|
|
668
|
+
let port = 0;
|
|
669
|
+
try {
|
|
670
|
+
port = await startThumbServer(d.log);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
d.log(`[organize] no thumbnail server (${e.message}); sorting still works, the web page will show placeholders`);
|
|
674
|
+
}
|
|
675
|
+
let errors = 0;
|
|
676
|
+
let idle = 0;
|
|
677
|
+
while (!signal.aborted) {
|
|
678
|
+
let did = false;
|
|
679
|
+
try {
|
|
680
|
+
did = await organizeTick(d, base, port);
|
|
681
|
+
errors = 0;
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
if (errors++ === 0)
|
|
685
|
+
d.log(`[organize] ${e.message}`);
|
|
686
|
+
}
|
|
687
|
+
idle = did ? 0 : idle + 1;
|
|
688
|
+
await new Promise((r) => setTimeout(r, organizeDelay(did, idle, errors)));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/** 4 s while sorting is going on, easing to 12 s after about a minute with nothing asked (a new sort is still picked up within 12 s); much longer
|
|
692
|
+
* while ViralSpin is unreachable. Every 4 s around the clock was ~21,600 requests a day per computer, each one a database read. */
|
|
693
|
+
function organizeDelay(did, idle, errors) {
|
|
694
|
+
if (did)
|
|
695
|
+
return 500;
|
|
696
|
+
if (errors)
|
|
697
|
+
return Math.min(60000, 4000 * 2 ** Math.min(errors, 4));
|
|
698
|
+
return Math.min(12000, 4000 + Math.max(0, idle - 15) * 1000);
|
|
699
|
+
}
|