From e257b344d127d38273d5828231f4d36f4b292bae Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 20 May 2026 14:29:39 -0400 Subject: [PATCH] First pass at full fork pretty much complete. --- diskwriter/diskwriter.go | 51 ++++++++++++++++++ diskwriter/diskwriter_test.go | 57 ++++++++++++++++++++ hall/hall.go | 13 ++--- local-server.sh | 4 +- rtpconn/rtpconn_test.go | 97 +++++++++++++++++++++++++++++++++++ rtpconn/webclient.go | 18 +++++++ skald.go | 24 +++++++++ skald.md | 15 +++++- static/skald.html | 1 + static/skald.js | 32 +++++++++++- todo.txt | 14 ++--- 11 files changed, 307 insertions(+), 19 deletions(-) diff --git a/diskwriter/diskwriter.go b/diskwriter/diskwriter.go index 33f7fd8..d34dff6 100644 --- a/diskwriter/diskwriter.go +++ b/diskwriter/diskwriter.go @@ -34,6 +34,8 @@ const ( var Directory string +var activeRecordings sync.Map + type Client struct { hall *hall.Hall id string @@ -250,6 +252,7 @@ func newRecorder(client *Client, directory string) (*recorder, error) { done: make(chan error, 1), sources: make(map[*diskTrack]chan []byte), } + activeRecordings.Store(path, struct{}{}) go r.mix() return r, nil } @@ -358,6 +361,7 @@ func (r *recorder) Close() error { err := <-r.done stdinErr := r.stdin.Close() waitErr := r.cmd.Wait() + activeRecordings.Delete(r.path) if err != nil { return err @@ -368,6 +372,10 @@ func (r *recorder) Close() error { if waitErr != nil { return waitErr } + now := time.Now() + if err := os.Chtimes(r.path, now, now); err != nil { + return err + } return nil } @@ -658,6 +666,49 @@ func openDiskFile(directory, extension string) (*os.File, error) { return nil, errors.New("couldn't create file") } +func CleanupOldRecordings(retention time.Duration) error { + if retention <= 0 { + return nil + } + cutoff := time.Now().Add(-retention) + err := filepath.WalkDir( + Directory, + func(path string, d os.DirEntry, err error) error { + if err != nil { + log.Printf("Recording cleanup %v: %v", path, err) + return nil + } + if d.IsDir() { + return nil + } + name := d.Name() + if !strings.HasPrefix(name, "skald-recording-") || + filepath.Ext(name) != ".ogg" { + return nil + } + if _, ok := activeRecordings.Load(path); ok { + return nil + } + info, err := d.Info() + if err != nil { + log.Printf("Recording cleanup %v: %v", path, err) + return nil + } + if info.ModTime().After(cutoff) { + return nil + } + if err := os.Remove(path); err != nil { + log.Printf("Recording cleanup %v: %v", path, err) + } + return nil + }, + ) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + func drainStderr(prefix string, r io.Reader) { buf := make([]byte, 1024) for { diff --git a/diskwriter/diskwriter_test.go b/diskwriter/diskwriter_test.go index 2da6ee1..ef69512 100644 --- a/diskwriter/diskwriter_test.go +++ b/diskwriter/diskwriter_test.go @@ -46,6 +46,63 @@ func TestOpenDiskFileUsesSkaldOggName(t *testing.T) { } } +func TestCleanupOldRecordings(t *testing.T) { + oldDirectory := Directory + defer func() { + Directory = oldDirectory + }() + + Directory = t.TempDir() + hallDir := filepath.Join(Directory, "test") + if err := os.MkdirAll(hallDir, 0700); err != nil { + t.Fatal(err) + } + + oldRecording := filepath.Join(hallDir, "skald-recording-20000101-000000.ogg") + activeRecording := filepath.Join(hallDir, "skald-recording-20000101-010000.ogg") + newRecording := filepath.Join(hallDir, "skald-recording-29990101-000000.ogg") + otherFile := filepath.Join(hallDir, "notes.txt") + for _, name := range []string{oldRecording, activeRecording, newRecording, otherFile} { + if err := os.WriteFile(name, []byte("test"), 0600); err != nil { + t.Fatal(err) + } + } + + oldTime := time.Now().Add(-48 * time.Hour) + newTime := time.Now() + if err := os.Chtimes(oldRecording, oldTime, oldTime); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(activeRecording, oldTime, oldTime); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(newRecording, newTime, newTime); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(otherFile, oldTime, oldTime); err != nil { + t.Fatal(err) + } + + activeRecordings.Store(activeRecording, struct{}{}) + defer activeRecordings.Delete(activeRecording) + + if err := CleanupOldRecordings(24 * time.Hour); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(oldRecording); !os.IsNotExist(err) { + t.Fatalf("old recording still exists or stat failed unexpectedly: %v", err) + } + if _, err := os.Stat(newRecording); err != nil { + t.Fatalf("new recording was removed: %v", err) + } + if _, err := os.Stat(activeRecording); err != nil { + t.Fatalf("active recording was removed: %v", err) + } + if _, err := os.Stat(otherFile); err != nil { + t.Fatalf("non-recording file was removed: %v", err) + } +} + func TestMixFrameClipsAndSumsSources(t *testing.T) { a := make(chan []byte, 1) b := make(chan []byte, 1) diff --git a/hall/hall.go b/hall/hall.go index fa9b7e5..04781df 100644 --- a/hall/hall.go +++ b/hall/hall.go @@ -857,12 +857,13 @@ type Configuration struct { modTime time.Time `json:"-"` fileSize int64 `json:"-"` - CanonicalHost string `json:"canonicalHost,omitempty"` - AllowOrigin []string `json:"allowOrigin,omitempty"` - AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"` - ProxyURL string `json:"proxyURL,omitempty"` - WritableHalls bool `json:"writableHalls,omitempty"` - Users map[string]UserDescription `json:"users,omitempty"` + CanonicalHost string `json:"canonicalHost,omitempty"` + AllowOrigin []string `json:"allowOrigin,omitempty"` + AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"` + ProxyURL string `json:"proxyURL,omitempty"` + WritableHalls bool `json:"writableHalls,omitempty"` + RecordingRetention string `json:"recordingRetention,omitempty"` + Users map[string]UserDescription `json:"users,omitempty"` // obsolete fields Admin []ClientPattern `json:"admin,omitempty"` diff --git a/local-server.sh b/local-server.sh index de52249..89ddd18 100755 --- a/local-server.sh +++ b/local-server.sh @@ -37,7 +37,7 @@ cleanup() { } trap cleanup EXIT INT TERM -mkdir -p "$dataDir" "$hallsDir" "$recordingsDir" +mkdir -p "$dataDir" "$hallsDir" "$recordingsDir/$hallName" hallFile="$hallsDir/$hallName.json" escapedAdminUsername="$(json_string "$adminUsername")" @@ -47,7 +47,7 @@ cat > "$hallFile" <Chat History
+
diff --git a/static/skald.js b/static/skald.js index b18c245..4f0bcaa 100644 --- a/static/skald.js +++ b/static/skald.js @@ -2101,6 +2101,19 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa displayError(`${from} said: ${message}`, kind); break; } + case 'recording': { + if(!privileged) { + console.error(`Got unprivileged message of kind ${kind}`); + return; + } + let text = '' + message; + if(text === 'Recording started') + announceUrgent(text); + else + announceChat(text); + displayMessage(text); + break; + } case 'mute': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); @@ -2283,9 +2296,10 @@ let lastMessage = {}; /** * @param {string} text + * @param {string} elementId */ -function announceChat(text) { - let announcement = document.getElementById('chat-announcements'); +function announceLiveRegion(text, elementId) { + let announcement = document.getElementById(elementId); if(!announcement) return; @@ -2295,6 +2309,20 @@ function announceChat(text) { }, 20); } +/** + * @param {string} text + */ +function announceChat(text) { + announceLiveRegion(text, 'chat-announcements'); +} + +/** + * @param {string} text + */ +function announceUrgent(text) { + announceLiveRegion(text, 'urgent-announcements'); +} + /** * @param {string} id * @param {string} peerId diff --git a/todo.txt b/todo.txt index 83bd296..0f308fa 100644 --- a/todo.txt +++ b/todo.txt @@ -121,8 +121,8 @@ X Verify runtime protocol backward compatibility with Skald is intentionally bro X Remove video fields from hall descriptions, stats, API responses, and protocol docs + Check WHIP/WHEP-style endpoints for video assumptions and either make audio-only or remove + Ensure browsers that request audio+video get a working audio-only session or a clear video rejection -- Verify two browser clients can join one hall and exchange audio only -- Verify a client attempting camera or screenshare cannot publish video +X Verify two browser clients can join one hall and exchange audio only +X Verify a client attempting camera or screenshare cannot publish video # ============================================================================ # PHASE 5: FRONTEND - AUDIO-ONLY USER EXPERIENCE @@ -145,7 +145,7 @@ X Remove video fields from hall descriptions, stats, API responses, and protocol - Preserve keyboard access with Tab and Shift+Tab through all controls - Ensure controls have labels or accessible names after icon/button cleanup X Verify audio-only UI loads without 404s for renamed assets -- Verify joining, muting, chat, recording controls, and leaving work from keyboard +X Verify joining, muting, chat, recording controls, and leaving work from keyboard # ============================================================================ # PHASE 6: RECORDING REWRITE - SINGLE MIXED OGG OPUS @@ -226,12 +226,12 @@ X Build skaldctl binary X Run a local server and verify static pages load without 404s X Test: create a hall with skaldctl X Test: list halls with skaldctl -- Test: join one hall from two browsers and confirm two-way audio -- Test: attempt to publish video and confirm rejection -- Test: start recording and confirm one Ogg Opus file appears +X Test: join one hall from two browsers and confirm two-way audio +X Test: attempt to publish video and confirm rejection +X Test: start recording and confirm one Ogg Opus file appears - Test: join a third participant during recording and confirm the mix continues - Test: mute/unmute during recording and confirm the output reflects it -- Test: leave all participants and confirm recording stops cleanly +X Test: leave all participants and confirm recording stops cleanly - Test: play the recording in a standard audio player X Test: restart server and verify halls/config load from the new paths X Remove obsolete files that are no longer served or built