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 ADDED
@@ -0,0 +1,31 @@
1
+ # Lucille
2
+
3
+ Named for the incomporable Lucille Ball, lucille is a part of the 4's Entertainment System providing for streaming, and downloadable video.
4
+ It utilizes bittorrent via the webtorrent protocol to enable P2P capabilites to save bandwidth, and provide scalability that other distributed systems cannot while simultaneously saving on bandwidth costs obviating the need for ad-supported revenue.
5
+ The point is for it to get so popular it breaks.
6
+ Then we'll know we're doing something right.
7
+
8
+ ## Overview
9
+
10
+ This first iteration of lucille is built on Digital Ocean infrastructure utilizing the following stack:
11
+
12
+ -------------
13
+ || Storage ||
14
+ || Seeder ||
15
+ || Tracker ||
16
+ || Viewer ||
17
+ -------------
18
+
19
+ If you're unfamiliar torrents, no worries!
20
+ All they mean is that while you're streaming your video, you're enabling others to stream it as well.
21
+ This happens seemlessly for you in the background, and lucille doesn't track whether you do or don't, so feel free to turn it off if you'd like.
22
+
23
+ ### Video posters
24
+
25
+ Posters will likely interface with lucille through a client application that will handle the upload and storage of their video.
26
+ The first such application will be part of the shoppe plugin for the federated wiki, and can be found here: https://github.com/planet-nine-app/wiki-plugin-shoppe.
27
+
28
+ ### Video watchers
29
+
30
+ Watchers will likewise interface through a client application, and the first one will probably be at the main lucille hub, which is tbd, or in fed wikis as well.
31
+ We shall see.
package/STORAGE.md ADDED
@@ -0,0 +1,419 @@
1
+ # Storage and Seeding: Build Guide
2
+
3
+ This covers the two backend Node services you need before the wiki and frontend
4
+ can do anything useful: the DO Spaces storage layer and the WebTorrent seeder +
5
+ tracker. Both are designed to run as long-lived fedwiki plugin processes.
6
+
7
+ ---
8
+
9
+ ## Part 1 — DO Spaces (Storage)
10
+
11
+ ### What it does
12
+
13
+ Spaces is S3-compatible object storage. It holds the raw video files and
14
+ optionally serves them through a built-in CDN. The seeder pulls from Spaces
15
+ when it first seeds a file; after that, WebTorrent peers take over the majority
16
+ of delivery.
17
+
18
+ ### Setup
19
+
20
+ Create a Space in the DigitalOcean control panel. Pick the region closest to
21
+ your users. Enable the CDN endpoint — you'll use this URL to serve the initial
22
+ seed bytes cheaply before P2P kicks in.
23
+
24
+ Generate a Spaces access key under **API → Spaces Keys**. You'll get an access
25
+ key ID and a secret. Store both as environment variables — never in code.
26
+
27
+ ```
28
+ DO_SPACES_KEY=your_access_key
29
+ DO_SPACES_SECRET=your_secret_key
30
+ DO_SPACES_REGION=nyc3
31
+ DO_SPACES_BUCKET=your-bucket-name
32
+ DO_SPACES_CDN_ENDPOINT=https://your-bucket.nyc3.cdn.digitaloceanspaces.com
33
+ ```
34
+
35
+ ### Install
36
+
37
+ ```bash
38
+ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
39
+ ```
40
+
41
+ ### Client setup
42
+
43
+ ```js
44
+ // spaces.js
45
+ const { S3Client, PutObjectCommand, GetObjectCommand,
46
+ ListObjectsV2Command } = require('@aws-sdk/client-s3')
47
+ const { getSignedUrl } = require('@aws-sdk/s3-request-presigner')
48
+
49
+ const client = new S3Client({
50
+ endpoint: `https://${process.env.DO_SPACES_REGION}.digitaloceanspaces.com`,
51
+ region: process.env.DO_SPACES_REGION,
52
+ credentials: {
53
+ accessKeyId: process.env.DO_SPACES_KEY,
54
+ secretAccessKey: process.env.DO_SPACES_SECRET
55
+ }
56
+ })
57
+
58
+ const BUCKET = process.env.DO_SPACES_BUCKET
59
+ ```
60
+
61
+ ### Uploading a video
62
+
63
+ Stream the file into Spaces rather than buffering the whole thing in memory.
64
+ For large files you'll want multipart upload, but the SDK handles that
65
+ automatically when you pass a stream body.
66
+
67
+ ```js
68
+ const fs = require('fs')
69
+
70
+ async function uploadVideo(localPath, key) {
71
+ const stream = fs.createReadStream(localPath)
72
+ const stat = fs.statSync(localPath)
73
+
74
+ await client.send(new PutObjectCommand({
75
+ Bucket: BUCKET,
76
+ Key: key, // e.g. 'videos/my-film.mp4'
77
+ Body: stream,
78
+ ContentLength: stat.size,
79
+ ContentType: 'video/mp4',
80
+ ACL: 'private' // keep private; serve via presigned URL or CDN
81
+ }))
82
+
83
+ console.log(`Uploaded ${key} (${(stat.size / 1e9).toFixed(2)} GB)`)
84
+ }
85
+ ```
86
+
87
+ ### Generating a presigned download URL
88
+
89
+ The auth plugin will call this before handing the viewer a magnet link. The
90
+ presigned URL lets the seeder fetch the file without making it fully public.
91
+ Expiry of 1 hour is plenty — the seeder only needs it once at startup.
92
+
93
+ ```js
94
+ async function presignedGet(key, expiresIn = 3600) {
95
+ const command = new GetObjectCommand({ Bucket: BUCKET, Key: key })
96
+ return getSignedUrl(client, command, { expiresIn })
97
+ }
98
+ ```
99
+
100
+ ### Listing available videos
101
+
102
+ ```js
103
+ async function listVideos(prefix = 'videos/') {
104
+ const response = await client.send(new ListObjectsV2Command({
105
+ Bucket: BUCKET,
106
+ Prefix: prefix
107
+ }))
108
+ return (response.Contents || []).map(obj => ({
109
+ key: obj.Key,
110
+ size: obj.Size,
111
+ lastModified: obj.LastModified
112
+ }))
113
+ }
114
+
115
+ module.exports = { uploadVideo, presignedGet, listVideos }
116
+ ```
117
+
118
+ ### CDN delivery
119
+
120
+ Once a file is in Spaces, its CDN URL is:
121
+
122
+ ```
123
+ https://<bucket>.<region>.cdn.digitaloceanspaces.com/<key>
124
+ ```
125
+
126
+ You can use this CDN URL directly as the download source when creating a
127
+ torrent. The CDN absorbs the initial cold-start bandwidth before P2P takes
128
+ over, and it caches globally so the seeder Droplet's egress bill stays low.
129
+
130
+ ---
131
+
132
+ ## Part 2 — WebTorrent Seeder
133
+
134
+ ### What it does
135
+
136
+ A long-lived Node process that seeds every video file, maintains an in-memory
137
+ torrent registry, and exposes a small HTTP API so the auth plugin can get
138
+ magnet links without spawning its own WebTorrent client.
139
+
140
+ ### Install
141
+
142
+ ```bash
143
+ npm install webtorrent express
144
+ ```
145
+
146
+ ### The seeder process
147
+
148
+ ```js
149
+ // seeder.js
150
+ const WebTorrent = require('webtorrent')
151
+ const express = require('express')
152
+ const https = require('https')
153
+ const fs = require('fs')
154
+ const path = require('path')
155
+ const os = require('os')
156
+
157
+ const { presignedGet, listVideos } = require('./spaces')
158
+
159
+ const client = new WebTorrent()
160
+ const app = express()
161
+ app.use(express.json())
162
+
163
+ // Map of Spaces key → torrent instance
164
+ const torrents = new Map()
165
+
166
+ // Download a file from a URL into a temp directory and return the local path
167
+ function downloadToTemp(url, filename) {
168
+ return new Promise((resolve, reject) => {
169
+ const dest = path.join(os.tmpdir(), filename)
170
+ // Skip if already cached locally
171
+ if (fs.existsSync(dest)) return resolve(dest)
172
+
173
+ const file = fs.createWriteStream(dest)
174
+ https.get(url, res => {
175
+ res.pipe(file)
176
+ file.on('finish', () => file.close(() => resolve(dest)))
177
+ }).on('error', err => {
178
+ fs.unlink(dest, () => {})
179
+ reject(err)
180
+ })
181
+ })
182
+ }
183
+
184
+ // Seed a single video by its Spaces key
185
+ async function seedVideo(key) {
186
+ if (torrents.has(key)) return torrents.get(key)
187
+
188
+ console.log(`Seeding: ${key}`)
189
+ const url = await presignedGet(key)
190
+ const filename = path.basename(key)
191
+ const localPath = await downloadToTemp(url, filename)
192
+
193
+ return new Promise((resolve, reject) => {
194
+ client.seed(localPath, {
195
+ // Point peers at your tracker
196
+ announce: [process.env.TRACKER_URL || 'ws://localhost:8000']
197
+ }, torrent => {
198
+ torrents.set(key, torrent)
199
+ console.log(`Seeding ${filename} — magnet: ${torrent.magnetURI}`)
200
+ resolve(torrent)
201
+ })
202
+ })
203
+ }
204
+
205
+ // Seed everything in Spaces at startup
206
+ async function seedAll() {
207
+ const videos = await listVideos()
208
+ for (const video of videos) {
209
+ await seedVideo(video.key).catch(err =>
210
+ console.error(`Failed to seed ${video.key}:`, err.message)
211
+ )
212
+ }
213
+ }
214
+
215
+ // API routes consumed by the auth plugin
216
+
217
+ // GET /magnet?key=videos/my-film.mp4
218
+ app.get('/magnet', async (req, res) => {
219
+ const { key } = req.query
220
+ if (!key) return res.status(400).json({ error: 'key required' })
221
+
222
+ try {
223
+ const torrent = await seedVideo(key)
224
+ res.json({
225
+ magnetURI: torrent.magnetURI,
226
+ infoHash: torrent.infoHash,
227
+ name: torrent.name
228
+ })
229
+ } catch (err) {
230
+ res.status(500).json({ error: err.message })
231
+ }
232
+ })
233
+
234
+ // GET /status — health check and active torrent list
235
+ app.get('/status', (req, res) => {
236
+ const active = []
237
+ for (const [key, torrent] of torrents.entries()) {
238
+ active.push({
239
+ key,
240
+ infoHash: torrent.infoHash,
241
+ peers: torrent.numPeers,
242
+ uploadSpeed: torrent.uploadSpeed,
243
+ downloaded: torrent.downloaded
244
+ })
245
+ }
246
+ res.json({ torrents: active, totalPeers: client.torrents.reduce((n, t) => n + t.numPeers, 0) })
247
+ })
248
+
249
+ // POST /seed — add a newly uploaded video without restarting
250
+ app.post('/seed', async (req, res) => {
251
+ const { key } = req.body
252
+ if (!key) return res.status(400).json({ error: 'key required' })
253
+ try {
254
+ const torrent = await seedVideo(key)
255
+ res.json({ magnetURI: torrent.magnetURI })
256
+ } catch (err) {
257
+ res.status(500).json({ error: err.message })
258
+ }
259
+ })
260
+
261
+ const PORT = process.env.SEEDER_PORT || 7000
262
+ app.listen(PORT, async () => {
263
+ console.log(`Seeder API listening on port ${PORT}`)
264
+ await seedAll()
265
+ })
266
+
267
+ // Graceful shutdown — flush torrents before exit
268
+ process.on('SIGTERM', () => {
269
+ console.log('Shutting down seeder...')
270
+ client.destroy(() => process.exit(0))
271
+ })
272
+ ```
273
+
274
+ ### Notes on the temp cache
275
+
276
+ The seeder downloads each file from Spaces once and caches it in `/tmp`. On
277
+ a 7TB Storage-Optimized Droplet you could cache everything locally and skip
278
+ the Spaces download on restart entirely — just point `client.seed()` at a
279
+ permanent local path. For smaller Droplets, the tmpdir approach is fine; the
280
+ file is re-downloaded from Spaces on each cold start.
281
+
282
+ If your library is large and cold starts are slow, pre-warm the cache on
283
+ Droplet boot using a startup script that calls `POST /seed` for each key.
284
+
285
+ ### Environment variables
286
+
287
+ ```
288
+ TRACKER_URL=ws://your-tracker-host:8000
289
+ SEEDER_PORT=7000
290
+ # Plus the DO_SPACES_* vars from Part 1
291
+ ```
292
+
293
+ ---
294
+
295
+ ## Part 3 — Tracker / Signaling Server
296
+
297
+ ### What it does
298
+
299
+ A WebTorrent-compatible tracker that brokers peer discovery. Viewers register
300
+ their info-hash here and the tracker tells them who else has the same torrent.
301
+ This is a thin WebSocket server — it does no streaming itself.
302
+
303
+ ### Install
304
+
305
+ ```bash
306
+ npm install bittorrent-tracker
307
+ ```
308
+
309
+ ### The tracker process
310
+
311
+ ```js
312
+ // tracker.js
313
+ const Tracker = require('bittorrent-tracker').Server
314
+
315
+ const server = new Tracker({
316
+ udp: false, // browsers can't speak UDP; WebRTC/WS only
317
+ http: true, // fallback for non-WebSocket clients
318
+ ws: true, // WebRTC signaling over WebSocket — the main path
319
+ stats: true // enables the /stats endpoint
320
+ })
321
+
322
+ server.on('error', err => console.error('Tracker error:', err.message))
323
+
324
+ server.on('warning', err => console.warn('Tracker warning:', err.message))
325
+
326
+ server.on('listening', () => {
327
+ const wsAddr = server.ws.address()
328
+ const httpAddr = server.http.address()
329
+ console.log(`Tracker WS listening on ws://0.0.0.0:${wsAddr.port}`)
330
+ console.log(`Tracker HTTP listening on http://0.0.0.0:${httpAddr.port}`)
331
+ })
332
+
333
+ // Optional: log peer join/leave for debugging
334
+ server.on('start', (addr, params) => {
335
+ console.log(`Peer joined infoHash=${params.info_hash.toString('hex').slice(0,8)}`)
336
+ })
337
+ server.on('stop', (addr, params) => {
338
+ console.log(`Peer left infoHash=${params.info_hash.toString('hex').slice(0,8)}`)
339
+ })
340
+
341
+ const PORT = process.env.TRACKER_PORT || 8000
342
+ server.listen(PORT)
343
+
344
+ process.on('SIGTERM', () => server.close(() => process.exit(0)))
345
+ ```
346
+
347
+ That's the entire tracker. The `bittorrent-tracker` package does the rest —
348
+ peer list management, announce intervals, scrape responses.
349
+
350
+ ### Pointing the seeder at the tracker
351
+
352
+ In `seeder.js` above the `announce` array already reads from `TRACKER_URL`.
353
+ Set it to your tracker's public address:
354
+
355
+ ```
356
+ TRACKER_URL=ws://your-tracker-droplet-ip:8000
357
+ ```
358
+
359
+ Viewers pass the same URL via the magnet link's `&tr=` parameter, which the
360
+ auth plugin appends before returning the magnet URI to the browser.
361
+
362
+ ---
363
+
364
+ ## Part 4 — Wiring it together
365
+
366
+ ### Request flow at stream time
367
+
368
+ ```
369
+ viewer pays → auth plugin → GET /magnet?key=videos/film.mp4 → seeder API
370
+ → seeder returns magnetURI (with tracker URL embedded)
371
+ → auth plugin returns magnetURI to viewer
372
+ → viewer's WebTorrent (browser) opens magnet
373
+ → connects to tracker → discovers seeder + other peers
374
+ → streams from peer mesh, seeder fills gaps
375
+ ```
376
+
377
+ ### Startup order
378
+
379
+ 1. Tracker starts first (seeder needs its URL)
380
+ 2. Seeder starts, seeds all Spaces content, registers with tracker
381
+ 3. Auth plugin starts, ready to serve magnet links
382
+
383
+ ### Running as services
384
+
385
+ Both processes are long-lived. On a plain Droplet, use `pm2`:
386
+
387
+ ```bash
388
+ npm install -g pm2
389
+ pm2 start tracker.js --name tracker
390
+ pm2 start seeder.js --name seeder
391
+ pm2 save
392
+ pm2 startup # generates the systemd hook so they survive reboots
393
+ ```
394
+
395
+ ### Firewall rules (ufw)
396
+
397
+ ```bash
398
+ ufw allow 7000/tcp # seeder API (internal only — restrict to your auth droplet IP)
399
+ ufw allow 8000/tcp # tracker HTTP
400
+ ufw allow 8000/udp # tracker UDP (optional, not needed for browser clients)
401
+ ```
402
+
403
+ The seeder API (port 7000) should only be reachable from the auth plugin's
404
+ Droplet, not the public internet. Use DigitalOcean's VPC or a firewall rule
405
+ scoped to your auth Droplet's private IP.
406
+
407
+ ---
408
+
409
+ ## Part 5 — Deployment checklist
410
+
411
+ - [ ] DO Spaces bucket created, CDN enabled
412
+ - [ ] Spaces access key generated, stored as env vars
413
+ - [ ] Tracker Droplet running `tracker.js`, port 8000 open
414
+ - [ ] Seeder Droplet (Premium CPU-Optimized) running `seeder.js`
415
+ - [ ] `TRACKER_URL` env var set on seeder pointing to tracker's public IP
416
+ - [ ] `POST /seed` called after every new upload to Spaces
417
+ - [ ] Seeder API (port 7000) firewalled to auth Droplet only
418
+ - [ ] `pm2 startup` run on both Droplets so processes survive reboots
419
+ - [ ] `/status` endpoint smoke-tested to confirm peers are connecting
@@ -0,0 +1 @@
1
+ {"title":"test-video-1773549834196","description":"A test video","tags":["test","integration"],"uuid":"50a84a5a-6b2b-492e-b279-61eddb392e34","videoId":"1bc176d66a289f8dc346048841aa54e05a3664f83e830e6189a73b961f565fe4","createdAt":"2026-03-15T04:43:54.199Z"}
@@ -0,0 +1 @@
1
+ {"title":"test-video-1773549541230","description":"A test video","tags":["test","integration"],"uuid":"dfa790d7-c4f5-43a9-b7ff-f166f0f344c9","videoId":"6b2c45b7f4f89b1bda0d627adfe01287f83b34aeb919faacc6d4dc2b67c56b64","createdAt":"2026-03-15T04:39:01.233Z"}
@@ -0,0 +1 @@
1
+ {"privateKey":"c1446ecd82f3d199e6a15660f57a2294214ee10b6ebf86431ca6acfa5bd5b23d","pubKey":"035195b65ea792314cba1400189d6dccc0ceb074a592e22500c3e94dead25ecc00"}
@@ -0,0 +1 @@
1
+ 50a84a5a-6b2b-492e-b279-61eddb392e34
@@ -0,0 +1 @@
1
+ dfa790d7-c4f5-43a9-b7ff-f166f0f344c9
@@ -0,0 +1 @@
1
+ {"pubKey":"02b0071b6d90ce7598eabdcfa0596f95359799fd413e300dcbdca823095a4cf884","videos":{},"uuid":"50a84a5a-6b2b-492e-b279-61eddb392e34"}
@@ -0,0 +1 @@
1
+ {"pubKey":"030e4e2f145c4b68863ca42868e08b1559c3c9c10774e61c3f4ef9766d0cdf7e30","videos":{},"uuid":"dfa790d7-c4f5-43a9-b7ff-f166f0f344c9"}
@@ -0,0 +1 @@
1
+ {"test-video-1773549834196":{"title":"test-video-1773549834196","description":"A test video","tags":["test","integration"],"uuid":"50a84a5a-6b2b-492e-b279-61eddb392e34","videoId":"1bc176d66a289f8dc346048841aa54e05a3664f83e830e6189a73b961f565fe4","createdAt":"2026-03-15T04:43:54.199Z"}}
@@ -0,0 +1 @@
1
+ {"test-video-1773549541230":{"title":"test-video-1773549541230","description":"A test video","tags":["test","integration"],"uuid":"dfa790d7-c4f5-43a9-b7ff-f166f0f344c9","videoId":"6b2c45b7f4f89b1bda0d627adfe01287f83b34aeb919faacc6d4dc2b67c56b64","createdAt":"2026-03-15T04:39:01.233Z"}}