/**
* MediaService — Business logic for media upload, serving, and HLS streaming.
*
* Extracts media operations from route handlers into a reusable service.
*/
const MIME_TO_EXT = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/webm': '.webm',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg'
}
const EXT_TO_MIME = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
mp4: 'video/mp4', webm: 'video/webm',
mp3: 'audio/mpeg', ogg: 'audio/ogg'
}
export class MediaService {
constructor ({ mediaStore, videoProcessor, driveManager, noteManager, sitePublisher, authManager }) {
this.mediaStore = mediaStore
this.videoProcessor = videoProcessor
this.driveManager = driveManager
this.noteManager = noteManager
this.sitePublisher = sitePublisher
this.authManager = authManager
}
/**
* Upload media to content-addressed store.
*
* @param {string} notebook - Notebook slug
* @param {Buffer} body - File content
* @param {string} contentType - MIME type from request header
* @param {Object} options - { maxSize }
* @returns {Promise<Object>} { url, hash, filename, size, contentType }
*/
async upload (notebook, body, contentType, options = {}) {
if (!body || body.length === 0) {
throw Object.assign(new Error('Empty body'), { statusCode: 400 })
}
const maxSize = options.maxSize || 50 * 1024 * 1024
if (body.length > maxSize) {
throw Object.assign(
new Error(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`),
{ statusCode: 413 }
)
}
// Content-addressed storage
const hash = await this.mediaStore.put(body, contentType)
// Enqueue video for HLS processing
if (contentType.startsWith('video/')) {
this.videoProcessor.enqueue(hash).catch(err => {
console.error('[Video] Processing failed for ' + hash.substring(0, 16) + ':', err.message)
})
}
// Cancel pending GC for re-referenced media
this.mediaStore.cancelOrphan(hash)
// Record in SharedNotebook for multi-writer sync
const snb = await this.noteManager._getSharedNotebook(notebook)
if (snb) {
await snb.addMedia(null, { hash, mimeType: contentType, filename: hash, size: body.length }, 'user')
}
// Publish site in background
if (this.sitePublisher) {
this.sitePublisher.publishNotebook(notebook).catch(() => {})
}
const url = '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + hash
return { url, hash, filename: hash, size: body.length, contentType }
}
/**
* Resolve media by identifier with 4-level fallback.
*
* Returns { source, info, data } or null if not found.
* - source: 'mediastore' | 'legacy' | 'drive' | 'shared'
* - info: { size, mimeType } (for mediastore/legacy)
* - data: Buffer (for drive/shared fallbacks)
*/
async resolve (notebook, identifier) {
// Path-traversal guard: identifier is a media hash/filename used to build a
// drive path (/media/<identifier>) — it is never a path itself.
if (typeof identifier !== 'string' || !identifier ||
identifier.includes('/') || identifier.includes('\\') ||
identifier.includes('..') || identifier.includes('\0')) {
return null
}
// 1. MediaStore (content-addressed)
const info = await this.mediaStore.getInfo(identifier)
if (info) {
return { source: 'mediastore', info, hash: identifier }
}
// 2. Legacy filename → hash mapping
const legacyEntry = await this.mediaStore._index.get('legacy:' + identifier)
if (legacyEntry && legacyEntry.value && legacyEntry.value.hash) {
const legacyInfo = await this.mediaStore.getInfo(legacyEntry.value.hash)
if (legacyInfo) {
return { source: 'legacy', info: legacyInfo, hash: legacyEntry.value.hash }
}
}
// 3. Hyperdrive path
const drive = await this.driveManager.getDrive(notebook)
if (drive) {
const buf = await drive.get('/media/' + identifier)
if (buf) {
const ext = identifier.split('.').pop()
const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
return { source: 'drive', data: buf, mimeType }
}
}
// 4. SharedNotebook blob store
const snb = await this.noteManager._getSharedNotebook(notebook)
if (snb) {
try {
await snb.update()
let entry = await snb.view.get('media-file:' + identifier)
if (!entry) entry = await snb.view.get('media:_:' + identifier)
if (entry && entry.value && entry.value.blobId) {
const buf = await snb.getBlob(entry.value.blobId)
if (buf) {
const ext = identifier.split('.').pop()
const mimeType = EXT_TO_MIME[ext] || 'application/octet-stream'
return { source: 'shared', data: buf, mimeType }
}
}
} catch (err) {
console.error('[Media Serve] Blob fallback error:', err.message)
}
}
return null
}
/**
* Get video processing status.
*/
async getVideoStatus (hash) {
return this.videoProcessor.getStatus(hash)
}
/**
* Get HLS master playlist with rewritten URLs.
*
* @param {string} notebook - Notebook slug
* @param {string} hash - Video hash
* @param {string} pathPrefix - Server path prefix
* @returns {Promise<string|null>} Rewritten M3U8 content or null
*/
async getMasterPlaylist (notebook, hash, pathPrefix) {
const videoIndex = await this.videoProcessor.getStatus(hash)
if (!videoIndex || videoIndex.status !== 'ready' || !videoIndex.manifest) return null
const masterBuf = await this.mediaStore.get(videoIndex.manifest)
if (!masterBuf) return null
const basePath = (pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + hash + '/stream/'
let content = masterBuf.toString()
// Rewrite hash references to URLs
content = content.replace(/^([a-f0-9]{64})$/gm, function (_, h) {
return basePath + h
})
return content
}
/**
* Get HLS segment/level playlist with rewritten URLs.
*
* @param {string} notebook
* @param {string} videoHash - Parent video hash
* @param {string} segmentHash - Segment or level playlist hash
* @param {string} pathPrefix
* @returns {Promise<Object|null>} { content, mimeType, isPlaylist } or null
*/
async getSegment (notebook, videoHash, segmentHash, pathPrefix) {
const info = await this.mediaStore.getInfo(segmentHash)
if (!info) return null
// Level playlist — rewrite .ts filenames
if (info.mimeType === 'application/vnd.apple.mpegurl') {
const buf = await this.mediaStore.get(segmentHash)
let content = buf.toString()
const videoIndex = await this.videoProcessor.getStatus(videoHash)
if (videoIndex && videoIndex.levels) {
const level = videoIndex.levels.find(l => l.m3u8 === segmentHash)
if (level && level.segments) {
const basePath = (pathPrefix || '') + '/api/notebooks/' + encodeURIComponent(notebook) + '/media/' + videoHash + '/stream/'
const fileToHash = new Map()
for (const seg of level.segments) {
fileToHash.set(seg.file, seg.hash)
}
content = content.replace(/^(.+\.ts)$/gm, function (_, filename) {
const h = fileToHash.get(filename)
return h ? basePath + h : filename
})
}
}
return { content, mimeType: 'application/vnd.apple.mpegurl', isPlaylist: true }
}
// Regular segment — return info for streaming
return { info, mimeType: info.mimeType || 'video/mp2t', isPlaylist: false }
}
}