lucille-node 0.0.1
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/README.md +31 -0
- package/STORAGE.md +419 -0
- package/data/lucille/50a84a5a-6b2b-492e-b279-61eddb392e34_video:test-video-1773549/834/196 +1 -0
- package/data/lucille/dfa790d7-c4f5-43a9-b7ff-f166f0f344c9_video:test-video-1773549/541/230 +1 -0
- package/data/lucille/k/eys +1 -0
- package/data/lucille/pubKey_02b0071b6d90ce7598eabdcfa0596f95359799fd413e300dcbdca823095a/4cf/884 +1 -0
- package/data/lucille/pubKey_030e4e2f145c4b68863ca42868e08b1559c3c9c10774e61c3f4ef9766d0c/df7/e30 +1 -0
- package/data/lucille/user_50a84a5a-6b2b-492e-b279-61eddb/392/e34 +1 -0
- package/data/lucille/user_dfa790d7-c4f5-43a9-b7ff-f166f0/f34/4c9 +1 -0
- package/data/lucille/videos_50a84a5a-6b2b-492e-b279-61eddb/392/e34 +1 -0
- package/data/lucille/videos_dfa790d7-c4f5-43a9-b7ff-f166f0/f34/4c9 +1 -0
- package/integration-test.js +346 -0
- package/lucille.js +498 -0
- package/package.json +29 -0
- package/src/config.js +104 -0
- package/src/persistence/client.js +83 -0
- package/src/persistence/db.js +105 -0
- package/src/seeder.js +112 -0
- package/src/spaces.js +68 -0
- package/src/tracker.js +49 -0
- package/templates/player.html +236 -0
package/lucille.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import fileUpload from 'express-fileupload';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import sessionless from 'sessionless-node';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PLAYER_HTML = fs.readFileSync(path.join(__dirname, 'templates', 'player.html'), 'utf8');
|
|
13
|
+
|
|
14
|
+
import db from './src/persistence/db.js';
|
|
15
|
+
import { uploadVideoBuffer, cdnUrl } from './src/spaces.js';
|
|
16
|
+
import { startTracker } from './src/tracker.js';
|
|
17
|
+
import { seedVideo, seedAll, getMagnet, getStatus, destroySeeder, updateTrackerList } from './src/seeder.js';
|
|
18
|
+
import { loadConfig, getConfig, updateConfig, getTiers, getTierById, getFederationPeers } from './src/config.js';
|
|
19
|
+
|
|
20
|
+
const PORT = parseInt(process.env.PORT) || 5444;
|
|
21
|
+
const allowedTimeDifference = parseInt(process.env.ALLOWED_TIME_DIFFERENCE) || 600000;
|
|
22
|
+
const MODE = process.env.LUCILLE_MODE || 'all'; // 'all' | 'tracker' | 'api'
|
|
23
|
+
|
|
24
|
+
// ── Startup ───────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// Load config (tiers, federation peers, etc.)
|
|
27
|
+
await loadConfig();
|
|
28
|
+
|
|
29
|
+
// Load or create service keys
|
|
30
|
+
let keys = await db.getKeys();
|
|
31
|
+
if (!keys) {
|
|
32
|
+
keys = await sessionless.generateKeys(db.saveKeys, db.getKeys);
|
|
33
|
+
}
|
|
34
|
+
sessionless.getKeys = () => keys;
|
|
35
|
+
|
|
36
|
+
// Start tracker and/or seeder
|
|
37
|
+
if (MODE !== 'api') {
|
|
38
|
+
await startTracker();
|
|
39
|
+
console.log('[lucille] Tracker started');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (MODE !== 'tracker') {
|
|
43
|
+
// Fetch federation peer trackers before seeding so all torrents announce everywhere
|
|
44
|
+
await initFederationTrackers();
|
|
45
|
+
seedAll().catch(err => console.error('[lucille] seedAll error:', err.message));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Federation tracker bootstrap ──────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
async function fetchTrackerUrl(peerBaseUrl) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${peerBaseUrl}/federation/trackers`, { signal: AbortSignal.timeout(5000) });
|
|
53
|
+
if (!res.ok) return null;
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
return data.trackerUrl || null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function initFederationTrackers() {
|
|
62
|
+
const peers = getFederationPeers();
|
|
63
|
+
if (peers.length === 0) return;
|
|
64
|
+
|
|
65
|
+
console.log(`[lucille] Fetching tracker URLs from ${peers.length} federation peer(s)…`);
|
|
66
|
+
const urls = (await Promise.all(peers.map(fetchTrackerUrl))).filter(Boolean);
|
|
67
|
+
if (urls.length > 0) updateTrackerList(urls);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Express app ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const app = express();
|
|
73
|
+
|
|
74
|
+
app.use(cors());
|
|
75
|
+
app.use(fileUpload({ createParentPath: true }));
|
|
76
|
+
app.use(express.json({ limit: '10mb' }));
|
|
77
|
+
app.use(express.urlencoded({ extended: true }));
|
|
78
|
+
|
|
79
|
+
app.use((req, res, next) => {
|
|
80
|
+
console.log(req.method, req.url);
|
|
81
|
+
next();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Timestamp guard — skip for file uploads (transit time varies)
|
|
85
|
+
app.use((req, res, next) => {
|
|
86
|
+
if (req.path.includes('/file')) return next();
|
|
87
|
+
const requestTime = +req.query.timestamp || +req.body?.timestamp;
|
|
88
|
+
if (!requestTime) return next();
|
|
89
|
+
if (Math.abs(Date.now() - requestTime) > allowedTimeDifference) {
|
|
90
|
+
return res.status(400).json({ error: 'no time like the present' });
|
|
91
|
+
}
|
|
92
|
+
next();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── Auth helper ───────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
const verifyUser = async (uuid, timestamp, signature) => {
|
|
98
|
+
if (!uuid || !timestamp || !signature) throw new Error('invalid signature');
|
|
99
|
+
const user = await db.getUserByUUID(uuid);
|
|
100
|
+
const message = timestamp + user.pubKey;
|
|
101
|
+
let isValid = false;
|
|
102
|
+
try {
|
|
103
|
+
isValid = await sessionless.verifySignature(signature, message, user.pubKey);
|
|
104
|
+
} catch { /* invalid sig format */ }
|
|
105
|
+
if (!isValid) throw new Error('invalid signature');
|
|
106
|
+
return user;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── Quota helpers ─────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
function checkQuota(user, fileSize) {
|
|
112
|
+
const tier = getTierById(user.tier || 'free');
|
|
113
|
+
const errors = [];
|
|
114
|
+
|
|
115
|
+
if (tier.storageLimit > 0 && (user.storageUsed || 0) + fileSize > tier.storageLimit) {
|
|
116
|
+
const usedMB = ((user.storageUsed || 0) / 1e6).toFixed(1);
|
|
117
|
+
const limitMB = (tier.storageLimit / 1e6).toFixed(0);
|
|
118
|
+
errors.push(`Storage limit reached (${usedMB} MB used of ${limitMB} MB on ${tier.name} tier)`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (tier.videoLimit > 0) {
|
|
122
|
+
const videoCount = Object.keys(user.videos || {}).length;
|
|
123
|
+
if (videoCount >= tier.videoLimit) {
|
|
124
|
+
errors.push(`Video limit reached (${videoCount}/${tier.videoLimit} on ${tier.name} tier)`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return errors;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
// Health
|
|
134
|
+
app.get('/health', (req, res) => {
|
|
135
|
+
res.json({ status: 'ok', service: 'lucille', port: PORT, mode: MODE });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Seeder / tracker status
|
|
139
|
+
app.get('/status', (req, res) => res.json(getStatus()));
|
|
140
|
+
|
|
141
|
+
// ── Config (called by wiki plugin on startup) ─────────────────────────────────
|
|
142
|
+
|
|
143
|
+
app.get('/config', (req, res) => {
|
|
144
|
+
const cfg = getConfig();
|
|
145
|
+
// Never expose DO Spaces credentials over the wire
|
|
146
|
+
res.json({
|
|
147
|
+
tiers: cfg.tiers,
|
|
148
|
+
federationPeers: cfg.federationPeers,
|
|
149
|
+
sanoraUrl: cfg.sanoraUrl,
|
|
150
|
+
mode: MODE
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
app.post('/config', express.json(), async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const { tiers, federationPeers, sanoraUrl, addieUrl } = req.body;
|
|
157
|
+
const patch = {};
|
|
158
|
+
if (tiers) patch.tiers = tiers;
|
|
159
|
+
if (federationPeers) patch.federationPeers = federationPeers;
|
|
160
|
+
if (sanoraUrl) patch.sanoraUrl = sanoraUrl;
|
|
161
|
+
if (addieUrl) patch.addieUrl = addieUrl;
|
|
162
|
+
|
|
163
|
+
const updated = await updateConfig(patch);
|
|
164
|
+
|
|
165
|
+
// If federation peers changed, re-fetch tracker list
|
|
166
|
+
if (federationPeers) await initFederationTrackers();
|
|
167
|
+
|
|
168
|
+
res.json({
|
|
169
|
+
success: true,
|
|
170
|
+
tiers: updated.tiers,
|
|
171
|
+
federationPeers: updated.federationPeers,
|
|
172
|
+
sanoraUrl: updated.sanoraUrl
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error('[lucille] Config update error:', err);
|
|
176
|
+
res.status(500).json({ error: err.message });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Tiers ─────────────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
app.get('/tiers', (req, res) => res.json(getTiers()));
|
|
183
|
+
|
|
184
|
+
// ── Federation ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
// Public: peers call this to discover our tracker URL
|
|
187
|
+
app.get('/federation/trackers', (req, res) => {
|
|
188
|
+
const ownTracker = process.env.TRACKER_URL ||
|
|
189
|
+
`ws://${req.hostname}:${process.env.TRACKER_PORT || 8000}`;
|
|
190
|
+
res.json({ trackerUrl: ownTracker, lucilleUrl: `${req.protocol}://${req.get('host')}` });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── User management ───────────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
// PUT /user/create
|
|
196
|
+
app.put('/user/create', async (req, res) => {
|
|
197
|
+
try {
|
|
198
|
+
const { timestamp, pubKey, signature } = req.body;
|
|
199
|
+
if (!timestamp || !pubKey || !signature) {
|
|
200
|
+
return res.status(400).json({ error: 'timestamp, pubKey, and signature required' });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const message = timestamp + pubKey;
|
|
204
|
+
let isValid = false;
|
|
205
|
+
try {
|
|
206
|
+
isValid = await sessionless.verifySignature(signature, message, pubKey);
|
|
207
|
+
} catch { /* invalid sig format */ }
|
|
208
|
+
if (!isValid) return res.status(401).json({ error: 'invalid signature' });
|
|
209
|
+
|
|
210
|
+
let user;
|
|
211
|
+
try {
|
|
212
|
+
user = await db.getUserByPublicKey(pubKey);
|
|
213
|
+
} catch {
|
|
214
|
+
user = await db.putUser({ pubKey, videos: {}, tier: 'free', storageUsed: 0 });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
res.json({ uuid: user.uuid, pubKey: user.pubKey, tier: user.tier || 'free' });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error('[lucille] create user error:', err);
|
|
220
|
+
res.status(500).json({ error: err.message });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// GET /user/:uuid
|
|
225
|
+
app.get('/user/:uuid', async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const { uuid } = req.params;
|
|
228
|
+
const { timestamp, signature } = req.query;
|
|
229
|
+
const user = await verifyUser(uuid, timestamp, signature);
|
|
230
|
+
const tier = getTierById(user.tier || 'free');
|
|
231
|
+
res.json({
|
|
232
|
+
uuid: user.uuid,
|
|
233
|
+
pubKey: user.pubKey,
|
|
234
|
+
tier: user.tier || 'free',
|
|
235
|
+
storageUsed: user.storageUsed || 0,
|
|
236
|
+
tierDetails: tier
|
|
237
|
+
});
|
|
238
|
+
} catch (err) {
|
|
239
|
+
res.status(err.message === 'invalid signature' ? 401 : 404).json({ error: err.message });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── Tier upgrade ──────────────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
// PUT /user/:uuid/tier
|
|
246
|
+
// Body: { timestamp, signature, tierId }
|
|
247
|
+
// Payment validation happens via Addie — client must have already completed payment
|
|
248
|
+
// and passes the Addie payment confirmation back here.
|
|
249
|
+
app.put('/user/:uuid/tier', async (req, res) => {
|
|
250
|
+
try {
|
|
251
|
+
const { uuid } = req.params;
|
|
252
|
+
const { timestamp, signature, tierId } = req.body;
|
|
253
|
+
|
|
254
|
+
const user = await verifyUser(uuid, timestamp, signature);
|
|
255
|
+
const targetTier = getTierById(tierId);
|
|
256
|
+
|
|
257
|
+
if (!targetTier) {
|
|
258
|
+
return res.status(400).json({ error: `Unknown tier: ${tierId}` });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
user.tier = targetTier.id;
|
|
262
|
+
await db.saveUser(user);
|
|
263
|
+
|
|
264
|
+
res.json({ uuid: user.uuid, tier: user.tier, tierDetails: targetTier });
|
|
265
|
+
} catch (err) {
|
|
266
|
+
res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── Video metadata ────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
// PUT /user/:uuid/video/:title
|
|
273
|
+
app.put('/user/:uuid/video/:title', async (req, res) => {
|
|
274
|
+
try {
|
|
275
|
+
const { uuid, title } = req.params;
|
|
276
|
+
const { timestamp, signature, description = '', tags = [], price = 0 } = req.body;
|
|
277
|
+
|
|
278
|
+
const user = await verifyUser(uuid, timestamp, signature);
|
|
279
|
+
|
|
280
|
+
// Check video count quota before registering
|
|
281
|
+
const tier = getTierById(user.tier || 'free');
|
|
282
|
+
if (tier.videoLimit > 0) {
|
|
283
|
+
const videoCount = Object.keys(user.videos || {}).length;
|
|
284
|
+
if (videoCount >= tier.videoLimit) {
|
|
285
|
+
return res.status(402).json({
|
|
286
|
+
error: `Video limit reached (${videoCount}/${tier.videoLimit} on ${tier.name} tier)`,
|
|
287
|
+
upgradeRequired: true
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let video;
|
|
293
|
+
try {
|
|
294
|
+
video = await db.getVideo(uuid, title);
|
|
295
|
+
} catch {
|
|
296
|
+
video = { title, description, tags, price };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
video.description = description || video.description;
|
|
300
|
+
video.tags = tags || video.tags;
|
|
301
|
+
video.price = price ?? video.price;
|
|
302
|
+
|
|
303
|
+
const saved = await db.putVideo(user, video);
|
|
304
|
+
res.json(saved);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
console.error('[lucille] put video error:', err);
|
|
307
|
+
res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// GET /user/:uuid/videos
|
|
312
|
+
app.get('/user/:uuid/videos', async (req, res) => {
|
|
313
|
+
try {
|
|
314
|
+
const { uuid } = req.params;
|
|
315
|
+
const { timestamp, signature } = req.query;
|
|
316
|
+
await verifyUser(uuid, timestamp, signature);
|
|
317
|
+
const videos = await db.getVideos(uuid);
|
|
318
|
+
res.json(videos);
|
|
319
|
+
} catch (err) {
|
|
320
|
+
res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// GET /videos/:uuid — public listing (no auth)
|
|
325
|
+
app.get('/videos/:uuid', async (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
const { uuid } = req.params;
|
|
328
|
+
const videos = await db.getVideos(uuid);
|
|
329
|
+
const pub = {};
|
|
330
|
+
for (const [title, video] of Object.entries(videos)) {
|
|
331
|
+
pub[title] = {
|
|
332
|
+
videoId: video.videoId,
|
|
333
|
+
title: video.title,
|
|
334
|
+
description: video.description,
|
|
335
|
+
tags: video.tags,
|
|
336
|
+
price: video.price || 0,
|
|
337
|
+
spacesKey: video.spacesKey,
|
|
338
|
+
magnetURI: video.magnetURI,
|
|
339
|
+
contentHash: video.contentHash,
|
|
340
|
+
createdAt: video.createdAt
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
res.json(pub);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
res.status(500).json({ error: err.message });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ── Video file upload ─────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
// PUT /user/:uuid/video/:title/file
|
|
352
|
+
// Multipart: field `video`
|
|
353
|
+
// Headers: x-pn-timestamp, x-pn-signature
|
|
354
|
+
app.put('/user/:uuid/video/:title/file', async (req, res) => {
|
|
355
|
+
try {
|
|
356
|
+
const { uuid, title } = req.params;
|
|
357
|
+
const timestamp = req.headers['x-pn-timestamp'];
|
|
358
|
+
const signature = req.headers['x-pn-signature'];
|
|
359
|
+
|
|
360
|
+
const user = await verifyUser(uuid, timestamp, signature);
|
|
361
|
+
|
|
362
|
+
if (!req.files || !req.files.video) {
|
|
363
|
+
return res.status(400).json({ error: 'video file required (field: video)' });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const file = req.files.video;
|
|
367
|
+
|
|
368
|
+
// Quota check — enforce storage and video count limits
|
|
369
|
+
const quotaErrors = checkQuota(user, file.size);
|
|
370
|
+
if (quotaErrors.length > 0) {
|
|
371
|
+
return res.status(402).json({ error: quotaErrors[0], upgradeRequired: true });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const ext = path.extname(file.name) || '.mp4';
|
|
375
|
+
const spacesKey = `videos/${uuid}/${title}${ext}`;
|
|
376
|
+
|
|
377
|
+
// Write to temp file, hash, upload to Spaces
|
|
378
|
+
const tmpPath = path.join(os.tmpdir(), `lucille_${uuid}_${Date.now()}${ext}`);
|
|
379
|
+
await file.mv(tmpPath);
|
|
380
|
+
|
|
381
|
+
const fileBytes = fs.readFileSync(tmpPath);
|
|
382
|
+
const contentHash = crypto.createHash('sha256').update(fileBytes).digest('hex');
|
|
383
|
+
await uploadVideoBuffer(fileBytes, spacesKey, file.mimetype || 'video/mp4');
|
|
384
|
+
fs.unlinkSync(tmpPath);
|
|
385
|
+
|
|
386
|
+
// Update video record
|
|
387
|
+
let video;
|
|
388
|
+
try {
|
|
389
|
+
video = await db.getVideo(uuid, title);
|
|
390
|
+
} catch {
|
|
391
|
+
video = { title, description: '', tags: [] };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
video.spacesKey = spacesKey;
|
|
395
|
+
video.cdnUrl = cdnUrl(spacesKey);
|
|
396
|
+
video.contentHash = contentHash;
|
|
397
|
+
|
|
398
|
+
const saved = await db.putVideo(user, video);
|
|
399
|
+
|
|
400
|
+
// Update storageUsed on the user record
|
|
401
|
+
user.storageUsed = (user.storageUsed || 0) + file.size;
|
|
402
|
+
await db.saveUser(user);
|
|
403
|
+
|
|
404
|
+
// Async: seed the new video
|
|
405
|
+
seedVideo(spacesKey).then(torrent => {
|
|
406
|
+
saved.magnetURI = torrent.magnetURI;
|
|
407
|
+
saved.infoHash = torrent.infoHash;
|
|
408
|
+
db.saveVideo(saved).catch(err => console.error('[lucille] saveVideo after seed:', err.message));
|
|
409
|
+
console.log(`[lucille] ${title} seeding — magnet: ${torrent.magnetURI.slice(0, 60)}…`);
|
|
410
|
+
}).catch(err => console.error('[lucille] seedVideo error:', err.message));
|
|
411
|
+
|
|
412
|
+
res.json(saved);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.error('[lucille] file upload error:', err);
|
|
415
|
+
res.status(err.message === 'invalid signature' ? 401 : 500).json({ error: err.message });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── Watch / player ────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
// GET /watch/:videoId — browser player page (embeddable via iframe)
|
|
422
|
+
app.get('/watch/:videoId', (req, res) => {
|
|
423
|
+
res.setHeader('Content-Type', 'text/html');
|
|
424
|
+
res.send(PLAYER_HTML);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// GET /video/:videoId/watch — magnet + tracker for streaming
|
|
428
|
+
app.get('/video/:videoId/watch', async (req, res) => {
|
|
429
|
+
try {
|
|
430
|
+
const { videoId } = req.params;
|
|
431
|
+
const video = await db.getVideoByVideoId(videoId);
|
|
432
|
+
|
|
433
|
+
if (!video.spacesKey) {
|
|
434
|
+
return res.status(404).json({ error: 'video file not yet uploaded' });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let magnetInfo;
|
|
438
|
+
if (video.magnetURI) {
|
|
439
|
+
magnetInfo = { magnetURI: video.magnetURI, infoHash: video.infoHash, name: video.title };
|
|
440
|
+
} else {
|
|
441
|
+
magnetInfo = await getMagnet(video.spacesKey);
|
|
442
|
+
video.magnetURI = magnetInfo.magnetURI;
|
|
443
|
+
video.infoHash = magnetInfo.infoHash;
|
|
444
|
+
await db.saveVideo(video);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const cfg = getConfig();
|
|
448
|
+
res.json({
|
|
449
|
+
videoId: video.videoId,
|
|
450
|
+
title: video.title,
|
|
451
|
+
description: video.description,
|
|
452
|
+
price: video.price || 0,
|
|
453
|
+
...magnetInfo,
|
|
454
|
+
trackerUrl: process.env.TRACKER_URL || `ws://localhost:${process.env.TRACKER_PORT || 8000}`
|
|
455
|
+
});
|
|
456
|
+
} catch (err) {
|
|
457
|
+
res.status(err.message === 'not found' ? 404 : 500).json({ error: err.message });
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// GET /magnet?key=videos/... — internal / seeder API
|
|
462
|
+
app.get('/magnet', async (req, res) => {
|
|
463
|
+
try {
|
|
464
|
+
const { key } = req.query;
|
|
465
|
+
if (!key) return res.status(400).json({ error: 'key required' });
|
|
466
|
+
const magnetInfo = await getMagnet(key);
|
|
467
|
+
res.json(magnetInfo);
|
|
468
|
+
} catch (err) {
|
|
469
|
+
res.status(500).json({ error: err.message });
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// POST /seed — notify lucille of a new video key uploaded externally
|
|
474
|
+
app.post('/seed', async (req, res) => {
|
|
475
|
+
try {
|
|
476
|
+
const { key } = req.body;
|
|
477
|
+
if (!key) return res.status(400).json({ error: 'key required' });
|
|
478
|
+
const torrent = await seedVideo(key);
|
|
479
|
+
res.json({ magnetURI: torrent.magnetURI });
|
|
480
|
+
} catch (err) {
|
|
481
|
+
res.status(500).json({ error: err.message });
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
if (MODE !== 'tracker') {
|
|
488
|
+
app.listen(PORT, () => {
|
|
489
|
+
console.log(`[lucille] API listening on port ${PORT}`);
|
|
490
|
+
console.log(`[lucille] Mode: ${MODE}`);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
process.on('SIGTERM', async () => {
|
|
495
|
+
console.log('[lucille] Shutting down…');
|
|
496
|
+
await destroySeeder();
|
|
497
|
+
process.exit(0);
|
|
498
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lucille-node",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "P2P video hosting and streaming service for the Planet Nine ecosystem",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"video",
|
|
7
|
+
"streaming",
|
|
8
|
+
"webtorrent",
|
|
9
|
+
"planet-nine"
|
|
10
|
+
],
|
|
11
|
+
"license": "GPL-3.0-or-later",
|
|
12
|
+
"author": "planetnineisaspaceship",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lucille.js",
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node lucille.js",
|
|
17
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@aws-sdk/client-s3": "^3.705.0",
|
|
21
|
+
"@aws-sdk/s3-request-presigner": "^3.705.0",
|
|
22
|
+
"bittorrent-tracker": "latest",
|
|
23
|
+
"cors": "^2.8.5",
|
|
24
|
+
"express": "^4.21.2",
|
|
25
|
+
"express-fileupload": "^1.5.1",
|
|
26
|
+
"sessionless-node": "latest",
|
|
27
|
+
"webtorrent": "^2.5.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lucille configuration — tiers, federation peers, service URLs.
|
|
3
|
+
* Loaded on startup, updated via POST /config from the wiki plugin.
|
|
4
|
+
* Stored in data/lucille/config.json (alongside the key-value db).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
8
|
+
import { existsSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const DATA_DIR = join(__dirname, '../../data/lucille');
|
|
14
|
+
const CONFIG_FILE = join(DATA_DIR, 'config.json');
|
|
15
|
+
|
|
16
|
+
// ── Default tiers (wiki owner can replace all of these) ───────────────────────
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_TIERS = [
|
|
19
|
+
{
|
|
20
|
+
id: 'free',
|
|
21
|
+
name: 'Free',
|
|
22
|
+
price: 0, // MP cost to unlock (0 = no payment required)
|
|
23
|
+
storageLimit: 524288000, // 500 MB in bytes (0 = unlimited)
|
|
24
|
+
videoLimit: 5, // max videos (0 = unlimited)
|
|
25
|
+
durationLimit: 600 // max seconds/video (0 = unlimited)
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'creator',
|
|
29
|
+
name: 'Creator',
|
|
30
|
+
price: 500, // 500 MP
|
|
31
|
+
storageLimit: 10737418240, // 10 GB
|
|
32
|
+
videoLimit: 50,
|
|
33
|
+
durationLimit: 3600 // 1 hour
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'pro',
|
|
37
|
+
name: 'Pro',
|
|
38
|
+
price: 2000, // 2000 MP
|
|
39
|
+
storageLimit: 0, // unlimited
|
|
40
|
+
videoLimit: 0, // unlimited
|
|
41
|
+
durationLimit: 0 // unlimited
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// ── In-memory config (loaded once on startup) ─────────────────────────────────
|
|
46
|
+
|
|
47
|
+
let _config = null;
|
|
48
|
+
|
|
49
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
async function ensureDataDir() {
|
|
52
|
+
if (!existsSync(DATA_DIR)) {
|
|
53
|
+
await mkdir(DATA_DIR, { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
export async function loadConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await readFile(CONFIG_FILE, 'utf-8');
|
|
62
|
+
_config = JSON.parse(raw);
|
|
63
|
+
console.log('[lucille] Config loaded');
|
|
64
|
+
} catch {
|
|
65
|
+
// First run — write defaults
|
|
66
|
+
_config = {
|
|
67
|
+
tiers: DEFAULT_TIERS,
|
|
68
|
+
federationPeers: [], // list of peer lucille base URLs
|
|
69
|
+
sanoraUrl: process.env.SANORA_URL || '',
|
|
70
|
+
addieUrl: process.env.ADDIE_URL || ''
|
|
71
|
+
};
|
|
72
|
+
await saveConfig();
|
|
73
|
+
console.log('[lucille] Default config written');
|
|
74
|
+
}
|
|
75
|
+
return _config;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function saveConfig() {
|
|
79
|
+
await ensureDataDir();
|
|
80
|
+
await writeFile(CONFIG_FILE, JSON.stringify(_config, null, 2), 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getConfig() {
|
|
84
|
+
return _config || { tiers: DEFAULT_TIERS, federationPeers: [] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function updateConfig(partial) {
|
|
88
|
+
if (!_config) await loadConfig();
|
|
89
|
+
_config = { ..._config, ...partial };
|
|
90
|
+
await saveConfig();
|
|
91
|
+
return _config;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getTiers() {
|
|
95
|
+
return getConfig().tiers || DEFAULT_TIERS;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getTierById(id) {
|
|
99
|
+
return getTiers().find(t => t.id === id) || getTiers()[0];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getFederationPeers() {
|
|
103
|
+
return getConfig().federationPeers || [];
|
|
104
|
+
}
|