First pass at full fork pretty much complete.
This commit is contained in:
@@ -34,6 +34,8 @@ const (
|
|||||||
|
|
||||||
var Directory string
|
var Directory string
|
||||||
|
|
||||||
|
var activeRecordings sync.Map
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
hall *hall.Hall
|
hall *hall.Hall
|
||||||
id string
|
id string
|
||||||
@@ -250,6 +252,7 @@ func newRecorder(client *Client, directory string) (*recorder, error) {
|
|||||||
done: make(chan error, 1),
|
done: make(chan error, 1),
|
||||||
sources: make(map[*diskTrack]chan []byte),
|
sources: make(map[*diskTrack]chan []byte),
|
||||||
}
|
}
|
||||||
|
activeRecordings.Store(path, struct{}{})
|
||||||
go r.mix()
|
go r.mix()
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
@@ -358,6 +361,7 @@ func (r *recorder) Close() error {
|
|||||||
err := <-r.done
|
err := <-r.done
|
||||||
stdinErr := r.stdin.Close()
|
stdinErr := r.stdin.Close()
|
||||||
waitErr := r.cmd.Wait()
|
waitErr := r.cmd.Wait()
|
||||||
|
activeRecordings.Delete(r.path)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -368,6 +372,10 @@ func (r *recorder) Close() error {
|
|||||||
if waitErr != nil {
|
if waitErr != nil {
|
||||||
return waitErr
|
return waitErr
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := os.Chtimes(r.path, now, now); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,6 +666,49 @@ func openDiskFile(directory, extension string) (*os.File, error) {
|
|||||||
return nil, errors.New("couldn't create file")
|
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) {
|
func drainStderr(prefix string, r io.Reader) {
|
||||||
buf := make([]byte, 1024)
|
buf := make([]byte, 1024)
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -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) {
|
func TestMixFrameClipsAndSumsSources(t *testing.T) {
|
||||||
a := make(chan []byte, 1)
|
a := make(chan []byte, 1)
|
||||||
b := make(chan []byte, 1)
|
b := make(chan []byte, 1)
|
||||||
|
|||||||
@@ -862,6 +862,7 @@ type Configuration struct {
|
|||||||
AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"`
|
AllowAdminOrigin []string `json:"allowAdminOrigin,omitempty"`
|
||||||
ProxyURL string `json:"proxyURL,omitempty"`
|
ProxyURL string `json:"proxyURL,omitempty"`
|
||||||
WritableHalls bool `json:"writableHalls,omitempty"`
|
WritableHalls bool `json:"writableHalls,omitempty"`
|
||||||
|
RecordingRetention string `json:"recordingRetention,omitempty"`
|
||||||
Users map[string]UserDescription `json:"users,omitempty"`
|
Users map[string]UserDescription `json:"users,omitempty"`
|
||||||
|
|
||||||
// obsolete fields
|
// obsolete fields
|
||||||
|
|||||||
+2
-2
@@ -37,7 +37,7 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir"
|
mkdir -p "$dataDir" "$hallsDir" "$recordingsDir/$hallName"
|
||||||
|
|
||||||
hallFile="$hallsDir/$hallName.json"
|
hallFile="$hallsDir/$hallName.json"
|
||||||
escapedAdminUsername="$(json_string "$adminUsername")"
|
escapedAdminUsername="$(json_string "$adminUsername")"
|
||||||
@@ -47,7 +47,7 @@ cat > "$hallFile" <<EOF
|
|||||||
{"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true}
|
{"users":{"$escapedAdminUsername":{"password":"$escapedAdminPassword","permissions":"op"}},"wildcard-user":{"password":"$escapedGuestPassword","permissions":"present"},"allow-recording":true}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ ! -x "$binary" ]]; then
|
if [[ ! -x "$binary" ]] || find . -name '*.go' -newer "$binary" -print -quit | grep -q .; then
|
||||||
echo "Building $binary"
|
echo "Building $binary"
|
||||||
go build -o "$binary" .
|
go build -o "$binary" .
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,11 +1,70 @@
|
|||||||
package rtpconn
|
package rtpconn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pion/webrtc/v4"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/skald/conn"
|
||||||
|
"git.stormux.org/storm/skald/hall"
|
||||||
"git.stormux.org/storm/skald/rtptime"
|
"git.stormux.org/storm/skald/rtptime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type testClient struct {
|
||||||
|
hall *hall.Hall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Hall() *hall.Hall {
|
||||||
|
return c.hall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Addr() net.Addr {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Id() string {
|
||||||
|
return "test-client"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Username() string {
|
||||||
|
return "Test User"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) SetUsername(string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Permissions() []string {
|
||||||
|
return []string{"present"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) SetPermissions([]string) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Data() map[string]interface{} {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) PushConn(*hall.Hall, string, conn.Up, []conn.UpTrack, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) RequestConns(hall.Client, *hall.Hall, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Joined(string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) PushClient(string, string, string, string, []string, map[string]interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testClient) Kick(string, *string, string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownTrackAtomics(t *testing.T) {
|
func TestDownTrackAtomics(t *testing.T) {
|
||||||
down := &rtpDownTrack{
|
down := &rtpDownTrack{
|
||||||
atomics: &downTrackAtomics{},
|
atomics: &downTrackAtomics{},
|
||||||
@@ -38,6 +97,44 @@ func TestDownTrackAtomics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewUpConnRejectsVideoMediaSection(t *testing.T) {
|
||||||
|
g, err := hall.Add("rtpconn-video-rejection-test", &hall.Description{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Add hall: %v", err)
|
||||||
|
}
|
||||||
|
defer hall.Delete(g.Name())
|
||||||
|
|
||||||
|
offer := `v=0
|
||||||
|
o=- 1 1 IN IP4 127.0.0.1
|
||||||
|
s=-
|
||||||
|
t=0 0
|
||||||
|
m=audio 9 UDP/TLS/RTP/SAVPF 111
|
||||||
|
c=IN IP4 0.0.0.0
|
||||||
|
a=mid:0
|
||||||
|
a=sendrecv
|
||||||
|
a=rtpmap:111 opus/48000/2
|
||||||
|
m=video 9 UDP/TLS/RTP/SAVPF 96
|
||||||
|
c=IN IP4 0.0.0.0
|
||||||
|
a=mid:1
|
||||||
|
a=sendrecv
|
||||||
|
a=rtpmap:96 VP8/90000
|
||||||
|
`
|
||||||
|
|
||||||
|
up, err := newUpConn(&testClient{hall: g}, "up-test", "audio", offer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newUpConn: %v", err)
|
||||||
|
}
|
||||||
|
defer up.pc.Close()
|
||||||
|
|
||||||
|
transceivers := up.pc.GetTransceivers()
|
||||||
|
if len(transceivers) != 1 {
|
||||||
|
t.Fatalf("expected only audio transceiver, got %d", len(transceivers))
|
||||||
|
}
|
||||||
|
if kind := transceivers[0].Kind(); kind != webrtc.RTPCodecTypeAudio {
|
||||||
|
t.Fatalf("expected audio transceiver, got %v", kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSadd(t *testing.T) {
|
func TestSadd(t *testing.T) {
|
||||||
ts := []struct{ x, y, z uint64 }{
|
ts := []struct{ x, y, z uint64 }{
|
||||||
{0, 0, 0},
|
{0, 0, 0},
|
||||||
|
|||||||
@@ -58,6 +58,18 @@ func isWSNormalError(err error) bool {
|
|||||||
websocket.CloseGoingAway)
|
websocket.CloseGoingAway)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func broadcastRecordingStatus(g *hall.Hall, message string) {
|
||||||
|
err := broadcast(g.GetClients(nil), clientMessage{
|
||||||
|
Type: "usermessage",
|
||||||
|
Kind: "recording",
|
||||||
|
Privileged: true,
|
||||||
|
Value: message,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("broadcast(recording): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type webClient struct {
|
type webClient struct {
|
||||||
hall *hall.Hall
|
hall *hall.Hall
|
||||||
addr net.Addr
|
addr net.Addr
|
||||||
@@ -1670,18 +1682,24 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
|||||||
disk.Close()
|
disk.Close()
|
||||||
return c.error(err)
|
return c.error(err)
|
||||||
}
|
}
|
||||||
|
broadcastRecordingStatus(g, "Recording started")
|
||||||
requestConns(disk, c.hall, "")
|
requestConns(disk, c.hall, "")
|
||||||
case "unrecord":
|
case "unrecord":
|
||||||
if !member("record", c.permissions) {
|
if !member("record", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
}
|
}
|
||||||
|
stopped := false
|
||||||
for _, cc := range g.GetClients(c) {
|
for _, cc := range g.GetClients(c) {
|
||||||
disk, ok := cc.(*diskwriter.Client)
|
disk, ok := cc.(*diskwriter.Client)
|
||||||
if ok {
|
if ok {
|
||||||
disk.Close()
|
disk.Close()
|
||||||
hall.DelClient(disk)
|
hall.DelClient(disk)
|
||||||
|
stopped = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if stopped {
|
||||||
|
broadcastRecordingStatus(g, "Recording stopped")
|
||||||
|
}
|
||||||
case "subhalls":
|
case "subhalls":
|
||||||
if !member("op", c.permissions) {
|
if !member("op", c.permissions) {
|
||||||
return c.error(hall.UserError("not authorised"))
|
return c.error(hall.UserError("not authorised"))
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func main() {
|
|||||||
go func() {
|
go func() {
|
||||||
hall.Update()
|
hall.Update()
|
||||||
token.Expire()
|
token.Expire()
|
||||||
|
cleanupRecordings()
|
||||||
}()
|
}()
|
||||||
case <-slowTicker.C:
|
case <-slowTicker.C:
|
||||||
go relayTest()
|
go relayTest()
|
||||||
@@ -169,6 +170,29 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupRecordings() {
|
||||||
|
conf, err := hall.GetConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Recording cleanup: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conf.RecordingRetention == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retention, err := time.ParseDuration(conf.RecordingRetention)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Recording cleanup: bad recordingRetention %q: %v",
|
||||||
|
conf.RecordingRetention, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if retention <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := diskwriter.CleanupOldRecordings(retention); err != nil {
|
||||||
|
log.Printf("Recording cleanup: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func relayTest() {
|
func relayTest() {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
d, err := ice.RelayTest(20 * time.Second)
|
d, err := ice.RelayTest(20 * time.Second)
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ file may look as follows:
|
|||||||
{
|
{
|
||||||
"users":{"vetinari": {"password":"lagniappe", "permissions": "admin"}},
|
"users":{"vetinari": {"password":"lagniappe", "permissions": "admin"}},
|
||||||
"canonicalHost": "skald.example.org",
|
"canonicalHost": "skald.example.org",
|
||||||
"writableHalls": true
|
"writableHalls": true,
|
||||||
|
"recordingRetention": "168h"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,7 +141,8 @@ or, better, with a hashed password:
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"canonicalHost": "skald.example.org",
|
"canonicalHost": "skald.example.org",
|
||||||
"writableHalls": true
|
"writableHalls": true,
|
||||||
|
"recordingRetention": "168h"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -156,6 +158,12 @@ fields are as follows:
|
|||||||
to modify hall definitions; if unset or false, then only read-only
|
to modify hall definitions; if unset or false, then only read-only
|
||||||
access is allowed;
|
access is allowed;
|
||||||
|
|
||||||
|
- `recordingRetention`: if set, completed recording files older than this
|
||||||
|
duration are automatically deleted from the recordings directory. The
|
||||||
|
value uses Go duration syntax, for example `"24h"` for one day or
|
||||||
|
`"168h"` for seven days. If unset, recordings are kept until deleted
|
||||||
|
manually;
|
||||||
|
|
||||||
- `allowOrigin` is an array containing the list of HTTP origins that
|
- `allowOrigin` is an array containing the list of HTTP origins that
|
||||||
are allowed to access the server;
|
are allowed to access the server;
|
||||||
|
|
||||||
@@ -421,6 +429,9 @@ with an operator-visible error instead of silently creating a broken file.
|
|||||||
The recorder closes when recording is stopped explicitly or when the hall no
|
The recorder closes when recording is stopped explicitly or when the hall no
|
||||||
longer has any non-system clients.
|
longer has any non-system clients.
|
||||||
|
|
||||||
|
If `recordingRetention` is set in `data/config.json`, Skald periodically
|
||||||
|
deletes completed recording files older than the configured duration.
|
||||||
|
|
||||||
## Client Authorisation
|
## Client Authorisation
|
||||||
|
|
||||||
Skald implements three authorisation methods: a username/password
|
Skald implements three authorisation methods: a username/password
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
<h2 id="chat-history-heading" class="sr-only">Chat History</h2>
|
<h2 id="chat-history-heading" class="sr-only">Chat History</h2>
|
||||||
<div id="box"></div>
|
<div id="box"></div>
|
||||||
<div id="chat-announcements" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
|
<div id="chat-announcements" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
|
||||||
|
<div id="urgent-announcements" role="alert" aria-live="assertive" aria-atomic="true" class="sr-only"></div>
|
||||||
<div id="audio-streams" aria-hidden="true"></div>
|
<div id="audio-streams" aria-hidden="true"></div>
|
||||||
<div class="reply">
|
<div class="reply">
|
||||||
<form id="inputform">
|
<form id="inputform">
|
||||||
|
|||||||
+30
-2
@@ -2101,6 +2101,19 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
|
|||||||
displayError(`${from} said: ${message}`, kind);
|
displayError(`${from} said: ${message}`, kind);
|
||||||
break;
|
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': {
|
case 'mute': {
|
||||||
if(!privileged) {
|
if(!privileged) {
|
||||||
console.error(`Got unprivileged message of kind ${kind}`);
|
console.error(`Got unprivileged message of kind ${kind}`);
|
||||||
@@ -2283,9 +2296,10 @@ let lastMessage = {};
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} text
|
* @param {string} text
|
||||||
|
* @param {string} elementId
|
||||||
*/
|
*/
|
||||||
function announceChat(text) {
|
function announceLiveRegion(text, elementId) {
|
||||||
let announcement = document.getElementById('chat-announcements');
|
let announcement = document.getElementById(elementId);
|
||||||
if(!announcement)
|
if(!announcement)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -2295,6 +2309,20 @@ function announceChat(text) {
|
|||||||
}, 20);
|
}, 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} id
|
||||||
* @param {string} peerId
|
* @param {string} peerId
|
||||||
|
|||||||
@@ -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
|
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
|
+ 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
|
+ 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
|
X Verify two browser clients can join one hall and exchange audio only
|
||||||
- Verify a client attempting camera or screenshare cannot publish video
|
X Verify a client attempting camera or screenshare cannot publish video
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PHASE 5: FRONTEND - AUDIO-ONLY USER EXPERIENCE
|
# 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
|
- Preserve keyboard access with Tab and Shift+Tab through all controls
|
||||||
- Ensure controls have labels or accessible names after icon/button cleanup
|
- Ensure controls have labels or accessible names after icon/button cleanup
|
||||||
X Verify audio-only UI loads without 404s for renamed assets
|
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
|
# 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 Run a local server and verify static pages load without 404s
|
||||||
X Test: create a hall with skaldctl
|
X Test: create a hall with skaldctl
|
||||||
X Test: list halls with skaldctl
|
X Test: list halls with skaldctl
|
||||||
- Test: join one hall from two browsers and confirm two-way audio
|
X Test: join one hall from two browsers and confirm two-way audio
|
||||||
- Test: attempt to publish video and confirm rejection
|
X Test: attempt to publish video and confirm rejection
|
||||||
- Test: start recording and confirm one Ogg Opus file appears
|
X Test: start recording and confirm one Ogg Opus file appears
|
||||||
- Test: join a third participant during recording and confirm the mix continues
|
- Test: join a third participant during recording and confirm the mix continues
|
||||||
- Test: mute/unmute during recording and confirm the output reflects it
|
- 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
|
- Test: play the recording in a standard audio player
|
||||||
X Test: restart server and verify halls/config load from the new paths
|
X Test: restart server and verify halls/config load from the new paths
|
||||||
X Remove obsolete files that are no longer served or built
|
X Remove obsolete files that are no longer served or built
|
||||||
|
|||||||
Reference in New Issue
Block a user