diff --git a/README.md b/README.md index 06c3258..8b7116e 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,11 @@ Each row has the match status and type, a recommendation, both paths, where link bit depth and duration for both sides, the quality verdict and any notes. The file opens cleanly in Excel, Numbers and LibreOffice (UTF-8, and cells a spreadsheet would treat as formulas are escaped). +**Listening in build mode.** Every song in the Build tab has a play button. A mini-player along the bottom of the page plays the +folder in order, with seeking, previous/next, volume and space to pause. Songs are streamed from the master library as they are +(nothing is converted), so they play when the browser supports the format: FLAC, MP3, AAC/M4A, Ogg/Opus and WAV in current browsers, +while formats such as WMA or APE show a message instead. + **Folders without music** (the *Cleanup* tab). Choose the slave or master library and **Find folders**: Playlink lists the top-most folders with no music file anywhere inside them (music = the pair's file extensions), such as album folders left empty after linking or ones holding only a cover or playlist, with what they still contain. Empty folders are pre-selected, folders that still hold files are diff --git a/internal/api/listen_test.go b/internal/api/listen_test.go new file mode 100644 index 0000000..da803d7 --- /dev/null +++ b/internal/api/listen_test.go @@ -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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index f23435b..e6a7ca8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) +} diff --git a/internal/engine/listen.go b/internal/engine/listen.go new file mode 100644 index 0000000..a249e96 --- /dev/null +++ b/internal/engine/listen.go @@ -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 +} diff --git a/web/src/index.css b/web/src/index.css index 534d446..f388bdb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -79,3 +79,33 @@ select:focus-visible, textarea:focus-visible { outline: none; } + +/* Player sliders: a slim track filled with the brand gradient up to --fill. */ +.player-range { + appearance: none; + height: 4px; + border-radius: 9999px; + cursor: pointer; + background: linear-gradient(90deg, var(--color-accent), var(--color-accent-2)) 0 / var(--fill, 0%) 100% no-repeat, var(--color-line); +} +.player-range:disabled { + cursor: default; +} +.player-range::-webkit-slider-thumb { + appearance: none; + width: 12px; + height: 12px; + border-radius: 9999px; + background: #fff; + box-shadow: 0 0 0 3px rgb(255 122 89 / 0.25); +} +.player-range::-moz-range-thumb { + width: 12px; + height: 12px; + border: 0; + border-radius: 9999px; + background: #fff; +} +.player-range:focus-visible { + outline-offset: 4px; +} diff --git a/web/src/pages/Build.tsx b/web/src/pages/Build.tsx index 171c23b..5aada13 100644 --- a/web/src/pages/Build.tsx +++ b/web/src/pages/Build.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronRight, Disc3, FileAudio, Folder, Link2, Unlink } from "lucide-react"; +import { AudioLines, ChevronRight, Disc3, Folder, Link2, Play, Unlink } from "lucide-react"; import { api, type Pair, type TreeEntry } from "../api"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; import { useToast } from "../toast"; +import { MiniPlayer, type Track } from "./Player"; export function Build({ pair }: { pair: Pair }) { const [path, setPath] = useState(""); const [sel, setSel] = useState>(new Set()); const [confirm, setConfirm] = useState(null); + // The mini-player keeps its own copy of the folder it started in. + const [player, setPlayer] = useState<{ queue: Track[]; index: number } | null>(null); + const nowPlaying = player ? player.queue[player.index].rel : null; const qc = useQueryClient(); const toast = useToast(); const tree = useQuery({ @@ -37,7 +41,7 @@ export function Build({ pair }: { pair: Pair }) { const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel)); return ( -
+
Build the slave library from the master. Browse the master library, pick folders or songs, and Playlink hardlinks them into the slave library at the same relative path. No extra space is used. Existing slave files are never @@ -91,13 +95,29 @@ export function Build({ pair }: { pair: Pair }) { {all.map((e) => (
toggle(e.rel)} /> - {e.dir ? : } + {e.dir ? ( + + ) : ( + + )} {e.dir ? ( ) : ( - {e.name} + {e.name} )} {e.dir && !!e.linked && {e.linked} linked} {e.state === "linked" && in slave} @@ -108,6 +128,16 @@ export function Build({ pair }: { pair: Pair }) {

Folder link counts come from the last scan. Song states are live.

+ {player && ( + setPlayer({ ...player, index })} + onClose={() => setPlayer(null)} + /> + )} + setConfirm(null)} title={confirm === "build" ? "Link into slave library?" : "Remove from slave library?"}>

{confirm === "build" ? ( diff --git a/web/src/pages/Player.tsx b/web/src/pages/Player.tsx new file mode 100644 index 0000000..4b8d4b8 --- /dev/null +++ b/web/src/pages/Player.tsx @@ -0,0 +1,219 @@ +import { useEffect, useRef, useState } from "react"; +import { CircleAlert, Disc3, Pause, Play, SkipBack, SkipForward, Volume1, Volume2, VolumeX, X } from "lucide-react"; +import { cx } from "../ui"; + +/** A song the player can play: its path in the master library and a display name. */ +export type Track = { rel: string; name: string }; + +export function listenURL(pairId: number, rel: string) { + return `/api/pairs/${pairId}/listen?side=master&path=${encodeURIComponent(rel)}`; +} + +function clock(sec: number) { + if (!isFinite(sec) || sec < 0) return "0:00"; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function loadVolume() { + try { + const v = Number(localStorage.getItem("playlink.volume")); + return isFinite(v) && v > 0 && v <= 1 ? v : 0.8; + } catch { + return 0.8; + } +} + +/** + * The build mode mini-player: a bar along the bottom of the page that plays + * one folder's songs in order. + */ +export function MiniPlayer({ + pairId, + queue, + index, + onIndex, + onClose, +}: { + pairId: number; + queue: Track[]; + index: number; + onIndex: (i: number) => void; + onClose: () => void; +}) { + const audio = useRef(null); + const [playing, setPlaying] = useState(false); + const [time, setTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(loadVolume); + const [muted, setMuted] = useState(false); + const [error, setError] = useState(null); + const track = queue[index]; + const folder = track.rel.includes("/") ? track.rel.slice(0, track.rel.lastIndexOf("/")) : ""; + + // A new song: load it and start playing. + useEffect(() => { + const a = audio.current; + if (!a) return; + setError(null); + setTime(0); + setDuration(0); + a.src = listenURL(pairId, track.rel); + a.play().catch(() => {}); // a refused autoplay just leaves it paused + }, [pairId, track.rel]); + + useEffect(() => { + if (audio.current) audio.current.volume = volume; + try { + localStorage.setItem("playlink.volume", String(volume)); + } catch { + /* private mode */ + } + }, [volume]); + useEffect(() => { + if (audio.current) audio.current.muted = muted; + }, [muted]); + + // Space toggles play/pause, unless typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement; + if (e.code !== "Space" || ["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(t.tagName)) return; + e.preventDefault(); + toggle(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + const toggle = () => { + const a = audio.current; + if (!a) return; + if (a.paused) a.play().catch(() => {}); + else a.pause(); + }; + const hasPrev = index > 0; + const hasNext = index < queue.length - 1; + // Back restarts the song unless it just began. + const prev = () => { + const a = audio.current; + if (a && a.currentTime > 3) a.currentTime = 0; + else if (hasPrev) onIndex(index - 1); + }; + const VolIcon = muted || volume === 0 ? VolumeX : volume < 0.5 ? Volume1 : Volume2; + const pct = duration > 0 ? (time / duration) * 100 : 0; + + return ( +

+ {/* Thin progress line along the top edge. */} +
+
+
+
+
+
+ +
+
+
+ {track.name} +
+
+ {error ? ( + + {error} + + ) : ( + <> + {folder || "master"} ยท {index + 1} of {queue.length} + + )} +
+
+
+ +
+ + + +
+ +
+ {clock(time)} + { + const t = Number(e.target.value); + if (audio.current) audio.current.currentTime = t; + setTime(t); + }} + aria-label="Seek" + /> + {clock(duration)} +
+ +
+ + { + setVolume(Number(e.target.value)); + setMuted(false); + }} + aria-label="Volume" + /> +
+ + +
+ +
+ ); +}