| … | … | @@ -375,6 +375,13 @@ export function registerNotebookRoutes (router, server) { |
| 375 | 375 | // Content-addressed storage via MediaStore |
| 376 | 376 | const hash = await server.mediaStore.put(body, contentType) |
| 377 | 377 | |
| 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 | |
| 378 | 385 | // Cancel any pending GC for this hash (re-referenced) |
| 379 | 386 | server.mediaStore.cancelOrphan(hash) |
| 380 | 387 | |
| … | … | @@ -497,6 +504,101 @@ export function registerNotebookRoutes (router, server) { |
| 497 | 504 | res.end(buf) |
| 498 | 505 | }) |
| 499 | 506 | |
| 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 | |
| 500 | 602 | // ==================== DATA EXPORT ==================== |
| 501 | 603 | |
| 502 | 604 | // Export single notebook (authenticated read) |