feat(torrent): act as WebTorrent peer for browser ↔ unarr P2P streaming
Wires anacrolix/torrent's built-in webtorrent package so a browser running webtorrent.js can fetch pieces from this CLI via WebRTC data channels. The daemon stays the seeder; we never relay bytes through TorrentClaw infrastructure — same legal posture as today. Changes: - internal/config: new [downloads.webrtc] section (enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass). Disabled by default, opt-in via config.toml. When enabled but trackers / STUN slices are empty, defaults are reapplied on Load() so users get a working setup with a single `enabled = true`. - internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers / ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList and forces NoUpload=false when WebRTC is on (browsers can't pull otherwise). buildMagnet now accepts variadic extra trackers and the downloader method prepends WSS trackers so anacrolix's webtorrent.TrackerClient picks them up first. - internal/engine/webrtc.go: BuildICEServers helper converts the TOML WebRTCConfig into []webrtc.ICEServer with shared TURN credentials. - internal/cmd/daemon.go + download.go: pass WebRTC config through to the engine. Tests (8 new, all green; full suite 0 lint issues, 0 vet): - buildMagnet free function: defaults-only, with extras, trim+empty-skip - downloader method: WebRTC disabled keeps WSS out, enabled prepends them - BuildICEServers: nil when disabled, STUN-only path, TURN+credentials - NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC peer enabled, magnet contains wss://tracker.torrentclaw.com) End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a manual test once tracker.torrentclaw.com WSS is live.
This commit is contained in:
parent
6955b6144b
commit
f6117ddeb9
6 changed files with 310 additions and 13 deletions
|
|
@ -189,6 +189,9 @@ func runDaemonStart() error {
|
|||
MaxUploadRate: maxUl,
|
||||
ListenPort: cfg.Download.ListenPort,
|
||||
SeedEnabled: false,
|
||||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create torrent downloader: %w", err)
|
||||
|
|
|
|||
|
|
@ -114,6 +114,9 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
|
|||
StallTimeout: 10 * time.Minute,
|
||||
MaxTimeout: 0, // unlimited
|
||||
SeedEnabled: false,
|
||||
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
|
||||
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
|
||||
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create downloader: %w", err)
|
||||
|
|
|
|||
|
|
@ -34,16 +34,30 @@ type AgentConfig struct {
|
|||
}
|
||||
|
||||
type DownloadConfig struct {
|
||||
Dir string `toml:"dir"`
|
||||
PreferredMethod string `toml:"preferred_method"`
|
||||
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
||||
MaxConcurrent int `toml:"max_concurrent"`
|
||||
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
||||
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
|
||||
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
||||
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
||||
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
||||
Dir string `toml:"dir"`
|
||||
PreferredMethod string `toml:"preferred_method"`
|
||||
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
||||
MaxConcurrent int `toml:"max_concurrent"`
|
||||
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
|
||||
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
|
||||
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
|
||||
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
|
||||
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
|
||||
WebRTC WebRTCConfig `toml:"webrtc"`
|
||||
}
|
||||
|
||||
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
|
||||
// can fetch pieces via WebRTC data channels — required by the in-browser
|
||||
// player on torrentclaw.com. Disabled by default; enabling implies upload
|
||||
// is allowed for active torrents (browsers can't download otherwise).
|
||||
type WebRTCConfig struct {
|
||||
Enabled bool `toml:"enabled"` // master switch
|
||||
Trackers []string `toml:"trackers"` // wss:// signaling trackers
|
||||
STUNServers []string `toml:"stun_servers"` // stun:host:port
|
||||
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
|
||||
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
|
||||
TURNPass string `toml:"turn_pass"` // optional
|
||||
}
|
||||
|
||||
type OrganizeConfig struct {
|
||||
|
|
@ -86,6 +100,11 @@ func Default() Config {
|
|||
PreferredMethod: "auto",
|
||||
MaxConcurrent: 3,
|
||||
StreamPort: 11818,
|
||||
WebRTC: WebRTCConfig{
|
||||
Enabled: false,
|
||||
Trackers: []string{"wss://tracker.torrentclaw.com"},
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
Organize: OrganizeConfig{
|
||||
Enabled: true,
|
||||
|
|
@ -144,6 +163,19 @@ func Load(path string) (Config, error) {
|
|||
if cfg.Download.StreamPort == 0 {
|
||||
cfg.Download.StreamPort = 11818
|
||||
}
|
||||
// Re-apply WebRTC defaults only when the user enabled WebRTC but didn't
|
||||
// supply trackers/STUN — leave both empty if disabled to keep config diffs clean.
|
||||
if cfg.Download.WebRTC.Enabled {
|
||||
if len(cfg.Download.WebRTC.Trackers) == 0 {
|
||||
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
|
||||
}
|
||||
if len(cfg.Download.WebRTC.STUNServers) == 0 {
|
||||
cfg.Download.WebRTC.STUNServers = []string{
|
||||
"stun:stun.l.google.com:19302",
|
||||
"stun:stun1.l.google.com:19302",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
alog "github.com/anacrolix/log"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/storage"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/time/rate"
|
||||
|
|
@ -70,6 +71,14 @@ type TorrentConfig struct {
|
|||
SeedEnabled bool
|
||||
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
|
||||
SeedTime time.Duration // min seed time after completion (default 0)
|
||||
|
||||
// WebRTC peer (WebTorrent protocol) for browser ↔ unarr P2P streaming.
|
||||
// When enabled, anacrolix/torrent's built-in webtorrent package handles
|
||||
// the WSS signaling + WebRTC data channels. Implies upload allowed for
|
||||
// every torrent in the client (browsers can't pull pieces otherwise).
|
||||
WebRTCEnabled bool
|
||||
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
|
||||
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
|
||||
}
|
||||
|
||||
// TorrentDownloader downloads torrents via BitTorrent P2P.
|
||||
|
|
@ -96,9 +105,27 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
|
|||
tcfg := torrent.NewDefaultClientConfig()
|
||||
tcfg.DataDir = cfg.DataDir
|
||||
tcfg.Seed = cfg.SeedEnabled
|
||||
tcfg.NoUpload = !cfg.SeedEnabled
|
||||
// WebRTC peers (browsers) can only pull pieces from us if upload is
|
||||
// enabled. We honour SeedEnabled for the long-tail seed-after-complete
|
||||
// behaviour but unconditionally allow upload while WebRTC is on so an
|
||||
// active download can still serve to a watching browser.
|
||||
tcfg.NoUpload = !cfg.SeedEnabled && !cfg.WebRTCEnabled
|
||||
tcfg.Logger = alog.Default.FilterLevel(alog.Critical)
|
||||
|
||||
// WebRTC / WebTorrent peer: anacrolix auto-routes ws://+wss:// trackers
|
||||
// to the bundled webtorrent.TrackerClient. We only need to populate the
|
||||
// ICE server list so the SDP offers we send carry usable candidates.
|
||||
if cfg.WebRTCEnabled {
|
||||
tcfg.DisableWebtorrent = false
|
||||
if len(cfg.ICEServers) > 0 {
|
||||
tcfg.ICEServerList = cfg.ICEServers
|
||||
}
|
||||
log.Printf("[torrent] WebRTC peer enabled (trackers=%d ice_servers=%d)",
|
||||
len(cfg.WebRTCTrackers), len(cfg.ICEServers))
|
||||
} else {
|
||||
tcfg.DisableWebtorrent = true
|
||||
}
|
||||
|
||||
// --- Performance optimizations ---
|
||||
|
||||
// Storage: mmap instead of default file backend.
|
||||
|
|
@ -235,7 +262,7 @@ func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, erro
|
|||
}
|
||||
|
||||
func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) {
|
||||
magnet := buildMagnet(task.InfoHash)
|
||||
magnet := d.buildMagnet(task.InfoHash)
|
||||
|
||||
t, err := d.client.AddMagnet(magnet)
|
||||
if err != nil {
|
||||
|
|
@ -604,14 +631,33 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
|
|||
return totalBytes, fileName
|
||||
}
|
||||
|
||||
func buildMagnet(infoHash string) string {
|
||||
// buildMagnet composes a magnet URI for the info hash. extraTrackers (e.g.
|
||||
// wss://… for WebRTC peer signaling) are prepended so anacrolix's
|
||||
// webtorrent.TrackerClient picks them up first; the static UDP list
|
||||
// follows. Empty / whitespace entries in extraTrackers are skipped.
|
||||
func buildMagnet(infoHash string, extraTrackers ...string) string {
|
||||
params := []string{"xt=urn:btih:" + infoHash}
|
||||
for _, t := range extraTrackers {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
params = append(params, "tr="+url.QueryEscape(t))
|
||||
}
|
||||
for _, tracker := range defaultTrackers {
|
||||
params = append(params, "tr="+url.QueryEscape(tracker))
|
||||
}
|
||||
return "magnet:?" + strings.Join(params, "&")
|
||||
}
|
||||
|
||||
// buildMagnet on the downloader injects its WebRTC trackers when enabled.
|
||||
func (d *TorrentDownloader) buildMagnet(infoHash string) string {
|
||||
if d != nil && d.cfg.WebRTCEnabled {
|
||||
return buildMagnet(infoHash, d.cfg.WebRTCTrackers...)
|
||||
}
|
||||
return buildMagnet(infoHash)
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
|
|
|
|||
36
internal/engine/webrtc.go
Normal file
36
internal/engine/webrtc.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
)
|
||||
|
||||
// BuildICEServers converts a config.WebRTCConfig into the
|
||||
// []webrtc.ICEServer slice that anacrolix/torrent's webtorrent client
|
||||
// needs. STUN entries become bare URLs; TURN entries inherit the shared
|
||||
// TURNUser / TURNPass credentials. Returns nil when WebRTC is disabled.
|
||||
func BuildICEServers(cfg config.WebRTCConfig) []webrtc.ICEServer {
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
var servers []webrtc.ICEServer
|
||||
for _, s := range cfg.STUNServers {
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
servers = append(servers, webrtc.ICEServer{URLs: []string{s}})
|
||||
}
|
||||
for _, t := range cfg.TURNServers {
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
entry := webrtc.ICEServer{URLs: []string{t}}
|
||||
if cfg.TURNUser != "" {
|
||||
entry.Username = cfg.TURNUser
|
||||
entry.Credential = cfg.TURNPass
|
||||
entry.CredentialType = webrtc.ICECredentialTypePassword
|
||||
}
|
||||
servers = append(servers, entry)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
177
internal/engine/webrtc_test.go
Normal file
177
internal/engine/webrtc_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/torrentclaw/unarr/internal/config"
|
||||
)
|
||||
|
||||
const validHash = "aaf2c71b0e0a03d3f9b2a3e1d5c6b7a8f0e1d2c3"
|
||||
|
||||
// TestBuildMagnet_NoExtras verifies the legacy free-function path keeps
|
||||
// emitting only the static defaultTrackers list.
|
||||
func TestBuildMagnet_NoExtras(t *testing.T) {
|
||||
got := buildMagnet(validHash)
|
||||
if !strings.HasPrefix(got, "magnet:?xt=urn:btih:"+validHash) {
|
||||
t.Fatalf("magnet missing xt: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")) {
|
||||
t.Fatal("expected default UDP tracker absent")
|
||||
}
|
||||
if strings.Contains(got, "wss%3A") {
|
||||
t.Fatalf("unexpected WSS tracker leaked when none requested: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMagnet_WithExtraTrackers verifies extraTrackers (e.g. WebRTC
|
||||
// WSS endpoints) are prepended before the defaults and properly URL-encoded.
|
||||
func TestBuildMagnet_WithExtraTrackers(t *testing.T) {
|
||||
got := buildMagnet(validHash, "wss://tracker.torrentclaw.com")
|
||||
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
|
||||
encUDP := url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")
|
||||
if !strings.Contains(got, "tr="+encWss) {
|
||||
t.Fatalf("WSS tracker missing: %s", got)
|
||||
}
|
||||
wssIdx := strings.Index(got, encWss)
|
||||
udpIdx := strings.Index(got, encUDP)
|
||||
if wssIdx < 0 || udpIdx < 0 || wssIdx > udpIdx {
|
||||
t.Fatalf("WSS tracker should appear BEFORE UDP defaults: wss=%d udp=%d", wssIdx, udpIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildMagnet_TrimsAndSkipsEmpty makes sure callers passing config-derived
|
||||
// slices with stray whitespace or empty strings don't get malformed magnets.
|
||||
func TestBuildMagnet_TrimsAndSkipsEmpty(t *testing.T) {
|
||||
got := buildMagnet(validHash, " wss://tracker.torrentclaw.com ", "", " ")
|
||||
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
|
||||
if !strings.Contains(got, "tr="+encWss) {
|
||||
t.Fatalf("trimmed WSS tracker missing: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "tr=&") || strings.HasSuffix(got, "tr=") {
|
||||
t.Fatalf("empty tracker emitted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTorrentDownloader_buildMagnet_WebRTCDisabled confirms the downloader
|
||||
// method does NOT inject WebRTCTrackers when WebRTCEnabled is false.
|
||||
func TestTorrentDownloader_buildMagnet_WebRTCDisabled(t *testing.T) {
|
||||
d := &TorrentDownloader{cfg: TorrentConfig{
|
||||
WebRTCEnabled: false,
|
||||
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
|
||||
}}
|
||||
got := d.buildMagnet(validHash)
|
||||
if strings.Contains(got, "wss%3A") {
|
||||
t.Fatalf("WSS tracker leaked while WebRTCEnabled=false: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTorrentDownloader_buildMagnet_WebRTCEnabled confirms the WSS trackers
|
||||
// are present when WebRTCEnabled is true.
|
||||
func TestTorrentDownloader_buildMagnet_WebRTCEnabled(t *testing.T) {
|
||||
d := &TorrentDownloader{cfg: TorrentConfig{
|
||||
WebRTCEnabled: true,
|
||||
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com", "wss://tracker2.example.com"},
|
||||
}}
|
||||
got := d.buildMagnet(validHash)
|
||||
for _, want := range []string{
|
||||
"wss://tracker.torrentclaw.com",
|
||||
"wss://tracker2.example.com",
|
||||
} {
|
||||
if !strings.Contains(got, url.QueryEscape(want)) {
|
||||
t.Fatalf("expected tracker %q missing in magnet: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildICEServers_DisabledReturnsNil ensures we don't leak STUN/TURN
|
||||
// configuration into the torrent client when the user has WebRTC off.
|
||||
func TestBuildICEServers_DisabledReturnsNil(t *testing.T) {
|
||||
got := BuildICEServers(config.WebRTCConfig{
|
||||
Enabled: false,
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
||||
})
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil ICE servers when disabled, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildICEServers_STUNOnly converts STUN entries to bare ICEServer
|
||||
// records with no credentials.
|
||||
func TestBuildICEServers_STUNOnly(t *testing.T) {
|
||||
got := BuildICEServers(config.WebRTCConfig{
|
||||
Enabled: true,
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302", "", "stun:stun1.l.google.com:19302"},
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 STUN servers (empty skipped), got %d (%+v)", len(got), got)
|
||||
}
|
||||
if got[0].URLs[0] != "stun:stun.l.google.com:19302" {
|
||||
t.Fatalf("first server unexpected: %+v", got[0])
|
||||
}
|
||||
if got[0].Username != "" || got[0].Credential != nil {
|
||||
t.Fatalf("STUN entry should have no credentials, got %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTorrentDownloader_WebRTCEnabled creates a downloader with the
|
||||
// WebRTC peer fully wired up and confirms the constructor doesn't error
|
||||
// (anacrolix accepts the ICE server list, port binds, etc.).
|
||||
func TestNewTorrentDownloader_WebRTCEnabled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dl, err := NewTorrentDownloader(TorrentConfig{
|
||||
DataDir: dir,
|
||||
ListenPort: 0, // let the OS pick — avoid clashes in CI
|
||||
WebRTCEnabled: true,
|
||||
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
|
||||
ICEServers: BuildICEServers(config.WebRTCConfig{
|
||||
Enabled: true,
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WebRTC-enabled downloader failed to start: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := dl.Shutdown(context.Background()); err != nil {
|
||||
t.Logf("shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Magnet for any task should now contain the WSS tracker.
|
||||
got := dl.buildMagnet(validHash)
|
||||
if !strings.Contains(got, "wss%3A%2F%2Ftracker.torrentclaw.com") {
|
||||
t.Fatalf("WebRTC magnet missing WSS tracker: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildICEServers_TURNWithCreds applies TURNUser/TURNPass to every TURN
|
||||
// entry so the operator only specifies them once.
|
||||
func TestBuildICEServers_TURNWithCreds(t *testing.T) {
|
||||
got := BuildICEServers(config.WebRTCConfig{
|
||||
Enabled: true,
|
||||
STUNServers: []string{"stun:stun.l.google.com:19302"},
|
||||
TURNServers: []string{"turn:turn.example.com:3478"},
|
||||
TURNUser: "alice",
|
||||
TURNPass: "s3cr3t",
|
||||
})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 1 STUN + 1 TURN, got %d", len(got))
|
||||
}
|
||||
turn := got[1]
|
||||
if turn.URLs[0] != "turn:turn.example.com:3478" {
|
||||
t.Fatalf("TURN URL wrong: %+v", turn)
|
||||
}
|
||||
if turn.Username != "alice" {
|
||||
t.Fatalf("TURN username wrong: %s", turn.Username)
|
||||
}
|
||||
if turn.Credential != "s3cr3t" {
|
||||
t.Fatalf("TURN credential wrong: %v", turn.Credential)
|
||||
}
|
||||
if turn.CredentialType != webrtc.ICECredentialTypePassword {
|
||||
t.Fatalf("TURN credential type wrong: %v", turn.CredentialType)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue