@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.
- package/dist/organize-watch.js +5 -1
- package/dist/phoneReceive.js +225 -136
- package/package.json +2 -1
package/dist/organize-watch.js
CHANGED
|
@@ -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)(
|
|
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) {
|
package/dist/phoneReceive.js
CHANGED
|
@@ -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.
|
|
11
|
-
exports.
|
|
9
|
+
exports.freePath = freePath;
|
|
10
|
+
exports.lanAddresses = lanAddresses;
|
|
11
|
+
exports.receiveRoot = receiveRoot;
|
|
12
12
|
exports.receiveStatus = receiveStatus;
|
|
13
|
-
exports.
|
|
14
|
-
exports.
|
|
13
|
+
exports.setReceiveRoot = setReceiveRoot;
|
|
14
|
+
exports.startLanReceiver = startLanReceiver;
|
|
15
|
+
exports.stopLanReceiver = stopLanReceiver;
|
|
15
16
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* is
|
|
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
|
-
|
|
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
|
|
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);
|
|
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
|
-
/**
|
|
64
|
-
function
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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();
|
|
91
107
|
}
|
|
92
|
-
/** Start
|
|
93
|
-
function
|
|
94
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
-
|
|
114
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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 >
|
|
163
|
+
req.on("data", (c) => { raw += c; if (raw.length > max)
|
|
139
164
|
req.destroy(); });
|
|
140
|
-
req.on("end", () => {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
+
catch (e) {
|
|
254
|
+
fs_1.default.rm(tmp, { force: true }, () => undefined);
|
|
255
|
+
return json(res, 500, { error: e.message });
|
|
169
256
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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: "
|
|
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.
|
|
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": {
|