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
+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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user