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.
@@ -0,0 +1,83 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+
4
+ const keyDepth = 2;
5
+ const delimiter = '_';
6
+ const basePath = 'data/lucille';
7
+
8
+ const filePathForKey = async (key) => {
9
+ let mutatingKey = key.replace(':', delimiter).trim();
10
+ const filePathParts = [];
11
+ for (let i = 0; i < keyDepth; i++) {
12
+ filePathParts.push(mutatingKey.slice(-3));
13
+ mutatingKey = mutatingKey.slice(0, -3);
14
+ }
15
+ filePathParts.push(mutatingKey);
16
+ filePathParts.push(basePath);
17
+
18
+ const filePath = filePathParts.reverse().join('/');
19
+ return filePath;
20
+ };
21
+
22
+ const set = async (key, value) => {
23
+ const filePath = await filePathForKey(key);
24
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
25
+ await fs.writeFile(filePath, value);
26
+ return true;
27
+ };
28
+
29
+ const get = async (key) => {
30
+ const filePath = await filePathForKey(key);
31
+ try {
32
+ return await fs.readFile(filePath, 'utf8');
33
+ } catch (err) {
34
+ return null;
35
+ }
36
+ };
37
+
38
+ const getAll = async (pattern) => {
39
+ try {
40
+ const entries = await fs.readdir(basePath, { recursive: true, withFileTypes: true });
41
+
42
+ const filteredEntries = entries.filter(entry => {
43
+ const isFile = entry.isFile();
44
+ const relativePath = entry.path
45
+ ? path.relative(basePath, path.join(entry.path, entry.name))
46
+ : entry.name;
47
+ return isFile && relativePath.indexOf(pattern) > -1;
48
+ });
49
+
50
+ const files = filteredEntries.map(async entry => {
51
+ const fullPath = path.join(entry.path || basePath, entry.name);
52
+ const relativePath = entry.path
53
+ ? path.relative(basePath, path.join(entry.path, entry.name))
54
+ : entry.name;
55
+ return {
56
+ filename: relativePath,
57
+ content: await fs.readFile(fullPath, 'utf8')
58
+ };
59
+ });
60
+
61
+ return await Promise.all(files);
62
+ } catch (err) {
63
+ return [];
64
+ }
65
+ };
66
+
67
+ const del = async (key) => {
68
+ const filePath = await filePathForKey(key);
69
+ await fs.unlink(filePath);
70
+ return true;
71
+ };
72
+
73
+ const createClient = () => {
74
+ return {
75
+ on: () => createClient,
76
+ };
77
+ };
78
+
79
+ createClient.connect = () => {
80
+ return { set, get, getAll, del };
81
+ };
82
+
83
+ export { createClient };
@@ -0,0 +1,105 @@
1
+ import crypto from 'crypto';
2
+ import { createClient } from './client.js';
3
+ import sessionless from 'sessionless-node';
4
+
5
+ const client = await createClient()
6
+ .on('error', err => console.log('Client Error', err))
7
+ .connect();
8
+
9
+ const db = {
10
+ getUserByUUID: async (uuid) => {
11
+ const user = await client.get(`user:${uuid}`);
12
+ if (!user) throw new Error('not found');
13
+ return JSON.parse(user);
14
+ },
15
+
16
+ getUserByPublicKey: async (pubKey) => {
17
+ const uuid = await client.get(`pubKey:${pubKey}`);
18
+ if (!uuid) throw new Error('not found');
19
+ const user = await client.get(`user:${uuid}`);
20
+ if (!user) throw new Error('not found');
21
+ return JSON.parse(user);
22
+ },
23
+
24
+ putUser: async (user) => {
25
+ const uuid = sessionless.generateUUID();
26
+ user.uuid = uuid;
27
+ await client.set(`user:${uuid}`, JSON.stringify(user));
28
+ await client.set(`pubKey:${user.pubKey}`, uuid);
29
+ return user;
30
+ },
31
+
32
+ saveUser: async (user) => {
33
+ await client.set(`user:${user.uuid}`, JSON.stringify(user));
34
+ return true;
35
+ },
36
+
37
+ deleteUser: async (user) => {
38
+ await client.del(`pubKey:${user.pubKey}`);
39
+ await client.del(`user:${user.uuid}`);
40
+ return true;
41
+ },
42
+
43
+ saveKeys: async (keys) => {
44
+ await client.set('keys', JSON.stringify(keys));
45
+ },
46
+
47
+ getKeys: async () => {
48
+ const keyString = await client.get('keys');
49
+ return JSON.parse(keyString);
50
+ },
51
+
52
+ putVideo: async (user, video) => {
53
+ const uuid = user.uuid;
54
+ video.uuid = uuid;
55
+ video.videoId = crypto.createHash('sha256').update(uuid + video.title).digest('hex');
56
+ video.createdAt = video.createdAt || new Date().toISOString();
57
+ await client.set(`${uuid}:video:${video.title}`, JSON.stringify(video));
58
+
59
+ // Reverse index for O(1) watch lookup
60
+ await client.set(`videoId:${video.videoId}`, JSON.stringify({ uuid, title: video.title }));
61
+
62
+ const videosJSON = (await client.get(`videos:${uuid}`)) || '{}';
63
+ const videos = JSON.parse(videosJSON);
64
+ videos[video.title] = video;
65
+ await client.set(`videos:${uuid}`, JSON.stringify(videos));
66
+
67
+ return video;
68
+ },
69
+
70
+ getVideo: async (uuid, title) => {
71
+ const videoJSON = await client.get(`${uuid}:video:${title}`);
72
+ if (!videoJSON) throw new Error('not found');
73
+ return JSON.parse(videoJSON);
74
+ },
75
+
76
+ saveVideo: async (video) => {
77
+ await client.set(`${video.uuid}:video:${video.title}`, JSON.stringify(video));
78
+ if (video.videoId) {
79
+ await client.set(`videoId:${video.videoId}`, JSON.stringify({ uuid: video.uuid, title: video.title }));
80
+ }
81
+
82
+ const videosJSON = (await client.get(`videos:${video.uuid}`)) || '{}';
83
+ const videos = JSON.parse(videosJSON);
84
+ videos[video.title] = video;
85
+ await client.set(`videos:${video.uuid}`, JSON.stringify(videos));
86
+
87
+ return true;
88
+ },
89
+
90
+ getVideos: async (uuid) => {
91
+ const videosJSON = (await client.get(`videos:${uuid}`)) || '{}';
92
+ return JSON.parse(videosJSON);
93
+ },
94
+
95
+ getVideoByVideoId: async (videoId) => {
96
+ const refJSON = await client.get(`videoId:${videoId}`);
97
+ if (!refJSON) throw new Error('not found');
98
+ const { uuid, title } = JSON.parse(refJSON);
99
+ const videoJSON = await client.get(`${uuid}:video:${title}`);
100
+ if (!videoJSON) throw new Error('not found');
101
+ return JSON.parse(videoJSON);
102
+ }
103
+ };
104
+
105
+ export default db;
package/src/seeder.js ADDED
@@ -0,0 +1,112 @@
1
+ import WebTorrent from 'webtorrent';
2
+ import https from 'https';
3
+ import http from 'http';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import { presignedGet, listVideos } from './spaces.js';
8
+ import { TRACKER_PORT } from './tracker.js';
9
+
10
+ const wtClient = new WebTorrent();
11
+
12
+ // Map of Spaces key → torrent instance
13
+ const torrents = new Map();
14
+
15
+ // Full announce list: own tracker + all federation peer trackers.
16
+ // Populated on startup by lucille.js after fetching peers; falls back to own tracker only.
17
+ let _trackerList = null;
18
+
19
+ const getOwnTrackerUrl = () =>
20
+ process.env.TRACKER_URL || `ws://localhost:${TRACKER_PORT}`;
21
+
22
+ /**
23
+ * Called from lucille.js after fetching federation peer tracker URLs.
24
+ * Merges own tracker with peer trackers, deduplicates.
25
+ */
26
+ const updateTrackerList = (peerTrackerUrls = []) => {
27
+ const all = [getOwnTrackerUrl(), ...peerTrackerUrls].filter(Boolean);
28
+ _trackerList = [...new Set(all)];
29
+ console.log(`[lucille] Tracker announce list (${_trackerList.length}): ${_trackerList.join(', ')}`);
30
+ };
31
+
32
+ const getAnnounceList = () => {
33
+ return _trackerList || [getOwnTrackerUrl()];
34
+ };
35
+
36
+ const downloadToTemp = (url, filename) => {
37
+ return new Promise((resolve, reject) => {
38
+ const dest = path.join(os.tmpdir(), filename);
39
+ if (fs.existsSync(dest)) return resolve(dest);
40
+
41
+ const file = fs.createWriteStream(dest);
42
+ const getter = url.startsWith('https') ? https : http;
43
+
44
+ getter.get(url, res => {
45
+ res.pipe(file);
46
+ file.on('finish', () => file.close(() => resolve(dest)));
47
+ }).on('error', err => {
48
+ fs.unlink(dest, () => {});
49
+ reject(err);
50
+ });
51
+ });
52
+ };
53
+
54
+ const seedVideo = async (key) => {
55
+ if (torrents.has(key)) return torrents.get(key);
56
+
57
+ console.log(`[lucille] Seeding: ${key}`);
58
+ const url = await presignedGet(key);
59
+ const filename = path.basename(key);
60
+ const localPath = await downloadToTemp(url, filename);
61
+
62
+ return new Promise((resolve, reject) => {
63
+ wtClient.seed(localPath, {
64
+ announce: getAnnounceList()
65
+ }, torrent => {
66
+ torrents.set(key, torrent);
67
+ console.log(`[lucille] Seeding ${filename} — ${torrent.magnetURI.slice(0, 60)}…`);
68
+ resolve(torrent);
69
+ });
70
+ });
71
+ };
72
+
73
+ const seedAll = async () => {
74
+ const videos = await listVideos();
75
+ for (const video of videos) {
76
+ await seedVideo(video.key).catch(err =>
77
+ console.error(`[lucille] Failed to seed ${video.key}:`, err.message)
78
+ );
79
+ }
80
+ };
81
+
82
+ const getMagnet = async (key) => {
83
+ const torrent = await seedVideo(key);
84
+ return {
85
+ magnetURI: torrent.magnetURI,
86
+ infoHash: torrent.infoHash,
87
+ name: torrent.name
88
+ };
89
+ };
90
+
91
+ const getStatus = () => {
92
+ const active = [];
93
+ for (const [key, torrent] of torrents.entries()) {
94
+ active.push({
95
+ key,
96
+ infoHash: torrent.infoHash,
97
+ peers: torrent.numPeers,
98
+ uploadSpeed: torrent.uploadSpeed,
99
+ downloaded: torrent.downloaded
100
+ });
101
+ }
102
+ return {
103
+ trackers: getAnnounceList(),
104
+ torrents: active,
105
+ totalPeers: wtClient.torrents.reduce((n, t) => n + t.numPeers, 0)
106
+ };
107
+ };
108
+
109
+ const destroySeeder = () =>
110
+ new Promise(resolve => wtClient.destroy(() => resolve()));
111
+
112
+ export { seedVideo, seedAll, getMagnet, getStatus, destroySeeder, updateTrackerList };
package/src/spaces.js ADDED
@@ -0,0 +1,68 @@
1
+ import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
2
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
+ import fs from 'fs';
4
+
5
+ const client = new S3Client({
6
+ endpoint: `https://${process.env.DO_SPACES_REGION}.digitaloceanspaces.com`,
7
+ region: process.env.DO_SPACES_REGION,
8
+ credentials: {
9
+ accessKeyId: process.env.DO_SPACES_KEY,
10
+ secretAccessKey: process.env.DO_SPACES_SECRET
11
+ }
12
+ });
13
+
14
+ const BUCKET = process.env.DO_SPACES_BUCKET;
15
+
16
+ const uploadVideo = async (localPath, key) => {
17
+ const stream = fs.createReadStream(localPath);
18
+ const stat = fs.statSync(localPath);
19
+
20
+ await client.send(new PutObjectCommand({
21
+ Bucket: BUCKET,
22
+ Key: key,
23
+ Body: stream,
24
+ ContentLength: stat.size,
25
+ ContentType: 'video/mp4',
26
+ ACL: 'private'
27
+ }));
28
+
29
+ console.log(`Uploaded ${key} (${(stat.size / 1e9).toFixed(2)} GB)`);
30
+ return key;
31
+ };
32
+
33
+ const uploadVideoBuffer = async (buffer, key, contentType = 'video/mp4') => {
34
+ await client.send(new PutObjectCommand({
35
+ Bucket: BUCKET,
36
+ Key: key,
37
+ Body: buffer,
38
+ ContentLength: buffer.length,
39
+ ContentType: contentType,
40
+ ACL: 'private'
41
+ }));
42
+
43
+ console.log(`Uploaded ${key} (${(buffer.length / 1e6).toFixed(2)} MB)`);
44
+ return key;
45
+ };
46
+
47
+ const presignedGet = async (key, expiresIn = 3600) => {
48
+ const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
49
+ return getSignedUrl(client, command, { expiresIn });
50
+ };
51
+
52
+ const listVideos = async (prefix = 'videos/') => {
53
+ const response = await client.send(new ListObjectsV2Command({
54
+ Bucket: BUCKET,
55
+ Prefix: prefix
56
+ }));
57
+ return (response.Contents || []).map(obj => ({
58
+ key: obj.Key,
59
+ size: obj.Size,
60
+ lastModified: obj.LastModified
61
+ }));
62
+ };
63
+
64
+ const cdnUrl = (key) => {
65
+ return `${process.env.DO_SPACES_CDN_ENDPOINT}/${key}`;
66
+ };
67
+
68
+ export { uploadVideo, uploadVideoBuffer, presignedGet, listVideos, cdnUrl };
package/src/tracker.js ADDED
@@ -0,0 +1,49 @@
1
+ import { Server } from 'bittorrent-tracker';
2
+
3
+ const TRACKER_PORT = parseInt(process.env.TRACKER_PORT) || 8000;
4
+
5
+ let trackerServer = null;
6
+
7
+ const startTracker = () => {
8
+ return new Promise((resolve, reject) => {
9
+ trackerServer = new Server({
10
+ udp: false,
11
+ http: true,
12
+ ws: true,
13
+ stats: true
14
+ });
15
+
16
+ trackerServer.on('error', err => console.error('Tracker error:', err.message));
17
+ trackerServer.on('warning', msg => console.warn('Tracker warning:', msg));
18
+
19
+ trackerServer.on('listening', () => {
20
+ console.log(`Tracker WS listening on ws://0.0.0.0:${TRACKER_PORT}`);
21
+ console.log(`Tracker HTTP listening on http://0.0.0.0:${TRACKER_PORT}`);
22
+ resolve(trackerServer);
23
+ });
24
+
25
+ trackerServer.on('start', (addr, params) => {
26
+ const hash = params.info_hash ? params.info_hash.toString('hex').slice(0, 8) : 'unknown';
27
+ console.log(`Peer joined infoHash=${hash}`);
28
+ });
29
+
30
+ trackerServer.on('stop', (addr, params) => {
31
+ const hash = params.info_hash ? params.info_hash.toString('hex').slice(0, 8) : 'unknown';
32
+ console.log(`Peer left infoHash=${hash}`);
33
+ });
34
+
35
+ trackerServer.listen(TRACKER_PORT, () => resolve(trackerServer));
36
+ });
37
+ };
38
+
39
+ const stopTracker = () => {
40
+ return new Promise((resolve) => {
41
+ if (trackerServer) {
42
+ trackerServer.close(() => resolve());
43
+ } else {
44
+ resolve();
45
+ }
46
+ });
47
+ };
48
+
49
+ export { startTracker, stopTracker, TRACKER_PORT };
@@ -0,0 +1,236 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Lucille</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ body {
11
+ background: #000;
12
+ color: #fff;
13
+ font-family: system-ui, sans-serif;
14
+ height: 100dvh;
15
+ display: flex;
16
+ flex-direction: column;
17
+ overflow: hidden;
18
+ }
19
+
20
+ #video-wrap {
21
+ flex: 1;
22
+ position: relative;
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ background: #000;
27
+ min-height: 0;
28
+ }
29
+
30
+ video {
31
+ max-width: 100%;
32
+ max-height: 100%;
33
+ width: 100%;
34
+ display: block;
35
+ }
36
+
37
+ /* Overlay shown while loading */
38
+ #overlay {
39
+ position: absolute;
40
+ inset: 0;
41
+ display: flex;
42
+ flex-direction: column;
43
+ align-items: center;
44
+ justify-content: center;
45
+ gap: 16px;
46
+ background: rgba(0,0,0,0.75);
47
+ transition: opacity 0.4s;
48
+ }
49
+
50
+ #overlay.hidden { opacity: 0; pointer-events: none; }
51
+
52
+ .spinner {
53
+ width: 48px;
54
+ height: 48px;
55
+ border: 4px solid rgba(255,255,255,0.2);
56
+ border-top-color: #fff;
57
+ border-radius: 50%;
58
+ animation: spin 0.8s linear infinite;
59
+ }
60
+ @keyframes spin { to { transform: rotate(360deg); } }
61
+
62
+ #overlay-msg {
63
+ font-size: 0.9rem;
64
+ color: rgba(255,255,255,0.7);
65
+ text-align: center;
66
+ padding: 0 24px;
67
+ }
68
+
69
+ #progress-bar-wrap {
70
+ width: 200px;
71
+ height: 4px;
72
+ background: rgba(255,255,255,0.15);
73
+ border-radius: 2px;
74
+ overflow: hidden;
75
+ display: none;
76
+ }
77
+ #progress-bar {
78
+ height: 100%;
79
+ width: 0%;
80
+ background: #fff;
81
+ border-radius: 2px;
82
+ transition: width 0.3s;
83
+ }
84
+
85
+ /* Bottom info bar */
86
+ #info-bar {
87
+ flex-shrink: 0;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: space-between;
91
+ padding: 8px 16px;
92
+ background: #111;
93
+ font-size: 0.8rem;
94
+ color: rgba(255,255,255,0.5);
95
+ gap: 12px;
96
+ min-height: 36px;
97
+ }
98
+
99
+ #video-title {
100
+ font-weight: 600;
101
+ color: #fff;
102
+ white-space: nowrap;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ flex: 1;
106
+ }
107
+
108
+ #peer-count { white-space: nowrap; flex-shrink: 0; }
109
+ #peer-count.active { color: #4ade80; }
110
+
111
+ /* Error state */
112
+ #error-msg {
113
+ display: none;
114
+ position: absolute;
115
+ inset: 0;
116
+ align-items: center;
117
+ justify-content: center;
118
+ flex-direction: column;
119
+ gap: 12px;
120
+ background: #000;
121
+ color: rgba(255,255,255,0.6);
122
+ font-size: 0.9rem;
123
+ text-align: center;
124
+ padding: 24px;
125
+ }
126
+ #error-msg.show { display: flex; }
127
+ #error-msg strong { font-size: 1.1rem; color: #fff; }
128
+ </style>
129
+ </head>
130
+ <body>
131
+
132
+ <div id="video-wrap">
133
+ <video id="video" controls playsinline></video>
134
+
135
+ <div id="overlay">
136
+ <div class="spinner"></div>
137
+ <div id="overlay-msg">Fetching video info…</div>
138
+ <div id="progress-bar-wrap"><div id="progress-bar"></div></div>
139
+ </div>
140
+
141
+ <div id="error-msg">
142
+ <strong>Unable to load video</strong>
143
+ <span id="error-detail"></span>
144
+ </div>
145
+ </div>
146
+
147
+ <div id="info-bar">
148
+ <span id="video-title">Lucille</span>
149
+ <span id="peer-count">⬤ connecting…</span>
150
+ </div>
151
+
152
+ <script src="https://cdn.jsdelivr.net/npm/webtorrent@latest/webtorrent.min.js"></script>
153
+ <script>
154
+ (async () => {
155
+ const videoEl = document.getElementById('video');
156
+ const overlay = document.getElementById('overlay');
157
+ const overlayMsg = document.getElementById('overlay-msg');
158
+ const progressWrap = document.getElementById('progress-bar-wrap');
159
+ const progressBar = document.getElementById('progress-bar');
160
+ const titleEl = document.getElementById('video-title');
161
+ const peerEl = document.getElementById('peer-count');
162
+ const errorEl = document.getElementById('error-msg');
163
+ const errorDetail = document.getElementById('error-detail');
164
+
165
+ function showError(msg) {
166
+ overlay.classList.add('hidden');
167
+ errorEl.classList.add('show');
168
+ errorDetail.textContent = msg;
169
+ }
170
+
171
+ // --- 1. Extract videoId from URL path ---
172
+ const videoId = window.location.pathname.split('/').filter(Boolean).pop();
173
+ if (!videoId) { showError('No video ID in URL.'); return; }
174
+
175
+ // --- 2. Call the streaming API ---
176
+ let watchInfo;
177
+ try {
178
+ const res = await fetch(`/video/${videoId}/watch`);
179
+ if (!res.ok) { showError(`Video not found (${res.status}).`); return; }
180
+ watchInfo = await res.json();
181
+ } catch (err) {
182
+ showError('Could not reach Lucille API.');
183
+ return;
184
+ }
185
+
186
+ const { magnetURI, title, trackerUrl } = watchInfo;
187
+ if (title) {
188
+ titleEl.textContent = title;
189
+ document.title = title + ' — Lucille';
190
+ }
191
+
192
+ // --- 3. Stream via WebTorrent ---
193
+ overlayMsg.textContent = 'Connecting to peers…';
194
+
195
+ const client = new WebTorrent();
196
+
197
+ client.on('error', err => showError(err.message));
198
+
199
+ client.add(magnetURI, { announce: trackerUrl ? [trackerUrl] : [] }, torrent => {
200
+ // Pick the largest file (the video)
201
+ const file = torrent.files.reduce((a, b) => a.length > b.length ? a : b);
202
+
203
+ overlayMsg.textContent = 'Buffering…';
204
+ progressWrap.style.display = 'block';
205
+
206
+ file.renderTo(videoEl, err => {
207
+ if (err) { showError(err.message); return; }
208
+ overlay.classList.add('hidden');
209
+ });
210
+
211
+ // Progress while buffering
212
+ const progressInterval = setInterval(() => {
213
+ const pct = (torrent.progress * 100).toFixed(1);
214
+ progressBar.style.width = pct + '%';
215
+ if (torrent.progress > 0.01) {
216
+ overlayMsg.textContent = `Buffering… ${pct}%`;
217
+ }
218
+ if (torrent.progress >= 1) clearInterval(progressInterval);
219
+ }, 500);
220
+
221
+ // Peer count in info bar
222
+ setInterval(() => {
223
+ const n = torrent.numPeers;
224
+ peerEl.textContent = `⬤ ${n} peer${n !== 1 ? 's' : ''}`;
225
+ peerEl.className = n > 0 ? 'active' : '';
226
+ }, 2000);
227
+
228
+ // Hide overlay once enough is downloaded to play
229
+ videoEl.addEventListener('canplay', () => {
230
+ overlay.classList.add('hidden');
231
+ }, { once: true });
232
+ });
233
+ })();
234
+ </script>
235
+ </body>
236
+ </html>