@viralspin/mcp 0.5.4 → 0.5.5

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.
@@ -249,7 +249,7 @@ function startThumbServer(log) {
249
249
  return res.end();
250
250
  }
251
251
  if (route === "/receive/stop") {
252
- (0, phoneReceive_1.stopReceive)(log);
252
+ (0, phoneReceive_1.stopReceive)();
253
253
  res.setHeader("Content-Type", "application/json");
254
254
  return res.end(JSON.stringify((0, phoneReceive_1.receiveStatus)()));
255
255
  }
@@ -706,6 +706,10 @@ async function organizeLoop(d, base, signal) {
706
706
  catch (e) {
707
707
  d.log(`[organize] no thumbnail server (${e.message}); sorting still works, the web page will show placeholders`);
708
708
  }
709
+ // "Send to my computer" from the iPhone (phoneReceive.ts): on the Wi-Fi for as long as the connector runs; finished files are recorded on
710
+ // their My Story rows (the preview row the phone's Add to My Story made), so the web knows where each original now is
711
+ void (0, phoneReceive_1.startLanReceiver)({ log: d.log, register: (planId, files) => d.req("POST", `${base}/v1/library/organized/local`, { plan_id: planId, device: os_1.default.hostname(), files }).then(() => undefined) })
712
+ .catch((e) => d.log(`[receive] not listening on the network (${e.message}); "Send to my computer" won't find this computer`));
709
713
  let errors = 0;
710
714
  let idle = 0;
711
715
  while (!signal.aborted) {
@@ -3,25 +3,31 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.RECEIVE_PORT = void 0;
7
- exports.lanAddresses = lanAddresses;
6
+ exports.stopReceive = exports.startReceive = exports.defaultDest = exports.RECEIVE_PORT = void 0;
8
7
  exports.isPrivate = isPrivate;
9
8
  exports.safePart = safePart;
10
- exports.targetPath = targetPath;
11
- exports.defaultDest = defaultDest;
9
+ exports.freePath = freePath;
10
+ exports.lanAddresses = lanAddresses;
11
+ exports.receiveRoot = receiveRoot;
12
12
  exports.receiveStatus = receiveStatus;
13
- exports.stopReceive = stopReceive;
14
- exports.startReceive = startReceive;
13
+ exports.setReceiveRoot = setReceiveRoot;
14
+ exports.startLanReceiver = startLanReceiver;
15
+ exports.stopLanReceiver = stopLanReceiver;
15
16
  /**
16
- * RECEIVE FROM AN IPHONE, over the home network. The iPhone app sends the ORIGINAL photos and videos of the topics the creator ticked, straight
17
- * to this computer, into <folder>/<topic>/<file name>: nothing goes through ViralSpin's servers.
17
+ * SEND TO MY COMPUTER — the PC end. The iPhone app (Album Sorter ▸ results ▸ "Send to my computer", ComputerSender.swift) finds this computer on
18
+ * the Wi-Fi over Bonjour (`_viralspin._tcp`), pairs with the 4-digit code shown here and on the web, and sends the ORIGINALS of the ticked groups
19
+ * into <root>/From iPhone/<Group>/<filename>. Nothing goes through ViralSpin's servers. Protocol (docs/web-parity/MAC-WEB-CHAT.md 2026-11-03
20
+ * 21:30 / 23:05 and 2026-11-04; the Mac's reference is ViralSpinMobile/qa/demo/receive.py):
18
21
  *
19
- * The web page starts a session over loopback (POST /receive/start, ViralSpin origins only, like every other loopback route). Only then does
20
- * this open a second, small server on the LAN (RECEIVE_PORT), which answers only:
21
- * - requests carrying the session's one-time token (shown to the phone in a QR code), and
22
- * - from private-network addresses (10/8, 172.16/12, 192.168/16, link-local, loopback),
23
- * and it closes itself after IDLE_MS without a request, or when the page stops it. A name or topic is reduced to a plain file name, so nothing
24
- * is ever written outside the chosen folder.
22
+ * POST /pair {code, device, plan_id} → {token, name} | 403
23
+ * POST /receive/manifest {plan_id, groups:[{name, items:[{rel, filename, bytes, kind}]}]}
24
+ * PUT /receive/file/{plan}/{rel}?part=photo|video|live&filename=&group= body = the file → {rel, part, bytes, sha256}
25
+ * POST /receive/done/{plan} → {received:[{rel, bytes, sha256, parts}], missing:[rel]}
26
+ *
27
+ * The sha256 is of the file AS WRITTEN (read back from disk), because the phone only offers "Free up space" for files whose hash matches what it
28
+ * read. A file never overwrites another (`name (2).ext`). Only private-network addresses are answered; a token comes only from the code, and the
29
+ * code changes after 5 wrong tries. When a batch is done the connector records each full file on its organized_assets row (`register`), so the
30
+ * row behind the phone's preview in My Story knows where the original now is.
25
31
  */
26
32
  const http_1 = __importDefault(require("http"));
27
33
  const fs_1 = __importDefault(require("fs"));
@@ -29,21 +35,7 @@ const os_1 = __importDefault(require("os"));
29
35
  const path_1 = __importDefault(require("path"));
30
36
  const crypto_1 = __importDefault(require("crypto"));
31
37
  exports.RECEIVE_PORT = 47840;
32
- const IDLE_MS = 30 * 60000;
33
- let session = null;
34
- /** This computer's addresses a phone on the same Wi-Fi can reach: private IPv4s, wired/wireless first, no virtual adapters' link-local. */
35
- function lanAddresses() {
36
- const out = [];
37
- for (const [name, addrs] of Object.entries(os_1.default.networkInterfaces())) {
38
- if (/vEthernet|VirtualBox|VMware|WSL|Hyper-V|docker|utun|tailscale|ZeroTier/i.test(name))
39
- continue;
40
- for (const a of addrs ?? [])
41
- if (a.family === "IPv4" && !a.internal && isPrivate(a.address))
42
- out.push(a.address);
43
- }
44
- return [...new Set(out)];
45
- }
46
- /** Is this a private / local address (the only ones the LAN server answers)? */
38
+ /** Is this a private / local address (the only ones answered)? */
47
39
  function isPrivate(ip) {
48
40
  const v = ip.replace(/^::ffff:/, "");
49
41
  if (v === "127.0.0.1" || v === "::1")
@@ -55,145 +47,242 @@ function isPrivate(ip) {
55
47
  }
56
48
  return /^fe80:/i.test(v) || /^f[cd][0-9a-f]{2}:/i.test(v);
57
49
  }
58
- /** A topic or file name as one safe path part: no separators, no "..", no characters Windows refuses, never empty. */
50
+ /** A group or file name as one safe path part: no separators, no characters Windows refuses, no leading dots, no trailing dot/space, never empty. */
59
51
  function safePart(s, fallback) {
60
- const t = String(s ?? "").normalize("NFC").replace(/[\\/:*?"<>|\x00-\x1f]/g, " ").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 120); // no leading dots, no trailing dot or space (Windows)
52
+ const t = String(s ?? "").normalize("NFC").replace(/[\\/:*?"<>|\x00-\x1f]/g, " ").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 120);
61
53
  return t && t !== "." && t !== ".." ? t : fallback;
62
54
  }
63
- /** Where a file of a topic lands: <dest>/<topic>/<name>, inside dest whatever the request said. */
64
- function targetPath(dest, topic, name) {
65
- const p = path_1.default.join(dest, safePart(topic, "Other"), safePart(name, "photo"));
66
- if (!path_1.default.resolve(p).startsWith(path_1.default.resolve(dest) + path_1.default.sep))
67
- throw new Error("outside the folder");
55
+ /** A path for this file in this folder that does not exist yet: name.ext, then "name (2).ext", "name (3).ext"… */
56
+ function freePath(folder, filename) {
57
+ const ext = path_1.default.extname(filename), base = filename.slice(0, filename.length - ext.length);
58
+ let p = path_1.default.join(folder, filename);
59
+ for (let k = 2; fs_1.default.existsSync(p) || fs_1.default.existsSync(`${p}.part`); k++)
60
+ p = path_1.default.join(folder, `${base} (${k})${ext}`);
68
61
  return p;
69
62
  }
70
- function defaultDest() { return path_1.default.join(os_1.default.homedir(), "Pictures", "From iPhone"); }
71
- function receiveStatus() {
72
- if (!session)
73
- return { active: false, received: 0, skipped: 0, bytes: 0 };
74
- const { dest, port, hosts, token, received, skipped, bytes, last, phone, startedAt } = session;
75
- return { active: true, dest, port, hosts, token, received, skipped, bytes, last, phone, startedAt };
63
+ /** This computer's addresses a phone on the same Wi-Fi can reach (private IPv4s, no virtual adapters). */
64
+ function lanAddresses() {
65
+ const out = [];
66
+ for (const [name, addrs] of Object.entries(os_1.default.networkInterfaces())) {
67
+ if (/vEthernet|VirtualBox|VMware|WSL|Hyper-V|docker|utun|tailscale|ZeroTier/i.test(name))
68
+ continue;
69
+ for (const a of addrs ?? [])
70
+ if (a.family === "IPv4" && !a.internal && isPrivate(a.address))
71
+ out.push(a.address);
72
+ }
73
+ return [...new Set(out)];
76
74
  }
77
- function stopReceive(log) {
78
- if (!session)
79
- return;
80
- clearTimeout(session.idle);
81
- session.server.close();
82
- log?.(`[receive] stopped: ${session.received} file(s) received, ${session.skipped} already there`);
83
- session = null;
75
+ const STATE = path_1.default.join(os_1.default.homedir(), ".viralspin", "organize");
76
+ /** Where received files go: the folder the creator chose, else the newest sort's folder on this computer, else ~/Pictures/ViralSpin. */
77
+ function receiveRoot() {
78
+ if (rootOverride)
79
+ return rootOverride;
80
+ try {
81
+ const plans = fs_1.default.readdirSync(STATE).filter((f) => /^[\w-]{4,40}\.json$/.test(f)).map((f) => ({ f, t: fs_1.default.statSync(path_1.default.join(STATE, f)).mtimeMs })).sort((a, b) => b.t - a.t);
82
+ for (const { f } of plans) {
83
+ const root = JSON.parse(fs_1.default.readFileSync(path_1.default.join(STATE, f), "utf8")).root;
84
+ if (root && fs_1.default.existsSync(root))
85
+ return root;
86
+ }
87
+ }
88
+ catch { /* no sorts yet */ }
89
+ return path_1.default.join(os_1.default.homedir(), "Pictures", "ViralSpin");
84
90
  }
85
- function touch(log) {
86
- if (!session)
87
- return;
88
- session.lastAt = Date.now();
89
- clearTimeout(session.idle);
90
- session.idle = setTimeout(() => stopReceive(log), IDLE_MS);
91
+ exports.defaultDest = receiveRoot;
92
+ const friendlyName = () => process.env.VIRALSPIN_PC_NAME || `${(os_1.default.userInfo().username || "My").replace(/^./, (c) => c.toUpperCase())}'s ${process.platform === "darwin" ? "Mac" : "PC"}`;
93
+ const newCode = () => String(crypto_1.default.randomInt(0, 10000)).padStart(4, "0");
94
+ let st = null;
95
+ let rootOverride = null;
96
+ function receiveStatus() {
97
+ if (!st)
98
+ return { active: false, received: 0, skipped: 0, bytes: 0, dest: receiveRoot() };
99
+ return { active: true, name: st.name, device: st.device, code: st.code, port: st.port, hosts: lanAddresses(), dest: receiveRoot(), received: st.received, skipped: 0, bytes: st.bytes, last: st.last, phone: st.phone, paired: st.tokens.size };
100
+ }
101
+ /** The folder received files go into from now on (undefined = back to the newest sort's folder). */
102
+ function setReceiveRoot(dest) {
103
+ rootOverride = dest && dest.trim() ? path_1.default.resolve(dest.trim()) : null;
104
+ if (rootOverride)
105
+ fs_1.default.mkdirSync(rootOverride, { recursive: true });
106
+ return receiveStatus();
91
107
  }
92
- /** Start (or restart into another folder) a receive session. Resolves once the LAN server listens. */
93
- function startReceive(destIn, log) {
94
- const dest = path_1.default.resolve(destIn && destIn.trim() ? destIn.trim() : defaultDest());
95
- fs_1.default.mkdirSync(dest, { recursive: true });
96
- if (session && session.dest === dest) {
97
- touch(log);
108
+ /** Start listening on the LAN and advertise over Bonjour. Runs for as long as the connector does. */
109
+ function startLanReceiver(o) {
110
+ if (st)
98
111
  return Promise.resolve(receiveStatus());
99
- }
100
- stopReceive(log);
101
- const token = crypto_1.default.randomBytes(12).toString("base64url");
102
- const server = http_1.default.createServer((req, res) => handle(req, res, log));
112
+ const server = http_1.default.createServer((req, res) => void handle(req, res));
103
113
  return new Promise((resolve, reject) => {
104
- let port = exports.RECEIVE_PORT;
114
+ const first = o.port ?? exports.RECEIVE_PORT;
115
+ let port = first;
105
116
  const tryListen = () => server.listen(port, "0.0.0.0");
106
- server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < exports.RECEIVE_PORT + 5) {
117
+ server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < first + 5) {
107
118
  port++;
108
119
  tryListen();
109
120
  }
110
121
  else
111
122
  reject(e); });
112
123
  server.on("listening", () => {
113
- session = { token, dest, server, port, hosts: lanAddresses(), received: 0, skipped: 0, bytes: 0, lastAt: Date.now(), startedAt: Date.now(), idle: setTimeout(() => stopReceive(log), IDLE_MS) };
114
- log(`[receive] waiting for an iPhone on ${session.hosts.join(", ") || "this network"}:${port}, saving into ${dest}`);
124
+ st = { server, port, code: newCode(), wrong: 0, tokens: new Set(), name: friendlyName(), device: os_1.default.hostname(), manifests: new Map(), got: new Map(), received: 0, bytes: 0, register: o.register, log: o.log };
125
+ if (o.advertise !== false) {
126
+ try {
127
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
128
+ const { Bonjour } = require("bonjour-service");
129
+ const b = new Bonjour();
130
+ b.publish({ name: st.name, type: "viralspin", protocol: "tcp", port, txt: { name: st.name, device: st.device } });
131
+ st.bonjour = b;
132
+ }
133
+ catch (e) {
134
+ o.log(`[receive] not announced on the network (${e.message}); the phone can still use this computer's address`);
135
+ }
136
+ }
137
+ o.log(`[receive] "Send to my computer" from the iPhone: ${st.name} on ${lanAddresses().join(", ") || "this network"}:${port}, code ${st.code}, files into ${path_1.default.join(receiveRoot(), "From iPhone")}`);
115
138
  resolve(receiveStatus());
116
139
  });
117
140
  tryListen();
118
141
  });
119
142
  }
120
- function json(res, code, body) { res.statusCode = code; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(body)); }
121
- function handle(req, res, log) {
122
- const s = session;
123
- const ip = String(req.socket.remoteAddress ?? "");
124
- const url = new URL(req.url || "/", "http://x");
125
- if (!s || !isPrivate(ip))
126
- return json(res, 403, { error: "not receiving" });
127
- const tok = url.searchParams.get("t") ?? String(req.headers["x-viralspin-token"] ?? "");
128
- if (!tok || tok.length !== s.token.length || !crypto_1.default.timingSafeEqual(Buffer.from(tok), Buffer.from(s.token)))
129
- return json(res, 403, { error: "wrong code" });
130
- touch(log);
131
- s.phone = ip.replace(/^::ffff:/, "");
132
- // the phone checks it found the right computer
133
- if (url.pathname === "/hello" && req.method === "GET")
134
- return json(res, 200, { ok: true, computer: os_1.default.hostname(), folder: path_1.default.basename(s.dest) });
135
- // which of these are here already: [{ topic, name, size? }] -> [true/false]. A file of that name in that topic (and size, when given) is skipped
136
- if (url.pathname === "/have" && req.method === "POST") {
143
+ function stopLanReceiver() {
144
+ if (!st)
145
+ return;
146
+ try {
147
+ st.bonjour?.unpublishAll();
148
+ st.bonjour?.destroy();
149
+ }
150
+ catch { /* already gone */ }
151
+ st.server.close();
152
+ st = null;
153
+ }
154
+ // the names the web's loopback routes use (organize-watch.ts)
155
+ const startReceive = (dest, log) => { setReceiveRoot(dest); return st ? Promise.resolve(receiveStatus()) : startLanReceiver({ log }); };
156
+ exports.startReceive = startReceive;
157
+ const stopReceive = () => setReceiveRoot(undefined);
158
+ exports.stopReceive = stopReceive;
159
+ function json(res, code, body) { const b = JSON.stringify(body); res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(b) }); res.end(b); }
160
+ function readJson(req, max = 8000000) {
161
+ return new Promise((resolve) => {
137
162
  let raw = "";
138
- req.on("data", (c) => { raw += c; if (raw.length > 4000000)
163
+ req.on("data", (c) => { raw += c; if (raw.length > max)
139
164
  req.destroy(); });
140
- req.on("end", () => {
141
- try {
142
- const files = (JSON.parse(raw).files ?? []);
143
- const have = files.slice(0, 5000).map((f) => {
144
- try {
145
- const st = fs_1.default.statSync(targetPath(s.dest, f.topic, f.name));
146
- return !f.size || st.size === f.size;
147
- }
148
- catch {
149
- return false;
150
- }
151
- });
152
- s.skipped += have.filter(Boolean).length;
153
- json(res, 200, { have });
165
+ req.on("end", () => { try {
166
+ resolve(JSON.parse(raw || "{}"));
167
+ }
168
+ catch {
169
+ resolve(null);
170
+ } });
171
+ req.on("error", () => resolve(null));
172
+ });
173
+ }
174
+ function sha256File(p) {
175
+ return new Promise((resolve, reject) => { const h = crypto_1.default.createHash("sha256"); fs_1.default.createReadStream(p).on("data", (c) => h.update(c)).on("end", () => resolve(h.digest("hex"))).on("error", reject); });
176
+ }
177
+ /** The parts a delivered item needs: a video its video, a Live Photo its photo AND its video half, a photo its photo. */
178
+ const needs = (kind) => (kind === "video" ? ["video"] : kind === "live" ? ["photo", "live"] : ["photo"]);
179
+ async function handle(req, res) {
180
+ const s = st;
181
+ if (!s || !isPrivate(String(req.socket.remoteAddress ?? "")))
182
+ return json(res, 403, { error: "not on this network" });
183
+ const url = new URL(req.url || "/", "http://x");
184
+ if (req.method === "POST" && url.pathname === "/pair") {
185
+ const b = await readJson(req, 10000);
186
+ if (!b || String(b.code ?? "").trim() !== s.code) {
187
+ if (++s.wrong >= 5) {
188
+ s.code = newCode();
189
+ s.wrong = 0;
190
+ s.log(`[receive] 5 wrong codes: the code is now ${s.code}`);
154
191
  }
155
- catch {
156
- json(res, 400, { error: "bad list" });
192
+ return json(res, 403, { error: "wrong code" });
193
+ }
194
+ const token = crypto_1.default.randomBytes(16).toString("hex");
195
+ s.tokens.add(token);
196
+ s.wrong = 0;
197
+ s.phone = String(b.device ?? "an iPhone").slice(0, 60);
198
+ s.log(`[receive] paired with ${s.phone}`);
199
+ return json(res, 200, { token, name: s.name });
200
+ }
201
+ const tok = String(req.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
202
+ if (!tok || !s.tokens.has(tok))
203
+ return json(res, 401, { error: "pair first" });
204
+ if (req.method === "POST" && url.pathname === "/receive/manifest") {
205
+ const m = await readJson(req);
206
+ if (!m)
207
+ return json(res, 400, { error: "bad manifest" });
208
+ const plan = String(m.plan_id ?? "plan").replace(/[^\w-]/g, "").slice(0, 40) || "plan";
209
+ const items = new Map();
210
+ let size = 0;
211
+ for (const g of m.groups ?? [])
212
+ for (const it of g.items ?? []) {
213
+ items.set(String(it.rel), { group: String(g.name ?? "Group"), filename: String(it.filename ?? "file"), kind: String(it.kind ?? "image") });
214
+ size += Number(it.bytes) || 0;
157
215
  }
158
- });
159
- return;
216
+ s.manifests.set(plan, items);
217
+ s.log(`[receive] ${items.size} file(s), ${(size / 1e6).toFixed(1)} MB in ${(m.groups ?? []).length} group(s) coming from ${s.phone ?? "the iPhone"}`);
218
+ return json(res, 200, { ok: true });
160
219
  }
161
- // one file: PUT /file?topic=&name=&taken=<unix seconds>, the bytes as the body. Written to a .part file and renamed when complete
162
- if (url.pathname === "/file" && req.method === "PUT") {
163
- let to;
220
+ const fm = /^\/receive\/file\/([^/]+)\/(.+)$/.exec(url.pathname);
221
+ if (req.method === "PUT" && fm) {
222
+ const plan = decodeURIComponent(fm[1]).replace(/[^\w-]/g, "").slice(0, 40), rel = decodeURIComponent(fm[2]);
223
+ const part = (url.searchParams.get("part") ?? "photo").replace(/[^a-z]/g, "") || "photo";
224
+ const known = s.manifests.get(plan)?.get(rel);
225
+ const group = safePart(url.searchParams.get("group") ?? known?.group ?? "Group", "Group");
226
+ const filename = safePart(url.searchParams.get("filename") ?? known?.filename ?? "file", "file");
227
+ const folder = path_1.default.join(receiveRoot(), "From iPhone", group);
228
+ fs_1.default.mkdirSync(folder, { recursive: true });
229
+ const to = freePath(folder, filename), tmp = `${to}.part`;
230
+ const out = fs_1.default.createWriteStream(tmp);
231
+ const done = new Promise((resolve) => { out.on("finish", () => resolve(true)); out.on("error", () => resolve(false)); req.on("aborted", () => resolve(false)); });
232
+ req.pipe(out);
233
+ if (!(await done)) {
234
+ out.destroy();
235
+ fs_1.default.rm(tmp, { force: true }, () => undefined);
236
+ return json(res, 500, { error: "the file did not arrive whole" });
237
+ }
164
238
  try {
165
- to = targetPath(s.dest, url.searchParams.get("topic") ?? "", url.searchParams.get("name") ?? "");
239
+ fs_1.default.renameSync(tmp, to);
240
+ const bytes = fs_1.default.statSync(to).size, sha256 = await sha256File(to); // AS WRITTEN: what the phone checks before offering to free up space
241
+ const claimed = String(req.headers["x-sha256"] ?? "").toLowerCase();
242
+ if (claimed && claimed !== sha256)
243
+ s.log(`[receive] ✗ ${group}/${path_1.default.basename(to)}: hash differs from the phone's`);
244
+ const rec = { rel, part, bytes, sha256, path: to };
245
+ const forPlan = s.got.get(plan) ?? new Map();
246
+ forPlan.set(rel, { ...(forPlan.get(rel) ?? {}), [part]: rec });
247
+ s.got.set(plan, forPlan);
248
+ s.received++;
249
+ s.bytes += bytes;
250
+ s.last = `${group}/${path_1.default.basename(to)}`;
251
+ return json(res, 200, { rel, part, bytes, sha256 });
166
252
  }
167
- catch {
168
- return json(res, 400, { error: "bad name" });
253
+ catch (e) {
254
+ fs_1.default.rm(tmp, { force: true }, () => undefined);
255
+ return json(res, 500, { error: e.message });
169
256
  }
170
- fs_1.default.mkdirSync(path_1.default.dirname(to), { recursive: true });
171
- const part = `${to}.part`;
172
- const out = fs_1.default.createWriteStream(part);
173
- let n = 0;
174
- req.on("data", (c) => { n += c.length; });
175
- req.pipe(out);
176
- out.on("finish", () => {
177
- try {
178
- fs_1.default.renameSync(part, to);
179
- const taken = Number(url.searchParams.get("taken"));
180
- if (taken > 0) {
181
- const d = new Date(taken * 1000);
182
- fs_1.default.utimesSync(to, d, d);
183
- } // the photo's own date, so the folder sorts by when it was taken
184
- s.received++;
185
- s.bytes += n;
186
- s.last = path_1.default.relative(s.dest, to);
187
- json(res, 200, { ok: true });
257
+ }
258
+ const dm = /^\/receive\/done\/([^/]+)$/.exec(url.pathname);
259
+ if (req.method === "POST" && dm) {
260
+ const plan = decodeURIComponent(dm[1]).replace(/[^\w-]/g, "").slice(0, 40);
261
+ const want = s.manifests.get(plan) ?? new Map(), got = s.got.get(plan) ?? new Map();
262
+ const received = [];
263
+ const missing = [];
264
+ const summary = (parts) => Object.fromEntries(Object.entries(parts).map(([k, v]) => [k, { bytes: v.bytes, sha256: v.sha256 }]));
265
+ for (const [rel, info] of want) {
266
+ const parts = got.get(rel) ?? {};
267
+ if (needs(info.kind).every((p) => parts[p])) {
268
+ const main = parts.video ?? parts.photo;
269
+ received.push({ rel, bytes: main.bytes, sha256: main.sha256, parts: summary(parts) });
188
270
  }
189
- catch (e) {
190
- json(res, 500, { error: e.message });
271
+ else
272
+ missing.push(rel);
273
+ }
274
+ for (const [rel, parts] of got)
275
+ if (!want.has(rel)) {
276
+ const main = parts.video ?? parts.photo ?? Object.values(parts)[0];
277
+ received.push({ rel, bytes: main.bytes, sha256: main.sha256, parts: summary(parts) });
191
278
  }
192
- });
193
- const fail = () => { out.destroy(); fs_1.default.rm(part, { force: true }, () => undefined); };
194
- req.on("aborted", fail);
195
- out.on("error", () => { fail(); json(res, 500, { error: "could not write" }); });
279
+ s.log(`[receive] done ${plan}: ${received.length} received, ${missing.length} missing`);
280
+ json(res, 200, { received, missing });
281
+ // the full files on their My Story rows (the preview stays; the row now says where the original is)
282
+ const files = received.map((r) => { const p = got.get(r.rel); const main = p.video ?? p.photo ?? Object.values(p)[0]; return { rel: r.rel, path: main.path, bytes: main.bytes, sha256: main.sha256 }; });
283
+ if (s.register && files.length)
284
+ s.register(plan, files).catch((e) => s.log(`[receive] could not record them in My Story: ${e.message}`));
196
285
  return;
197
286
  }
198
- json(res, 404, { error: "unknown" });
287
+ json(res, 404, { error: "no such route" });
199
288
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viralspin/mcp",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "ViralSpin MCP server — publish local videos and carousels to TikTok, Instagram, and YouTube from Claude Code",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.0.0",
37
+ "bonjour-service": "^1.4.4",
37
38
  "form-data": "^4.0.0"
38
39
  },
39
40
  "devDependencies": {