Files
hermes-agent/apps/desktop/electron/media-range.ts
T
hermes-seaeye[bot] 7469c0f2a5 fmt(js): npm run fix on merge (#108522)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-11 19:00:50 +00:00

190 lines
5.8 KiB
TypeScript

import { createReadStream, promises as fsp } from 'node:fs'
import path from 'node:path'
import { Readable } from 'node:stream'
/**
* Range-aware responses for local audio/video served through `hermes-media://stream/…`.
*
* The media protocol used to delegate local files to `net.fetch(file://…)` and forward the
* renderer's `Range` header, expecting a `206 Partial Content` back. In practice Electron's
* file:// loader ignores `Range` and always answers `200` with the whole body and no
* `Accept-Ranges`, so Chromium reports `video.seekable` as `[0, 0]`: the progress bar cannot be
* dragged and any seek snaps back to 0. Short clips hide this (they buffer fully within a
* second and seeking inside buffered data works), long recordings expose it immediately.
*
* This module answers the request itself: it parses `Range`, slices the file with
* `createReadStream`, and returns `206` with `Content-Range` / `Accept-Ranges` (plus `416` for
* unsatisfiable ranges, header-only `HEAD` responses, and `404` when the file is gone).
*/
const MEDIA_MIME: Record<string, string> = {
'.aac': 'audio/aac',
'.avi': 'video/x-msvideo',
'.flac': 'audio/flac',
'.m4a': 'audio/mp4',
'.m4v': 'video/mp4',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.oga': 'audio/ogg',
'.ogg': 'audio/ogg',
'.ogv': 'video/ogg',
'.opus': 'audio/ogg',
'.wav': 'audio/wav',
'.webm': 'video/webm'
}
export function mediaMimeFor(filePath: string): string {
return MEDIA_MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
}
export interface ByteRange {
start: number
/** Inclusive. */
end: number
}
/**
* Parse `Range: bytes=a-b` / `bytes=a-` / `bytes=-n`.
*
* Multi-range requests (any comma in the header) are deliberately ignored as a whole — RFC 7233
* lets a server answer `200` with the full representation instead of `multipart/byteranges`,
* and Chromium's media stack only ever sends a single range. Returns `null` when there is no
* usable Range header (serve the whole file) and `'unsatisfiable'` when the range starts beyond
* the end of the file (answer 416).
*/
export function parseByteRange(header: string | null | undefined, size: number): ByteRange | null | 'unsatisfiable' {
if (!header || header.includes(',')) {
return null
}
const m = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header)
if (!m || (m[1] === '' && m[2] === '')) {
return null
}
if (size <= 0) {
return 'unsatisfiable'
}
let start: number
let end: number
if (m[1] === '') {
// Suffix range: the last n bytes.
const suffix = Math.min(Number(m[2]), size)
if (suffix <= 0) {
return 'unsatisfiable'
}
start = size - suffix
end = size - 1
} else {
start = Number(m[1])
end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1)
}
if (!Number.isFinite(start) || !Number.isFinite(end) || start >= size || start > end) {
return 'unsatisfiable'
}
return { start, end }
}
export interface LocalMediaResponseInit {
method?: string
rangeHeader?: string | null
}
/** Build the 200 / 206 / 404 / 416 response for a local media file (HEAD gets headers only). */
export async function buildLocalMediaResponse(
resolvedPath: string,
init: LocalMediaResponseInit = {}
): Promise<Response> {
let handle: fsp.FileHandle
try {
handle = await fsp.open(resolvedPath, 'r')
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code
if (code === 'ENOENT' || code === 'ENOTDIR') {
return new Response('Media not found', { status: 404 })
}
throw error
}
// fstat the handle we are about to read from, so `Content-Length` and the streamed bytes come
// from the same open file even if the path is truncated or replaced meanwhile.
const stat = await handle.stat()
const size = stat.size
const mime = mediaMimeFor(resolvedPath)
const isHead = (init.method || 'GET').toUpperCase() === 'HEAD'
const range = parseByteRange(init.rangeHeader, size)
const baseHeaders: Record<string, string> = {
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
'Content-Type': mime,
'Last-Modified': stat.mtime.toUTCString()
}
const stream = (opts: { end?: number; start?: number }) =>
// Hand the FileHandle itself (not the raw fd) to the stream: it owns and closes it on end, so
// the handle is never closed twice (EBADF on garbage collection).
Readable.toWeb(
createReadStream(resolvedPath, { ...opts, autoClose: true, fd: handle })
) as unknown as ReadableStream
if (range === 'unsatisfiable') {
await handle.close()
return new Response(null, { headers: { ...baseHeaders, 'Content-Range': `bytes */${size}` }, status: 416 })
}
if (range === null) {
const headers = { ...baseHeaders, 'Content-Length': String(size) }
if (isHead || size === 0) {
await handle.close()
return new Response(null, { headers, status: 200 })
}
return new Response(stream({}), { headers, status: 200 })
}
const length = range.end - range.start + 1
const headers = {
...baseHeaders,
'Content-Length': String(length),
'Content-Range': `bytes ${range.start}-${range.end}/${size}`
}
if (isHead) {
await handle.close()
return new Response(null, { headers, status: 206 })
}
return new Response(stream({ end: range.end, start: range.start }), { headers, status: 206 })
}
/**
* Production `fetchLocal` for `hermes-media://stream/…`.
*
* Electron's `file://` loader ignores `Range` and answers `200` with the whole body, so Chromium
* reports `video.seekable` as `[0, 0]`. Pass this into the media-protocol handler instead.
*/
export function fetchLocalMedia(resolvedPath: string, headers: Headers, method: string): Promise<Response> {
return buildLocalMediaResponse(resolvedPath, {
method,
rangeHeader: headers.get('range')
})
}