OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / services / media-service.js

226 lines · javascript ref:
/**
 * 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 }
  }
}