Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
601553c582 | ||
|
|
8b5fe84e62 | ||
|
|
33efafecd5 | ||
|
|
8ccec822b4 | ||
|
|
e738156fd7 | ||
|
|
a2981903c2 | ||
|
|
5d4fcf9686 | ||
|
|
50854f09b3 | ||
|
|
54104a5c3c | ||
|
|
715c267079 | ||
|
|
c48cc989f4 | ||
|
|
44970293f8 | ||
|
|
423ddb8e22 | ||
|
|
c593ed381d | ||
|
|
b6a39721dd | ||
|
|
159e24438d | ||
|
|
2c3e8fa3c1 | ||
|
|
ba8ce8bf66 | ||
|
|
84341875b7 | ||
|
|
d52911160d | ||
|
|
4ef036b0f4 | ||
|
|
eb3184bcfa | ||
|
|
5c9b2333f9 | ||
|
|
77c7c08587 | ||
|
|
d9a424ea79 | ||
|
|
826c7e8a9e | ||
|
|
3e6f6de032 | ||
|
|
3909c506a2 | ||
|
|
e9556d44da | ||
|
|
c85529370b | ||
|
|
285a722fac | ||
|
|
234c696af9 | ||
|
|
177840b94a | ||
|
|
35e684bd10 | ||
|
|
9b4adab8fc | ||
|
|
d74d1b55f0 | ||
|
|
4d52d7d1fa | ||
|
|
02559acc39 | ||
|
|
892fd3d89e | ||
|
|
0bb372d8cb | ||
|
|
ddc8e9bed0 | ||
|
|
5b2a3e3127 | ||
|
|
a476142f23 | ||
|
|
ad090a0f86 | ||
|
|
5365cc0325 | ||
|
|
37404aac5c |
@@ -1,57 +0,0 @@
|
||||
name: ActivityPub Federation Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "webserver/**"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-federation-tests.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "webserver/**"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-federation-tests.yaml"
|
||||
|
||||
jobs:
|
||||
federation-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: "same_content_newer"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.23"
|
||||
cache: true
|
||||
|
||||
- name: Install Caddy
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y caddy
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get install -y snac2 ffmpeg
|
||||
|
||||
- name: Run setup
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
sudo ./setup.sh
|
||||
|
||||
- name: Run federation test
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
CI=true USER_COUNT=20 ./run.sh
|
||||
@@ -1,47 +0,0 @@
|
||||
name: ActivityPub Follower Validation Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "config/**"
|
||||
- "main.go"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-follower-validation-tests.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "config/**"
|
||||
- "main.go"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-follower-validation-tests.yaml"
|
||||
|
||||
jobs:
|
||||
follower-validation-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: "same_content_newer"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.23"
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y sqlite3 ffmpeg jq
|
||||
|
||||
- name: Run follower validation test
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
./test-follower-validation.sh
|
||||
@@ -0,0 +1,130 @@
|
||||
name: ActivityPub Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "core/chat/**"
|
||||
- "config/**"
|
||||
- "main.go"
|
||||
- "webserver/**"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-tests.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "activitypub/**"
|
||||
- "core/chat/**"
|
||||
- "config/**"
|
||||
- "main.go"
|
||||
- "webserver/**"
|
||||
- "test/automated/activitypub/**"
|
||||
- ".github/workflows/activitypub-tests.yaml"
|
||||
|
||||
jobs:
|
||||
federation-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: "same_content_newer"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.23"
|
||||
cache: true
|
||||
|
||||
- name: Install Caddy
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y caddy
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get install -y snac2 ffmpeg
|
||||
|
||||
- name: Run setup
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
sudo ./setup.sh
|
||||
|
||||
- name: Run federation test
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
CI=true USER_COUNT=20 ./test-federation.sh
|
||||
|
||||
chat-sanitization-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: "same_content_newer"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.23"
|
||||
cache: true
|
||||
|
||||
- name: Install Caddy
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y caddy
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get install -y snac2 ffmpeg
|
||||
|
||||
- name: Run setup
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
sudo ./setup.sh
|
||||
|
||||
- name: Run chat sanitization test
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
CI=true ./test-chat-sanitization.sh
|
||||
|
||||
follower-validation-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: "same_content_newer"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "^1.23"
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y sqlite3 ffmpeg jq
|
||||
|
||||
- name: Run follower validation test
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
cd test/automated/activitypub
|
||||
./test-follower-validation.sh
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add comment
|
||||
uses: peter-evans/create-or-update-comment@9143c495e69aa6ad64fa02b7515ccab6bd35aad1
|
||||
uses: peter-evans/create-or-update-comment@57232238742e38b2ccc27136ce596ccae7ca28b4
|
||||
with:
|
||||
issue-number: ${{ github.event.issue.number }}
|
||||
body: |
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
- name: Publish to Chromatic
|
||||
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true' }}
|
||||
uses: chromaui/action@v15
|
||||
uses: chromaui/action@v16
|
||||
with:
|
||||
workingDir: web
|
||||
projectToken: f47410569b62
|
||||
|
||||
@@ -38,6 +38,7 @@ on:
|
||||
jobs:
|
||||
formatting:
|
||||
name: Code formatting
|
||||
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -133,6 +134,7 @@ jobs:
|
||||
|
||||
unused-code:
|
||||
name: Test for unused code
|
||||
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -197,7 +199,7 @@ jobs:
|
||||
web-bundle:
|
||||
name: Build and bundle web project
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'owncast/owncast'
|
||||
if: github.repository == 'owncast/owncast' && !cancelled()
|
||||
needs: [formatting, unused-code]
|
||||
steps:
|
||||
- id: skip_check
|
||||
|
||||
@@ -16,6 +16,8 @@ on:
|
||||
# Run the workflow every hour
|
||||
- cron: '0 * * * *'
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'web/i18n/en/translation.json'
|
||||
- 'web/**/*.tsx'
|
||||
|
||||
@@ -4,12 +4,24 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/chat/events"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
// sanitizeActorName strips HTML tags from the ActivityPub actor display name.
|
||||
// Falls back to the username if the display name is empty or entirely HTML.
|
||||
func sanitizeActorName(displayName, username string) string {
|
||||
strict := bluemonday.StrictPolicy()
|
||||
name := strict.Sanitize(displayName)
|
||||
if name == "" {
|
||||
name = strict.Sanitize(username)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
@@ -30,10 +42,7 @@ func handleEngagementActivity(eventType events.EventType, isLiveNotification boo
|
||||
}
|
||||
|
||||
// Send chat message
|
||||
actorName := actor.Name
|
||||
if actorName == "" {
|
||||
actorName = actor.Username
|
||||
}
|
||||
actorName := sanitizeActorName(actor.Name, actor.Username)
|
||||
actorIRI := actor.ActorIriString()
|
||||
|
||||
userPrefix := fmt.Sprintf("%s ", actorName)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeActorName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
displayName string
|
||||
username string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "plain display name",
|
||||
displayName: "Alice",
|
||||
username: "alice",
|
||||
expected: "Alice",
|
||||
},
|
||||
{
|
||||
name: "display name with emoji",
|
||||
displayName: "Alice 🦊",
|
||||
username: "alice",
|
||||
expected: "Alice 🦊",
|
||||
},
|
||||
{
|
||||
name: "display name with unicode",
|
||||
displayName: "Ålice Böb",
|
||||
username: "alice",
|
||||
expected: "Ålice Böb",
|
||||
},
|
||||
{
|
||||
name: "empty display name falls back to username",
|
||||
displayName: "",
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "script tag in display name",
|
||||
displayName: `<script>alert("xss")</script>`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "iframe injection in display name",
|
||||
displayName: `<iframe src="https://evil.com" style="position:fixed;top:0;left:0;width:100%;height:100%"></iframe>`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "img tag in display name",
|
||||
displayName: `<img src="https://evil.com/track.png">`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "form injection in display name",
|
||||
displayName: `<form action="https://evil.com/steal"><input name="pw" type="password"></form>`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "meta refresh in display name",
|
||||
displayName: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "mixed text and HTML in display name",
|
||||
displayName: `Alice <script>alert(1)</script> Bob`,
|
||||
username: "alice",
|
||||
expected: "Alice Bob",
|
||||
},
|
||||
{
|
||||
name: "custom emoji HTML in display name",
|
||||
displayName: `Alice :blobcat: <img src="https://instance.com/emoji/blobcat.png" class="custom-emoji">`,
|
||||
username: "alice",
|
||||
expected: "Alice :blobcat: ",
|
||||
},
|
||||
{
|
||||
name: "HTML in both display name and username",
|
||||
displayName: `<script>alert(1)</script>`,
|
||||
username: `<b>alice</b>`,
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "entirely HTML display name falls back to username",
|
||||
displayName: `<div></div>`,
|
||||
username: "alice",
|
||||
expected: "alice",
|
||||
},
|
||||
{
|
||||
name: "style tag in display name",
|
||||
displayName: `<style>body{display:none}</style>Alice`,
|
||||
username: "alice",
|
||||
expected: "Alice",
|
||||
},
|
||||
{
|
||||
name: "nested HTML tags",
|
||||
displayName: `<div><span><a href="https://evil.com">Click me</a></span></div>`,
|
||||
username: "alice",
|
||||
expected: "Click me",
|
||||
},
|
||||
{
|
||||
name: "event handler attributes",
|
||||
displayName: `<img src=x onerror="alert(1)">Alice`,
|
||||
username: "alice",
|
||||
expected: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := sanitizeActorName(tt.displayName, tt.username)
|
||||
if result != tt.expected {
|
||||
t.Errorf("sanitizeActorName(%q, %q) = %q, want %q", tt.displayName, tt.username, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,6 @@ func SendFediverseAction(eventType string, userAccountName string, image *string
|
||||
}
|
||||
|
||||
message.SetDefaults()
|
||||
message.RenderBody()
|
||||
|
||||
if err := Broadcast(&message); err != nil {
|
||||
log.Errorln("error sending system message", err)
|
||||
|
||||
+10
-41
@@ -1,7 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -99,53 +98,23 @@ func createInitialOfflineState() error {
|
||||
// offline video stream state only. No live stream HLS segments will continue to be
|
||||
// referenced.
|
||||
func transitionToOfflineVideoStreamContent() {
|
||||
log.Traceln("Placing offline fMP4 content into HLS directories")
|
||||
log.Traceln("Firing transcoder with offline stream state")
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
_transcoder := transcoder.NewTranscoder()
|
||||
_transcoder.SetIdentifier("offline")
|
||||
_transcoder.SetLatencyLevel(models.GetLatencyLevel(4))
|
||||
_transcoder.SetIsEvent(true)
|
||||
|
||||
offlineInitPath, offlineSegmentPath, err := saveOfflineFMP4ToDisk()
|
||||
offlineFilePath, err := saveOfflineClipToDisk("offline-v2.ts")
|
||||
if err != nil {
|
||||
log.Fatalln("unable to save offline fMP4 files:", err)
|
||||
log.Fatalln("unable to save offline clip:", err)
|
||||
}
|
||||
|
||||
variants := configRepository.GetStreamOutputVariants()
|
||||
if len(variants) == 0 {
|
||||
// Ensure at least one variant directory exists.
|
||||
variants = make([]models.StreamOutputVariant, 1)
|
||||
}
|
||||
|
||||
for index := range variants {
|
||||
variantDir := filepath.Join(config.HLSStoragePath, fmt.Sprintf("%d", index))
|
||||
if err := os.MkdirAll(variantDir, 0o750); err != nil {
|
||||
log.Errorln("unable to create variant directory:", err)
|
||||
continue
|
||||
}
|
||||
makeVariantIndexOffline(index, offlineInitPath, offlineSegmentPath)
|
||||
}
|
||||
|
||||
// Write the master playlist that points to each variant's playlist.
|
||||
masterPlaylistPath := filepath.Join(config.HLSStoragePath, "stream.m3u8")
|
||||
masterFile, err := os.Create(masterPlaylistPath) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Errorln("unable to create master playlist:", err)
|
||||
} else {
|
||||
_, _ = masterFile.WriteString("#EXTM3U\n")
|
||||
_, _ = masterFile.WriteString("#EXT-X-VERSION:7\n")
|
||||
_, _ = masterFile.WriteString("#EXT-X-INDEPENDENT-SEGMENTS\n")
|
||||
for index := range variants {
|
||||
_, _ = fmt.Fprintf(masterFile, "#EXT-X-STREAM-INF:BANDWIDTH=0\n")
|
||||
_, _ = fmt.Fprintf(masterFile, "%d/stream.m3u8\n", index)
|
||||
}
|
||||
if err := masterFile.Close(); err != nil {
|
||||
log.Errorln("unable to close master playlist:", err)
|
||||
}
|
||||
|
||||
// Notify the storage provider so it can rewrite variant URLs
|
||||
// to absolute paths for S3 or remote serving endpoints.
|
||||
_storage.MasterPlaylistWritten(masterPlaylistPath)
|
||||
}
|
||||
_transcoder.SetInput(offlineFilePath)
|
||||
go _transcoder.Start(false)
|
||||
|
||||
// Copy the logo to be the thumbnail
|
||||
configRepository := configrepository.Get()
|
||||
logo := configRepository.GetLogoPath()
|
||||
dst := filepath.Join(config.TempDir, "thumbnail.jpg")
|
||||
if err = utils.Copy(filepath.Join("data", logo), dst); err != nil {
|
||||
|
||||
+33
-61
@@ -5,13 +5,14 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/grafov/m3u8"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/static"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func appendOfflineToVariantPlaylist(index int, playlistFilePath string, initFilename string, segmentFilename string) {
|
||||
func appendOfflineToVariantPlaylist(index int, playlistFilePath string) {
|
||||
existingPlaylistContents, err := os.ReadFile(playlistFilePath) // nolint: gosec
|
||||
if err != nil {
|
||||
log.Debugln("unable to read existing playlist file", err)
|
||||
@@ -33,10 +34,9 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string, initFile
|
||||
|
||||
// Manually append the offline clip to the end of the media playlist.
|
||||
_, _ = atomicWriteTmpPlaylistFile.WriteString("#EXT-X-DISCONTINUITY\n")
|
||||
_, _ = fmt.Fprintf(atomicWriteTmpPlaylistFile, "#EXT-X-MAP:URI=\"%s\"\n", initFilename)
|
||||
// If "offline" content gets changed then change the duration below
|
||||
_, _ = atomicWriteTmpPlaylistFile.WriteString("#EXTINF:8.000000,\n")
|
||||
_, _ = fmt.Fprintf(atomicWriteTmpPlaylistFile, "%s\n", segmentFilename)
|
||||
_, _ = atomicWriteTmpPlaylistFile.WriteString("offline-v2.ts\n")
|
||||
_, _ = atomicWriteTmpPlaylistFile.WriteString("#EXT-X-ENDLIST\n")
|
||||
|
||||
if err := atomicWriteTmpPlaylistFile.Close(); err != nil {
|
||||
@@ -48,89 +48,61 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string, initFile
|
||||
}
|
||||
}
|
||||
|
||||
func makeVariantIndexOffline(index int, offlineInitPath string, offlineSegmentPath string) {
|
||||
variantDir := filepath.Join(config.HLSStoragePath, fmt.Sprintf("%d", index))
|
||||
playlistFilePath := filepath.Join(variantDir, "stream.m3u8")
|
||||
func makeVariantIndexOffline(index int, offlineFilePath string, offlineFilename string) {
|
||||
playlistFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, "%d/stream.m3u8"), index)
|
||||
segmentFilePath := fmt.Sprintf(filepath.Join(config.HLSStoragePath, "%d/%s"), index, offlineFilename)
|
||||
|
||||
initDest := filepath.Join(variantDir, "offline-init.mp4")
|
||||
segmentDest := filepath.Join(variantDir, "offline-v2.m4s")
|
||||
|
||||
if err := utils.Copy(offlineInitPath, initDest); err != nil {
|
||||
log.Warnln(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := _storage.Save(initDest, 0); err != nil {
|
||||
if err := utils.Copy(offlineFilePath, segmentFilePath); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
||||
if err := utils.Copy(offlineSegmentPath, segmentDest); err != nil {
|
||||
log.Warnln(err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := _storage.Save(segmentDest, 0); err != nil {
|
||||
if _, err := _storage.Save(segmentFilePath, 0); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
||||
if utils.DoesFileExists(playlistFilePath) {
|
||||
appendOfflineToVariantPlaylist(index, playlistFilePath, "offline-init.mp4", "offline-v2.m4s")
|
||||
appendOfflineToVariantPlaylist(index, playlistFilePath)
|
||||
} else {
|
||||
createEmptyOfflinePlaylist(playlistFilePath, "offline-init.mp4", "offline-v2.m4s")
|
||||
createEmptyOfflinePlaylist(playlistFilePath, offlineFilename)
|
||||
}
|
||||
if _, err := _storage.Save(playlistFilePath, 0); err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createEmptyOfflinePlaylist(playlistFilePath string, initFilename string, segmentFilename string) {
|
||||
func createEmptyOfflinePlaylist(playlistFilePath string, offlineFilename string) {
|
||||
p, err := m3u8.NewMediaPlaylist(1, 1)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
|
||||
// If "offline" content gets changed then change the duration below
|
||||
if err := p.Append(offlineFilename, 8.0, ""); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
|
||||
p.Close()
|
||||
f, err := os.Create(playlistFilePath) //nolint:gosec
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, _ = f.WriteString("#EXTM3U\n")
|
||||
_, _ = f.WriteString("#EXT-X-VERSION:7\n")
|
||||
_, _ = f.WriteString("#EXT-X-TARGETDURATION:8\n")
|
||||
_, _ = fmt.Fprintf(f, "#EXT-X-MAP:URI=\"%s\"\n", initFilename)
|
||||
// If "offline" content gets changed then change the duration below
|
||||
_, _ = f.WriteString("#EXTINF:8.000000,\n")
|
||||
_, _ = fmt.Fprintf(f, "%s\n", segmentFilename)
|
||||
_, _ = f.WriteString("#EXT-X-ENDLIST\n")
|
||||
if _, err := f.Write(p.Encode().Bytes()); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveOfflineFMP4ToDisk() (initPath string, segmentPath string, err error) {
|
||||
initData := static.GetOfflineInitSegment()
|
||||
initTmp, err := os.CreateTemp(config.TempDir, "offline-init-*.mp4")
|
||||
func saveOfflineClipToDisk(offlineFilename string) (string, error) {
|
||||
offlineFileData := static.GetOfflineSegment()
|
||||
offlineTmpFile, err := os.CreateTemp(config.TempDir, offlineFilename)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to create temp file for offline init segment: %s", err)
|
||||
log.Errorln("unable to create temp file for offline video segment", err)
|
||||
}
|
||||
|
||||
if _, err = initTmp.Write(initData); err != nil {
|
||||
return "", "", fmt.Errorf("unable to write offline init segment to disk: %s", err)
|
||||
if _, err = offlineTmpFile.Write(offlineFileData); err != nil {
|
||||
return "", fmt.Errorf("unable to write offline segment to disk: %s", err)
|
||||
}
|
||||
|
||||
if err := initTmp.Close(); err != nil {
|
||||
return "", "", fmt.Errorf("unable to close offline init segment: %s", err)
|
||||
}
|
||||
initPath, _ = filepath.Abs(initTmp.Name())
|
||||
|
||||
segData := static.GetOfflineMediaSegment()
|
||||
segTmp, err := os.CreateTemp(config.TempDir, "offline-v2-*.m4s")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("unable to create temp file for offline media segment: %s", err)
|
||||
}
|
||||
|
||||
if _, err = segTmp.Write(segData); err != nil {
|
||||
return "", "", fmt.Errorf("unable to write offline media segment to disk: %s", err)
|
||||
}
|
||||
|
||||
if err := segTmp.Close(); err != nil {
|
||||
return "", "", fmt.Errorf("unable to close offline media segment: %s", err)
|
||||
}
|
||||
segmentPath, _ = filepath.Abs(segTmp.Name())
|
||||
|
||||
return initPath, segmentPath, nil
|
||||
offlineFilePath := offlineTmpFile.Name()
|
||||
return filepath.Abs(offlineFilePath)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func getAllFilesRecursive(baseDirectory string) (map[string][]os.FileInfo, error
|
||||
directory = info.Name()
|
||||
}
|
||||
|
||||
if filepath.Ext(info.Name()) == ".m4s" {
|
||||
if filepath.Ext(info.Name()) == ".ts" {
|
||||
files[directory] = append(files[directory], info)
|
||||
}
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) {
|
||||
err := s.s3Client.ListObjectsPages(allObjectsListRequest,
|
||||
func(page *s3.ListObjectsOutput, lastPage bool) bool {
|
||||
for _, item := range page.Contents {
|
||||
if strings.HasSuffix(*item.Key, ".m4s") {
|
||||
if strings.HasSuffix(*item.Key, ".ts") {
|
||||
allObjects = append(allObjects, s3object{
|
||||
key: *item.Key,
|
||||
lastModified: *item.LastModified,
|
||||
|
||||
+4
-2
@@ -96,7 +96,9 @@ func SetStreamAsDisconnected() {
|
||||
_stats.LastConnectTime = nil
|
||||
_broadcaster = nil
|
||||
|
||||
offlineInitPath, offlineSegmentPath, err := saveOfflineFMP4ToDisk()
|
||||
offlineFilename := "offline-v2.ts"
|
||||
|
||||
offlineFilePath, err := saveOfflineClipToDisk(offlineFilename)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return
|
||||
@@ -120,7 +122,7 @@ func SetStreamAsDisconnected() {
|
||||
}
|
||||
|
||||
for index := range _currentBroadcast.OutputSettings {
|
||||
makeVariantIndexOffline(index, offlineInitPath, offlineSegmentPath)
|
||||
makeVariantIndexOffline(index, offlineFilePath, offlineFilename)
|
||||
}
|
||||
|
||||
StartOfflineCleanupTimer()
|
||||
|
||||
@@ -18,7 +18,6 @@ type FileWriterReceiverServiceCallback interface {
|
||||
SegmentWritten(localFilePath string)
|
||||
VariantPlaylistWritten(localFilePath string)
|
||||
MasterPlaylistWritten(localFilePath string)
|
||||
InitSegmentWritten(localFilePath string)
|
||||
}
|
||||
|
||||
// FileWriterReceiverService accepts transcoder responses via HTTP and fires the callbacks.
|
||||
@@ -82,10 +81,8 @@ func (s *FileWriterReceiverService) uploadHandler(w http.ResponseWriter, r *http
|
||||
func (s *FileWriterReceiverService) fileWritten(path string) {
|
||||
if utils.GetRelativePathFromAbsolutePath(path) == "hls/stream.m3u8" {
|
||||
s.callbacks.MasterPlaylistWritten(path)
|
||||
} else if strings.HasSuffix(path, ".m4s") {
|
||||
} else if strings.HasSuffix(path, ".ts") {
|
||||
s.callbacks.SegmentWritten(path)
|
||||
} else if strings.HasSuffix(path, "init.mp4") {
|
||||
s.callbacks.InitSegmentWritten(path)
|
||||
} else if strings.HasSuffix(path, ".m3u8") {
|
||||
s.callbacks.VariantPlaylistWritten(path)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,3 @@ func (h *HLSHandler) VariantPlaylistWritten(localFilePath string) {
|
||||
func (h *HLSHandler) MasterPlaylistWritten(localFilePath string) {
|
||||
h.Storage.MasterPlaylistWritten(localFilePath)
|
||||
}
|
||||
|
||||
// InitSegmentWritten is fired when an fMP4 init segment is written to disk.
|
||||
func (h *HLSHandler) InitSegmentWritten(localFilePath string) {
|
||||
h.Storage.SegmentWritten(localFilePath)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
@@ -65,7 +64,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||
var modTime time.Time
|
||||
var names []string
|
||||
for _, f := range files {
|
||||
if path.Ext(f.Name()) != ".m4s" {
|
||||
if path.Ext(f.Name()) != ".ts" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -89,21 +88,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||
return nil
|
||||
}
|
||||
configRepository := configrepository.Get()
|
||||
// With multiple variants ffmpeg names init segments init_0.mp4, init_1.mp4, etc.
|
||||
// With a single variant it uses init.mp4.
|
||||
initSegment := path.Join(framePath, fmt.Sprintf("init_%d.mp4", variantIndex))
|
||||
if !utils.DoesFileExists(initSegment) {
|
||||
initSegment = path.Join(framePath, "init.mp4")
|
||||
}
|
||||
mostRecentFile := path.Join(framePath, names[0])
|
||||
|
||||
// The segment may have been cleaned up between ReadDir and now.
|
||||
if !utils.DoesFileExists(mostRecentFile) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fMP4 segments require the init segment to be readable by ffmpeg
|
||||
inputArg := fmt.Sprintf("concat:%s|%s", initSegment, mostRecentFile)
|
||||
ffmpegPath := utils.ValidatedFfmpegPath(configRepository.GetFfMpegPath())
|
||||
outputFileTemp := path.Join(config.TempDir, "tempthumbnail.jpg")
|
||||
|
||||
@@ -111,7 +96,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||
"-y", // Overwrite file
|
||||
"-threads", "1", // Low priority processing
|
||||
"-t", "1", // Pull from frame 1
|
||||
"-i", inputArg, // Input (init segment + media segment)
|
||||
"-i", mostRecentFile, // Input
|
||||
"-f", "image2", // format
|
||||
"-vframes", "1", // Single frame
|
||||
outputFileTemp,
|
||||
@@ -126,7 +111,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
|
||||
log.Errorln(err)
|
||||
}
|
||||
|
||||
makeAnimatedGifPreview(inputArg, previewGifFile)
|
||||
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -245,9 +245,7 @@ func (t *Transcoder) getFlags() *execInfo {
|
||||
ffmpegFlags = append(ffmpegFlags, hlsOptionsString...)
|
||||
ffmpegFlags = append(ffmpegFlags, hlsEventString...)
|
||||
ffmpegFlags = append(ffmpegFlags, []string{
|
||||
"-hls_segment_type", "fmp4",
|
||||
"-hls_fmp4_init_filename", "init.mp4",
|
||||
"-bsf:a", "aac_adtstoasc", // Convert AAC ADTS headers to ASC for fMP4 container compatibility
|
||||
"-segment_format_options", "mpegts_flags=mpegts_copyts=1",
|
||||
}...)
|
||||
ffmpegFlags = append(ffmpegFlags, t.codec.ExtraArguments()...)
|
||||
|
||||
@@ -259,7 +257,7 @@ func (t *Transcoder) getFlags() *execInfo {
|
||||
// Filenames
|
||||
"-master_pl_name", "stream.m3u8",
|
||||
|
||||
"-hls_segment_filename", localListenerAddress + "/%v/stream-" + t.segmentIdentifier + "-%d.m4s", // Send HLS segments back to us over HTTP
|
||||
"-hls_segment_filename", localListenerAddress + "/%v/stream-" + t.segmentIdentifier + "-%d.ts", // Send HLS segments back to us over HTTP
|
||||
"-max_muxing_queue_size", "400", // Workaround for Too many packets error: https://trac.ffmpeg.org/ticket/6375?cversion=0
|
||||
|
||||
"-method", "PUT", // HLS results sent back to us will be over PUTs
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegNvencCommand(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel cuda -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_nvenc -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -tune:v:0 ll -map a:0? -c:a:0 copy -preset p3 -map v:0 -c:v:1 h264_nvenc -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -tune:v:1 ll -map a:0? -c:a:1 copy -preset p5 -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset p1 -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdoieGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel cuda -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_nvenc -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -tune:v:0 ll -map a:0? -c:a:0 copy -preset p3 -map v:0 -c:v:1 h264_nvenc -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -tune:v:1 ll -map a:0? -c:a:1 copy -preset p5 -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset p1 -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdoieGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegOmxCommand(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_omx -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_omx -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_omx -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_omx -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegQuicksyncCommand(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -init_hw_device qsv=hw -filter_hw_device hw -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_qsv -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -filter:v:0 hwupload=extra_hw_frames=64,format=qsv -preset medium -map v:0 -c:v:1 h264_qsv -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -filter:v:1 hwupload=extra_hw_frames=64,format=qsv -preset veryslow -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset veryfast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -pix_fmt qsv -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -init_hw_device qsv=hw -filter_hw_device hw -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_qsv -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -filter:v:0 hwupload=extra_hw_frames=64,format=qsv -preset medium -map v:0 -c:v:1 h264_qsv -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -filter:v:1 hwupload=extra_hw_frames=64,format=qsv -preset veryslow -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset veryfast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt qsv -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegVaapiCommand(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel vaapi -hwaccel_output_format vaapi -vaapi_device /dev/dri/renderD128 -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_vaapi -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -filter:v:0 hwupload=extra_hw_frames=64,format=vaapi -preset veryfast -map v:0 -c:v:1 h264_vaapi -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -filter:v:1 hwupload=extra_hw_frames=64,format=vaapi -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -pix_fmt vaapi -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -hwaccel vaapi -hwaccel_output_format vaapi -vaapi_device /dev/dri/renderD128 -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_vaapi -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -map a:0? -c:a:0 copy -filter:v:0 hwupload=extra_hw_frames=64,format=vaapi -preset veryfast -map v:0 -c:v:1 h264_vaapi -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -filter:v:1 hwupload=extra_hw_frames=64,format=vaapi -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt vaapi -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegVideoToolboxCommand(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_videotoolbox -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -realtime true -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_videotoolbox -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -pix_fmt nv12 -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 h264_videotoolbox -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -realtime true -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 h264_videotoolbox -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -pix_fmt nv12 -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdFsdfzGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestFFmpegx264Command(t *testing.T) {
|
||||
cmd := transcoder.GetString()
|
||||
|
||||
expectedLogPath := "data/logs/transcoder.log"
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -x264-params:v:0 scenecut=0:open_gop=0 -bufsize:v:0 1088k -profile:v:0 high -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -x264-params:v:1 scenecut=0:open_gop=0 -bufsize:v:1 3572k -profile:v:1 high -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4 -bsf:a aac_adtstoasc -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.m4s -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
expected := `FFREPORT=file=` + expectedLogPath + `:level=32 ` + transcoder.ffmpegPath + ` -hide_banner -loglevel warning -fflags +genpts -flags +cgop -i fakecontent.flv -map v:0 -c:v:0 libx264 -b:v:0 1008k -maxrate:v:0 1088k -g:v:0 90 -keyint_min:v:0 90 -r:v:0 30 -x264-params:v:0 scenecut=0:open_gop=0 -bufsize:v:0 1088k -profile:v:0 high -map a:0? -c:a:0 copy -preset veryfast -map v:0 -c:v:1 libx264 -b:v:1 3308k -maxrate:v:1 3572k -g:v:1 72 -keyint_min:v:1 72 -r:v:1 24 -x264-params:v:1 scenecut=0:open_gop=0 -bufsize:v:1 3572k -profile:v:1 high -map a:0? -c:a:1 copy -preset fast -map v:0 -c:v:2 copy -map a:0? -c:a:2 copy -preset ultrafast -var_stream_map v:0,a:0 v:1,a:1 v:2,a:2 -f hls -hls_time 3 -hls_list_size 10 -hls_flags program_date_time+independent_segments+omit_endlist -segment_format_options mpegts_flags=mpegts_copyts=1 -tune zerolatency -pix_fmt yuv420p -sc_threshold 0 -master_pl_name stream.m3u8 -hls_segment_filename http://127.0.0.1:8123/%v/stream-jdofFGg-%d.ts -max_muxing_queue_size 400 -method PUT http://127.0.0.1:8123/%v/stream.m3u8`
|
||||
|
||||
if cmd != expected {
|
||||
t.Errorf("ffmpeg command does not match expected.\nGot %s\n, want: %s", cmd, expected)
|
||||
|
||||
@@ -16,25 +16,25 @@ require (
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
github.com/mattn/go-sqlite3 v1.14.38
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mssola/user_agent v0.6.0
|
||||
github.com/nakabonne/tstorage v0.3.6
|
||||
github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590
|
||||
github.com/oapi-codegen/runtime v1.3.0
|
||||
github.com/oapi-codegen/runtime v1.3.1
|
||||
github.com/oschwald/geoip2-golang v1.13.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5
|
||||
github.com/schollz/sqlite3dump v1.3.1
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/shirou/gopsutil/v4 v4.25.8
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569
|
||||
github.com/yuin/goldmark v1.7.13
|
||||
github.com/yuin/goldmark-emoji v1.0.6
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/mod v0.34.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/time v0.15.0
|
||||
gopkg.in/evanphx/json-patch.v5 v5.9.11
|
||||
@@ -48,8 +48,7 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -60,7 +59,6 @@ require (
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/lestrrat-go/strftime v1.1.1 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
@@ -68,16 +66,14 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhr
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
@@ -87,13 +87,15 @@ github.com/lestrrat-go/strftime v1.1.1 h1:zgf8QCsgj27GlKBy3SU9/8MMgegZ8UCzlCyHYr
|
||||
github.com/lestrrat-go/strftime v1.1.1/go.mod h1:YDrzHJAODYQ+xxvrn5SG01uFIQAeDTzpxNVppCz7Nmw=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
|
||||
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4=
|
||||
@@ -106,6 +108,8 @@ github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 h1:PnxRU8L8Y2q82vFC2Qd
|
||||
github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590/go.mod h1:XmAOs6UJXpNXRwKk+KY/nv5kL6xXYXyellk+A1pTlko=
|
||||
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
|
||||
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
|
||||
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
|
||||
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
|
||||
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
@@ -130,12 +134,12 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnHyvtYjDeq0zlVHn9K/ZXoy17ylucdo=
|
||||
github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
||||
github.com/schollz/sqlite3dump v1.3.1 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA=
|
||||
github.com/schollz/sqlite3dump v1.3.1/go.mod h1:mzSTjZpJH4zAb1FN3iNlhWPbbdyeBpOaTW0hukyMHyI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/cobra v0.0.4-0.20190109003409-7547e83b2d85/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
@@ -153,10 +157,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569 h1:xzABM9let0HLLqFypcxvLmlvEciCHL7+Lv+4vwZqecI=
|
||||
github.com/teris-io/shortid v0.0.0-20220617161101-71ec9f2aa569/go.mod h1:2Ly+NIftZN4de9zRmENdYbvPQeaVIYKWpLFStLFEBgI=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g=
|
||||
github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||
@@ -186,8 +190,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -266,9 +270,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v5 v5.9.11 h1:OMPeiLomOQwe8+Ku4nwXsdOmrRw2vGUpP3XgLj3ojNw=
|
||||
gopkg.in/evanphx/json-patch.v5 v5.9.11/go.mod h1:/kvTRh1TVm5wuM6OkHxqXtE/1nUZZpihg29RtuIyfvk=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+5
-13
@@ -51,20 +51,12 @@ func GetWebIndexTemplate() (*template.Template, error) {
|
||||
return tmpl, err
|
||||
}
|
||||
|
||||
//go:embed offline-init.mp4
|
||||
var offlineInitSegment []byte
|
||||
//go:embed offline-v2.ts
|
||||
var offlineVideoSegment []byte
|
||||
|
||||
// GetOfflineInitSegment will return the fMP4 init segment for the offline clip.
|
||||
func GetOfflineInitSegment() []byte {
|
||||
return getFileSystemStaticFileOrDefault("offline-init.mp4", offlineInitSegment)
|
||||
}
|
||||
|
||||
//go:embed offline-v2.m4s
|
||||
var offlineMediaSegment []byte
|
||||
|
||||
// GetOfflineMediaSegment will return the fMP4 media segment for the offline clip.
|
||||
func GetOfflineMediaSegment() []byte {
|
||||
return getFileSystemStaticFileOrDefault("offline-v2.m4s", offlineMediaSegment)
|
||||
// GetOfflineSegment will return the offline video segment data.
|
||||
func GetOfflineSegment() []byte {
|
||||
return getFileSystemStaticFileOrDefault("offline-v2.ts", offlineVideoSegment)
|
||||
}
|
||||
|
||||
//go:embed img/logo.png
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
certs/
|
||||
README.md
|
||||
@@ -0,0 +1,36 @@
|
||||
FROM golang:1.25-bookworm
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc make \
|
||||
curl libcurl4-openssl-dev libssl-dev \
|
||||
jq sqlite3 ffmpeg \
|
||||
libnss3-tools \
|
||||
lsof procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install mkcert (architecture-aware)
|
||||
RUN ARCH="$(dpkg --print-architecture)" && \
|
||||
curl -sL "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-${ARCH}" -o /usr/local/bin/mkcert && \
|
||||
chmod +x /usr/local/bin/mkcert
|
||||
|
||||
# Install Caddy (architecture-aware)
|
||||
RUN ARCH="$(dpkg --print-architecture)" && \
|
||||
curl -sL "https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin caddy
|
||||
|
||||
# Build and install snac2
|
||||
RUN git clone --depth 1 https://codeberg.org/grunfink/snac2.git /tmp/snac2-src \
|
||||
&& cd /tmp/snac2-src && make && cp snac /usr/local/bin/snac \
|
||||
&& rm -rf /tmp/snac2-src
|
||||
|
||||
# Install mkcert CA into system trust store so snac2 trusts the test certs
|
||||
RUN mkcert -install
|
||||
|
||||
# Allow git operations on the mounted repo
|
||||
RUN git config --global --add safe.directory /owncast
|
||||
|
||||
WORKDIR /owncast
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -2,83 +2,29 @@
|
||||
|
||||
This test verifies Owncast's ActivityPub federation by having snac2 users follow the Owncast instance and confirming message delivery.
|
||||
|
||||
## One-Time Setup
|
||||
All test infrastructure (snac2, Caddy, mkcert, Go) runs inside a Docker container so you don't need to install anything on the host besides Docker.
|
||||
|
||||
### 1. Install mkcert
|
||||
## Prerequisites
|
||||
|
||||
- Docker installed and running
|
||||
|
||||
## Running the Tests
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install mkcert
|
||||
|
||||
# Or download directly
|
||||
curl -sLO https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64
|
||||
chmod +x mkcert-v1.4.4-linux-amd64
|
||||
sudo mv mkcert-v1.4.4-linux-amd64 /usr/local/bin/mkcert
|
||||
```
|
||||
|
||||
### 2. Install the local CA
|
||||
|
||||
This installs a Certificate Authority into your system's trust store. All certificates generated by mkcert will be trusted.
|
||||
|
||||
```bash
|
||||
mkcert -install
|
||||
```
|
||||
|
||||
### 3. Generate certificates for the test domains
|
||||
|
||||
```bash
|
||||
cd test/automated/activitypub
|
||||
mkdir -p certs
|
||||
mkcert -cert-file certs/cert.pem -key-file certs/key.pem owncast.local snac.local localhost 127.0.0.1
|
||||
```
|
||||
|
||||
### 4. Add hosts entries
|
||||
|
||||
```bash
|
||||
sudo sh -c 'echo "127.0.0.1 owncast.local snac.local" >> /etc/hosts'
|
||||
```
|
||||
|
||||
### 5. Install snac2
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install snac2
|
||||
|
||||
# Or build from source
|
||||
git clone https://codeberg.org/grunfink/snac2.git
|
||||
cd snac2
|
||||
make
|
||||
sudo make install
|
||||
```
|
||||
|
||||
### 6. Install Caddy
|
||||
|
||||
Caddy is used as the HTTPS reverse proxy for TLS termination. It will be installed automatically by `setup.sh`, or you can install it manually:
|
||||
|
||||
```bash
|
||||
# Download binary directly
|
||||
curl -sL "https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_amd64.tar.gz" | sudo tar -xz -C /usr/local/bin caddy
|
||||
|
||||
# Or see: https://caddyserver.com/docs/install
|
||||
```
|
||||
|
||||
## Running the Test
|
||||
|
||||
```bash
|
||||
# Run with default 100 users
|
||||
# Run the federation test with default 100 users
|
||||
./run.sh
|
||||
|
||||
# Run with fewer users for quick testing
|
||||
USER_COUNT=10 ./run.sh
|
||||
|
||||
# Run the follower validation test
|
||||
./run.sh test-follower-validation.sh
|
||||
|
||||
# Keep servers running after test for debugging
|
||||
KEEP_RUNNING=true ./run.sh
|
||||
|
||||
# Adjust follow request throttling (default 0.1s)
|
||||
FOLLOW_DELAY=0.2 ./run.sh
|
||||
|
||||
# Run in CI mode (skip interactive prompts)
|
||||
CI=true ./run.sh
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
@@ -88,7 +34,7 @@ CI=true ./run.sh
|
||||
| `USER_COUNT` | 100 | Number of test users to create |
|
||||
| `FOLLOW_DELAY` | 0.1 | Delay in seconds between follow requests |
|
||||
| `KEEP_RUNNING` | false | Keep servers running after test for debugging |
|
||||
| `CI` | false | Skip interactive prompts for CI environments |
|
||||
| `CI` | false | Always true inside the container |
|
||||
| `PROXY_PORT` | 8443 | HTTPS proxy port |
|
||||
| `SNAC_PORT` | 9080 | snac2 HTTP port |
|
||||
| `OWNCAST_PORT` | 8080 | Owncast HTTP port |
|
||||
@@ -111,23 +57,37 @@ The test reports:
|
||||
- **Follow Success Rate**: Percentage of follow requests that succeeded
|
||||
- **Delivery Rate**: Percentage of registered followers who received the message
|
||||
|
||||
## Docker Image Details
|
||||
|
||||
The Docker image (`owncast-ap-test`) bundles all dependencies:
|
||||
- Go (for building Owncast)
|
||||
- snac2 (built from source)
|
||||
- Caddy (HTTPS reverse proxy)
|
||||
- mkcert (TLS certificates trusted by the container)
|
||||
- sqlite3, jq, curl
|
||||
|
||||
Go module and build caches are stored in named Docker volumes (`owncast-ap-test-gomod`, `owncast-ap-test-gobuild`) so repeated runs are faster.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate errors from snac2
|
||||
### Docker build fails
|
||||
|
||||
Make sure you ran `mkcert -install` and generated the certificates. The CA must be in the system trust store for snac2 to trust the certificates.
|
||||
Make sure Docker is running. On macOS, Docker Desktop or a compatible runtime (colima, OrbStack, etc.) is required.
|
||||
|
||||
### Port already in use
|
||||
|
||||
Kill any leftover processes:
|
||||
If a previous container didn't shut down cleanly:
|
||||
```bash
|
||||
pkill -f "snac httpd /tmp"
|
||||
pkill -f "caddy run"
|
||||
docker ps -a | grep owncast-ap-test
|
||||
docker rm -f <container_id>
|
||||
```
|
||||
|
||||
### Hosts file not configured
|
||||
### Cleaning up Docker resources
|
||||
|
||||
Verify the entries exist:
|
||||
```bash
|
||||
grep -E 'owncast.local|snac.local' /etc/hosts
|
||||
# Remove the image
|
||||
docker rmi owncast-ap-test
|
||||
|
||||
# Remove Go caches
|
||||
docker volume rm owncast-ap-test-gomod owncast-ap-test-gobuild
|
||||
```
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Generate mkcert certificates for the test domains.
|
||||
# These are placed in a container-local path so they don't leak into the
|
||||
# mounted source tree.
|
||||
export CERT_DIR="/tmp/test-certs"
|
||||
mkdir -p "${CERT_DIR}"
|
||||
mkcert -cert-file "${CERT_DIR}/cert.pem" \
|
||||
-key-file "${CERT_DIR}/key.pem" \
|
||||
owncast.local snac.local localhost 127.0.0.1
|
||||
|
||||
# Change CWD away from the mounted repo root so Owncast doesn't pick up the
|
||||
# host's (possibly wrong-architecture) ffmpeg binary via ./ffmpeg detection.
|
||||
# Stay inside the git repo so `git rev-parse --show-toplevel` still works.
|
||||
cd /owncast/test/automated/activitypub
|
||||
|
||||
# Run the specified test script (default: test-federation.sh)
|
||||
TEST_SCRIPT="${1:-test-federation.sh}"
|
||||
exec "/owncast/test/automated/activitypub/${TEST_SCRIPT}"
|
||||
@@ -1,14 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Run the ActivityPub federation test
|
||||
# Run the ActivityPub federation test inside a Docker container.
|
||||
#
|
||||
# Usage:
|
||||
# ./run.sh # Run with 100 users
|
||||
# ./run.sh # Run federation test with 100 users
|
||||
# ./run.sh test-follower-validation.sh # Run follower validation test
|
||||
# USER_COUNT=50 ./run.sh # Run with 50 users
|
||||
# KEEP_RUNNING=true ./run.sh # Keep servers running after test
|
||||
#
|
||||
# Prerequisites:
|
||||
# Add to /etc/hosts: 127.0.0.1 owncast.local snac.local
|
||||
# Docker must be installed and running.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "${SCRIPT_DIR}/test-federation.sh" "$@"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
IMAGE_NAME="owncast-ap-test"
|
||||
|
||||
echo "Building Docker image..."
|
||||
docker build -t "${IMAGE_NAME}" "${SCRIPT_DIR}"
|
||||
|
||||
# Collect environment variables to pass through
|
||||
ENV_ARGS=()
|
||||
for var in USER_COUNT FOLLOW_DELAY KEEP_RUNNING CI PROXY_PORT SNAC_PORT OWNCAST_PORT CLEAR_SHARED_INBOX_PERCENT; do
|
||||
if [[ -n "${!var}" ]]; then
|
||||
ENV_ARGS+=("-e" "${var}=${!var}")
|
||||
fi
|
||||
done
|
||||
|
||||
# Always skip interactive prompts inside the container
|
||||
ENV_ARGS+=("-e" "CI=true")
|
||||
|
||||
# Port-forward when KEEP_RUNNING is set so the user can access the services
|
||||
EXTRA_ARGS=()
|
||||
if [[ "${KEEP_RUNNING}" == "true" ]]; then
|
||||
OWNCAST_PORT="${OWNCAST_PORT:-8080}"
|
||||
PROXY_PORT="${PROXY_PORT:-8443}"
|
||||
EXTRA_ARGS+=("-p" "${OWNCAST_PORT}:${OWNCAST_PORT}" "-p" "${PROXY_PORT}:${PROXY_PORT}")
|
||||
fi
|
||||
|
||||
echo "Running test in Docker container..."
|
||||
docker run --rm \
|
||||
--add-host owncast.local:127.0.0.1 \
|
||||
--add-host snac.local:127.0.0.1 \
|
||||
-v "${REPO_ROOT}:/owncast" \
|
||||
-v owncast-ap-test-gomod:/go/pkg/mod \
|
||||
-v owncast-ap-test-gobuild:/root/.cache/go-build \
|
||||
"${ENV_ARGS[@]}" \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
"${IMAGE_NAME}" \
|
||||
"$@"
|
||||
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
#!/bin/bash
|
||||
# shellcheck disable=SC2317,SC2329 # cleanup() is invoked via trap, not direct call
|
||||
# shellcheck disable=SC2034 # SNAC_URL is unused but kept for consistency with other AP tests
|
||||
|
||||
# Chat Sanitization Test for Fediverse Engagement Events
|
||||
#
|
||||
# This test verifies that malicious HTML and markdown in ActivityPub actor
|
||||
# display names is sanitized before being rendered in chat messages.
|
||||
#
|
||||
# The test:
|
||||
# 1. Starts snac2 + Owncast + HTTPS proxy (same as federation test)
|
||||
# 2. Creates snac2 users with malicious display names (HTML, markdown)
|
||||
# 3. Enables engagement display in chat
|
||||
# 4. Has the malicious users follow Owncast
|
||||
# 5. Queries the chat messages from the database
|
||||
# 6. Verifies no HTML tags or markdown artifacts appear in stored messages
|
||||
#
|
||||
# Prerequisites:
|
||||
# Add to /etc/hosts: 127.0.0.1 owncast.local snac.local
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
# Configuration
|
||||
FOLLOW_DELAY="${FOLLOW_DELAY:-0.3}"
|
||||
CI="${CI:-false}"
|
||||
PROXY_PORT="${PROXY_PORT:-8443}"
|
||||
SNAC_PORT="${SNAC_PORT:-9080}"
|
||||
SNAC_HOSTNAME="snac.local"
|
||||
OWNCAST_PORT="${OWNCAST_PORT:-8080}"
|
||||
OWNCAST_HOSTNAME="owncast.local"
|
||||
ADMIN_USER="admin"
|
||||
ADMIN_PASS="abc123"
|
||||
FEDERATION_USERNAME="streamer"
|
||||
|
||||
# URLs
|
||||
SNAC_URL="https://${SNAC_HOSTNAME}:${PROXY_PORT}"
|
||||
OWNCAST_URL="https://${OWNCAST_HOSTNAME}:${PROXY_PORT}"
|
||||
|
||||
# Directories
|
||||
TEMP_DIR=""
|
||||
SNAC_DATA_DIR=""
|
||||
SNAC_BIN=""
|
||||
OWNCAST_DB=""
|
||||
|
||||
# PIDs
|
||||
SNAC_PID=""
|
||||
OWNCAST_PID=""
|
||||
PROXY_PID=""
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_test() { echo -e "${CYAN}[TEST]${NC} $1"; }
|
||||
|
||||
# Test users: username -> malicious display name
|
||||
# These simulate real-world attack payloads from the security advisory
|
||||
declare -A MALICIOUS_USERS
|
||||
MALICIOUS_USERS=(
|
||||
["htmlscript"]='<script>alert("xss")</script>'
|
||||
["htmliframe"]='<iframe src="https://evil.com"></iframe>'
|
||||
["htmlimg"]='<img src="https://evil.com/track.png">'
|
||||
["htmlform"]='<form action="https://evil.com"><input type="password"></form>'
|
||||
["htmlmeta"]='<meta http-equiv="refresh" content="0;url=https://evil.com">'
|
||||
["htmlstyle"]='<style>body{display:none}</style>Visible'
|
||||
["mdimage"]=''
|
||||
["mdlink"]='[Click me](https://evil.com)'
|
||||
["mixedhtml"]='Alice <script>alert(1)</script> Bob'
|
||||
["cleanuser"]='Legitimate User'
|
||||
)
|
||||
|
||||
# Patterns that must NOT appear in stored chat message bodies
|
||||
FORBIDDEN_PATTERNS=(
|
||||
'<script'
|
||||
'<iframe'
|
||||
'<img'
|
||||
'<form'
|
||||
'<meta'
|
||||
'<style'
|
||||
'<input'
|
||||
'src="https://evil'
|
||||
'action="https://evil'
|
||||
'onerror='
|
||||
'onload='
|
||||
)
|
||||
|
||||
kill_leftover_processes() {
|
||||
local killed=false
|
||||
if pkill -f "snac httpd /tmp" 2>/dev/null; then killed=true; fi
|
||||
if pkill -f "local-proxy.js" 2>/dev/null; then killed=true; fi
|
||||
|
||||
local proxy_pid
|
||||
proxy_pid=$(lsof -ti :"${PROXY_PORT}" 2>/dev/null) || true
|
||||
if [[ -n "${proxy_pid}" ]]; then
|
||||
kill "${proxy_pid}" 2>/dev/null || true
|
||||
killed=true
|
||||
fi
|
||||
|
||||
local snac_pid
|
||||
snac_pid=$(lsof -ti :"${SNAC_PORT}" 2>/dev/null) || true
|
||||
if [[ -n "${snac_pid}" ]]; then
|
||||
kill "${snac_pid}" 2>/dev/null || true
|
||||
killed=true
|
||||
fi
|
||||
|
||||
if [[ "${killed}" == "true" ]]; then
|
||||
log_info "Killed leftover processes from previous run"
|
||||
sleep 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
log_info "Cleaning up..."
|
||||
if [[ -n "${PROXY_PID}" ]] && kill -0 "${PROXY_PID}" 2>/dev/null; then
|
||||
kill "${PROXY_PID}" 2>/dev/null || true
|
||||
wait "${PROXY_PID}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${OWNCAST_PID}" ]] && kill -0 "${OWNCAST_PID}" 2>/dev/null; then
|
||||
kill "${OWNCAST_PID}" 2>/dev/null || true
|
||||
wait "${OWNCAST_PID}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${SNAC_PID}" ]] && kill -0 "${SNAC_PID}" 2>/dev/null; then
|
||||
kill "${SNAC_PID}" 2>/dev/null || true
|
||||
wait "${SNAC_PID}" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then
|
||||
rm -rf "${TEMP_DIR}"
|
||||
fi
|
||||
log_info "Cleanup complete."
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
setup_temp_dir() {
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
SNAC_DATA_DIR="${TEMP_DIR}/snac-data"
|
||||
OWNCAST_DB="${TEMP_DIR}/owncast.db"
|
||||
log_info "Temp directory: ${TEMP_DIR}"
|
||||
}
|
||||
|
||||
check_hosts_entry() {
|
||||
if ! grep -q "${OWNCAST_HOSTNAME}" /etc/hosts 2>/dev/null || ! grep -q "${SNAC_HOSTNAME}" /etc/hosts 2>/dev/null; then
|
||||
log_warn "Required /etc/hosts entries not found."
|
||||
log_warn "Please add: 127.0.0.1 ${OWNCAST_HOSTNAME} ${SNAC_HOSTNAME}"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Hosts entries verified"
|
||||
}
|
||||
|
||||
install_snac2() {
|
||||
log_info "Setting up snac2..."
|
||||
if command -v snac &> /dev/null; then
|
||||
SNAC_BIN=$(command -v snac)
|
||||
log_info "Using system snac2: ${SNAC_BIN}"
|
||||
return
|
||||
fi
|
||||
|
||||
local snac_src="${TEMP_DIR}/snac2-src"
|
||||
log_info "Cloning snac2..."
|
||||
git clone --depth 1 https://codeberg.org/grunfink/snac2.git "${snac_src}" 2>/dev/null
|
||||
log_info "Building snac2..."
|
||||
pushd "${snac_src}" > /dev/null
|
||||
make
|
||||
SNAC_BIN="${snac_src}/snac"
|
||||
popd > /dev/null
|
||||
log_info "snac2 built: ${SNAC_BIN}"
|
||||
}
|
||||
|
||||
check_certs() {
|
||||
# CERT_DIR may be set by the Docker entrypoint; fall back to local certs/
|
||||
CERT_DIR="${CERT_DIR:-${SCRIPT_DIR}/certs}"
|
||||
if [[ ! -f "${CERT_DIR}/cert.pem" ]] || [[ ! -f "${CERT_DIR}/key.pem" ]]; then
|
||||
log_error "Certificates not found in ${CERT_DIR}. See README.md for setup."
|
||||
exit 1
|
||||
fi
|
||||
log_info "Using certificates from ${CERT_DIR}"
|
||||
}
|
||||
|
||||
init_snac2() {
|
||||
log_info "Initializing snac2..."
|
||||
local snac_host_port="${SNAC_HOSTNAME}:${PROXY_PORT}"
|
||||
printf "127.0.0.1\n%s\n%s\n\ntest@test.local\n" "${SNAC_PORT}" "${snac_host_port}" | \
|
||||
"${SNAC_BIN}" init "${SNAC_DATA_DIR}" > /dev/null 2>&1
|
||||
log_info "snac2 initialized"
|
||||
}
|
||||
|
||||
create_malicious_users() {
|
||||
log_info "Creating snac2 users with malicious display names..."
|
||||
|
||||
local run_id
|
||||
run_id=$(date +%s%N | sha256sum | head -c 8)
|
||||
|
||||
local created=0
|
||||
for username in "${!MALICIOUS_USERS[@]}"; do
|
||||
local displayname="${MALICIOUS_USERS[$username]}"
|
||||
local full_username="${username}${run_id}"
|
||||
|
||||
if printf "%s\n%s\n" "${full_username}" "${displayname}" | "${SNAC_BIN}" adduser "${SNAC_DATA_DIR}" > /dev/null 2>&1; then
|
||||
# Update the key to include the run_id so we can find them later
|
||||
MALICIOUS_USERS["${username}"]="${displayname}"
|
||||
# Store the full username for follow requests
|
||||
SNAC_FULL_USERNAMES["${username}"]="${full_username}"
|
||||
created=$((created + 1))
|
||||
log_info " Created user '${full_username}' with display name: ${displayname}"
|
||||
else
|
||||
log_error " Failed to create user '${full_username}'"
|
||||
fi
|
||||
done
|
||||
|
||||
log_info "Created ${created} users with malicious display names"
|
||||
}
|
||||
|
||||
start_proxy() {
|
||||
log_info "Starting HTTPS reverse proxy (Caddy)..."
|
||||
if ! command -v caddy &> /dev/null; then
|
||||
log_error "Caddy is not installed."
|
||||
return 1
|
||||
fi
|
||||
|
||||
export PROXY_PORT OWNCAST_PORT SNAC_PORT
|
||||
export CERT_FILE="${CERT_DIR}/cert.pem"
|
||||
export KEY_FILE="${CERT_DIR}/key.pem"
|
||||
|
||||
local caddy_log="${TEMP_DIR}/caddy.log"
|
||||
caddy run --config "${SCRIPT_DIR}/Caddyfile" --adapter caddyfile > "${caddy_log}" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
sleep 2
|
||||
|
||||
if ! kill -0 "${PROXY_PID}" 2>/dev/null; then
|
||||
log_error "Caddy failed to start"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local max_attempts=10
|
||||
local attempt=0
|
||||
while [[ ${attempt} -lt ${max_attempts} ]]; do
|
||||
if curl -sk "https://127.0.0.1:${PROXY_PORT}/" > /dev/null 2>&1; then
|
||||
log_info "Caddy proxy is ready"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
log_error "Caddy proxy did not become ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
start_snac2() {
|
||||
log_info "Starting snac2 server..."
|
||||
local snac_log="${TEMP_DIR}/snac2.log"
|
||||
DEBUG=0 "${SNAC_BIN}" httpd "${SNAC_DATA_DIR}" > "${snac_log}" 2>&1 &
|
||||
SNAC_PID=$!
|
||||
|
||||
local max_attempts=30
|
||||
local attempt=0
|
||||
while [[ ${attempt} -lt ${max_attempts} ]]; do
|
||||
if curl -s "http://127.0.0.1:${SNAC_PORT}/" > /dev/null 2>&1; then
|
||||
log_info "snac2 is ready"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
log_error "snac2 did not become ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
build_owncast() {
|
||||
log_info "Building Owncast..."
|
||||
pushd "${REPO_ROOT}" > /dev/null
|
||||
CGO_ENABLED=1 go build -o owncast main.go
|
||||
popd > /dev/null
|
||||
log_info "Owncast built"
|
||||
}
|
||||
|
||||
start_owncast() {
|
||||
log_info "Starting Owncast..."
|
||||
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
|
||||
OWNCAST_INSECURE_SKIP_VERIFY=true \
|
||||
"${REPO_ROOT}/owncast" -database "${OWNCAST_DB}" &
|
||||
OWNCAST_PID=$!
|
||||
|
||||
local max_attempts=30
|
||||
local attempt=0
|
||||
while [[ ${attempt} -lt ${max_attempts} ]]; do
|
||||
if curl -s "http://localhost:${OWNCAST_PORT}/api/status" > /dev/null 2>&1; then
|
||||
log_info "Owncast is ready"
|
||||
return 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
log_error "Owncast did not become ready"
|
||||
return 1
|
||||
}
|
||||
|
||||
configure_owncast() {
|
||||
log_info "Configuring Owncast..."
|
||||
local base_url="http://localhost:${OWNCAST_PORT}"
|
||||
local auth
|
||||
auth=$(echo -n "${ADMIN_USER}:${ADMIN_PASS}" | base64)
|
||||
|
||||
# Set server URL
|
||||
curl -s -X POST "${base_url}/api/admin/config/serverurl" \
|
||||
-H "Authorization: Basic ${auth}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"value\": \"${OWNCAST_URL}\"}" > /dev/null
|
||||
|
||||
# Set federation username
|
||||
curl -s -X POST "${base_url}/api/admin/config/federation/username" \
|
||||
-H "Authorization: Basic ${auth}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"value\": \"${FEDERATION_USERNAME}\"}" > /dev/null
|
||||
|
||||
# Enable federation
|
||||
curl -s -X POST "${base_url}/api/admin/config/federation/enable" \
|
||||
-H "Authorization: Basic ${auth}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": true}' > /dev/null
|
||||
|
||||
# Disable private mode
|
||||
curl -s -X POST "${base_url}/api/admin/config/federation/private" \
|
||||
-H "Authorization: Basic ${auth}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": false}' > /dev/null
|
||||
|
||||
# Enable engagement display in chat
|
||||
curl -s -X POST "${base_url}/api/admin/config/federation/showengagement" \
|
||||
-H "Authorization: Basic ${auth}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"value": true}' > /dev/null
|
||||
|
||||
log_info "Owncast configured (engagement display enabled)"
|
||||
}
|
||||
|
||||
send_follow_requests() {
|
||||
log_info "Sending follow requests from malicious users..."
|
||||
local owncast_actor="${OWNCAST_URL}/federation/user/${FEDERATION_USERNAME}"
|
||||
local successful=0
|
||||
|
||||
for username in "${!MALICIOUS_USERS[@]}"; do
|
||||
local full_username="${SNAC_FULL_USERNAMES[$username]}"
|
||||
local follow_output
|
||||
follow_output=$("${SNAC_BIN}" follow "${SNAC_DATA_DIR}" "${full_username}" "${owncast_actor}" 2>&1)
|
||||
local follow_exit=$?
|
||||
|
||||
if [[ ${follow_exit} -eq 0 ]] && [[ ! "${follow_output}" =~ "cannot" ]]; then
|
||||
successful=$((successful + 1))
|
||||
log_info " ${full_username} followed Owncast"
|
||||
else
|
||||
log_warn " ${full_username} follow failed: ${follow_output}"
|
||||
fi
|
||||
|
||||
sleep "${FOLLOW_DELAY}"
|
||||
done
|
||||
|
||||
log_test "${successful}/${#MALICIOUS_USERS[@]} follow requests sent"
|
||||
|
||||
# Wait for follow requests to be processed
|
||||
local user_count=${#MALICIOUS_USERS[@]}
|
||||
local wait_time=$((15 + user_count))
|
||||
log_info "Waiting ${wait_time}s for engagement events to be processed..."
|
||||
sleep "${wait_time}"
|
||||
}
|
||||
|
||||
verify_chat_sanitization() {
|
||||
log_info "Verifying chat message sanitization..."
|
||||
|
||||
local passed=true
|
||||
local tests_run=0
|
||||
local tests_passed=0
|
||||
|
||||
# Query fediverse engagement messages from the database
|
||||
local messages
|
||||
messages=$(sqlite3 "${OWNCAST_DB}" \
|
||||
"SELECT body FROM messages WHERE eventType IN ('FEDIVERSE_ENGAGEMENT_FOLLOW', 'FEDIVERSE_ENGAGEMENT_LIKE', 'FEDIVERSE_ENGAGEMENT_REPOST');" 2>/dev/null)
|
||||
|
||||
if [[ -z "${messages}" ]]; then
|
||||
log_error "No fediverse engagement messages found in database"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local message_count
|
||||
message_count=$(echo "${messages}" | wc -l | tr -d ' ')
|
||||
log_info "Found ${message_count} engagement messages in chat"
|
||||
|
||||
# Test 1: No forbidden HTML patterns in any message
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
tests_run=$((tests_run + 1))
|
||||
if echo "${messages}" | grep -qi "${pattern}"; then
|
||||
log_error "FAIL: Found forbidden pattern '${pattern}' in chat messages:"
|
||||
echo "${messages}" | grep -i "${pattern}" | while read -r line; do
|
||||
log_error " Body: ${line}"
|
||||
done
|
||||
passed=false
|
||||
else
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: No '${pattern}' found in messages"
|
||||
fi
|
||||
done
|
||||
|
||||
# Test 2: Verify the clean user's message is present
|
||||
# Note: snac2 may not serve the display name in actor objects, so we check
|
||||
# for either the display name or the username fallback.
|
||||
tests_run=$((tests_run + 1))
|
||||
local clean_username="${SNAC_FULL_USERNAMES[cleanuser]}"
|
||||
if echo "${messages}" | grep -q "Legitimate User"; then
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: Clean display name 'Legitimate User' preserved correctly"
|
||||
elif echo "${messages}" | grep -q "${clean_username}"; then
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: Clean user present via username fallback '${clean_username}'"
|
||||
else
|
||||
log_error "FAIL: Clean user message not found in chat"
|
||||
passed=false
|
||||
fi
|
||||
|
||||
# Test 3: Verify that messages with stripped HTML fell back to expected content
|
||||
# Messages from users whose display names were entirely HTML should show
|
||||
# the follow action text but not the HTML
|
||||
tests_run=$((tests_run + 1))
|
||||
if echo "${messages}" | grep -q "followed this stream"; then
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: Follow action text present in messages"
|
||||
else
|
||||
log_error "FAIL: No 'followed this stream' text found in any message"
|
||||
passed=false
|
||||
fi
|
||||
|
||||
# Test 4: No markdown image syntax rendered as HTML img tags
|
||||
tests_run=$((tests_run + 1))
|
||||
if echo "${messages}" | grep -qi '<img.*src=.*evil'; then
|
||||
log_error "FAIL: Markdown image syntax was rendered as HTML img tag"
|
||||
passed=false
|
||||
else
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: No markdown-rendered img tags found"
|
||||
fi
|
||||
|
||||
# Test 5: No markdown link syntax rendered as HTML anchor tags with evil URLs
|
||||
tests_run=$((tests_run + 1))
|
||||
if echo "${messages}" | grep -qi '<a.*href=.*evil'; then
|
||||
log_error "FAIL: Markdown link syntax was rendered as HTML anchor tag"
|
||||
passed=false
|
||||
else
|
||||
tests_passed=$((tests_passed + 1))
|
||||
log_test "PASS: No markdown-rendered anchor tags with evil URLs found"
|
||||
fi
|
||||
|
||||
# Print all stored messages for inspection
|
||||
echo ""
|
||||
log_info "All stored engagement messages:"
|
||||
echo "${messages}" | while read -r line; do
|
||||
log_info " ${line}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Chat Sanitization Test Results"
|
||||
echo "========================================"
|
||||
echo "Tests run: ${tests_run}"
|
||||
echo "Tests passed: ${tests_passed}"
|
||||
echo "========================================"
|
||||
|
||||
if [[ "${passed}" == "true" ]]; then
|
||||
echo -e "${GREEN}TEST PASSED${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}TEST FAILED${NC}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# We need an associative array for the full usernames
|
||||
declare -A SNAC_FULL_USERNAMES
|
||||
|
||||
main() {
|
||||
kill_leftover_processes
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Chat Sanitization Test"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# Setup infrastructure
|
||||
setup_temp_dir
|
||||
check_hosts_entry
|
||||
install_snac2
|
||||
check_certs
|
||||
init_snac2
|
||||
create_malicious_users
|
||||
start_proxy
|
||||
start_snac2
|
||||
build_owncast
|
||||
start_owncast
|
||||
configure_owncast
|
||||
sleep 2
|
||||
|
||||
# Send follows from malicious users
|
||||
send_follow_requests
|
||||
|
||||
# Verify results
|
||||
if verify_chat_sanitization; then
|
||||
exit 0
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -47,6 +47,7 @@ OWNCAST_URL="https://${OWNCAST_HOSTNAME}:${PROXY_PORT}"
|
||||
TEMP_DIR=""
|
||||
SNAC_DATA_DIR=""
|
||||
SNAC_BIN=""
|
||||
OWNCAST_BIN=""
|
||||
OWNCAST_DB=""
|
||||
|
||||
# PIDs and state
|
||||
@@ -55,6 +56,7 @@ OWNCAST_PID=""
|
||||
PROXY_PID=""
|
||||
TEST_STREAM_PID=""
|
||||
SNAC_USERNAMES=()
|
||||
CONFIRMED_FOLLOWERS=()
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
@@ -182,8 +184,8 @@ install_snac2() {
|
||||
}
|
||||
|
||||
check_certs() {
|
||||
# Use pre-generated mkcert certificates from the script directory
|
||||
CERT_DIR="${SCRIPT_DIR}/certs"
|
||||
# CERT_DIR may be set by the Docker entrypoint; fall back to local certs/
|
||||
CERT_DIR="${CERT_DIR:-${SCRIPT_DIR}/certs}"
|
||||
|
||||
if [[ ! -f "${CERT_DIR}/cert.pem" ]] || [[ ! -f "${CERT_DIR}/key.pem" ]]; then
|
||||
log_error "Certificates not found in ${CERT_DIR}"
|
||||
@@ -390,11 +392,12 @@ verify_snac_shared_inbox() {
|
||||
build_owncast() {
|
||||
log_info "Building Owncast..."
|
||||
|
||||
OWNCAST_BIN="${TEMP_DIR}/owncast"
|
||||
pushd "${REPO_ROOT}" > /dev/null
|
||||
CGO_ENABLED=1 go build -o owncast main.go
|
||||
CGO_ENABLED=1 go build -o "${OWNCAST_BIN}" main.go
|
||||
popd > /dev/null
|
||||
|
||||
log_info "Owncast built"
|
||||
log_info "Owncast built: ${OWNCAST_BIN}"
|
||||
}
|
||||
|
||||
start_owncast() {
|
||||
@@ -403,7 +406,7 @@ start_owncast() {
|
||||
# Start Owncast with test environment variables and debug flags
|
||||
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
|
||||
OWNCAST_INSECURE_SKIP_VERIFY=true \
|
||||
"${REPO_ROOT}/owncast" -database "${OWNCAST_DB}" &
|
||||
"${OWNCAST_BIN}" -database "${OWNCAST_DB}" &
|
||||
OWNCAST_PID=$!
|
||||
|
||||
log_info "Owncast started with PID ${OWNCAST_PID}"
|
||||
@@ -539,8 +542,8 @@ send_follow_requests() {
|
||||
log_info "snac2 queue has ${queue_count} pending items"
|
||||
|
||||
# Give snac2 background thread time to process all pending follows
|
||||
# Wait longer for more users
|
||||
local wait_time=$((10 + USER_COUNT / 10))
|
||||
# Each follow requires an HTTP round-trip; scale wait with user count
|
||||
local wait_time=$((10 + USER_COUNT / 5))
|
||||
log_info "Waiting ${wait_time}s for snac2 to process follow requests..."
|
||||
sleep "${wait_time}"
|
||||
}
|
||||
@@ -562,6 +565,28 @@ verify_followers() {
|
||||
echo "${count}"
|
||||
}
|
||||
|
||||
populate_confirmed_followers() {
|
||||
# Query Owncast API for actual registered followers and map their IRIs
|
||||
# back to snac2 usernames so we only check those inboxes for delivery.
|
||||
CONFIRMED_FOLLOWERS=()
|
||||
local auth
|
||||
auth=$(echo -n "${ADMIN_USER}:${ADMIN_PASS}" | base64)
|
||||
|
||||
local response
|
||||
response=$(curl -s "http://localhost:${OWNCAST_PORT}/api/admin/followers?limit=1000" \
|
||||
-H "Authorization: Basic ${auth}" 2>/dev/null || echo '{"results":[]}')
|
||||
|
||||
# Follower IRIs look like https://snac.local:8443/username
|
||||
while IFS= read -r iri; do
|
||||
if [[ -n "${iri}" ]]; then
|
||||
local username="${iri##*/}"
|
||||
CONFIRMED_FOLLOWERS+=("${username}")
|
||||
fi
|
||||
done < <(echo "${response}" | jq -r '.results[]?.link // empty' 2>/dev/null)
|
||||
|
||||
log_info "Confirmed ${#CONFIRMED_FOLLOWERS[@]} follower usernames from Owncast API"
|
||||
}
|
||||
|
||||
send_test_message() {
|
||||
log_info "Sending test message from Owncast..."
|
||||
|
||||
@@ -579,9 +604,16 @@ send_test_message() {
|
||||
}
|
||||
|
||||
check_snac_inboxes_count() {
|
||||
local users_with_messages=0
|
||||
# Only check users confirmed as followers by Owncast, not all snac2 users.
|
||||
# snac2's shared inbox distributes messages to all users it considers followers,
|
||||
# which may include users whose follow hasn't been registered by Owncast yet.
|
||||
local usernames_to_check=("${CONFIRMED_FOLLOWERS[@]}")
|
||||
if [[ ${#usernames_to_check[@]} -eq 0 ]]; then
|
||||
usernames_to_check=("${SNAC_USERNAMES[@]}")
|
||||
fi
|
||||
|
||||
for username in "${SNAC_USERNAMES[@]}"; do
|
||||
local users_with_messages=0
|
||||
for username in "${usernames_to_check[@]}"; do
|
||||
if user_has_message "${username}"; then
|
||||
users_with_messages=$((users_with_messages + 1))
|
||||
fi
|
||||
@@ -614,11 +646,16 @@ user_has_message() {
|
||||
check_snac_inboxes() {
|
||||
log_info "Checking snac2 user inboxes for delivered messages..." >&2
|
||||
|
||||
local usernames_to_check=("${CONFIRMED_FOLLOWERS[@]}")
|
||||
if [[ ${#usernames_to_check[@]} -eq 0 ]]; then
|
||||
usernames_to_check=("${SNAC_USERNAMES[@]}")
|
||||
fi
|
||||
|
||||
local users_with_messages=0
|
||||
local users_without_messages=()
|
||||
local total=${#SNAC_USERNAMES[@]}
|
||||
local total=${#usernames_to_check[@]}
|
||||
|
||||
for username in "${SNAC_USERNAMES[@]}"; do
|
||||
for username in "${usernames_to_check[@]}"; do
|
||||
if user_has_message "${username}"; then
|
||||
users_with_messages=$((users_with_messages + 1))
|
||||
else
|
||||
@@ -626,7 +663,7 @@ check_snac_inboxes() {
|
||||
fi
|
||||
done
|
||||
|
||||
log_test "${users_with_messages}/${total} users received the message" >&2
|
||||
log_test "${users_with_messages}/${total} confirmed followers received the message" >&2
|
||||
|
||||
if [[ ${#users_without_messages[@]} -gt 0 ]] && [[ ${#users_without_messages[@]} -le 10 ]]; then
|
||||
log_warn "Users missing messages: ${users_without_messages[*]}" >&2
|
||||
@@ -847,7 +884,7 @@ main() {
|
||||
|
||||
# Wait for all followers to be registered (with timeout)
|
||||
local followers=0
|
||||
local max_wait=60 # Maximum seconds to wait for followers
|
||||
local max_wait=$((60 + USER_COUNT)) # Scale timeout with user count
|
||||
local waited=0
|
||||
local check_interval=2
|
||||
|
||||
@@ -889,6 +926,7 @@ main() {
|
||||
echo "----------------------------------------"
|
||||
echo "STEP 4: Owncast sends message to followers"
|
||||
echo "----------------------------------------"
|
||||
populate_confirmed_followers
|
||||
send_test_message
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ TEST_START_TIME=""
|
||||
|
||||
# Directories
|
||||
TEMP_DIR=""
|
||||
OWNCAST_BIN=""
|
||||
OWNCAST_DB=""
|
||||
|
||||
# PIDs
|
||||
@@ -151,11 +152,12 @@ setup_temp_dir() {
|
||||
build_owncast() {
|
||||
log_info "Building Owncast..."
|
||||
|
||||
OWNCAST_BIN="${TEMP_DIR}/owncast"
|
||||
pushd "${REPO_ROOT}" > /dev/null
|
||||
CGO_ENABLED=1 go build -o owncast main.go
|
||||
CGO_ENABLED=1 go build -o "${OWNCAST_BIN}" main.go
|
||||
popd > /dev/null
|
||||
|
||||
log_info "Owncast built"
|
||||
log_info "Owncast built: ${OWNCAST_BIN}"
|
||||
}
|
||||
|
||||
start_owncast() {
|
||||
@@ -164,7 +166,7 @@ start_owncast() {
|
||||
# Start Owncast with test configuration
|
||||
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
|
||||
OWNCAST_INSECURE_SKIP_VERIFY=true \
|
||||
"${REPO_ROOT}/owncast" \
|
||||
"${OWNCAST_BIN}" \
|
||||
-database "${OWNCAST_DB}" \
|
||||
-followervalidationinterval "${VALIDATION_INTERVAL}" \
|
||||
-enableVerboseLogging &
|
||||
|
||||
Generated
+4
-3
@@ -3621,10 +3621,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
|
||||
Generated
+6
-6
@@ -3036,9 +3036,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -5903,9 +5903,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true
|
||||
},
|
||||
"pirates": {
|
||||
|
||||
+4
-20
@@ -11,40 +11,24 @@ function install_ffmpeg() {
|
||||
fi
|
||||
|
||||
FFMPEG_VER="8.0"
|
||||
FFMPEG_BUILD_VERSION="20260223192056"
|
||||
FFMPEG_BUILD_VERSION="20251223233512"
|
||||
FFMPEG_PATH="$(pwd)"
|
||||
PATH=$FFMPEG_PATH:$PATH
|
||||
|
||||
# Determine the platform-specific archive name
|
||||
ARCH="$(uname -m)"
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
if [[ "$ARCH" == "arm64" ]]; then
|
||||
FFMPEG_ARCHIVE="ffmpeg${FFMPEG_VER}-darwin-arm64.tar.gz"
|
||||
else
|
||||
FFMPEG_ARCHIVE="ffmpeg${FFMPEG_VER}-darwin-amd64.tar.gz"
|
||||
fi
|
||||
else
|
||||
if [[ "$ARCH" == "aarch64" ]]; then
|
||||
FFMPEG_ARCHIVE="ffmpeg${FFMPEG_VER}-linux-arm64-static.tar.gz"
|
||||
else
|
||||
FFMPEG_ARCHIVE="ffmpeg${FFMPEG_VER}-linux-amd64-static.tar.gz"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "$FFMPEG_PATH/ffmpeg" ]]; then
|
||||
|
||||
ffmpeg_version=$("$FFMPEG_PATH/ffmpeg" -version | awk -F 'ffmpeg version' '{print $2}' | awk 'NR==1{print $1}')
|
||||
|
||||
if [[ "$ffmpeg_version" == "${FFMPEG_VER}.1" ]]; then
|
||||
if [[ "$ffmpeg_version" == "$FFMPEG_VER-static" ]]; then
|
||||
return 0
|
||||
else
|
||||
mv "$FFMPEG_PATH/ffmpeg" "$FFMPEG_PATH/ffmpeg.bk" || rm -f "$FFMPEG_PATH/ffmpeg"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Downloading ffmpeg v${FFMPEG_VER} release ${FFMPEG_BUILD_VERSION} (${FFMPEG_ARCHIVE})"
|
||||
echo "Downloading ffmpeg v${FFMPEG_VER} release ${FFMPEG_BUILD_VERSION}"
|
||||
rm -rf ffmpeg.tar.gz
|
||||
curl -sL --fail "https://github.com/owncast/ffmpeg-builds/releases/download/${FFMPEG_BUILD_VERSION}/${FFMPEG_ARCHIVE}" --output ffmpeg.tar.gz >/dev/null
|
||||
curl -sL --fail "https://github.com/owncast/ffmpeg-builds/releases/download/${FFMPEG_BUILD_VERSION}/ffmpeg${FFMPEG_VER}-amd64-static.tar.gz" --output ffmpeg.tar.gz >/dev/null
|
||||
tar -xzf ffmpeg.tar.gz
|
||||
rm -f ffmpeg.tar.gz
|
||||
chmod +x ffmpeg
|
||||
|
||||
Generated
+19
-36
@@ -5732,9 +5732,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -7309,9 +7309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -8071,9 +8071,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matcher-collection/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -8620,9 +8620,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -9149,35 +9149,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socketio-wildcard": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/socketio-wildcard/-/socketio-wildcard-2.0.0.tgz",
|
||||
@@ -9417,9 +9400,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
# Example: ./test/ocTestStream.sh ~/Downloads/*.mp4 rtmp://127.0.0.1/live/abc123
|
||||
|
||||
|
||||
ffmpeg_execs=( 'ffmpeg' )
|
||||
ffmpeg_paths=( '' './' )
|
||||
ffmpeg_execs=( 'ffmpeg' 'ffmpeg.exe' )
|
||||
ffmpeg_paths=( './' '../' '' )
|
||||
|
||||
for _ffmpeg_exec in "${ffmpeg_execs[@]}"; do
|
||||
for _ffmpeg_path in "${ffmpeg_paths[@]}"; do
|
||||
@@ -50,7 +50,7 @@ if [[ ${FILE_COUNT} -eq 0 ]]; then
|
||||
echo "Streaming internal test video loop to $DESTINATION_HOST"
|
||||
echo "...press ctrl+c to exit"
|
||||
|
||||
command "${ffmpeg_exec}" -hide_banner -loglevel warning -nostdin -re -f lavfi \
|
||||
command "${ffmpeg_exec}" -hide_banner -loglevel panic -nostdin -re -f lavfi \
|
||||
-i "testsrc=size=1280x720:rate=60[out0];sine=frequency=400:sample_rate=48000[out1]" \
|
||||
-vf "[in]drawtext=fontsize=96: box=1: boxcolor=black@0.75: boxborderw=5: fontcolor=white: x=(w-text_w)/2: y=((h-text_h)/2)+((h-text_h)/-2): text='Owncast Test Stream', drawtext=fontsize=96: box=1: boxcolor=black@0.75: boxborderw=5: fontcolor=white: x=(w-text_w)/2: y=((h-text_h)/2)+((h-text_h)/2): text='%{gmtime\:%H\\\\\:%M\\\\\:%S} UTC'[out]" \
|
||||
-nal-hrd cbr \
|
||||
|
||||
+5
-5
@@ -13,7 +13,7 @@ require (
|
||||
require (
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
|
||||
4d63.com/gochecknoglobals v0.2.2 // indirect
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
codeberg.org/chavacava/garif v0.2.0 // indirect
|
||||
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
|
||||
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
|
||||
@@ -277,10 +277,10 @@ require (
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
|
||||
google.golang.org/grpc v1.75.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
|
||||
+22
-22
@@ -2,8 +2,8 @@
|
||||
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
|
||||
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
|
||||
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
@@ -789,18 +789,18 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
@@ -1138,10 +1138,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -1154,8 +1154,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -1169,8 +1169,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+10
-12
@@ -215,25 +215,23 @@ func GetCacheDurationSecondsForPath(filePath string) int {
|
||||
|
||||
defaultDaysCached := 30
|
||||
|
||||
if filename == "thumbnail.jpg" || filename == "preview.gif" || filename == "init.mp4" {
|
||||
if filename == "thumbnail.jpg" || filename == "preview.gif" {
|
||||
// Thumbnails & preview gif re-generate during live
|
||||
return 20
|
||||
}
|
||||
|
||||
if filename == "/" || fileExtension == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
switch fileExtension {
|
||||
case ".m3u8", ".html":
|
||||
return 0
|
||||
case ".m4s", ".mp4", ".woff2":
|
||||
} else if fileExtension == ".js" || fileExtension == ".css" {
|
||||
// Cache javascript & CSS
|
||||
return 60 * 60 * 24 * defaultDaysCached
|
||||
} else if fileExtension == ".ts" || fileExtension == ".woff2" {
|
||||
// Cache video segments as long as you want. They can't change.
|
||||
// This matters most for local hosting of segments for recordings
|
||||
// and not for live or 3rd party storage.
|
||||
return 31557600
|
||||
case ".js", ".css", ".jpg", ".png", ".gif", ".svg":
|
||||
} else if fileExtension == ".m3u8" {
|
||||
return 0
|
||||
} else if fileExtension == ".jpg" || fileExtension == ".png" || fileExtension == ".gif" || fileExtension == ".svg" {
|
||||
return 60 * 60 * 24 * defaultDaysCached
|
||||
} else if fileExtension == ".html" || filename == "/" || fileExtension == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Default cache length in seconds
|
||||
|
||||
@@ -2,4 +2,70 @@
|
||||
|
||||
.player {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:global(.vjs-control-bar .vjs-control:hover .vjs-control-text) {
|
||||
/* This ensures the text isn't hidden by default VJS accessibility rules */
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
/* stylelint-disable-next-line property-no-deprecated */
|
||||
clip: auto !important; /* necessary to make the control text visible */
|
||||
margin: 0 !important;
|
||||
|
||||
/* Base tooltip styles */
|
||||
position: absolute;
|
||||
bottom: 45px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
padding: 5px 10px;
|
||||
background: rgb(20 20 20 / 90%);
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none; /* Prevents tooltip from flickering when mouse hits it */
|
||||
|
||||
/* If there's no text content, it stays invisible */
|
||||
&:empty {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent the "Mute"/"Unmute" tooltip from showing when interacting with the volume slider.
|
||||
* Since the slider is part of the volume panel container, hovering it technically
|
||||
* counts as hovering the mute control context. We force-hide the tooltip
|
||||
* unless the cursor is explicitly over the mute button icon itself.
|
||||
*/
|
||||
:global(.vjs-volume-panel:hover .vjs-mute-control:not(:hover) .vjs-control-text) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Move the first and last element slightly right and left to prevent them from overflowing respectively */
|
||||
:global(.vjs-control:first-child:hover .vjs-control-text) {
|
||||
left: 0;
|
||||
transform: translateX(6px);
|
||||
|
||||
&::after {
|
||||
left: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.vjs-control:last-child:hover .vjs-control-text) {
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: translateX(-6px);
|
||||
|
||||
&::after {
|
||||
left: auto;
|
||||
right: 15%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import React, { FC } from 'react';
|
||||
import videojs from 'video.js';
|
||||
import type VideoJsPlayer from 'video.js/dist/types/player';
|
||||
import { useTranslation } from 'next-export-i18n';
|
||||
|
||||
import styles from './VideoJS.module.scss';
|
||||
|
||||
require('video.js/dist/video-js.css');
|
||||
|
||||
const SHORTCUT_SUFFIXES: Record<string, string> = {
|
||||
Play: ' (Space)',
|
||||
Pause: ' (Space)',
|
||||
Mute: ' (m)',
|
||||
Unmute: ' (m)',
|
||||
Fullscreen: ' (f)',
|
||||
'Non-Fullscreen': ' (f)',
|
||||
'Picture-in-Picture': ' (i)',
|
||||
'Exit Picture-in-Picture': ' (i)',
|
||||
};
|
||||
|
||||
export type VideoJSProps = {
|
||||
options: any;
|
||||
onReady: (player: VideoJsPlayer, vjsInstance: typeof videojs) => void;
|
||||
@@ -14,14 +26,30 @@ export type VideoJSProps = {
|
||||
export const VideoJS: FC<VideoJSProps> = ({ options, onReady }) => {
|
||||
const videoRef = React.useRef<HTMLVideoElement | null>(null);
|
||||
const playerRef = React.useRef<VideoJsPlayer | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const addShortcutsToLanguage = (vjs: typeof videojs, langCode: string) => {
|
||||
const updates: Record<string, string> = {};
|
||||
Object.keys(SHORTCUT_SUFFIXES).forEach(key => {
|
||||
const currentLabel = key;
|
||||
const suffix = SHORTCUT_SUFFIXES[key];
|
||||
updates[key] = t(`${currentLabel}${suffix}`);
|
||||
});
|
||||
vjs.addLanguage(langCode, updates);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
// Make sure Video.js player is only initialized once
|
||||
if (!playerRef.current) {
|
||||
const videoElement = videoRef.current;
|
||||
|
||||
addShortcutsToLanguage(videojs, 'en');
|
||||
const finalOptions = {
|
||||
...options,
|
||||
noUITitleAttributes: true, // Prevents videojs from adding a title attribute to UI elements, thus preventing "double tooltips".
|
||||
};
|
||||
// eslint-disable-next-line no-multi-assign
|
||||
const player: VideoJsPlayer = (playerRef.current = videojs(videoElement, options, () => {
|
||||
const player: VideoJsPlayer = (playerRef.current = videojs(videoElement, finalOptions, () => {
|
||||
console.debug('player is ready');
|
||||
return onReady && onReady(player, videojs);
|
||||
}));
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "هذا البث غير متصل. تحقق قريباً!",
|
||||
"offlineFediverseOnly": "هذا البث غير متصل. <span class='follow-link'>تابع</span> {{fediverseAccount}} على فيديفيرس لرؤية المرة القادمة التي يذهب فيها {{streamer}} للحياة.",
|
||||
"offlineNotifyAndFediverse": "هذا البث غير متصل بالإنترنت. يمكنك أن تتلقى <span class='notify-link'>إشعارًا</span> في المرة القادمة {{streamer}} أو <span class='follow-link'>متابعة</span> {{fediverseAccount}} على موقع Fediverse.",
|
||||
"offlineNotifyOnly": "هذا البث غير متصل بالإنترنت. <span class='notify-link'>كن على علم</span> في المرة القادمة التي يتم فيها البث المباشر {{streamer}} ."
|
||||
"offlineNotifyOnly": "هذا البث غير متصل بالإنترنت. <span class='notify-link'>كن على علم</span> في المرة القادمة التي يتم فيها البث المباشر {{streamer}} .",
|
||||
"chatEmbedTitle": "محادثة {{name}}"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "لديك عناصر {{count}}",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "এই স্ট্রিম অফলাইন। কিছুক্ষণ পরে চেক করুন!",
|
||||
"offlineFediverseOnly": "এই স্ট্রিম অফলাইন। <span class='follow-link'>ফলো করুন</span> {{fediverseAccount}} কে Fediverse এ পরবর্তী সময়ের জন্য যখন {{streamer}} লাইভ হবে তা দেখার জন্য।",
|
||||
"offlineNotifyAndFediverse": "এই স্ট্রিম অফলাইন। আপনি <span class='notify-link'>বিজ্ঞপ্তি গ্রহণ করতে পারবেন</span> পরবর্তী সময় {{streamer}} লাইভ হলে অথবা <span class='follow-link'>ফলো করতে পারবেন</span> {{fediverseAccount}} কে Fediverse এ।",
|
||||
"offlineNotifyOnly": "এই স্ট্রিম অফলাইন। <span class='notify-link'>বিজ্ঞপ্তি পান</span> পরবর্তী সময় {{streamer}} লাইভ হলে।"
|
||||
"offlineNotifyOnly": "এই স্ট্রিম অফলাইন। <span class='notify-link'>বিজ্ঞপ্তি পান</span> পরবর্তী সময় {{streamer}} লাইভ হলে।",
|
||||
"chatEmbedTitle": "{{name}} চ্যাট"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "আপনার {{count}} টি আইটেম রয়েছে",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Dieser Stream ist offline. Schauen Sie bald wieder!",
|
||||
"offlineFediverseOnly": "Dieser Stream ist offline. <span class='follow-link'>Folgen Sie</span> {{fediverseAccount}} auf der Feier, um das nächste Mal {{streamer}} live zu sehen.",
|
||||
"offlineNotifyAndFediverse": "Dieser Stream ist offline. Sie können <span class='notify-link'>benachrichtigt werden,</span> wenn {{streamer}} das nächste Mal live geht, oder <span class='follow-link'>folgen Sie</span> {{fediverseAccount}} auf dem Fediverse.",
|
||||
"offlineNotifyOnly": "Dieser Stream ist offline. <span class='notify-link'>Werde benachrichtigt</span> wenn {{streamer}} das nächste Mal live geht."
|
||||
"offlineNotifyOnly": "Dieser Stream ist offline. <span class='notify-link'>Werde benachrichtigt</span> wenn {{streamer}} das nächste Mal live geht.",
|
||||
"chatEmbedTitle": "{{name}} Chat"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "Sie haben {{count}} Elemente",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Αυτό το ρεύμα είναι εκτός σύνδεσης. Ελέγξτε ξανά σύντομα!",
|
||||
"offlineFediverseOnly": "Αυτό το ρεύμα είναι εκτός σύνδεσης. <span class='follow-link'>Ακολουθήστε</span> {{fediverseAccount}} στο Fediverse για να δείτε την επόμενη φορά που το {{streamer}} ζωντανά.",
|
||||
"offlineNotifyAndFediverse": "Αυτή η ροή είναι εκτός σύνδεσης. Μπορείτε να ειδοποιηθείτε <span class='notify-link'></span> την επόμενη φορά που το {{streamer}} πηγαίνει ζωντανά ή <span class='follow-link'>ακολουθήστε το</span> {{fediverseAccount}} στο Fediverse.",
|
||||
"offlineNotifyOnly": "Αυτή η ροή είναι εκτός σύνδεσης. <span class='notify-link'>Θα ειδοποιηθείτε</span> την επόμενη φορά που το {{streamer}} θα συνδεθεί."
|
||||
"offlineNotifyOnly": "Αυτή η ροή είναι εκτός σύνδεσης. <span class='notify-link'>Θα ειδοποιηθείτε</span> την επόμενη φορά που το {{streamer}} θα συνδεθεί.",
|
||||
"chatEmbedTitle": "{{name}} Συζήτηση"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "Έχετε {{count}} αντικείμενα",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "This stream is offline. Check back soon!",
|
||||
"offlineFediverseOnly": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
|
||||
"offlineNotifyAndFediverse": "This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse.",
|
||||
"offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live."
|
||||
"offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
|
||||
"chatEmbedTitle": "{{name}} Chat"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "You have {{count}} items",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Esta corriente está fuera de línea. ¡Vuelve pronto!",
|
||||
"offlineFediverseOnly": "Este stream está fuera de línea. <span class='follow-link'>Sigue</span> {{fediverseAccount}} en el Fediverso para ver la próxima vez que {{streamer}} se ponga en directo.",
|
||||
"offlineNotifyAndFediverse": "Esta transmisión está desconectada. Puede <span class='notify-link'>recibir una notificación</span> la próxima vez que {{streamer}} salga en directo o <span class='follow-link'>seguir</span> {{fediverseAccount}} en Fediverse.",
|
||||
"offlineNotifyOnly": "Este stream está desconectado. <span class='notify-link'>Ser notificado</span> la próxima vez que {{streamer}} vaya en directo."
|
||||
"offlineNotifyOnly": "Este stream está desconectado. <span class='notify-link'>Ser notificado</span> la próxima vez que {{streamer}} vaya en directo.",
|
||||
"chatEmbedTitle": "Chat {{name}}"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "Tienes {{count}} elementos",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Torrente hau offline dago. Itzuli laster!",
|
||||
"offlineFediverseOnly": "Torrente hau offline dago. <span class='follow-link'>Jarraitzaile</span> {{fediverseAccount}} Fediversen hurrengoa {{streamer}} zuzenean joan denean ikusteko.",
|
||||
"offlineNotifyAndFediverse": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean edo <span class='follow-link'>jarraitu</span> {{fediverseAccount}} Fediversen.",
|
||||
"offlineNotifyOnly": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean."
|
||||
"offlineNotifyOnly": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean.",
|
||||
"chatEmbedTitle": "{{name}} Txata"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "{{count}} elementu dituzu",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Ce flux est hors ligne. Revenez bientôt !",
|
||||
"offlineFediverseOnly": "Ce flux est hors ligne. <span class='follow-link'>Suivez</span> {{fediverseAccount}} sur le Fédiverse pour voir la prochaine fois que {{streamer}} sera en live.",
|
||||
"offlineNotifyAndFediverse": "Ce flux est hors ligne. Vous pouvez <span class='notify-link'>être notifié</span> la prochaine fois que {{streamer}} sera en direct ou <span class='follow-link'>suivre</span> {{fediverseAccount}} sur le Fediverse.",
|
||||
"offlineNotifyOnly": "Ce flux est hors ligne. <span class='notify-link'>Être notifié</span> la prochaine fois que {{streamer}} sera en ligne."
|
||||
"offlineNotifyOnly": "Ce flux est hors ligne. <span class='notify-link'>Être notifié</span> la prochaine fois que {{streamer}} sera en ligne.",
|
||||
"chatEmbedTitle": "Chat {{name}}"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "Vous avez des objets {{count}}",
|
||||
|
||||
@@ -221,7 +221,8 @@
|
||||
"offlineBasic": "Tá an sruth seo as líne. Seiceáil arís go luath!",
|
||||
"offlineFediverseOnly": "Tá an sruth seo as líne. <span class='follow-link'>Lean</span> {{fediverseAccount}} ar an Fediverse chun a fheiceáil an uair a bhíonn {{streamer}} ar líne.",
|
||||
"offlineNotifyAndFediverse": "Tá an sruth seo as líne. Is féidir leat <span class='notify-link'>a bheith rabhaidh</span> an uair a bhíonn {{streamer}} ar líne nó <span class='follow-link'>lean</span> {{fediverseAccount}} ar an Fediverse.",
|
||||
"offlineNotifyOnly": "Tá an sruth seo as líne. <span class='notify-link'>Bí rabhaidh</span> an uair a bhíonn {{streamer}} ar líne."
|
||||
"offlineNotifyOnly": "Tá an sruth seo as líne. <span class='notify-link'>Bí rabhaidh</span> an uair a bhíonn {{streamer}} ar líne.",
|
||||
"chatEmbedTitle": "{{name}} Comhrá"
|
||||
},
|
||||
"Testing": {
|
||||
"itemCount": "Tá {{count}} réadanna agat",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user