@viralspin/mcp 0.5.3 → 0.5.4

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)(log);
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 }));
@@ -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,199 @@
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.RECEIVE_PORT = void 0;
7
+ exports.lanAddresses = lanAddresses;
8
+ exports.isPrivate = isPrivate;
9
+ exports.safePart = safePart;
10
+ exports.targetPath = targetPath;
11
+ exports.defaultDest = defaultDest;
12
+ exports.receiveStatus = receiveStatus;
13
+ exports.stopReceive = stopReceive;
14
+ exports.startReceive = startReceive;
15
+ /**
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.
18
+ *
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.
25
+ */
26
+ const http_1 = __importDefault(require("http"));
27
+ const fs_1 = __importDefault(require("fs"));
28
+ const os_1 = __importDefault(require("os"));
29
+ const path_1 = __importDefault(require("path"));
30
+ const crypto_1 = __importDefault(require("crypto"));
31
+ 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)? */
47
+ function isPrivate(ip) {
48
+ const v = ip.replace(/^::ffff:/, "");
49
+ if (v === "127.0.0.1" || v === "::1")
50
+ return true;
51
+ const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v);
52
+ if (m) {
53
+ const a = +m[1], b = +m[2];
54
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
55
+ }
56
+ return /^fe80:/i.test(v) || /^f[cd][0-9a-f]{2}:/i.test(v);
57
+ }
58
+ /** A topic or file name as one safe path part: no separators, no "..", no characters Windows refuses, never empty. */
59
+ 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)
61
+ return t && t !== "." && t !== ".." ? t : fallback;
62
+ }
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");
68
+ return p;
69
+ }
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 };
76
+ }
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;
84
+ }
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
+ }
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);
98
+ 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));
103
+ return new Promise((resolve, reject) => {
104
+ let port = exports.RECEIVE_PORT;
105
+ const tryListen = () => server.listen(port, "0.0.0.0");
106
+ server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < exports.RECEIVE_PORT + 5) {
107
+ port++;
108
+ tryListen();
109
+ }
110
+ else
111
+ reject(e); });
112
+ 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}`);
115
+ resolve(receiveStatus());
116
+ });
117
+ tryListen();
118
+ });
119
+ }
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") {
137
+ let raw = "";
138
+ req.on("data", (c) => { raw += c; if (raw.length > 4000000)
139
+ 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 });
154
+ }
155
+ catch {
156
+ json(res, 400, { error: "bad list" });
157
+ }
158
+ });
159
+ return;
160
+ }
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;
164
+ try {
165
+ to = targetPath(s.dest, url.searchParams.get("topic") ?? "", url.searchParams.get("name") ?? "");
166
+ }
167
+ catch {
168
+ return json(res, 400, { error: "bad name" });
169
+ }
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 });
188
+ }
189
+ catch (e) {
190
+ json(res, 500, { error: e.message });
191
+ }
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" }); });
196
+ return;
197
+ }
198
+ json(res, 404, { error: "unknown" });
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viralspin/mcp",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {