Files: play video and audio in the viewer, as the file is; reads answer Range requests

The viewer hands a video or audio file to the browser untouched — container, codec and
all — and the browser plays what it can; when it can't, the viewer says so and offers the
download. What the server adds is the part a player needs: /api/files/read now goes
through http.ServeContent for a real file of known size, so Range requests (seeking) and
HEAD work, and a download can resume. The root backend's helper takes an offset for that,
and a Seeker opens the backend once, at the range asked for, instead of streaming from
the top. Audio files are a preview kind too: their cover art is the thumbnail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 16:40:42 -07:00
co-authored by Claude Fable 5.1
parent c24f6f5cde
commit a9bcb88212
9 changed files with 343 additions and 19 deletions
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"hamlab/internal/files"
)
// A file read answers Range requests — what a <video> needs to seek — and HEAD, through
// the same handler that streams everything else.
func TestFilesReadRanges(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "clip.mp4")
data := make([]byte, 10000)
for i := range data {
data[i] = byte(i % 251)
}
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
s := &Server{fsLocal: files.Local{}}
get := func(method, extra string, hdr map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, "/api/files/read?path="+url.QueryEscape(p)+extra, nil)
for k, v := range hdr {
r.Header.Set(k, v)
}
w := httptest.NewRecorder()
s.filesRead(w, r)
return w
}
w := get("GET", "", nil)
if w.Code != 200 || w.Body.Len() != 10000 || w.Header().Get("Content-Type") != "video/mp4" || w.Header().Get("Accept-Ranges") != "bytes" {
t.Fatalf("whole: %d %d %q %q", w.Code, w.Body.Len(), w.Header().Get("Content-Type"), w.Header().Get("Accept-Ranges"))
}
w = get("GET", "", map[string]string{"Range": "bytes=100-199"})
if w.Code != 206 || w.Header().Get("Content-Range") != "bytes 100-199/10000" || w.Body.Len() != 100 || w.Body.Bytes()[0] != data[100] {
t.Fatalf("range: %d %q %d", w.Code, w.Header().Get("Content-Range"), w.Body.Len())
}
w = get("GET", "", map[string]string{"Range": "bytes=9990-"})
if w.Code != 206 || w.Body.Len() != 10 || w.Body.Bytes()[9] != data[9999] {
t.Fatalf("tail range: %d %d", w.Code, w.Body.Len())
}
w = get("HEAD", "", nil)
if w.Code != 200 || w.Body.Len() != 0 || w.Header().Get("Content-Length") != "10000" {
t.Fatalf("head: %d %d %q", w.Code, w.Body.Len(), w.Header().Get("Content-Length"))
}
if w := get("GET", "&max=50", nil); w.Code != 413 {
t.Fatalf("the editor's cap: %d", w.Code)
}
if w := get("GET", "", map[string]string{"Range": "bytes=20000-"}); w.Code != 416 {
t.Fatalf("a range past the end: %d", w.Code)
}
if err := os.Chmod(p, 0); err == nil && os.Geteuid() != 0 {
if w := get("GET", "", nil); w.Code != 403 {
t.Fatalf("unreadable: %d", w.Code)
}
}
}
+22 -11
View File
@@ -1928,16 +1928,6 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 413, "file too large")
return
}
f, err := fsys.Open(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
defer f.Close()
var body io.Reader = f
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
body = io.LimitReader(f, mx+1)
}
name := filepath.Base(p)
ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" {
@@ -1956,7 +1946,28 @@ func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
}
if in.Size > 0 && mx == 0 {
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
// A real file of known size goes through net/http, which speaks Range: a video player
// can seek and a download can resume. /proc files say size 0 and take the plain path.
rs := files.NewSeeker(r.Context(), fsys, p, in.Size)
defer rs.Close()
if _, local := fsys.(files.Local); local { // cheap here, and turns "can't open" into a status rather than a cut-off body
if err := rs.Check(); err != nil {
fsErr(w, err)
return
}
}
http.ServeContent(w, r, name, time.UnixMilli(in.Mtime), rs)
return
}
f, err := fsys.Open(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
defer f.Close()
var body io.Reader = f
if mx > 0 { // stat can lie (size 0 on /proc); the cap must hold on the bytes actually sent
body = io.LimitReader(f, mx+1)
}
_, _ = io.Copy(w, body)
}
+30 -2
View File
@@ -59,6 +59,7 @@ type FS interface {
List(ctx context.Context, path string) (Listing, error)
Stat(ctx context.Context, path string) (Info, error)
Open(ctx context.Context, path string) (io.ReadCloser, error)
OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) // Open from a byte offset: a range request, a player seeking
Write(ctx context.Context, path string, r io.Reader) error
Mkdir(ctx context.Context, path string) error
Touch(ctx context.Context, path string) error
@@ -231,6 +232,20 @@ func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
return os.Open(path)
}
func (l Local) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
f, err := l.Open(ctx, path)
if err != nil {
return nil, err
}
if offset > 0 {
if _, err := f.(*os.File).Seek(offset, io.SeekStart); err != nil {
f.Close()
return nil, err
}
}
return f, nil
}
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
func (Local) Write(_ context.Context, path string, r io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
@@ -336,7 +351,15 @@ func (r *cmdReader) Close() error {
}
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
c := s.cmd(ctx, "read", path)
return s.open(ctx, "read", path)
}
func (s Sudo) OpenAt(ctx context.Context, path string, offset int64) (io.ReadCloser, error) {
return s.open(ctx, "read", "--offset", strconv.FormatInt(offset, 10), path)
}
func (s Sudo) open(ctx context.Context, args ...string) (io.ReadCloser, error) {
c := s.cmd(ctx, args...)
errb := &strings.Builder{}
c.Stderr = errb
out, err := c.StdoutPipe()
@@ -430,6 +453,11 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
ctx := context.Background()
var l Local
op := args[0]
var offset int64
if op == "read" && len(args) >= 4 && args[1] == "--offset" { // read --offset N <path>
offset, _ = strconv.ParseInt(args[2], 10, 64)
args = append([]string{"read"}, args[3:]...)
}
clean := func(i int) (string, error) {
if i >= len(args) {
return "", errors.New("path required")
@@ -456,7 +484,7 @@ func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer)
}
return emit(stdout, out, stderr)
case "read":
f, err := l.Open(ctx, p)
f, err := l.OpenAt(ctx, p, offset)
if err != nil {
return fail(err)
}
+89
View File
@@ -0,0 +1,89 @@
package files
import (
"context"
"errors"
"io"
)
// Seeker is an FS file as http.ServeContent wants one — something to seek in — for a file
// whose size is already known from a stat. ServeContent seeks to the end for the size, to
// the start of the range asked for, then reads: so the backend is opened once, at that
// position, and only ever read forward. The Sudo backend's helper is started at the offset
// rather than streamed from the top and skipped.
type Seeker struct {
ctx context.Context
fsys FS
path string
size int64
pos int64 // where the next Read reads from
at int64 // where cur is positioned, when open
cur io.ReadCloser
}
func NewSeeker(ctx context.Context, fsys FS, path string, size int64) *Seeker {
return &Seeker{ctx: ctx, fsys: fsys, path: path, size: size}
}
// Check opens the file now, from the start, so a file that cannot be read is known before
// any headers go out. A later seek elsewhere simply reopens.
func (s *Seeker) Check() error {
if s.cur != nil {
return nil
}
f, err := s.fsys.OpenAt(s.ctx, s.path, 0)
if err != nil {
return err
}
s.cur, s.at = f, 0
return nil
}
func (s *Seeker) Seek(offset int64, whence int) (int64, error) {
var pos int64
switch whence {
case io.SeekStart:
pos = offset
case io.SeekCurrent:
pos = s.pos + offset
case io.SeekEnd:
pos = s.size + offset
default:
return 0, errors.New("bad whence")
}
if pos < 0 {
return 0, errors.New("seek before start")
}
if s.cur != nil && pos != s.at {
s.cur.Close()
s.cur = nil
}
s.pos = pos
return pos, nil
}
func (s *Seeker) Read(p []byte) (int, error) {
if s.pos >= s.size {
return 0, io.EOF
}
if s.cur == nil {
f, err := s.fsys.OpenAt(s.ctx, s.path, s.pos)
if err != nil {
return 0, err
}
s.cur, s.at = f, s.pos
}
n, err := s.cur.Read(p)
s.pos += int64(n)
s.at += int64(n)
return n, err
}
func (s *Seeker) Close() error {
if s.cur == nil {
return nil
}
err := s.cur.Close()
s.cur = nil
return err
}
+112
View File
@@ -0,0 +1,112 @@
package files
import (
"context"
"io"
"os"
"path/filepath"
"testing"
)
import "strings"
type stringsBuilder = strings.Builder
type countingFS struct {
Local
opens []int64
}
func (c *countingFS) OpenAt(ctx context.Context, path string, off int64) (io.ReadCloser, error) {
c.opens = append(c.opens, off)
return c.Local.OpenAt(ctx, path, off)
}
// ServeContent's moves — size from the end, seek to the range, read forward — open the
// backend once, at the range; a seek to where the stream already is costs nothing.
func TestSeeker(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
data := make([]byte, 1000)
for i := range data {
data[i] = byte(i)
}
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
fs := &countingFS{}
s := NewSeeker(context.Background(), fs, p, int64(len(data)))
if n, _ := s.Seek(0, io.SeekEnd); n != 1000 {
t.Fatalf("end = %d", n)
}
if _, err := s.Seek(100, io.SeekStart); err != nil {
t.Fatal(err)
}
buf := make([]byte, 50)
if n, err := io.ReadFull(s, buf); err != nil || n != 50 || buf[0] != 100 || buf[49] != 149 {
t.Fatalf("read at 100: %d %v %v", n, err, buf[:2])
}
if _, err := s.Seek(150, io.SeekStart); err != nil { // where it already is
t.Fatal(err)
}
if n, _ := io.ReadFull(s, buf); n != 50 || buf[0] != 150 {
t.Fatal("read after a no-op seek")
}
if len(fs.opens) != 1 || fs.opens[0] != 100 {
t.Fatalf("opens = %v, want [100]", fs.opens)
}
if _, err := s.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
rest, err := io.ReadAll(s)
if err != nil || len(rest) != 1000 || rest[999] != data[999] {
t.Fatalf("read all from the start: %d %v", len(rest), err)
}
if len(fs.opens) != 2 || fs.opens[1] != 0 {
t.Fatalf("opens = %v, want [100 0]", fs.opens)
}
if n, err := s.Read(buf); n != 0 || err != io.EOF {
t.Fatal("past the end is EOF")
}
if _, err := s.Seek(-1, io.SeekStart); err == nil {
t.Fatal("before the start")
}
if err := s.Close(); err != nil {
t.Fatal(err)
}
// Check opens at the start and keeps that stream for a read from the start
s2 := NewSeeker(context.Background(), fs, p, 1000)
if err := s2.Check(); err != nil {
t.Fatal(err)
}
s2.Seek(0, io.SeekEnd)
s2.Seek(0, io.SeekStart)
if n, _ := io.ReadFull(s2, buf); n != 50 || buf[0] != 0 {
t.Fatal("read from the start after Check")
}
if len(fs.opens) != 3 {
t.Fatalf("Check's open was not reused: %v", fs.opens)
}
s2.Close()
if err := NewSeeker(context.Background(), fs, filepath.Join(t.TempDir(), "nope"), 5).Check(); err == nil {
t.Fatal("Check must report a file that can't be opened")
}
}
// The helper's read takes an offset, for the Sudo backend.
func TestHelperReadOffset(t *testing.T) {
p := filepath.Join(t.TempDir(), "f")
if err := os.WriteFile(p, []byte("0123456789"), 0o644); err != nil {
t.Fatal(err)
}
var out, errb stringsBuilder
if rc := Helper([]string{"read", "--offset", "7", p}, nil, &out, &errb); rc != 0 {
t.Fatalf("rc %d: %s", rc, errb.String())
}
if out.String() != "789" {
t.Fatalf("got %q", out.String())
}
out.Reset()
if rc := Helper([]string{"read", p}, nil, &out, &errb); rc != 0 || out.String() != "0123456789" {
t.Fatalf("plain read: rc %d %q", rc, out.String())
}
}
+5 -1
View File
@@ -93,6 +93,7 @@ func (e *DecodeError) Error() string { return e.Msg }
var imageExt = set("png jpg jpeg jpe jfif gif webp bmp tif tiff avif heic heif hif jxl svg ico psd tga thm")
var videoExt = set("mp4 m4v mkv webm mov avi wmv flv mpg mpeg mpe m2v ts m2ts mts 3gp 3g2 ogv ogm mxf vob asf rm rmvb divx f4v mjpeg mjpg 360 osv insv lrf")
var audioExt = set("mp3 flac m4a m4b aac ogg oga opus wav wma aiff aif alac ape wv mka")
func set(s string) map[string]bool {
m := map[string]bool{}
@@ -102,7 +103,8 @@ func set(s string) map[string]bool {
return m
}
// Kind is "image", "video" or "" for a file name.
// Kind is "image", "video", "audio" or "" for a file name. An audio file's preview is its
// cover art, when it carries any.
func Kind(name string) string {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(name), "."))
switch {
@@ -110,6 +112,8 @@ func Kind(name string) string {
return "image"
case videoExt[ext]:
return "video"
case audioExt[ext]:
return "audio"
}
return ""
}
+15 -3
View File
@@ -17,6 +17,7 @@ function icon(e: FileEntry): string {
if (e.type === 'other') return '⚙️'
if (e.preview === 'image' || isImage(e.name)) return '🖼️'
if (e.preview === 'video') return '🎞️'
if (e.preview === 'audio') return '🎵'
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
@@ -132,7 +133,7 @@ class Near {
private forget(el: Element) { this.cbs.delete(el); this.obs.unobserve(el) }
}
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video'; near: Near | null; fallback: string }) {
function Thumb({ url, kind, near, fallback }: { url: string; kind: 'image' | 'video' | 'audio'; near: Near | null; fallback: string }) {
const ref = useRef<HTMLSpanElement>(null)
const [show, setShow] = useState(false)
const [failed, setFailed] = useState('')
@@ -189,11 +190,12 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
const [orig, setOrig] = useState('')
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const [cantPlay, setCantPlay] = useState(false) // the browser tried the file as it is and gave up
const name = path.split('/').pop() ?? path
const dirty = text !== null && text !== orig
useEffect(() => {
setInfo(null); setText(null); setErr('')
setInfo(null); setText(null); setErr(''); setCantPlay(false)
let stop = false
;(async () => {
try {
@@ -251,7 +253,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
)}
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
{info && text === null && !isImage(name) && !err && (
{/* Video and audio go to the browser as they are, codec and all, and it plays what it can. */}
{info && text === null && !isImage(name) && info.preview === 'video' && !cantPlay && (
<div className="preview"><video controls playsInline preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div>
)}
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
)}
{info && text === null && !isImage(name) && cantPlay && (
<div className="notice">This browser can't play {name} as it is — the container or codec isn't one it decodes. Download it instead.</div>
)}
{info && text === null && !isImage(name) && !info.preview && !err && (
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
)}
</div>
+1 -1
View File
@@ -249,7 +249,7 @@ export interface ARCDetail {
}
export interface ARCView { detail: ARCDetail; live: ARCLive | null }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean; preview?: 'image' | 'video' | 'audio' }
export interface ThumbGPU { api: string; device: string; name?: string; codecs?: string[]; reason?: string; permission?: boolean }
// How previews get made on the machine: ok with an engine, or why not and what would fix it.
export interface ThumbStatus { ok: boolean; engine: 'gpu' | 'cpu' | ''; summary: string; ffmpeg?: string; gpus: ThumbGPU[]; unusable?: ThumbGPU[]; error?: string; install?: { pm: string; package: string }; gpu_fix?: boolean; user: string; cache: { path: string; entries: number; bytes: number } }
+7 -1
View File
@@ -364,11 +364,17 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.file-editor { flex: 1; min-height: 0; border: 0; border-radius: 0; resize: none; font-size: 13px; }
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
.preview.audio { align-items: flex-start; }
.audio-box { display: flex; flex-direction: column; align-items: center; gap: 10px; width: min(560px, 100%); margin-top: 8vh; }
.audio-box .ficon { font-size: 48px; }
.audio-box audio { width: 100%; }
/* previews: the icon column becomes a thumbnail box for images and videos */
.file-list.thumbs .file-row { grid-template-columns: 72px 1fr 80px 80px 130px 32px; padding-top: 4px; padding-bottom: 4px; min-height: 52px; }
.fthumb { position: relative; width: 72px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--bg3); border-radius: 4px; overflow: hidden; }
.fthumb img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
.fthumb.video::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
.fthumb.video::after, .fthumb.audio::after { content: '▶'; position: absolute; right: 2px; bottom: 2px; font-size: 9px; line-height: 1; padding: 2px 3px; border-radius: 3px; background: rgba(0,0,0,.6); color: #fff; }
.fthumb.audio::after { content: '♪'; }
.fthumb .ficon { font-size: 18px; }
.thumb-note { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 10px 0; }
/* move: the folder picker */