Add a mini-player to build mode
Every song in the Build tab gets a play button. A bar along the bottom plays the folder in order with seeking, previous/next, volume (kept per browser) and space to pause, and says so when the browser can't play a format. Songs stream from the master library through a new endpoint with range requests; it only serves regular music files inside the library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
da74756c24
commit
491d679233
7 files changed
+443
-4
No files matched your search
@@ -0,0 +1,76 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/engine"
|
||||
"github.com/cdnutter/playlink/internal/probe"
|
||||
"github.com/cdnutter/playlink/internal/store"
|
||||
)
|
||||
|
||||
func TestListen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
master, slave := filepath.Join(root, "Albums"), filepath.Join(root, "Singles")
|
||||
os.MkdirAll(filepath.Join(master, "A"), 0o755)
|
||||
os.MkdirAll(slave, 0o755)
|
||||
os.WriteFile(filepath.Join(master, "A/song.flac"), []byte("0123456789"), 0o644)
|
||||
os.WriteFile(filepath.Join(master, "A/cover.jpg"), []byte("img"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "secret.flac"), []byte("outside"), 0o644)
|
||||
os.Symlink(filepath.Join(root, "secret.flac"), filepath.Join(master, "A/link.flac"))
|
||||
|
||||
st, err := store.Open(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.DB.Close()
|
||||
eng := engine.New(st, probe.Prober{}, []string{root}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
p := store.Pair{Name: "Music", MasterRoot: master, SlaveRoot: slave}
|
||||
if _, err := eng.NormalizePair(&p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.SavePair(ctx, &p)
|
||||
if err := st.CreateFirstUser(ctx, "chris", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, _ := st.UserByName(ctx, "chris")
|
||||
st.CreateSession(ctx, hashToken("tok"), u.ID, time.Now().Add(time.Hour).Unix())
|
||||
h := New(Config{}, st, eng, slog.New(slog.NewTextHandler(io.Discard, nil)), fstest.MapFS{}).Handler()
|
||||
get := func(path string, auth bool, rng string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/pairs/1/listen?side=master&path="+url.QueryEscape(path), nil)
|
||||
if auth {
|
||||
req.AddCookie(&http.Cookie{Name: cookieName, Value: "tok"})
|
||||
}
|
||||
if rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := get("A/song.flac", true, ""); rec.Code != 200 || rec.Body.String() != "0123456789" || rec.Header().Get("Content-Type") != "audio/flac" {
|
||||
t.Fatalf("song: %d %q %q", rec.Code, rec.Body.String(), rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if rec := get("A/song.flac", true, "bytes=2-5"); rec.Code != http.StatusPartialContent || rec.Body.String() != "2345" {
|
||||
t.Fatalf("seeking needs range requests: %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, bad := range []string{"A/cover.jpg", "../secret.flac", "A/link.flac", "/etc/passwd"} {
|
||||
if rec := get(bad, true, ""); rec.Code < 400 {
|
||||
t.Errorf("%s must be refused, got %d", bad, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := get("A/song.flac", false, ""); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("listening needs a session, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ func (s *Server) Handler() http.Handler {
|
||||
r.Post("/renames", s.handleRenames)
|
||||
r.Post("/link-identical", s.handleLinkIdentical)
|
||||
r.Get("/tree", s.handleTree)
|
||||
r.Get("/listen", s.handleListen)
|
||||
r.Get("/empty-folders", s.handleEmptyFolders)
|
||||
r.Post("/empty-folders/scan", s.handleEmptyFolderScan)
|
||||
r.Post("/empty-folders/remove", s.handleRemoveEmptyFolders)
|
||||
@@ -786,3 +787,33 @@ func (s *Server) handleRemoveEmptyFolders(w http.ResponseWriter, r *http.Request
|
||||
s.log.Info("removing folders without music", "pair", id, "folders", len(body.Folders))
|
||||
writeJSON(w, 202, j)
|
||||
}
|
||||
|
||||
// ---- listening (build mode player) ----
|
||||
|
||||
var audioTypes = map[string]string{
|
||||
".flac": "audio/flac", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".aac": "audio/aac", ".alac": "audio/mp4",
|
||||
".ogg": "audio/ogg", ".oga": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", ".aif": "audio/aiff",
|
||||
".aiff": "audio/aiff", ".mka": "audio/x-matroska", ".wma": "audio/x-ms-wma",
|
||||
}
|
||||
|
||||
// handleListen streams a song with range support, so the browser can seek.
|
||||
func (s *Server) handleListen(w http.ResponseWriter, r *http.Request) {
|
||||
id, _ := idParam(r)
|
||||
q := r.URL.Query()
|
||||
f, fi, err := s.eng.OpenSong(r.Context(), id, q.Get("side"), q.Get("path"))
|
||||
if err != nil {
|
||||
opErr(w, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
ct := audioTypes[strings.ToLower(filepath.Ext(fi.Name()))]
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.Header().Set("Cache-Control", "private, max-age=300")
|
||||
// A long song on a slow connection may take longer than the server's
|
||||
// write timeout.
|
||||
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
|
||||
http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cdnutter/playlink/internal/fsops"
|
||||
)
|
||||
|
||||
// OpenSong opens a song in one library for listening (the build mode
|
||||
// player). Only regular music files inside the library are served: no
|
||||
// symlinks, no other file types.
|
||||
func (e *Engine) OpenSong(ctx context.Context, pairID int64, side, rel string) (*os.File, os.FileInfo, error) {
|
||||
l, err := e.loadPair(ctx, pairID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := sideRoot(l, side)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
abs, err := root.Resolve(rel)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !l.isMusic(filepath.Base(abs)) {
|
||||
return nil, nil, fmt.Errorf("%w: not a music file", ErrInvalidBulk)
|
||||
}
|
||||
if _, err := fsops.RegularFile(abs); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// What was opened must still be the regular file that was checked, not
|
||||
// something swapped in for it since.
|
||||
fi, err := f.Stat()
|
||||
li, lerr := os.Lstat(abs)
|
||||
if err != nil || lerr != nil || !li.Mode().IsRegular() || !os.SameFile(fi, li) {
|
||||
f.Close()
|
||||
return nil, nil, errors.New("the file changed while opening it")
|
||||
}
|
||||
return f, fi, nil
|
||||
}
|
||||
Reference in new issue
Block a user