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:
co-authored by
Claude Fable 5.1
parent
c24f6f5cde
commit
a9bcb88212
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user