OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / c3a39cd

Add HLS serve endpoints and wire video processing into upload
- Add VideoProcessor to HypernoteServer (import + constructor) - Auto-enqueue videos for HLS processing on upload (non-blocking) - Add GET /api/media/:hash/status for processing status - Add GET /api/notebooks/:nb/media/:hash/stream for master playlist with hash→URL rewriting - Add GET /api/notebooks/:nb/media/:hash/stream/:segHash for level playlists (with .ts filename→hash rewriting) and TS segments - Update video index to store {file, hash} pairs per segment instead of bare hashes, enabling reliable level playlist rewriting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
c3a39cd1dfecdf3f192d76b3c1faa2a898c855f7
by plankatron <fleeky@gmail.com> · 2026-03-19T18:11:12.000Z

Changed files (4) +111 -1

hypernote-server.js +4 -0
……@@ -38,6 +38,7 @@ import { NotebookCrypto } from './lib/notebook-crypto.js'
3838import { LinkManager } from './lib/link-manager.js'
3939import { SitePublisher } from './lib/site-publisher.js'
4040import { MediaStore, parseRange } from './lib/media-store.js'
41import { VideoProcessor } from './lib/media/video-processor.js'
4142import { MultiwriterManager } from './lib/multiwriter/manager.js'
4243import { Router } from './lib/routes/index.js'
4344import { registerNotebookRoutes } from './lib/routes/notebooks.js'
……@@ -135,6 +136,9 @@ class HypernoteServer {
135136 // MediaStore (content-addressed blob storage)
136137 this.mediaStore = new MediaStore(this.store)
137138
139 // VideoProcessor (HLS transcoding, poster extraction)
140 this.videoProcessor = new VideoProcessor(this.mediaStore)
141
138142 // ReplicationManager (Hyperswarm P2P)
139143 this.replicationManager = new ReplicationManager(this.store, this.driveManager)
140144
lib/media/video-processor.js +1 -1
……@@ -204,7 +204,7 @@ export class VideoProcessor {
204204 height: l.height,
205205 bitrate: l.bitrate,
206206 m3u8: l.m3u8Hash,
207 segments: l.segmentHashes.map(s => s.hash)
207 segments: l.segmentHashes.map(s => ({ file: s.file, hash: s.hash }))
208208 })),
209209 processedAt: Date.now()
210210 }
lib/routes/notebooks.js +102 -0
……@@ -375,6 +375,13 @@ export function registerNotebookRoutes (router, server) {
375375 // Content-addressed storage via MediaStore
376376 const hash = await server.mediaStore.put(body, contentType)
377377
378 // Detect video and enqueue for HLS processing (background, non-blocking)
379 if (contentType.startsWith('video/')) {
380 server.videoProcessor.enqueue(hash).catch(err => {
381 console.error('[Video] Processing failed for ' + hash.substring(0, 16) + ':', err.message)
382 })
383 }
384
378385 // Cancel any pending GC for this hash (re-referenced)
379386 server.mediaStore.cancelOrphan(hash)
380387
……@@ -497,6 +504,101 @@ export function registerNotebookRoutes (router, server) {
497504 res.end(buf)
498505 })
499506
507 // ==================== VIDEO / HLS ====================
508
509 // Video processing status
510 router.get('/api/media/:hash/status', async (req, res) => {
511 const status = await server.videoProcessor.getStatus(req.params.hash)
512 if (!status) {
513 json(res, { status: 'unknown', hash: req.params.hash })
514 return
515 }
516 json(res, status)
517 })
518
519 // HLS master playlist — rewrite hash references to real segment URLs
520 router.get('/api/notebooks/:notebook/media/:hash/stream', async (req, res) => {
521 const videoIndex = await server.videoProcessor.getStatus(req.params.hash)
522 if (!videoIndex || videoIndex.status !== 'ready' || !videoIndex.manifest) {
523 res.statusCode = 404
524 res.end('Not found or still processing')
525 return
526 }
527
528 // Get the master playlist from MediaStore
529 const masterBuf = await server.mediaStore.get(videoIndex.manifest)
530 if (!masterBuf) {
531 res.statusCode = 404
532 res.end('Manifest not found')
533 return
534 }
535
536 // Rewrite hash references to real URLs
537 const basePath = (server.pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + req.params.hash + '/stream/'
538 let content = masterBuf.toString()
539
540 // Each line that's a plain hash (64 hex chars) → rewrite to level playlist URL
541 content = content.replace(/^([a-f0-9]{64})$/gm, function (_, hash) {
542 return basePath + hash
543 })
544
545 res.setHeader('Content-Type', 'application/vnd.apple.mpegurl')
546 res.setHeader('Cache-Control', 'no-cache')
547 res.end(content)
548 })
549
550 // HLS segment/playlist serve — stream from MediaStore, rewrite level playlists
551 router.get('/api/notebooks/:notebook/media/:hash/stream/:segmentHash', async (req, res) => {
552 const segHash = req.params.segmentHash
553 const info = await server.mediaStore.getInfo(segHash)
554 if (!info) {
555 res.statusCode = 404
556 res.end('Segment not found')
557 return
558 }
559
560 // If this is a level playlist (.m3u8), rewrite .ts filenames to hash URLs
561 if (info.mimeType === 'application/vnd.apple.mpegurl') {
562 const buf = await server.mediaStore.get(segHash)
563 let content = buf.toString()
564
565 // Find the video index to get segment filename→hash mapping
566 const videoIndex = await server.videoProcessor.getStatus(req.params.hash)
567 if (videoIndex && videoIndex.levels) {
568 // Find the level that owns this m3u8
569 const level = videoIndex.levels.find(l => l.m3u8 === segHash)
570 if (level && level.segments) {
571 const basePath = (server.pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(req.params.notebook) + '/media/' + req.params.hash + '/stream/'
572 // Build filename → hash map from {file, hash} pairs
573 const fileToHash = new Map()
574 for (const seg of level.segments) {
575 fileToHash.set(seg.file, seg.hash)
576 }
577 // Replace each .ts filename line with the hash-based URL
578 content = content.replace(/^(.+\.ts)$/gm, function (_, filename) {
579 const hash = fileToHash.get(filename)
580 return hash ? basePath + hash : filename
581 })
582 }
583 }
584
585 res.setHeader('Content-Type', 'application/vnd.apple.mpegurl')
586 res.setHeader('Cache-Control', 'no-cache')
587 res.end(content)
588 return
589 }
590
591 // Regular segment — stream it with immutable caching
592 res.setHeader('Content-Type', info.mimeType || 'video/mp2t')
593 res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
594 res.setHeader('Content-Length', info.size)
595
596 for await (const chunk of server.mediaStore.getStream(segHash)) {
597 res.write(chunk)
598 }
599 res.end()
600 })
601
500602 // ==================== DATA EXPORT ====================
501603
502604 // Export single notebook (authenticated read)
test/unit/video-processor.test.js +4 -0
……@@ -161,6 +161,10 @@ test('VideoProcessor - enqueue processes and writes video index', async (t) => {
161161 t.ok(status.levels, 'levels in index')
162162 t.ok(status.levels.length >= 1, 'at least one level')
163163 t.ok(status.levels[0].segments.length > 0, 'segments in level')
164 // Segments are now {file, hash} objects
165 t.ok(status.levels[0].segments[0].file, 'segment has file property')
166 t.ok(status.levels[0].segments[0].hash, 'segment has hash property')
167 t.is(status.levels[0].segments[0].hash.length, 64, 'segment hash is SHA-256 hex')
164168 t.ok(status.duration > 0, 'duration > 0')
165169
166170 await store.close()