@viralspin/mcp 0.5.3 → 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.
@@ -22,6 +22,7 @@ const child_process_1 = require("child_process");
22
22
  const fs_1 = __importDefault(require("fs"));
23
23
  const os_1 = __importDefault(require("os"));
24
24
  const http_1 = __importDefault(require("http"));
25
+ const phoneReceive_1 = require("./phoneReceive");
25
26
  const path_1 = __importDefault(require("path"));
26
27
  const organize_js_1 = require("./tools/organize.js");
27
28
  const plan_js_1 = require("./organizer/plan.js");
@@ -233,6 +234,39 @@ function startThumbServer(log) {
233
234
  res.setHeader("Cache-Control", "private, max-age=3600");
234
235
  return void fs_1.default.createReadStream(f).pipe(res);
235
236
  }
237
+ // receiving from an iPhone over the home network (phoneReceive.ts): the page starts, watches and stops it; the phone talks to the LAN server
238
+ if (route === "/receive/status") {
239
+ if (!ORIGIN_OK.test(origin)) {
240
+ res.statusCode = 403;
241
+ return res.end();
242
+ }
243
+ res.setHeader("Content-Type", "application/json");
244
+ return res.end(JSON.stringify({ ...(0, phoneReceive_1.receiveStatus)(), defaultDest: (0, phoneReceive_1.defaultDest)() }));
245
+ }
246
+ if (route === "/receive/start" || route === "/receive/stop") {
247
+ if (req.method !== "POST" || !ORIGIN_OK.test(origin)) {
248
+ res.statusCode = 403;
249
+ return res.end();
250
+ }
251
+ if (route === "/receive/stop") {
252
+ (0, phoneReceive_1.stopReceive)();
253
+ res.setHeader("Content-Type", "application/json");
254
+ return res.end(JSON.stringify((0, phoneReceive_1.receiveStatus)()));
255
+ }
256
+ let raw = "";
257
+ req.on("data", (c) => { raw += c; if (raw.length > 10000)
258
+ req.destroy(); });
259
+ req.on("end", () => {
260
+ let dest;
261
+ try {
262
+ dest = JSON.parse(raw || "{}").dest;
263
+ }
264
+ catch { /* the default folder */ }
265
+ (0, phoneReceive_1.startReceive)(dest, log).then((st) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(st)); })
266
+ .catch((e) => { res.statusCode = 500; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ error: e.message })); });
267
+ });
268
+ return;
269
+ }
236
270
  if (route === "/ping") {
237
271
  res.setHeader("Content-Type", "application/json");
238
272
  return res.end(JSON.stringify({ ok: true, items: 1 }));
@@ -672,6 +706,10 @@ async function organizeLoop(d, base, signal) {
672
706
  catch (e) {
673
707
  d.log(`[organize] no thumbnail server (${e.message}); sorting still works, the web page will show placeholders`);
674
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`));
675
713
  let errors = 0;
676
714
  let idle = 0;
677
715
  while (!signal.aborted) {
@@ -17,6 +17,8 @@ const plan_1 = require("./plan");
17
17
  const path_1 = __importDefault(require("path"));
18
18
  const images_1 = require("./images");
19
19
  const topics_1 = require("./topics");
20
+ const images_2 = require("./images");
21
+ const phoneTopics_1 = require("./phoneTopics");
20
22
  const same = (a, b) => a.trim().toLowerCase() === b.trim().toLowerCase();
21
23
  /** The creator's labels after the ops: the ones the sort started with, plus added ones, minus removed ones. */
22
24
  function labelsAfter(start, ops) {
@@ -33,6 +35,15 @@ function labelsAfter(start, ops) {
33
35
  function regroup(plan, ops) {
34
36
  for (const o of ops) {
35
37
  const out = o.op === "exclude" ? new Set(o.items) : null;
38
+ // the phone's topics, as the pictures they matched: those pictures take that topic (a later merge / rename / hide still applies to it)
39
+ if (o.op === "assign") {
40
+ for (const [i, t] of o.items) {
41
+ const it = plan.items[i];
42
+ if (it && it.dupOf === undefined)
43
+ it.topic = t;
44
+ }
45
+ continue;
46
+ }
36
47
  plan.items.forEach((it, i) => {
37
48
  if (!it.topic)
38
49
  return;
@@ -83,10 +94,35 @@ async function backfillEmbeddings(plan, embPath, onProgress) {
83
94
  }
84
95
  fs_1.default.writeFileSync(embPath, Buffer.from((buf ?? new Float32Array(0)).buffer));
85
96
  }
97
+ /**
98
+ * Every picture's fingerprint for matching the phone's: the one the scan kept, else (a HEIC sharp can't read, mostly) one made from the small
99
+ * preview the sort already saved on this computer. Videos and duplicates get none. Previews made here are kept on the item for next time.
100
+ */
101
+ async function pictureHashes(plan) {
102
+ const out = plan.items.map((it) => (it.kind === "image" && it.dupOf === undefined ? it.dhash : undefined));
103
+ const missing = plan.items.map((it, i) => i).filter((i) => plan.items[i].kind === "image" && plan.items[i].dupOf === undefined && !out[i] && plan.items[i].thumb && fs_1.default.existsSync(plan.items[i].thumb));
104
+ for (const i of missing) {
105
+ const h = await (0, images_2.dhash)(plan.items[i].thumb).catch(() => null);
106
+ if (h) {
107
+ out[i] = h;
108
+ plan.items[i].dhash = h;
109
+ }
110
+ }
111
+ return out;
112
+ }
86
113
  async function applyOps(planPath, embPath, add, onProgress) {
87
114
  const plan = JSON.parse(fs_1.default.readFileSync(planPath, "utf8"));
88
115
  if (plan.by !== "topic")
89
116
  throw new Error("Changing folders needs a sort By topic.");
117
+ // the phone's topics arrive as fingerprints: match them against this sort's pictures once, and keep only what matched
118
+ const filed = new Map();
119
+ add = await Promise.all(add.map(async (o) => {
120
+ if (o.op !== "phone_topics")
121
+ return o;
122
+ const m = (0, phoneTopics_1.matchPhoneTopics)(await pictureHashes(plan), o.rows ?? []);
123
+ filed.set(o.source ?? "iPhone", m);
124
+ return { op: "assign", items: [...m.entries()], source: o.source ?? "iPhone" };
125
+ }));
90
126
  const ops = [...(plan.ops ?? []), ...add];
91
127
  const before = new Map();
92
128
  for (const it of plan.items)
@@ -141,6 +177,10 @@ async function applyOps(planPath, embPath, add, onProgress) {
141
177
  lines.push(`Renamed ${o.from} to ${o.to}`);
142
178
  if (o.op === "exclude")
143
179
  lines.push(`Took ${o.items.length} out of ${o.bucket}`);
180
+ if (o.op === "assign") {
181
+ const m = filed.get(o.source ?? "iPhone");
182
+ lines.push(o.items.length ? `Filed ${o.items.length.toLocaleString()} pictures under your ${o.source ?? "iPhone"}'s topics${m ? `: ${(0, phoneTopics_1.sayTopics)(m)}` : ""}` : `None of these pictures are in your ${o.source ?? "iPhone"}'s sort`);
183
+ }
144
184
  if (o.op === "hide")
145
185
  lines.push(`Hid ${o.bucket} (${(before.get(o.bucket) ?? 0).toLocaleString()} pictures stay on your computer)`);
146
186
  }
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NEAR = void 0;
4
+ exports.matchPhoneTopics = matchPhoneTopics;
5
+ exports.sayTopics = sayTopics;
6
+ /**
7
+ * FILE THIS COMPUTER'S COPIES UNDER THE PHONE'S TOPICS. The iPhone app sorts the phone's library into topics and (opt-in) sends a small
8
+ * preview + topic per picture to My Story; the server fingerprints each preview (organized_assets.fp, the same 64-bit difference hash this
9
+ * organizer keeps per picture as `dhash`). Most people's phone photos are ALSO on their computer (an iCloud folder, an old backup, a camera
10
+ * roll dump), so a sort of that folder can take the phone's topics for the pictures it shares with the phone: no upload, nothing copied.
11
+ *
12
+ * A computer picture takes a phone topic when its hash is within NEAR bits of a phone picture's, the closest one winning. Videos are left
13
+ * alone (the phone sorts photos), and so are flat pictures (nearly every bit the same: a blank, dark or one-colour frame looks like any other).
14
+ * Candidates are found through the hash's four 16-bit quarters (two hashes within 3 bits always share one; 4 usually do), so 15k pictures
15
+ * against 5k phone rows is a few thousand comparisons, not 75 million.
16
+ */
17
+ const plan_1 = require("./plan");
18
+ exports.NEAR = 4;
19
+ const usable = (h) => !!h && /^[0-9a-f]{16}$/i.test(h);
20
+ const flat = (h) => { const n = (0, plan_1.hamming)(h.toLowerCase(), "0000000000000000"); return n < 6 || n > 58; };
21
+ /**
22
+ * For each computer picture (by index) with a hash, the phone topic it takes, or nothing. `hashes[i]` is picture i's hash (undefined for a
23
+ * video, a duplicate, or a picture that could not be read). Pure.
24
+ */
25
+ function matchPhoneTopics(hashes, rows) {
26
+ const bands = new Map();
27
+ const phone = rows.filter((r) => usable(r.fp) && !flat(r.fp) && r.topic && r.topic.trim()).map((r) => ({ fp: r.fp.toLowerCase(), topic: r.topic.trim() }));
28
+ phone.forEach((r, k) => {
29
+ for (let b = 0; b < 4; b++) {
30
+ const key = `${b}${r.fp.slice(b * 4, b * 4 + 4)}`;
31
+ const l = bands.get(key);
32
+ if (l)
33
+ l.push(k);
34
+ else
35
+ bands.set(key, [k]);
36
+ }
37
+ });
38
+ const out = new Map();
39
+ hashes.forEach((h, i) => {
40
+ if (!usable(h) || flat(h))
41
+ return;
42
+ const hh = h.toLowerCase();
43
+ let best = exports.NEAR + 1, topic = null;
44
+ const seen = new Set();
45
+ for (let b = 0; b < 4; b++) {
46
+ for (const k of bands.get(`${b}${hh.slice(b * 4, b * 4 + 4)}`) ?? []) {
47
+ if (seen.has(k))
48
+ continue;
49
+ seen.add(k);
50
+ const d = (0, plan_1.hamming)(hh, phone[k].fp);
51
+ if (d < best) {
52
+ best = d;
53
+ topic = phone[k].topic;
54
+ }
55
+ }
56
+ }
57
+ if (topic)
58
+ out.set(i, topic);
59
+ });
60
+ return out;
61
+ }
62
+ /** "Beach 220 · Family 140 · Tennis 38 · 4 more" for the note the app shows after the change. */
63
+ function sayTopics(m) {
64
+ const n = new Map();
65
+ for (const t of m.values())
66
+ n.set(t, (n.get(t) ?? 0) + 1);
67
+ const top = [...n.entries()].sort((a, b) => b[1] - a[1]);
68
+ const shown = top.slice(0, 4).map(([t, c]) => `${t} ${c.toLocaleString()}`).join(" · ");
69
+ return top.length > 4 ? `${shown} · ${top.length - 4} more` : shown;
70
+ }
@@ -0,0 +1,288 @@
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.stopReceive = exports.startReceive = exports.defaultDest = exports.RECEIVE_PORT = void 0;
7
+ exports.isPrivate = isPrivate;
8
+ exports.safePart = safePart;
9
+ exports.freePath = freePath;
10
+ exports.lanAddresses = lanAddresses;
11
+ exports.receiveRoot = receiveRoot;
12
+ exports.receiveStatus = receiveStatus;
13
+ exports.setReceiveRoot = setReceiveRoot;
14
+ exports.startLanReceiver = startLanReceiver;
15
+ exports.stopLanReceiver = stopLanReceiver;
16
+ /**
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):
21
+ *
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.
31
+ */
32
+ const http_1 = __importDefault(require("http"));
33
+ const fs_1 = __importDefault(require("fs"));
34
+ const os_1 = __importDefault(require("os"));
35
+ const path_1 = __importDefault(require("path"));
36
+ const crypto_1 = __importDefault(require("crypto"));
37
+ exports.RECEIVE_PORT = 47840;
38
+ /** Is this a private / local address (the only ones answered)? */
39
+ function isPrivate(ip) {
40
+ const v = ip.replace(/^::ffff:/, "");
41
+ if (v === "127.0.0.1" || v === "::1")
42
+ return true;
43
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v);
44
+ if (m) {
45
+ const a = +m[1], b = +m[2];
46
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
47
+ }
48
+ return /^fe80:/i.test(v) || /^f[cd][0-9a-f]{2}:/i.test(v);
49
+ }
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. */
51
+ function safePart(s, fallback) {
52
+ const t = String(s ?? "").normalize("NFC").replace(/[\\/:*?"<>|\x00-\x1f]/g, " ").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 120);
53
+ return t && t !== "." && t !== ".." ? t : fallback;
54
+ }
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}`);
61
+ return p;
62
+ }
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)];
74
+ }
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");
90
+ }
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();
107
+ }
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)
111
+ return Promise.resolve(receiveStatus());
112
+ const server = http_1.default.createServer((req, res) => void handle(req, res));
113
+ return new Promise((resolve, reject) => {
114
+ const first = o.port ?? exports.RECEIVE_PORT;
115
+ let port = first;
116
+ const tryListen = () => server.listen(port, "0.0.0.0");
117
+ server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < first + 5) {
118
+ port++;
119
+ tryListen();
120
+ }
121
+ else
122
+ reject(e); });
123
+ server.on("listening", () => {
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")}`);
138
+ resolve(receiveStatus());
139
+ });
140
+ tryListen();
141
+ });
142
+ }
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) => {
162
+ let raw = "";
163
+ req.on("data", (c) => { raw += c; if (raw.length > max)
164
+ req.destroy(); });
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}`);
191
+ }
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;
215
+ }
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 });
219
+ }
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
+ }
238
+ try {
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 });
252
+ }
253
+ catch (e) {
254
+ fs_1.default.rm(tmp, { force: true }, () => undefined);
255
+ return json(res, 500, { error: e.message });
256
+ }
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) });
270
+ }
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) });
278
+ }
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}`));
285
+ return;
286
+ }
287
+ json(res, 404, { error: "no such route" });
288
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viralspin/mcp",
3
- "version": "0.5.3",
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": {