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