From a1177e352a86fb93f2d64d71646bdcee8ba6b3a6 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 19 Jan 2026 12:14:33 +0100 Subject: [PATCH 01/18] feat: first commitraw_recording_tool --- go.mod | 36 +- go.sum | 72 ++- pkg/cmd/raw-recording-tool/README.md | 428 ++++++++++++++++ pkg/cmd/raw-recording-tool/completion.go | 299 +++++++++++ pkg/cmd/raw-recording-tool/extract_audio.go | 116 +++++ pkg/cmd/raw-recording-tool/extract_video.go | 114 +++++ pkg/cmd/raw-recording-tool/list_tracks.go | 237 +++++++++ pkg/cmd/raw-recording-tool/main.go | 325 ++++++++++++ pkg/cmd/raw-recording-tool/mix_audio.go | 90 ++++ pkg/cmd/raw-recording-tool/mux_av.go | 109 ++++ pkg/cmd/raw-recording-tool/process_all.go | 127 +++++ .../processing/archive_input.go | 102 ++++ .../processing/archive_json.go | 30 ++ .../processing/archive_metadata.go | 370 ++++++++++++++ .../processing/audio_mixer.go | 96 ++++ .../processing/audio_video_muxer.go | 151 ++++++ .../processing/constants.go | 15 + .../processing/container_converter.go | 337 +++++++++++++ .../processing/ffmpeg_converter.go | 319 ++++++++++++ .../processing/ffmpeg_helper.go | 176 +++++++ .../processing/gstreamer_converter.go | 473 ++++++++++++++++++ .../raw-recording-tool/processing/sdp_tool.go | 55 ++ .../processing/track_extractor.go | 127 +++++ 23 files changed, 4189 insertions(+), 15 deletions(-) create mode 100644 pkg/cmd/raw-recording-tool/README.md create mode 100644 pkg/cmd/raw-recording-tool/completion.go create mode 100644 pkg/cmd/raw-recording-tool/extract_audio.go create mode 100644 pkg/cmd/raw-recording-tool/extract_video.go create mode 100644 pkg/cmd/raw-recording-tool/list_tracks.go create mode 100644 pkg/cmd/raw-recording-tool/main.go create mode 100644 pkg/cmd/raw-recording-tool/mix_audio.go create mode 100644 pkg/cmd/raw-recording-tool/mux_av.go create mode 100644 pkg/cmd/raw-recording-tool/process_all.go create mode 100644 pkg/cmd/raw-recording-tool/processing/archive_input.go create mode 100644 pkg/cmd/raw-recording-tool/processing/archive_json.go create mode 100644 pkg/cmd/raw-recording-tool/processing/archive_metadata.go create mode 100644 pkg/cmd/raw-recording-tool/processing/audio_mixer.go create mode 100644 pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go create mode 100644 pkg/cmd/raw-recording-tool/processing/constants.go create mode 100644 pkg/cmd/raw-recording-tool/processing/container_converter.go create mode 100644 pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go create mode 100644 pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go create mode 100644 pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go create mode 100644 pkg/cmd/raw-recording-tool/processing/sdp_tool.go create mode 100644 pkg/cmd/raw-recording-tool/processing/track_extractor.go diff --git a/go.mod b/go.mod index 1677c79..1bb7d44 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,42 @@ go 1.22 require ( github.com/AlecAivazis/survey/v2 v2.3.4 + github.com/GetStream/getstream-go/v3 v3.7.0 github.com/GetStream/stream-chat-go/v5 v5.8.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/cheynewallace/tabby v1.1.1 github.com/gizak/termui/v3 v3.1.0 github.com/gorilla/websocket v1.5.0 + github.com/pion/rtcp v1.2.16 + github.com/pion/rtp v1.10.0 + github.com/pion/webrtc/v4 v4.2.3 github.com/spf13/cobra v1.4.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.11.0 ) +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pion/datachannel v1.6.0 // indirect + github.com/pion/dtls/v3 v3.0.10 // indirect + github.com/pion/ice/v4 v4.2.0 // indirect + github.com/pion/interceptor v0.1.43 // indirect + github.com/pion/logging v0.2.4 // indirect + github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/sctp v1.9.2 // indirect + github.com/pion/sdp/v3 v3.0.17 // indirect + github.com/pion/srtp/v3 v3.0.10 // indirect + github.com/pion/stun/v3 v3.1.1 // indirect + github.com/pion/transport/v4 v4.0.1 // indirect + github.com/pion/turn/v4 v4.1.4 // indirect + github.com/wlynxg/anet v0.0.5 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/time v0.10.0 // indirect +) + require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -38,12 +64,12 @@ require ( github.com/spf13/afero v1.8.2 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/stretchr/testify v1.7.1 + github.com/stretchr/testify v1.11.1 github.com/subosito/gotenv v1.2.0 // indirect - golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect - golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/term v0.29.0 // indirect + golang.org/x/text v0.22.0 // indirect gopkg.in/ini.v1 v1.66.4 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f11ddd2..c310063 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/AlecAivazis/survey/v2 v2.3.4 h1:pchTU9rsLUSvWEl2Aq9Pv3k0IE2fkqtGxazsk github.com/AlecAivazis/survey/v2 v2.3.4/go.mod h1:hrV6Y/kQCLhIZXGcriDCUBtB3wnN7156gMXJ3+b23xM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/GetStream/getstream-go/v3 v3.7.0 h1:GzpyJ1lUacTHAIGyh0mMrP7f0hd1bQ/UEU1nyrNC9tk= +github.com/GetStream/getstream-go/v3 v3.7.0/go.mod h1:myW37DwbXEM2DrQy578MsWZcJzw/wjFLF3iPm71wwgg= github.com/GetStream/stream-chat-go/v5 v5.8.1 h1:nO3pfa4p4o6KEZOAXaaII3bhdrMrfT2zs6VduchuJws= github.com/GetStream/stream-chat-go/v5 v5.8.1/go.mod h1:ET7NyUYplNy8+tyliin6Q3kKwbd/+FHQWMAW6zucisY= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -82,6 +84,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -135,6 +139,8 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= @@ -150,6 +156,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= @@ -190,6 +198,40 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= +github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= +github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/dtls/v3 v3.0.10 h1:k9ekkq1kaZoxnNEbyLKI8DI37j/Nbk1HWmMuywpQJgg= +github.com/pion/dtls/v3 v3.0.10/go.mod h1:YEmmBYIoBsY3jmG56dsziTv/Lca9y4Om83370CXfqJ8= +github.com/pion/ice/v4 v4.2.0 h1:jJC8S+CvXCCvIQUgx+oNZnoUpt6zwc34FhjWwCU4nlw= +github.com/pion/ice/v4 v4.2.0/go.mod h1:EgjBGxDgmd8xB0OkYEVFlzQuEI7kWSCFu+mULqaisy4= +github.com/pion/interceptor v0.1.43 h1:6hmRfnmjogSs300xfkR0JxYFZ9k5blTEvCD7wxEDuNQ= +github.com/pion/interceptor v0.1.43/go.mod h1:BSiC1qKIJt1XVr3l3xQ2GEmCFStk9tx8fwtCZxxgR7M= +github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= +github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= +github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= +github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= +github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= +github.com/pion/rtp v1.10.0 h1:XN/xca4ho6ZEcijpdF2VGFbwuHUfiIMf3ew8eAAE43w= +github.com/pion/rtp v1.10.0/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.9.2 h1:HxsOzEV9pWoeggv7T5kewVkstFNcGvhMPx0GvUOUQXo= +github.com/pion/sctp v1.9.2/go.mod h1:OTOlsQ5EDQ6mQ0z4MUGXt2CgQmKyafBEXhUVqLRB6G8= +github.com/pion/sdp/v3 v3.0.17 h1:9SfLAW/fF1XC8yRqQ3iWGzxkySxup4k4V7yN8Fs8nuo= +github.com/pion/sdp/v3 v3.0.17/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= +github.com/pion/srtp/v3 v3.0.10 h1:tFirkpBb3XccP5VEXLi50GqXhv5SKPxqrdlhDCJlZrQ= +github.com/pion/srtp/v3 v3.0.10/go.mod h1:3mOTIB0cq9qlbn59V4ozvv9ClW/BSEbRp4cY0VtaR7M= +github.com/pion/stun/v3 v3.1.1 h1:CkQxveJ4xGQjulGSROXbXq94TAWu8gIX2dT+ePhUkqw= +github.com/pion/stun/v3 v3.1.1/go.mod h1:qC1DfmcCTQjl9PBaMa5wSn3x9IPmKxSdcCsxBcDBndM= +github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= +github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= +github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o= +github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM= +github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= +github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= +github.com/pion/webrtc/v4 v4.2.3 h1:RtdWDnkenNQGxUrZqWa5gSkTm5ncsLg5d+zu0M4cXt4= +github.com/pion/webrtc/v4 v4.2.3/go.mod h1:7vsyFzRzaKP5IELUnj8zLcglPyIT6wWwqTppBZ1k6Kc= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -220,10 +262,13 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= +github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -241,6 +286,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -305,6 +352,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -362,23 +411,25 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= -golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= -golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -521,8 +572,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4= gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= @@ -530,8 +582,8 @@ gopkg.in/yaml.v2 v2.2.2/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.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= -gopkg.in/yaml.v3 v3.0.0/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= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/cmd/raw-recording-tool/README.md b/pkg/cmd/raw-recording-tool/README.md new file mode 100644 index 0000000..725a86d --- /dev/null +++ b/pkg/cmd/raw-recording-tool/README.md @@ -0,0 +1,428 @@ +# Raw-Tools CLI + +Post-processing tools for raw video call recordings with intelligent completion, validation, and advanced audio/video processing. + +## Features + +- **Discovery**: Use `list-tracks` to explore recording contents with screenshare detection +- **Smart Completion**: Shell completion with dynamic values from actual recordings +- **Validation**: Automatic validation of user inputs against available data +- **Multiple Formats**: Support for different output formats (table, JSON, completion) +- **Advanced Processing**: Extract, mux, mix and process audio/video with gap filling +- **Hybrid Architecture**: Optimized performance for different use cases + +## Commands + +### `list-tracks` - Discovery & Completion Hub + +The `list-tracks` command serves as both a discovery tool and completion engine for other commands. + +```bash +# Basic usage - see all tracks in table format (no --output needed) +raw-tools --inputFile recording.tar.gz list-tracks + +# Get JSON output for programmatic use +raw-tools --inputFile recording.tar.gz list-tracks --format json + +# Get completion-friendly lists +raw-tools --inputFile recording.tar.gz list-tracks --format users +raw-tools --inputFile recording.tar.gz list-tracks --format sessions +raw-tools --inputFile recording.tar.gz list-tracks --format tracks +``` + +**Options:** +- `--format ` - Output format: `table` (default), `json`, `users`, `sessions`, `tracks`, `completion` +- `--trackType ` - Filter by track type: `audio`, `video` (optional) +- `-h, --help` - Show help message + +**Output Formats:** +- `table` - Human-readable table with screenshare detection (default) +- `json` - Full metadata in JSON format for scripting +- `users` - List of user IDs only (for shell scripts) +- `sessions` - List of session IDs only (for automation) +- `tracks` - List of track IDs only (for filtering) +- `completion` - Shell completion format + +### `extract-audio` - Extract Audio Tracks + +Extract and convert individual audio tracks from raw recordings to WebM format. + +```bash +# Extract audio for all users +raw-tools --inputFile recording.zip --output ./output extract-audio + +# Extract audio for specific user with gap filling +raw-tools --inputFile recording.zip --output ./output extract-audio --userId user123 --fill_gaps + +# Extract audio for specific session +raw-tools --inputFile recording.zip --output ./output extract-audio --sessionId session456 + +# Extract specific track only +raw-tools --inputFile recording.zip --output ./output extract-audio --trackId track789 +``` + +**Options:** +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time +- `--fill_gaps` - Fill temporal gaps between segments with silence (recommended for playback) +- `-h, --help` - Show help message + +**Mutually Exclusive Filtering:** +- Only one filter can be specified at a time: `--userId`, `--sessionId`, or `--trackId` +- `--trackId` returns exactly one track (the specified track) +- `--sessionId` returns all tracks for that session (multiple tracks possible) +- `--userId` returns all tracks for that user (multiple tracks possible) +- If no filter is specified, all tracks are processed + +### `extract-video` - Extract Video Tracks + +Extract and convert individual video tracks from raw recordings to WebM format. + +```bash +# Extract video for all users +raw-tools --inputFile recording.zip --output ./output extract-video + +# Extract video for specific user with black frame filling +raw-tools --inputFile recording.zip --output ./output extract-video --userId user123 --fill_gaps + +# Extract screenshare video only +raw-tools --inputFile recording.zip --output ./output extract-video --userId user456 --fill_gaps +``` + +**Options:** +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time +- `--fill_gaps` - Fill temporal gaps between segments with black frames (recommended for playback) +- `-h, --help` - Show help message + +**Video Processing:** +- Supports regular camera video and screenshare video +- Automatically detects and preserves video codec (VP8, VP9, H264, AV1) +- Gap filling generates black frames matching original video dimensions and framerate + +### `mux-av` - Mux Audio/Video + +Combine individual audio and video tracks with proper synchronization and timing offsets. + +```bash +# Mux audio/video for all users +raw-tools --inputFile recording.zip --output ./output mux-av + +# Mux for specific user with proper sync +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 + +# Mux for specific session +raw-tools --inputFile recording.zip --output ./output mux-av --sessionId session456 + +# Mux specific tracks with precise control +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time +- `--media ` - Filter by media type: `user` (camera/microphone), `display` (screen sharing), or `both` (default) +- `-h, --help` - Show help message + +**Features:** +- Automatic timing synchronization between audio and video using RTCP timestamps +- Gap filling for seamless playback (always enabled for muxing) +- Single combined WebM output per user/session combination +- Intelligent offset calculation for perfect A/V sync +- Supports all video codecs (VP8, VP9, H264, AV1) with Opus audio +- Media type filtering ensures consistent pairing (user camera ↔ user microphone, display sharing ↔ display audio) + +**Media Type Examples:** +```bash +# Mux only user camera/microphone tracks +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media user + +# Mux only display sharing tracks +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media display + +# Mux both types with proper pairing (default) +raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media both +``` + +### `mix-audio` - Mix Multiple Audio Tracks + +Mix audio from multiple users/sessions into a single synchronized audio file, perfect for conference call reconstruction. + +```bash +# Mix audio from all users (full conference call) +raw-tools --inputFile recording.zip --output ./output mix-audio + +# Mix audio from specific user across all sessions +raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 + +# Mix audio from specific session (all users in that session) +raw-tools --inputFile recording.zip --output ./output mix-audio --sessionId session456 + +# Mix specific tracks with fine control +raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time +- `--no-fill-gaps` - Disable gap filling (not recommended for mixing, gaps enabled by default) +- `-h, --help` - Show help message + +**Perfect for:** +- Conference call audio reconstruction with proper timing +- Multi-participant audio analysis and review +- Creating complete session audio timelines +- Audio synchronization testing and validation +- Podcast-style recordings from video calls + +**Advanced Mixing:** +- Uses FFmpeg adelay and amix filters for professional-quality mixing +- Automatic timing offset calculation based on segment metadata +- Gap filling with silence maintains temporal relationships +- Output: Single `mixed_audio.webm` file with all tracks properly synchronized + +### `process-all` - Complete Workflow + +Execute audio extraction, video extraction, and muxing in a single command - the all-in-one solution. + +```bash +# Process everything for all users +raw-tools --inputFile recording.zip --output ./output process-all + +# Process everything for specific user +raw-tools --inputFile recording.zip --output ./output process-all --userId user123 + +# Process specific session with all participants +raw-tools --inputFile recording.zip --output ./output process-all --sessionId session456 + +# Process specific tracks with full workflow +raw-tools --inputFile recording.zip --output ./output process-all --userId user123 --sessionId session456 +``` + +**Options:** +- `--userId ` - Filter by user ID (returns all tracks for that user) +- `--sessionId ` - Filter by session ID (returns all tracks for that session) +- `--trackId ` - Filter by track ID (returns only that specific track) +- **Note**: These filters are mutually exclusive - only one can be specified at a time +- `-h, --help` - Show help message + +**Workflow Steps:** +1. **Audio Extraction** - Extracts all matching audio tracks with gap filling enabled +2. **Video Extraction** - Extracts all matching video tracks with gap filling enabled +3. **Audio/Video Muxing** - Combines corresponding audio and video tracks with sync + +**Outputs:** +- Individual audio tracks (WebM format): `audio_userId_sessionId_trackId.webm` +- Individual video tracks (WebM format): `video_userId_sessionId_trackId.webm` +- Combined audio/video files (WebM format): `muxed_userId_sessionId_combined.webm` +- All files include gap filling for seamless playback +- Perfect for bulk processing and automated workflows + +## Completion Workflow Architecture + +### 1. Discovery Phase +```bash +# First, explore what's in your recording +raw-tools --inputFile recording.zip list-tracks + +# Example output with screenshare detection: +# USER ID SESSION ID TRACK ID TYPE SCREENSHARE CODEC SEGMENTS +# -------------------- -------------------- -------------------- ------- ------------ --------------- -------- +# user_abc123 session_xyz789 track_001 audio No audio/opus 3 +# user_abc123 session_xyz789 track_002 video No video/VP8 2 +# user_def456 session_xyz789 track_003 video Yes video/VP8 1 +``` + +### 2. Shell Completion Setup + +```bash +# Install completion for your shell +source <(raw-tools completion bash) # Bash +source <(raw-tools completion zsh) # Zsh +raw-tools completion fish | source # Fish +``` + +### 3. Dynamic Completion in Action + +With completion enabled, the CLI will: +- **Auto-complete commands** and flags +- **Dynamically suggest user IDs** from the actual recording +- **Validate inputs** against available data +- **Provide helpful error messages** with discovery hints + +```bash +# Tab completion will suggest actual user IDs from your recording +raw-tools --inputFile recording.zip --output ./out extract-audio --userId +# Shows: user_abc123 user_def456 + +# Invalid inputs show helpful errors +raw-tools --inputFile recording.zip --output ./out extract-audio --userId invalid_user +# Error: userID 'invalid_user' not found in recording. Available users: user_abc123, user_def456 +# Tip: Use 'raw-tools --inputFile recording.zip --output ./out list-tracks --format users' to see available user IDs +``` + +### 4. Programmatic Integration + +```bash +# Get user IDs for scripts +USERS=$(raw-tools --inputFile recording.zip list-tracks --format users) + +# Process each user +for user in $USERS; do + raw-tools --inputFile recording.zip --output ./output extract-audio --userId "$user" --fill_gaps +done + +# Get JSON metadata for complex processing +raw-tools --inputFile recording.zip list-tracks --format json > metadata.json +``` + +## Workflow Examples + +### Example 1: Extract Audio for Each Participant + +```bash +# 1. Discover participants +raw-tools --inputFile call.zip list-tracks --format users + +# 2. Extract each participant's audio +for user in $(raw-tools --inputFile call.zip list-tracks --format users); do + echo "Extracting audio for user: $user" + raw-tools --inputFile call.zip --output ./extracted extract-audio --userId "$user" --fill_gaps +done +``` + +### Example 2: Quality Check Before Processing + +```bash +# 1. Get full metadata overview +raw-tools --inputFile recording.zip list-tracks --format json > recording_info.json + +# 2. Check track counts +audio_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType audio --format json | jq '.tracks | length') +video_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType video --format json | jq '.tracks | length') + +echo "Found $audio_tracks audio tracks and $video_tracks video tracks" + +# 3. Process only if we have both audio and video +if [ "$audio_tracks" -gt 0 ] && [ "$video_tracks" -gt 0 ]; then + raw-tools --inputFile recording.zip --output ./output mux-av +fi +``` + +### Example 3: Conference Call Audio Mixing + +```bash +# 1. Mix all participants into single audio file +raw-tools --inputFile conference.zip --output ./mixed mix-audio + +# 2. Mix specific users for focused conversation (individual commands) +raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user1 +raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user2 + +# 3. Create session-by-session mixed audio +for session in $(raw-tools --inputFile conference.zip list-tracks --format sessions); do + raw-tools --inputFile conference.zip --output "./mixed/$session" mix-audio --sessionId "$session" +done +``` + +### Example 4: Complete Processing Pipeline + +```bash +# All-in-one processing for the entire recording +raw-tools --inputFile recording.zip --output ./complete process-all + +# Results in: +# - ./complete/audio_*.webm (individual audio tracks) +# - ./complete/video_*.webm (individual video tracks) +# - ./complete/muxed_*.webm (combined A/V tracks) +``` + +### Example 5: Session-Based Processing + +```bash +# 1. Process each session separately +for session in $(raw-tools --inputFile recording.zip list-tracks --format sessions); do + echo "Processing session: $session" + + # Extract all audio from this session + raw-tools --inputFile recording.zip --output "./output/$session" extract-audio --sessionId "$session" --fill_gaps + + # Extract all video from this session + raw-tools --inputFile recording.zip --output "./output/$session" extract-video --sessionId "$session" --fill_gaps + + # Mux audio/video for this session + raw-tools --inputFile recording.zip --output "./output/$session" mux-av --sessionId "$session" +done +``` + +## Architecture & Performance + +### Hybrid Processing Architecture + +The tool uses an intelligent hybrid approach optimized for different use cases: + +**Fast Metadata Reading (`list-tracks`):** +- Direct tar.gz parsing for metadata-only operations +- Skips extraction of large media files (.rtpdump/.sdp) +- 10-50x faster than full extraction for discovery workflows + +**Full Processing (extraction commands):** +- Complete archive extraction to temporary directories +- Access to all media files for conversion and processing +- Unified processing pipeline for reliability + +### Command Categories + +1. **Discovery Commands** (`list-tracks`) + - Optimized for speed and shell completion + - Minimal resource usage + - Instant metadata access + +2. **Processing Commands** (`extract-*`, `mix-*`, `mux-*`, `process-all`) + - Full archive extraction and processing + - Complete media file access + - Advanced audio/video operations + +3. **Utility Commands** (`completion`, `help`) + - Shell integration and documentation + +## Benefits of the Architecture + +1. **Discoverability**: No need to guess user IDs, session IDs, or track IDs +2. **Performance**: Optimized operations for different use cases +3. **Validation**: Immediate feedback if specified IDs don't exist +4. **Efficiency**: Tab completion speeds up command construction +5. **Reliability**: Prevents typos and invalid commands +6. **Scriptability**: Programmatic access to metadata for automated workflows +7. **User Experience**: Helpful error messages with actionable suggestions +8. **Advanced Processing**: Conference call reconstruction and analysis capabilities + +## File Structure + +``` +cmd/raw-tools/ +├── main.go # Main CLI entry point and routing +├── metadata.go # Shared metadata parsing and filtering (hybrid architecture) +├── completion.go # Shell completion scripts generation +├── list_tracks.go # Discovery and completion command (optimized) +├── extract_audio.go # Audio extraction with validation +├── extract_video.go # Video extraction with validation +├── extract_track.go # Generic extraction logic (shared) +├── mix_audio.go # Multi-user audio mixing +├── mux_av.go # Audio/video synchronization and muxing +├── process_all.go # All-in-one processing workflow +└── README.md # This documentation +``` + +## Dependencies + +- **FFmpeg**: Required for media processing and conversion +- **Go 1.19+**: For building the CLI tool diff --git a/pkg/cmd/raw-recording-tool/completion.go b/pkg/cmd/raw-recording-tool/completion.go new file mode 100644 index 0000000..3ead091 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/completion.go @@ -0,0 +1,299 @@ +package main + +import ( + "fmt" + "os" +) + +// generateCompletion generates shell completion scripts +func generateCompletion(shell string) { + switch shell { + case "bash": + generateBashCompletion() + case "zsh": + generateZshCompletion() + case "fish": + generateFishCompletion() + default: + _, _ = fmt.Fprintf(os.Stderr, "Unsupported shell: %s\n", shell) + _, _ = fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") + os.Exit(1) + } +} + +// generateBashCompletion generates bash completion script +func generateBashCompletion() { + script := `#!/bin/bash + +_raw_tools_completion() { + local cur prev words cword + _init_completion || return + + # Complete subcommands + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "list-tracks extract-audio extract-video mux-av help" -- "$cur")) + return + fi + + local cmd="${words[1]}" + + case "$prev" in + --inputFile) + COMPREPLY=($(compgen -f -X "!*.zip" -- "$cur")) + return + ;; + --output) + COMPREPLY=($(compgen -d -- "$cur")) + return + ;; + --format) + case "$cmd" in + list-tracks) + COMPREPLY=($(compgen -W "table json completion users sessions tracks" -- "$cur")) + ;; + esac + return + ;; + --trackType) + COMPREPLY=($(compgen -W "audio video" -- "$cur")) + return + ;; + --userId|--sessionId|--trackId) + # Dynamic completion using list-tracks + if [[ -n "${_RAW_TOOLS_INPUT_FILE:-}" ]]; then + local completion_type="" + case "$prev" in + --userId) completion_type="users" ;; + --sessionId) completion_type="sessions" ;; + --trackId) completion_type="tracks" ;; + esac + if [[ -n "$completion_type" ]]; then + local values=$(raw-tools --inputFile "$_RAW_TOOLS_INPUT_FILE" --output /tmp list-tracks --format "$completion_type" 2>/dev/null) + COMPREPLY=($(compgen -W "$values" -- "$cur")) + fi + else + COMPREPLY=() + fi + return + ;; + esac + + # Complete global flags + local global_flags="--inputFile --inputS3 --output --verbose --help" + local cmd_flags="" + + case "$cmd" in + list-tracks) + cmd_flags="--format --trackType --completionType" + ;; + extract-audio|extract-video) + cmd_flags="--userId --sessionId --trackId --fill_gaps" + ;; + mux-av) + cmd_flags="--userId --sessionId --trackId --media" + ;; + mix-audio) + cmd_flags="" + ;; + esac + + COMPREPLY=($(compgen -W "$global_flags $cmd_flags" -- "$cur")) +} + +# Store input file for dynamic completion +_raw_tools_set_input_file() { + local i + for (( i=1; i < ${#COMP_WORDS[@]}; i++ )); do + if [[ "${COMP_WORDS[i]}" == "--inputFile" && i+1 < ${#COMP_WORDS[@]} ]]; then + export _RAW_TOOLS_INPUT_FILE="${COMP_WORDS[i+1]}" + break + fi + done +} + +# Hook to set input file before completion +complete -F _raw_tools_completion raw-tools + +# Wrapper to set input file +_raw_tools_wrapper() { + _raw_tools_set_input_file + _raw_tools_completion "$@" +} + +complete -F _raw_tools_wrapper raw-tools` + + fmt.Println(script) +} + +// generateZshCompletion generates zsh completion script +func generateZshCompletion() { + script := `#compdef raw-tools + +_raw_tools() { + local context state line + typeset -A opt_args + + _arguments -C \ + '1: :_raw_tools_commands' \ + '*:: :->args' + + case $state in + args) + case $words[1] in + list-tracks) + _raw_tools_list_tracks + ;; + extract-audio|extract-video) + _raw_tools_extract + ;; + mux-av) + _raw_tools_mux_av + ;; + esac + ;; + esac +} + +_raw_tools_commands() { + local commands=( + 'list-tracks:List all tracks with metadata' + 'extract-audio:Generate playable audio files' + 'extract-video:Generate playable video files' + 'mux-av:Mux audio and video tracks' + 'help:Show help' + ) + _describe 'commands' commands +} + +_raw_tools_global_args() { + _arguments \ + '--inputFile[Specify raw recording zip file]:file:_files -g "*.zip"' \ + '--inputS3[Specify raw recording zip file on S3]:s3path:' \ + '--output[Specify output directory]:directory:_directories' \ + '--verbose[Enable verbose logging]' \ + '--help[Show help]' +} + +_raw_tools_list_tracks() { + _arguments \ + '--format[Output format]:format:(table json completion users sessions tracks)' \ + '--trackType[Filter by track type]:type:(audio video)' \ + '--completionType[Completion type]:type:(users sessions tracks)' \ + '*: :_raw_tools_global_args' +} + +_raw_tools_extract() { + _arguments \ + '--userId[User ID filter]:userid:_raw_tools_complete_users' \ + '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ + '--trackId[Track ID filter]:trackid:_raw_tools_complete_tracks' \ + '--fill_gaps[Fill gaps with silence/black frames]' \ + '*: :_raw_tools_global_args' +} + +_raw_tools_mux_av() { + _arguments \ + '--userId[User ID filter]:userid:_raw_tools_complete_users' \ + '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ + '--trackId[Track ID filter]:trackid:_raw_tools_complete_tracks' \ + '--media[Media type]:media:(user display both)' \ + '*: :_raw_tools_global_args' +} +// no mix-audio specific flags + +# Dynamic completion helpers +_raw_tools_complete_users() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local users=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format users 2>/dev/null)) + _wanted users expl 'user ID' compadd "$@" $users + else + _wanted users expl 'user ID' compadd "$@" + fi +} + +_raw_tools_complete_sessions() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local sessions=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format sessions 2>/dev/null)) + _wanted sessions expl 'session ID' compadd "$@" $sessions + else + _wanted sessions expl 'session ID' compadd "$@" + fi +} + +_raw_tools_complete_tracks() { + local input_file + for ((i=1; i <= $#words; i++)); do + if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then + input_file=$words[i+1] + break + fi + done + + if [[ -n "$input_file" ]]; then + local tracks=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format tracks 2>/dev/null)) + _wanted tracks expl 'track ID' compadd "$@" $tracks + else + _wanted tracks expl 'track ID' compadd "$@" + fi +} + +_raw_tools "$@"` + + fmt.Println(script) +} + +// generateFishCompletion generates fish completion script +func generateFishCompletion() { + script := `# Fish completion for raw-tools + +# Complete commands +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'list-tracks' -d 'List all tracks with metadata' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-audio' -d 'Generate playable audio files' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-video' -d 'Generate playable video files' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'mux-av' -d 'Mux audio and video tracks' +complete -c raw-tools -f -n '__fish_use_subcommand' -a 'help' -d 'Show help' + +# Global options +complete -c raw-tools -l inputFile -d 'Specify raw recording zip file' -r -F +complete -c raw-tools -l inputS3 -d 'Specify raw recording zip file on S3' -r +complete -c raw-tools -l output -d 'Specify output directory' -r -a '(__fish_complete_directories)' +complete -c raw-tools -l verbose -d 'Enable verbose logging' +complete -c raw-tools -l help -d 'Show help' + +# list-tracks specific options +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l format -d 'Output format' -r -a 'table json completion users sessions tracks' +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l trackType -d 'Filter by track type' -r -a 'audio video' +complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l completionType -d 'Completion type' -r -a 'users sessions tracks' + +# extract commands specific options +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l userId -d 'User ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l sessionId -d 'Session ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l trackId -d 'Track ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l fill_gaps -d 'Fill gaps' + +# mux-av specific options +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l userId -d 'User ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'Session ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l trackId -d 'Track ID filter' -r +complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l media -d 'Media type' -r -a 'user display both' + +# mix-audio has no command-specific options` + + fmt.Println(script) +} diff --git a/pkg/cmd/raw-recording-tool/extract_audio.go b/pkg/cmd/raw-recording-tool/extract_audio.go new file mode 100644 index 0000000..67fa339 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/extract_audio.go @@ -0,0 +1,116 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type ExtractAudioArgs struct { + UserID string + SessionID string + TrackID string + FillGaps bool + FixDtx bool +} + +type ExtractAudioProcess struct { + logger *getstream.DefaultLogger +} + +func NewExtractAudioProcess(logger *getstream.DefaultLogger) *ExtractAudioProcess { + return &ExtractAudioProcess{logger: logger} +} + +func (p *ExtractAudioProcess) runExtractAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + // Parse command-specific flags + fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) + extractAudioArgs := &ExtractAudioArgs{} + fs.StringVar(&extractAudioArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&extractAudioArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") + fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", true, "Fill with silence when track was muted (default true)") + fs.BoolVar(&extractAudioArgs.FixDtx, "fix_dtx", true, "Fix DTX shrink audio (default true)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID) + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + os.Exit(1) + } + + p.logger.Info("Starting extract-audio command") + p.printBanner(globalArgs, extractAudioArgs) + + // Implement extract audio functionality + if e := extractAudioTracks(globalArgs, extractAudioArgs, metadata, p.logger); e != nil { + p.logger.Error("Failed to extract audio: %v", e) + } + + p.logger.Info("Extract audio command completed") +} + +func (p *ExtractAudioProcess) printBanner(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs) { + fmt.Printf("Extract audio command with mutually exclusive filtering:\n") + if globalArgs.InputFile != "" { + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + } + if globalArgs.InputDir != "" { + fmt.Printf(" Input directory: %s\n", globalArgs.InputDir) + } + if globalArgs.InputS3 != "" { + fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + } + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", extractAudioArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", extractAudioArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", extractAudioArgs.TrackID) + + if extractAudioArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", extractAudioArgs.TrackID) + } else if extractAudioArgs.SessionID != "" { + fmt.Printf(" → Processing all audio tracks for session '%s'\n", extractAudioArgs.SessionID) + } else if extractAudioArgs.UserID != "" { + fmt.Printf(" → Processing all audio tracks for user '%s'\n", extractAudioArgs.UserID) + } else { + fmt.Printf(" → Processing all audio tracks (no filters)\n") + } + fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) + fmt.Printf(" Fix DTX: %t\n", extractAudioArgs.FixDtx) +} + +func (p *ExtractAudioProcess) printUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-audio [command options]\n\n") + fmt.Fprintf(os.Stderr, "Generate playable audio files from raw recording tracks.\n") + fmt.Fprintf(os.Stderr, "Supports formats: webm, mp3, and others.\n\n") + fmt.Fprintf(os.Stderr, "Command Options (Mutually Exclusive Filters):\n") + fmt.Fprintf(os.Stderr, " --userId Filter by user ID\n") + fmt.Fprintf(os.Stderr, " --sessionId Filter by session ID\n") + fmt.Fprintf(os.Stderr, " --trackId Filter by track ID\n") + fmt.Fprintf(os.Stderr, " (specify at most one of the above)\n") + fmt.Fprintf(os.Stderr, " --fill_gaps Fix DTX shrink audio, fill with silence when muted\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # Extract audio for all users (no filters)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio\n\n") + fmt.Fprintf(os.Stderr, " # Extract audio for specific user (all their tracks)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123\n\n") + fmt.Fprintf(os.Stderr, " # Extract audio for specific session (all users in that session)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --sessionId session456\n\n") + fmt.Fprintf(os.Stderr, " # Extract a specific track\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --trackId track1\n\n") + fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") +} + +func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, extractAudioArgs.FixDtx, logger) +} diff --git a/pkg/cmd/raw-recording-tool/extract_video.go b/pkg/cmd/raw-recording-tool/extract_video.go new file mode 100644 index 0000000..d6af5ac --- /dev/null +++ b/pkg/cmd/raw-recording-tool/extract_video.go @@ -0,0 +1,114 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type ExtractVideoArgs struct { + UserID string + SessionID string + TrackID string + FillGaps bool +} + +type ExtractVideoProcess struct { + logger *getstream.DefaultLogger +} + +func NewExtractVideoProcess(logger *getstream.DefaultLogger) *ExtractVideoProcess { + return &ExtractVideoProcess{logger: logger} +} + +func (p *ExtractVideoProcess) runExtractVideo(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + // Parse command-specific flags + fs := flag.NewFlagSet("extract-video", flag.ExitOnError) + extractVideoArgs := &ExtractVideoArgs{} + fs.StringVar(&extractVideoArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&extractVideoArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") + fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", true, "Fill with black frame when track was muted (default true)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID) + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + os.Exit(1) + } + + p.logger.Info("Starting extract-video command") + p.printBanner(globalArgs, extractVideoArgs) + + // Extract video tracks + if e := extractVideoTracks(globalArgs, extractVideoArgs, metadata, p.logger); e != nil { + p.logger.Error("Failed to extract video tracks: %v", e) + os.Exit(1) + } + + p.logger.Info("Extract video command completed successfully") +} + +func (p *ExtractVideoProcess) printBanner(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs) { + fmt.Printf("Extract video command with mutually exclusive filtering:\n") + if globalArgs.InputFile != "" { + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + } + if globalArgs.InputDir != "" { + fmt.Printf(" Input directory: %s\n", globalArgs.InputDir) + } + if globalArgs.InputS3 != "" { + fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + } + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", extractVideoArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", extractVideoArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", extractVideoArgs.TrackID) + + if extractVideoArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", extractVideoArgs.TrackID) + } else if extractVideoArgs.SessionID != "" { + fmt.Printf(" → Processing all video tracks for session '%s'\n", extractVideoArgs.SessionID) + } else if extractVideoArgs.UserID != "" { + fmt.Printf(" → Processing all video tracks for user '%s'\n", extractVideoArgs.UserID) + } else { + fmt.Printf(" → Processing all video tracks (no filters)\n") + } + fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) +} + +func (p *ExtractVideoProcess) printUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-video [command options]\n\n") + fmt.Fprintf(os.Stderr, "Generate playable video files from raw recording tracks.\n") + fmt.Fprintf(os.Stderr, "Supports formats: webm, mp4, and others.\n\n") + fmt.Fprintf(os.Stderr, "Command Options (Mutually Exclusive Filters):\n") + fmt.Fprintf(os.Stderr, " --userId Filter by user ID\n") + fmt.Fprintf(os.Stderr, " --sessionId Filter by session ID\n") + fmt.Fprintf(os.Stderr, " --trackId Filter by track ID\n") + fmt.Fprintf(os.Stderr, " (specify at most one of the above)\n") + fmt.Fprintf(os.Stderr, " --fill_gaps Fill with black frames when muted\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # Extract video for all users (no filters)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video\n\n") + fmt.Fprintf(os.Stderr, " # Extract video for specific user (all their tracks)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId user123\n\n") + fmt.Fprintf(os.Stderr, " # Extract video for specific session (all users in that session)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --sessionId session456\n\n") + fmt.Fprintf(os.Stderr, " # Extract a specific track\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --trackId track1\n\n") + fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") +} + +func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, false, logger) +} diff --git a/pkg/cmd/raw-recording-tool/list_tracks.go b/pkg/cmd/raw-recording-tool/list_tracks.go new file mode 100644 index 0000000..27e7e16 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/list_tracks.go @@ -0,0 +1,237 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type ListTracksArgs struct { + Format string // "table", "json", "completion", "users", "sessions", "tracks" + TrackType string // Filter by track type: "audio", "video", or "" for all + CompletionType string // For completion format: "users", "sessions", "tracks" +} + +type ListTracksProcess struct { + logger *getstream.DefaultLogger +} + +func NewListTracksProcess(logger *getstream.DefaultLogger) *ListTracksProcess { + return &ListTracksProcess{logger: logger} +} + +func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + // Parse command-specific flags + fs := flag.NewFlagSet("list-tracks", flag.ExitOnError) + listTracksArgs := &ListTracksArgs{} + fs.StringVar(&listTracksArgs.Format, "format", "table", "Output format: table, json, completion, users, sessions, tracks") + fs.StringVar(&listTracksArgs.TrackType, "trackType", "", "Filter by track type: audio, video") + fs.StringVar(&listTracksArgs.CompletionType, "completionType", "tracks", "For completion format: users, sessions, tracks") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + + logger.Info("Starting list-tracks command") + + // Parse the recording metadata using efficient metadata-only approach + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else if globalArgs.InputDir != "" { + inputPath = globalArgs.InputDir + } else { + // TODO: Handle S3 input + return // For now, only support local files + } + + // Use efficient metadata-only parsing (optimized for list-tracks) + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + logger.Error("Failed to parse recording: %v", err) + } + + // Filter tracks if track type is specified + tracks := processing.FilterTracks(metadata.Tracks, "", "", "", listTracksArgs.TrackType, "") + + // Output in requested format + switch listTracksArgs.Format { + case "table": + p.printTracksTable(tracks) + case "json": + p.printTracksJSON(metadata) + case "completion": + p.printCompletion(metadata, listTracksArgs.CompletionType) + case "users": + p.printUsers(metadata.UserIDs) + case "sessions": + p.printSessions(metadata.Sessions) + case "tracks": + p.printTrackIDs(tracks) + default: + fmt.Fprintf(os.Stderr, "Unknown format: %s\n", listTracksArgs.Format) + os.Exit(1) + } + + logger.Info("List tracks command completed") +} + +// printTracksTable prints tracks in a human-readable table format +func (p *ListTracksProcess) printTracksTable(tracks []*processing.TrackInfo) { + if len(tracks) == 0 { + fmt.Println("No tracks found.") + return + } + + // Print header + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", "USER ID", "SESSION ID", "TRACK ID", "TYPE", "SCREENSHARE", "CODEC", "SEGMENTS") + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", + strings.Repeat("-", 22), + strings.Repeat("-", 38), + strings.Repeat("-", 38), + strings.Repeat("-", 6), + strings.Repeat("-", 12), + strings.Repeat("-", 15), + strings.Repeat("-", 8)) + + // Print tracks + for _, track := range tracks { + screenshareStatus := "No" + if track.IsScreenshare { + screenshareStatus = "Yes" + } + fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8d\n", + p.truncateString(track.UserID, 22), + p.truncateString(track.SessionID, 38), + p.truncateString(track.TrackID, 38), + track.TrackType, + screenshareStatus, + track.Codec, + track.SegmentCount) + } +} + +// truncateString truncates a string to a maximum length, adding "..." if needed +func (p *ListTracksProcess) truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +// printTracksJSON prints the full metadata in JSON format +func (p *ListTracksProcess) printTracksJSON(metadata *processing.RecordingMetadata) { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + return + } + fmt.Println(string(data)) +} + +// printCompletion prints completion-friendly output +func (p *ListTracksProcess) printCompletion(metadata *processing.RecordingMetadata, completionType string) { + switch completionType { + case "users": + p.printUsers(metadata.UserIDs) + case "sessions": + p.printSessions(metadata.Sessions) + case "tracks": + trackIDs := make([]string, 0) + for _, track := range metadata.Tracks { + trackIDs = append(trackIDs, track.TrackID) + } + // Remove duplicates and sort + uniqueTrackIDs := p.removeDuplicates(trackIDs) + sort.Strings(uniqueTrackIDs) + p.printTrackIDs(metadata.Tracks) + default: + fmt.Fprintf(os.Stderr, "Unknown completion type: %s\n", completionType) + } +} + +// printUsers prints user IDs, one per line +func (p *ListTracksProcess) printUsers(userIDs []string) { + sort.Strings(userIDs) + for _, userID := range userIDs { + fmt.Println(userID) + } +} + +// printSessions prints session IDs, one per line +func (p *ListTracksProcess) printSessions(sessions []string) { + sort.Strings(sessions) + for _, session := range sessions { + fmt.Println(session) + } +} + +// printTrackIDs prints unique track IDs, one per line +func (p *ListTracksProcess) printTrackIDs(tracks []*processing.TrackInfo) { + trackIDs := make([]string, 0) + seen := make(map[string]bool) + + for _, track := range tracks { + if !seen[track.TrackID] { + trackIDs = append(trackIDs, track.TrackID) + seen[track.TrackID] = true + } + } + + sort.Strings(trackIDs) + for _, trackID := range trackIDs { + fmt.Println(trackID) + } +} + +// removeDuplicates removes duplicate strings from a slice +func (p *ListTracksProcess) removeDuplicates(input []string) []string { + keys := make(map[string]bool) + result := make([]string, 0) + + for _, item := range input { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + + return result +} + +func (p *ListTracksProcess) printUsage() { + fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] list-tracks [command options]\n\n") + fmt.Fprintf(os.Stderr, "List all tracks in the raw recording with their metadata.\n") + fmt.Fprintf(os.Stderr, "Note: --output is optional for this command (only displays information).\n\n") + fmt.Fprintf(os.Stderr, "Command Options:\n") + fmt.Fprintf(os.Stderr, " --format Output format (default: table)\n") + fmt.Fprintf(os.Stderr, " table - Human readable table\n") + fmt.Fprintf(os.Stderr, " json - JSON format\n") + fmt.Fprintf(os.Stderr, " users - List of user IDs only\n") + fmt.Fprintf(os.Stderr, " sessions - List of session IDs only\n") + fmt.Fprintf(os.Stderr, " tracks - List of track IDs only\n") + fmt.Fprintf(os.Stderr, " completion - Shell completion format\n") + fmt.Fprintf(os.Stderr, " --trackType Filter by track type: audio, video\n") + fmt.Fprintf(os.Stderr, " --completionType For completion format: users, sessions, tracks\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " # List all tracks in table format (no output directory needed)\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks\n\n") + fmt.Fprintf(os.Stderr, " # Get JSON output for programmatic use\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format json\n\n") + fmt.Fprintf(os.Stderr, " # Get user IDs for completion\n") + fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format users\n") + fmt.Fprintf(os.Stderr, "\nGlobal Options: Use 'raw-tools --help' to see global options.\n") +} diff --git a/pkg/cmd/raw-recording-tool/main.go b/pkg/cmd/raw-recording-tool/main.go new file mode 100644 index 0000000..89ef516 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/main.go @@ -0,0 +1,325 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type GlobalArgs struct { + InputFile string + InputDir string + InputS3 string + Output string + Verbose bool + + WorkDir string +} + +func main() { + if len(os.Args) < 2 { + printGlobalUsage() + os.Exit(1) + } + + // Parse global flags first + globalArgs := &GlobalArgs{} + command, remainingArgs := parseGlobalFlags(os.Args[1:], globalArgs) + + if command == "" { + printGlobalUsage() + os.Exit(1) + } + + // Setup logger + logger := setupLogger(globalArgs.Verbose) + + switch command { + case "list-tracks": + p := NewListTracksProcess(logger) + p.runListTracks(remainingArgs, globalArgs) + case "completion": + runCompletion(remainingArgs) + case "help", "-h", "--help": + printGlobalUsage() + default: + if e := processCommand(command, globalArgs, remainingArgs, logger); e != nil { + logger.Error("Error processing command %s - %v", command, e) + os.Exit(1) + } + } +} + +func processCommand(command string, globalArgs *GlobalArgs, remainingArgs []string, logger *getstream.DefaultLogger) error { + // Extract to temp directory if needed (unified approach) + path := globalArgs.InputFile + if path == "" { + path = globalArgs.InputDir + } + + workingDir, cleanup, err := processing.ExtractToTempDir(path, logger) + if err != nil { + return fmt.Errorf("failed to prepare working directory: %w", err) + } + defer cleanup() + globalArgs.WorkDir = workingDir + + // Create output directory if it doesn't exist + if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + switch command { + case "extract-audio": + p := NewExtractAudioProcess(logger) + p.runExtractAudio(remainingArgs, globalArgs) + case "extract-video": + p := NewExtractVideoProcess(logger) + p.runExtractVideo(remainingArgs, globalArgs) + case "mux-av": + p := NewMuxAudioVideoProcess(logger) + p.runMuxAV(remainingArgs, globalArgs) + case "mix-audio": + p := NewMixAudioProcess(logger) + p.runMixAudio(remainingArgs, globalArgs) + case "process-all": + p := NewProcessAllProcess(logger) + p.runProcessAll(remainingArgs, globalArgs) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) + printGlobalUsage() + os.Exit(1) + } + + return nil +} + +// parseGlobalFlags parses global flags and returns the command and remaining args +func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) { + fs := flag.NewFlagSet("global", flag.ContinueOnError) + + fs.StringVar(&globalArgs.InputFile, "inputFile", "", "Specify raw recording zip file on file system") + fs.StringVar(&globalArgs.InputDir, "inputDir", "", "Specify raw recording directory on file system") + fs.StringVar(&globalArgs.InputS3, "inputS3", "", "Specify raw recording zip file on S3") + fs.StringVar(&globalArgs.Output, "output", "", "Specify an output directory") + fs.BoolVar(&globalArgs.Verbose, "verbose", false, "Enable verbose logging") + + // Find the command by looking for known commands + knownCommands := map[string]bool{ + "list-tracks": true, + "extract-audio": true, + "extract-video": true, + "mux-av": true, + "mix-audio": true, + "process-all": true, + "completion": true, + "help": true, + } + + commandIndex := -1 + for i, arg := range args { + if knownCommands[arg] { + commandIndex = i + break + } + } + + if commandIndex == -1 { + return "", nil + } + + // Parse global flags (everything before the command) + globalFlags := args[:commandIndex] + command := args[commandIndex] + remainingArgs := args[commandIndex+1:] + + err := fs.Parse(globalFlags) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing global flags: %v\n", err) + os.Exit(1) + } + + // Validate global arguments + if e := validateGlobalArgs(globalArgs, command); e != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", e) + printGlobalUsage() + os.Exit(1) + } + + return command, remainingArgs +} + +func setupLogger(verbose bool) *getstream.DefaultLogger { + var level getstream.LogLevel + if verbose { + level = getstream.LogLevelDebug + } else { + level = getstream.LogLevelInfo + } + logger := getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level) + return logger +} + +func validateGlobalArgs(globalArgs *GlobalArgs, command string) error { + if globalArgs.InputFile == "" && globalArgs.InputDir == "" && globalArgs.InputS3 == "" { + return fmt.Errorf("either --inputFile or --inputDir or --inputS3 must be specified") + } + + num := 0 + if globalArgs.InputFile != "" { + num++ + } + if globalArgs.InputDir != "" { + num++ + } + if globalArgs.InputS3 != "" { + num++ + } + if num > 1 { + return fmt.Errorf("--inputFile, --inputDir and --inputS3 are exclusive, only one is allowed") + } + + // --output is optional for list-tracks command (it only displays information) + if command != "list-tracks" && globalArgs.Output == "" { + return fmt.Errorf("--output directory must be specified") + } + + return nil +} + +// validateInputArgs validates input arguments using mutually exclusive logic +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*processing.RecordingMetadata, error) { + // Count how many filters are specified + filtersCount := 0 + if userID != "" { + filtersCount++ + } + if sessionID != "" { + filtersCount++ + } + if trackID != "" { + filtersCount++ + } + + // Ensure filters are mutually exclusive + if filtersCount > 1 { + return nil, fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") + } + + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else if globalArgs.InputDir != "" { + inputPath = globalArgs.InputDir + } else { + // TODO: Handle S3 validation + return nil, fmt.Errorf("Not implemented for now") + } + + // Parse metadata to validate the single specified argument + logger := setupLogger(false) // Use non-verbose for validation + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, fmt.Errorf("failed to parse recording for validation: %w", err) + } + + // If no filters specified, no validation needed + if filtersCount == 0 { + return metadata, nil + } + + // Validate the single specified filter + if trackID != "" { + found := false + for _, track := range metadata.Tracks { + if track.TrackID == trackID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) + } + } else if sessionID != "" { + found := false + for _, track := range metadata.Tracks { + if track.SessionID == sessionID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) + } + } else if userID != "" { + found := false + for _, uid := range metadata.UserIDs { + if uid == userID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) + } + } + + return metadata, nil +} + +func printGlobalUsage() { + fmt.Fprintf(os.Stderr, "Raw Recording Post Processing Tools\n\n") + fmt.Fprintf(os.Stderr, "Usage: %s [global options] [command options]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Global Options:\n") + fmt.Fprintf(os.Stderr, " --inputFile Specify raw recording zip file on file system\n") + fmt.Fprintf(os.Stderr, " --inputS3 Specify raw recording zip file on S3\n") + fmt.Fprintf(os.Stderr, " --output Specify an output directory (optional for list-tracks)\n") + fmt.Fprintf(os.Stderr, " --verbose Enable verbose logging\n\n") + fmt.Fprintf(os.Stderr, "Commands:\n") + fmt.Fprintf(os.Stderr, " list-tracks Return list of userId - sessionId - trackId - trackType\n") + fmt.Fprintf(os.Stderr, " extract-audio Generate a playable audio file (webm, mp3, ...)\n") + fmt.Fprintf(os.Stderr, " extract-video Generate a playable video file (webm, mp4, ...)\n") + fmt.Fprintf(os.Stderr, " mux-av Mux audio and video tracks\n") + fmt.Fprintf(os.Stderr, " mix-audio Mix multiple audio tracks into one file (supports mutually exclusive filters)\n") + fmt.Fprintf(os.Stderr, " process-all Process audio, video, and mux (all-in-one)\n") + fmt.Fprintf(os.Stderr, " completion Generate shell completion scripts\n") + fmt.Fprintf(os.Stderr, " help Show this help message\n\n") + fmt.Fprintf(os.Stderr, "Examples:\n") + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip list-tracks\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out extract-audio --userId user123\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out mix-audio --sessionId session456\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s --verbose --inputFile recording.zip --output ./out mux-av --trackId track789\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Use '%s [global options] --help' for command-specific options.\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "\nCompletion Setup:\n") + fmt.Fprintf(os.Stderr, " # Bash\n") + fmt.Fprintf(os.Stderr, " source <(%s completion bash)\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " # Zsh\n") + fmt.Fprintf(os.Stderr, " source <(%s completion zsh)\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " # Fish\n") + fmt.Fprintf(os.Stderr, " %s completion fish | source\n", os.Args[0]) +} + +func printHelpIfAsked(args []string, fn func()) { + // Check for help flag before parsing + for _, arg := range args { + if arg == "--help" || arg == "-h" { + fn() + os.Exit(0) + } + } +} +func runCompletion(args []string) { + if len(args) == 0 { + fmt.Fprintf(os.Stderr, "Usage: raw-tools completion \n") + fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") + os.Exit(1) + } + + shell := args[0] + generateCompletion(shell) +} diff --git a/pkg/cmd/raw-recording-tool/mix_audio.go b/pkg/cmd/raw-recording-tool/mix_audio.go new file mode 100644 index 0000000..fbcc903 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/mix_audio.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +// MixAudioArgs represents the arguments for the mix-audio command +type MixAudioArgs struct { + IncludeScreenShare bool +} + +type MixAudioProcess struct { + logger *getstream.DefaultLogger +} + +func NewMixAudioProcess(logger *getstream.DefaultLogger) *MixAudioProcess { + return &MixAudioProcess{logger: logger} +} + +// runMixAudio handles the mix-audio command +func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + mixAudioArgs := &MixAudioArgs{ + IncludeScreenShare: false, + } + + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, "", "", "") + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + os.Exit(1) + } + + p.logger.Info("Starting mix-audio command") + + // Execute the mix-audio operation + if e := p.mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, p.logger); e != nil { + p.logger.Error("Mix-audio failed: %v", e) + os.Exit(1) + } + + p.logger.Info("Mix-audio command completed successfully") +} + +// mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic +func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + mixer := processing.NewAudioMixer(logger) + mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + WithScreenshare: false, + WithExtract: true, + WithCleanup: false, + }, metadata, logger) + return nil +} + +// printMixAudioUsage prints the usage information for the mix-audio command +func (p *MixAudioProcess) printUsage() { + fmt.Println("Usage: raw-tools [global-options] mix-audio [options]") + fmt.Println() + fmt.Println("Mix all audio tracks from multiple users/sessions into a single audio file") + fmt.Println("with proper timing synchronization (like a conference call recording).") + fmt.Println() + fmt.Println("Options:") + fmt.Println(" --userId Filter by user ID (* for all users, default: *)") + fmt.Println(" --sessionId Filter by session ID (* for all sessions, default: *)") + fmt.Println(" --trackId Filter by track ID (* for all tracks, default: *)") + fmt.Println(" --no-fill-gaps Don't fill gaps with silence (not recommended for mixing)") + fmt.Println(" -h, --help Show this help message") + fmt.Println() + fmt.Println("Examples:") + fmt.Println(" # Mix all audio tracks from all users and sessions") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio") + fmt.Println() + fmt.Println(" # Mix audio tracks from a specific user") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --userId user123") + fmt.Println() + fmt.Println(" # Mix audio tracks from a specific session") + fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --sessionId session456") + fmt.Println() + fmt.Println("Output:") + fmt.Println(" Creates 'mixed_audio.webm' - a single audio file containing all mixed tracks") + fmt.Println(" with proper timing synchronization based on the original recording timeline.") +} diff --git a/pkg/cmd/raw-recording-tool/mux_av.go b/pkg/cmd/raw-recording-tool/mux_av.go new file mode 100644 index 0000000..1012d81 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/mux_av.go @@ -0,0 +1,109 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type MuxAVArgs struct { + UserID string + SessionID string + TrackID string + Media string // "user", "display", or "both" (default) +} + +type MuxAudioVideoProcess struct { + logger *getstream.DefaultLogger +} + +func NewMuxAudioVideoProcess(logger *getstream.DefaultLogger) *MuxAudioVideoProcess { + return &MuxAudioVideoProcess{logger: logger} +} + +func (p *MuxAudioVideoProcess) runMuxAV(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + // Parse command-specific flags + fs := flag.NewFlagSet("mux-av", flag.ExitOnError) + muxAVArgs := &MuxAVArgs{} + fs.StringVar(&muxAVArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&muxAVArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&muxAVArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") + fs.StringVar(&muxAVArgs.Media, "media", "both", "Filter by media type: 'user', 'display', or 'both'") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + os.Exit(1) + } + + p.logger.Info("Starting mux-av command") + + // Display hierarchy information for user clarity + fmt.Printf("Mux audio and video command with hierarchical filtering:\n") + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", muxAVArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", muxAVArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", muxAVArgs.TrackID) + fmt.Printf(" Media filter: %s\n", muxAVArgs.Media) + + if muxAVArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", muxAVArgs.TrackID) + } else if muxAVArgs.SessionID != "" { + fmt.Printf(" → Processing all tracks for session '%s'\n", muxAVArgs.SessionID) + } else if muxAVArgs.UserID != "" { + fmt.Printf(" → Processing all tracks for user '%s'\n", muxAVArgs.UserID) + } else { + fmt.Printf(" → Processing all tracks (no filters)\n") + } + + // Extract and mux audio/video tracks + if err := p.muxAudioVideoTracks(globalArgs, muxAVArgs, metadata, p.logger); err != nil { + p.logger.Error("Failed to mux audio/video tracks: %v", err) + os.Exit(1) + } + + p.logger.Info("Mux audio and video command completed successfully") +} + +func (p *MuxAudioVideoProcess) printUsage() { + fmt.Printf("Usage: raw-tools [global options] mux-av [options]\n") + fmt.Printf("\nMux audio and video tracks into a single file\n") + fmt.Printf("\nOptions:\n") + fmt.Printf(" --userId STRING Filter by user ID (mutually exclusive with --sessionId/--trackId)\n") + fmt.Printf(" --sessionId STRING Filter by session ID (mutually exclusive with --userId/--trackId)\n") + fmt.Printf(" --trackId STRING Filter by track ID (mutually exclusive with --userId/--sessionId)\n") + fmt.Printf(" --media STRING Filter by media type: 'user', 'display', or 'both' (default: \"both\")\n") + fmt.Printf("\nMedia Filtering:\n") + fmt.Printf(" --media user Only mux user camera audio/video pairs\n") + fmt.Printf(" --media display Only mux display sharing audio/video pairs\n") + fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") +} + +func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + muxer := processing.NewAudioVideoMuxer(p.logger) + if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: muxAVArgs.UserID, + SessionID: muxAVArgs.SessionID, + TrackID: muxAVArgs.TrackID, + Media: muxAVArgs.Media, + WithExtract: true, + WithCleanup: false, + }, metadata, logger); e != nil { + return e + } + return nil +} diff --git a/pkg/cmd/raw-recording-tool/process_all.go b/pkg/cmd/raw-recording-tool/process_all.go new file mode 100644 index 0000000..17a6a2e --- /dev/null +++ b/pkg/cmd/raw-recording-tool/process_all.go @@ -0,0 +1,127 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/GetStream/getstream-go/v3" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" +) + +type ProcessAllArgs struct { + UserID string + SessionID string + TrackID string +} + +type ProcessAllProcess struct { + logger *getstream.DefaultLogger +} + +func NewProcessAllProcess(logger *getstream.DefaultLogger) *ProcessAllProcess { + return &ProcessAllProcess{logger: logger} +} + +func (p *ProcessAllProcess) runProcessAll(args []string, globalArgs *GlobalArgs) { + printHelpIfAsked(args, p.printUsage) + + // Parse command-specific flags + fs := flag.NewFlagSet("process-all", flag.ExitOnError) + processAllArgs := &ProcessAllArgs{} + fs.StringVar(&processAllArgs.UserID, "userId", "", "Specify a userId (empty for all)") + fs.StringVar(&processAllArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") + fs.StringVar(&processAllArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") + + if err := fs.Parse(args); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) + os.Exit(1) + } + + // Validate input arguments against actual recording data + metadata, err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) + if err != nil { + fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) + os.Exit(1) + } + + p.logger.Info("Starting process-all command") + + // Display hierarchy information for user clarity + fmt.Printf("Process-all command (audio + video + mux) with hierarchical filtering:\n") + fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + fmt.Printf(" Output directory: %s\n", globalArgs.Output) + fmt.Printf(" User ID filter: %s\n", processAllArgs.UserID) + fmt.Printf(" Session ID filter: %s\n", processAllArgs.SessionID) + fmt.Printf(" Track ID filter: %s\n", processAllArgs.TrackID) + fmt.Printf(" Gap filling: always enabled\n") + + if processAllArgs.TrackID != "" { + fmt.Printf(" → Processing specific track '%s'\n", processAllArgs.TrackID) + } else if processAllArgs.SessionID != "" { + fmt.Printf(" → Processing all tracks for session '%s'\n", processAllArgs.SessionID) + } else if processAllArgs.UserID != "" { + fmt.Printf(" → Processing all tracks for user '%s'\n", processAllArgs.UserID) + } else { + fmt.Printf(" → Processing all tracks (no filters)\n") + } + + // Process all tracks and mux them + if err := p.processAllTracks(globalArgs, processAllArgs, metadata, p.logger); err != nil { + p.logger.Error("Failed to process and mux tracks: %v", err) + os.Exit(1) + } + + p.logger.Info("Process-all command completed successfully") +} + +func (p *ProcessAllProcess) printUsage() { + fmt.Printf("Usage: process-all [OPTIONS]\n") + fmt.Printf("\nProcess audio, video, and mux them into combined files (all-in-one workflow)\n") + fmt.Printf("Outputs 3 files per session: audio WebM, video WebM, and muxed WebM\n") + fmt.Printf("Gap filling is always enabled for seamless playback.\n") + fmt.Printf("\nOptions:\n") + fmt.Printf(" --userId STRING Specify a userId or * for all (default: \"*\")\n") + fmt.Printf(" --sessionId STRING Specify a sessionId or * for all (default: \"*\")\n") + fmt.Printf(" --trackId STRING Specify a trackId or * for all (default: \"*\")\n") + fmt.Printf("\nOutput files per session:\n") + fmt.Printf(" audio_{userId}_{sessionId}_{trackId}.webm - Audio-only file\n") + fmt.Printf(" video_{userId}_{sessionId}_{trackId}.webm - Video-only file\n") + fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") +} + +func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + + if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); e != nil { + return e + } + + if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); e != nil { + return e + } + + mixer := processing.NewAudioMixer(logger) + mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + WithScreenshare: false, + WithExtract: false, + WithCleanup: false, + }, metadata, logger) + + muxer := processing.NewAudioVideoMuxer(p.logger) + if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: "", + SessionID: "", + TrackID: "", + Media: "", + WithExtract: false, + WithCleanup: false, + }, metadata, logger); e != nil { + return e + } + + return nil +} diff --git a/pkg/cmd/raw-recording-tool/processing/archive_input.go b/pkg/cmd/raw-recording-tool/processing/archive_input.go new file mode 100644 index 0000000..5f61d8d --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/archive_input.go @@ -0,0 +1,102 @@ +package processing + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +// extractToTempDir extracts archive to temp directory or returns the directory path +// Returns: (workingDir, cleanupFunc, error) +func ExtractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { + // If it's already a directory, just return it + if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { + logger.Debug("Input is already a directory: %s", inputPath) + return inputPath, func() {}, nil + } + + // If it's a tar.gz file, extract it to temp directory + if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { + logger.Info("Extracting tar.gz archive to temporary directory...") + + tempDir, err := os.MkdirTemp("", "raw-tools-*") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + + cleanup := func() { + os.RemoveAll(tempDir) + } + + err = extractTarGzToDir(inputPath, tempDir, logger) + if err != nil { + cleanup() + return "", nil, fmt.Errorf("failed to extract tar.gz: %w", err) + } + + logger.Debug("Extracted archive to: %s", tempDir) + return tempDir, cleanup, nil + } + + return "", nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) +} + +// extractTarGzToDir extracts a tar.gz file to the specified directory +func extractTarGzToDir(tarGzPath, destDir string, logger *getstream.DefaultLogger) error { + file, err := os.Open(tarGzPath) + if err != nil { + return fmt.Errorf("failed to open tar.gz file: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("failed to read tar entry: %w", err) + } + + // Skip directories + if header.FileInfo().IsDir() { + continue + } + + // Create destination file + destPath := filepath.Join(destDir, header.Name) + + // Create directory structure if needed + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory structure: %w", err) + } + + // Extract file + outFile, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", destPath, err) + } + + _, err = io.Copy(outFile, tarReader) + outFile.Close() + if err != nil { + return fmt.Errorf("failed to extract file %s: %w", destPath, err) + } + } + + return nil +} diff --git a/pkg/cmd/raw-recording-tool/processing/archive_json.go b/pkg/cmd/raw-recording-tool/processing/archive_json.go new file mode 100644 index 0000000..85dfc81 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/archive_json.go @@ -0,0 +1,30 @@ +package processing + +type SessionTimingMetadata struct { + ParticipantID string `json:"participant_id"` + UserSessionID string `json:"user_session_id"` + Segments struct { + Audio []*SegmentMetadata `json:"audio"` + Video []*SegmentMetadata `json:"video"` + } `json:"segments"` +} + +type SegmentMetadata struct { + // Global information + BaseFilename string `json:"base_filename"` + + // Track information + Codec string `json:"codec"` + TrackID string `json:"track_id"` + TrackType string `json:"track_type"` + + // Packet timing information + FirstRtpRtpTimestamp uint32 `json:"first_rtp_rtp_timestamp"` + FirstRtpUnixTimestamp int64 `json:"first_rtp_unix_timestamp"` + LastRtpRtpTimestamp uint32 `json:"last_rtp_rtp_timestamp,omitempty"` + LastRtpUnixTimestamp int64 `json:"last_rtp_unix_timestamp,omitempty"` + FirstRtcpRtpTimestamp uint32 `json:"first_rtcp_rtp_timestamp,omitempty"` + FirstRtcpNtpTimestamp int64 `json:"first_rtcp_ntp_timestamp,omitempty"` + LastRtcpRtpTimestamp uint32 `json:"last_rtcp_rtp_timestamp,omitempty"` + LastRtcpNtpTimestamp int64 `json:"last_rtcp_ntp_timestamp,omitempty"` +} diff --git a/pkg/cmd/raw-recording-tool/processing/archive_metadata.go b/pkg/cmd/raw-recording-tool/processing/archive_metadata.go new file mode 100644 index 0000000..f1ae828 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/archive_metadata.go @@ -0,0 +1,370 @@ +package processing + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +// TrackInfo represents a single track with its metadata (deduplicated across segments) +type TrackInfo struct { + UserID string `json:"userId"` // participant_id from timing metadata + SessionID string `json:"sessionId"` // user_session_id from timing metadata + TrackID string `json:"trackId"` // track_id from segment + TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) + IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track + Codec string `json:"codec"` // codec info + SegmentCount int `json:"segmentCount"` // number of segments for this track + Segments []*SegmentInfo `json:"segments"` // list of filenames (for JSON output only) + + ConcatenatedContainerPath string +} + +type SegmentInfo struct { + metadata *SegmentMetadata + + RtpDumpPath string + SdpPath string + ContainerPath string + ContainerExt string + FFMpegOffset int64 +} + +// RecordingMetadata contains all tracks and session information +type RecordingMetadata struct { + Tracks []*TrackInfo `json:"tracks"` + UserIDs []string `json:"userIds"` + Sessions []string `json:"sessions"` +} + +// MetadataParser handles parsing of raw recording files +type MetadataParser struct { + logger *getstream.DefaultLogger +} + +// NewMetadataParser creates a new metadata parser +func NewMetadataParser(logger *getstream.DefaultLogger) *MetadataParser { + return &MetadataParser{ + logger: logger, + } +} + +// ParseMetadataOnly efficiently extracts only metadata from archives (optimized for list-tracks) +// This is much faster than full extraction when you only need timing metadata +func (p *MetadataParser) ParseMetadataOnly(inputPath string) (*RecordingMetadata, error) { + // If it's already a directory, use the normal path + if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { + return p.parseDirectory(inputPath) + } + + // If it's a tar.gz file, use selective extraction (much faster) + if strings.HasSuffix(strings.ToLower(inputPath), ".tar.gz") { + return p.parseMetadataOnlyFromTarGz(inputPath) + } + + return nil, fmt.Errorf("unsupported input format: %s (only tar.gz files and directories supported)", inputPath) +} + +// parseDirectory processes a directory containing recording files +func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, error) { + metadata := &RecordingMetadata{ + Tracks: make([]*TrackInfo, 0), + UserIDs: make([]string, 0), + Sessions: make([]string, 0), + } + + // Find and process timing metadata files + err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), "_timing_metadata.json") { + p.logger.Debug("Processing metadata file: %s", path) + + data, err := os.ReadFile(path) + if err != nil { + p.logger.Warn("Failed to read metadata file %s: %v", path, err) + return nil + } + + tracks, err := p.parseTimingMetadataFile(data) + if err != nil { + p.logger.Warn("Failed to parse metadata file %s: %v", path, err) + return nil + } + + metadata.Tracks = append(metadata.Tracks, tracks...) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to process directory: %w", err) + } + + // Build unique lists + metadata.UserIDs = p.extractUniqueUserIDs(metadata.Tracks) + metadata.Sessions = p.extractUniqueSessions(metadata.Tracks) + + return metadata, nil +} + +// parseMetadataOnlyFromTarGz efficiently extracts only timing metadata from tar.gz files +// This is optimized for list-tracks - only reads JSON files, skips all .rtpdump/.sdp files +func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*RecordingMetadata, error) { + p.logger.Debug("Reading metadata directly from tar.gz (efficient mode): %s", tarGzPath) + + file, err := os.Open(tarGzPath) + if err != nil { + return nil, fmt.Errorf("failed to open tar.gz file: %w", err) + } + defer file.Close() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + + metadata := &RecordingMetadata{ + Tracks: make([]*TrackInfo, 0), + UserIDs: make([]string, 0), + Sessions: make([]string, 0), + } + + filesRead := 0 + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("failed to read tar entry: %w", err) + } else if header.FileInfo().IsDir() { + continue + } + + // Only process timing metadata JSON files (skip all .rtpdump/.sdp files) + if strings.HasSuffix(strings.ToLower(header.Name), "_timing_metadata.json") { + p.logger.Debug("Processing metadata file: %s", header.Name) + + data, err := io.ReadAll(tarReader) + if err != nil { + p.logger.Warn("Failed to read metadata file %s: %v", header.Name, err) + continue + } + + tracks, err := p.parseTimingMetadataFile(data) + if err != nil { + p.logger.Warn("Failed to parse metadata file %s: %v", header.Name, err) + continue + } + + metadata.Tracks = append(metadata.Tracks, tracks...) + filesRead++ + } + // Skip all other files (.rtpdump, .sdp, etc.) - huge efficiency gain! + } + + p.logger.Debug("Efficiently read %d metadata files from archive (skipped all media data files)", filesRead) + + // Extract unique user IDs and sessions + metadata.UserIDs = p.extractUniqueUserIDs(metadata.Tracks) + metadata.Sessions = p.extractUniqueSessions(metadata.Tracks) + + return metadata, nil +} + +// parseTimingMetadataFile parses a timing metadata JSON file and extracts tracks +func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, error) { + var sessionMetadata SessionTimingMetadata + err := json.Unmarshal(data, &sessionMetadata) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) + } + + // Use a map to deduplicate tracks by unique key + trackMap := make(map[string]*TrackInfo) + + processSegment := func(segment *SegmentMetadata, trackType string) { + key := fmt.Sprintf("%s|%s|%s|%s", + sessionMetadata.ParticipantID, + sessionMetadata.UserSessionID, + segment.TrackID, + trackType) + + if existingTrack, exists := trackMap[key]; exists { + existingTrack.Segments = append(existingTrack.Segments, &SegmentInfo{metadata: segment}) + existingTrack.SegmentCount++ + } else { + // Create new track + track := &TrackInfo{ + UserID: sessionMetadata.ParticipantID, + SessionID: sessionMetadata.UserSessionID, + TrackID: segment.TrackID, + TrackType: p.cleanTrackType(segment.TrackType), + IsScreenshare: p.isScreenshareTrack(segment.TrackType), + Codec: segment.Codec, + SegmentCount: 1, + Segments: []*SegmentInfo{{metadata: segment}}, + } + trackMap[key] = track + } + } + + // Process audio segments + for _, segment := range sessionMetadata.Segments.Audio { + processSegment(segment, p.cleanTrackType(segment.TrackType)) + } + + // Process video segments + for _, segment := range sessionMetadata.Segments.Video { + processSegment(segment, p.cleanTrackType(segment.TrackType)) + } + + // Convert map to slice + tracks := make([]*TrackInfo, 0, len(trackMap)) + for _, track := range trackMap { + sort.Slice(track.Segments, func(i, j int) bool { + return track.Segments[i].metadata.FirstRtpUnixTimestamp < track.Segments[j].metadata.FirstRtpUnixTimestamp + }) + tracks = append(tracks, track) + } + + return tracks, nil +} + +// isScreenshareTrack detects if a track is screenshare-related +func (p *MetadataParser) isScreenshareTrack(trackType string) bool { + return trackType == "TRACK_TYPE_SCREEN_SHARE_AUDIO" || trackType == "TRACK_TYPE_SCREEN_SHARE" +} + +// cleanTrackType converts TRACK_TYPE_* to simple "audio" or "video" +func (p *MetadataParser) cleanTrackType(trackType string) string { + switch trackType { + case "TRACK_TYPE_AUDIO", "TRACK_TYPE_SCREEN_SHARE_AUDIO": + return "audio" + case "TRACK_TYPE_VIDEO", "TRACK_TYPE_SCREEN_SHARE": + return "video" + default: + return strings.ToLower(trackType) + } +} + +// extractUniqueUserIDs returns a sorted list of unique user IDs +func (p *MetadataParser) extractUniqueUserIDs(tracks []*TrackInfo) []string { + userIDMap := make(map[string]bool) + for _, track := range tracks { + userIDMap[track.UserID] = true + } + + userIDs := make([]string, 0, len(userIDMap)) + for userID := range userIDMap { + userIDs = append(userIDs, userID) + } + + return userIDs +} + +// NOTE: ExtractTrackFiles and extractTrackFromTarGz removed - no longer needed since we always work with directories + +// extractUniqueSessions returns a sorted list of unique session IDs +func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { + sessionMap := make(map[string]bool) + for _, track := range tracks { + sessionMap[track.SessionID] = true + } + + sessions := make([]string, 0, len(sessionMap)) + for session := range sessionMap { + sessions = append(sessions, session) + } + + return sessions +} + +// FilterTracks filters tracks based on mutually exclusive criteria +// Only one filter (userID, sessionID, or trackID) can be specified at a time +// Empty values are ignored, specific values must match +// If all are empty, all tracks are returned +func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackType, mediaFilter string) []*TrackInfo { + filtered := make([]*TrackInfo, 0) + + for _, track := range tracks { + if trackType != "" && track.TrackType != trackType { + continue // Skip tracks with wrong TrackType + } + + // Apply media type filtering if specified + if mediaFilter != "" && mediaFilter != "both" { + if mediaFilter == "user" && track.IsScreenshare { + continue // Skip display tracks when only user requested + } + if mediaFilter == "display" && !track.IsScreenshare { + continue // Skip user tracks when only display requested + } + } + + // Apply the single specified filter (mutually exclusive) + if trackID != "" { + // Filter by trackID - return only that specific track + if track.TrackID == trackID { + filtered = append(filtered, track) + } + } else if sessionID != "" { + // Filter by sessionID - return all tracks for that session + if track.SessionID == sessionID { + filtered = append(filtered, track) + } + } else if userID != "" { + // Filter by userID - return all tracks for that user + if track.UserID == userID { + filtered = append(filtered, track) + } + } else { + // No filters specified - return all tracks + filtered = append(filtered, track) + } + } + + return filtered +} + +func firstPacketNtpTimestamp(segment *SegmentMetadata) int64 { + if segment.FirstRtcpNtpTimestamp != 0 && segment.FirstRtcpRtpTimestamp != 0 { + rtpNtpTs := (segment.FirstRtcpRtpTimestamp - segment.FirstRtpRtpTimestamp) / sampleRate(segment) + return segment.FirstRtcpNtpTimestamp - int64(rtpNtpTs) + } else { + return segment.FirstRtpUnixTimestamp + } +} + +func lastPacketNtpTimestamp(segment *SegmentMetadata) int64 { + if segment.LastRtcpNtpTimestamp != 0 && segment.LastRtcpRtpTimestamp != 0 { + rtpNtpTs := (segment.LastRtpRtpTimestamp - segment.LastRtcpRtpTimestamp) / sampleRate(segment) + return segment.LastRtcpNtpTimestamp + int64(rtpNtpTs) + } else { + return segment.LastRtpUnixTimestamp + } +} + +func sampleRate(segment *SegmentMetadata) uint32 { + switch segment.TrackType { + case "TRACK_TYPE_AUDIO", + "TRACK_TYPE_SCREEN_SHARE_AUDIO": + return 48 + default: + return 90 + } +} diff --git a/pkg/cmd/raw-recording-tool/processing/audio_mixer.go b/pkg/cmd/raw-recording-tool/processing/audio_mixer.go new file mode 100644 index 0000000..b69751f --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/audio_mixer.go @@ -0,0 +1,96 @@ +package processing + +import ( + "fmt" + "path/filepath" + + "github.com/GetStream/getstream-go/v3" +) + +type AudioMixerConfig struct { + WorkDir string + OutputDir string + WithScreenshare bool + WithExtract bool + WithCleanup bool +} + +type AudioMixer struct { + logger *getstream.DefaultLogger +} + +func NewAudioMixer(logger *getstream.DefaultLogger) *AudioMixer { + return &AudioMixer{logger: logger} +} + +// MixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic +func (p *AudioMixer) MixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + // Step 1: Extract all matching audio tracks using existing ExtractTracks function + logger.Info("Step 1/2: Extracting all matching audio tracks...") + + if config.WithExtract { + mediaFilter := "user" + if config.WithScreenshare { + mediaFilter = "both" + } + + if err := ExtractTracks(config.WorkDir, config.OutputDir, "", "", "", metadata, "audio", mediaFilter, true, true, logger); err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + } + + fileOffsetMap := p.offset(metadata, config.WithScreenshare, logger) + if len(fileOffsetMap) == 0 { + return fmt.Errorf("no audio files were extracted - check your filter criteria") + } + + logger.Info("Found %d extracted audio files to mix", len(fileOffsetMap)) + + // Step 3: Mix all discovered audio files using existing webm.mixAudioFiles + outputFile := filepath.Join(config.OutputDir, "mixed_audio.webm") + + err := mixAudioFiles(outputFile, fileOffsetMap, logger) + if err != nil { + return fmt.Errorf("failed to mix audio files: %w", err) + } + + logger.Info("Successfully created mixed audio file: %s", outputFile) + + //// Clean up individual audio files (optional) + //for _, audioFile := range audioFiles { + // if err := os.Remove(audioFile.FilePath); err != nil { + // logger.Warn("Failed to clean up temporary file %s: %v", audioFile.FilePath, err) + // } + //} + + return nil +} + +func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, logger *getstream.DefaultLogger) []*FileOffset { + var offsets []*FileOffset + var firstTrack *TrackInfo + for _, t := range metadata.Tracks { + if t.TrackType == "audio" && (!t.IsScreenshare || withScreenshare) { + if firstTrack == nil { + firstTrack = t + offsets = append(offsets, &FileOffset{ + Name: t.ConcatenatedContainerPath, + Offset: 0, // Will be sorted later and rearranged + }) + } else { + offset, err := calculateSyncOffsetFromFiles(t, firstTrack, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset for audio tracks: %v", err) + continue + } + + offsets = append(offsets, &FileOffset{ + Name: t.ConcatenatedContainerPath, + Offset: offset, + }) + } + } + } + + return offsets +} diff --git a/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go b/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go new file mode 100644 index 0000000..20ef319 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go @@ -0,0 +1,151 @@ +package processing + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +type AudioVideoMuxerConfig struct { + WorkDir string + OutputDir string + UserID string + SessionID string + TrackID string + Media string + + WithExtract bool + WithCleanup bool +} + +type AudioVideoMuxer struct { + logger *getstream.DefaultLogger +} + +func NewAudioVideoMuxer(logger *getstream.DefaultLogger) *AudioVideoMuxer { + return &AudioVideoMuxer{logger: logger} +} + +func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { + if config.WithExtract { + // Extract audio tracks with gap filling enabled + logger.Info("Extracting audio tracks with gap filling...") + err := ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "audio", config.Media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) + } + + // Extract video tracks with gap filling enabled + logger.Info("Extracting video tracks with gap filling...") + err = ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "video", config.Media, true, true, logger) + if err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) + } + } + + // Group files by media type for proper pairing + pairedTracks := p.groupFilesByMediaType(metadata) + for audioTrack, videoTrack := range pairedTracks { + //logger.Info("Muxing %d user audio/video pairs", len(userAudio)) + err := p.muxTrackPairs(audioTrack, videoTrack, config.OutputDir, logger) + if err != nil { + logger.Error("Failed to mux user tracks: %v", err) + } + } + + return nil +} + +// calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata +func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo, logger *getstream.DefaultLogger) (int64, error) { + // Calculate offset: positive means video starts before audio + audioTs := audioTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := videoTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) + offset := audioTs - videoTs + + logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", + audioTrack.Segments[0].metadata.FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].metadata.FirstRtpUnixTimestamp, videoTs, offset)) + + return offset, nil +} + +// groupFilesByMediaType groups audio and video files by media type (user vs display) +func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map[*TrackInfo]*TrackInfo { + pairedTracks := make(map[*TrackInfo]*TrackInfo) + + matches := func(audio *TrackInfo, video *TrackInfo) bool { + return audio.UserID == video.UserID && + audio.SessionID == video.SessionID && + audio.IsScreenshare == video.IsScreenshare + } + + for _, at := range metadata.Tracks { + if at.TrackType == "audio" { + for _, vt := range metadata.Tracks { + if vt.TrackType == "video" && matches(at, vt) { + pairedTracks[at] = vt + break + } + } + } + } + + return pairedTracks +} + +// muxTrackPairs muxes audio/video pairs of the same media type +func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir string, logger *getstream.DefaultLogger) error { + // Calculate sync offset using segment timing information + offset, err := calculateSyncOffsetFromFiles(audio, video, logger) + if err != nil { + logger.Warn("Failed to calculate sync offset, using 0: %v", err) + offset = 0 + } + + // Generate output filename with media type indicator + outputFile := p.generateMediaAwareMuxedFilename(audio, video, outputDir) + + audioFile := audio.ConcatenatedContainerPath + videoFile := video.ConcatenatedContainerPath + + // Mux the audio and video files + logger.Info("Muxing %s + %s → %s (offset: %dms)", + filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) + + err = muxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + if err != nil { + logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) + return err + } + + logger.Info("Successfully created muxed file: %s", outputFile) + + // Clean up individual track files to avoid clutter + //os.Remove(audioFile) + //os.Remove(videoFile) + //} + // + //if len(audioFiles) != len(videoFiles) { + // logger.Warn("Mismatched %s track counts: %d audio, %d video", mediaTypeName, len(audioFiles), len(videoFiles)) + //} + + return nil +} + +// generateMediaAwareMuxedFilename creates output filename that indicates media type +func (p *AudioVideoMuxer) generateMediaAwareMuxedFilename(audioFile, videoFile *TrackInfo, outputDir string) string { + audioBase := filepath.Base(audioFile.Segments[0].ContainerPath) + audioBase = strings.TrimSuffix(audioBase, "."+audioFile.Segments[0].ContainerExt) + + // Replace "audio_" with "muxed_{mediaType}_" to create output name + var muxedName string + if audioFile.IsScreenshare { + muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + "." + videoFile.Segments[0].ContainerExt + } else { + muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + "." + videoFile.Segments[0].ContainerExt + } + + return filepath.Join(outputDir, muxedName) +} diff --git a/pkg/cmd/raw-recording-tool/processing/constants.go b/pkg/cmd/raw-recording-tool/processing/constants.go new file mode 100644 index 0000000..5d1f595 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/constants.go @@ -0,0 +1,15 @@ +package processing + +const ( + RtpDump = "rtpdump" + SuffixRtpDump = "." + RtpDump + + Sdp = "sdp" + SuffixSdp = "." + Sdp + + Webm = "webm" + SuffixWebm = "." + Webm + + Mp4 = "mp4" + SuffixMp4 = "." + Mp4 +) diff --git a/pkg/cmd/raw-recording-tool/processing/container_converter.go b/pkg/cmd/raw-recording-tool/processing/container_converter.go new file mode 100644 index 0000000..e52ea8d --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/container_converter.go @@ -0,0 +1,337 @@ +package processing + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/pion/rtp" + "github.com/pion/rtp/codecs" + "github.com/pion/webrtc/v4" + "github.com/pion/webrtc/v4/pkg/media/rtpdump" + "github.com/pion/webrtc/v4/pkg/media/samplebuilder" +) + +const audioMaxLate = 200 // 4sec +const videoMaxLate = 1000 // 4sec + +type RTPDump2WebMConverter struct { + logger *getstream.DefaultLogger + reader *rtpdump.Reader + recorder WebmRecorder + sampleBuilder *samplebuilder.SampleBuilder + + lastPkt *rtp.Packet + lastPktDuration uint32 + inserted uint16 +} + +type WebmRecorder interface { + OnRTP(pkt *rtp.Packet) error + PushRtpBuf(payload []byte) error + Close() error +} + +func newRTPDump2WebMConverter(logger *getstream.DefaultLogger) *RTPDump2WebMConverter { + return &RTPDump2WebMConverter{ + logger: logger, + } +} + +func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) (*SegmentInfo, bool), fixDtx bool, logger *getstream.DefaultLogger) error { + rtpdumpFiles := make(map[string]*SegmentInfo) + + // Walk through directory to find .rtpdump files + err := filepath.Walk(directory, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) { + segment, accepted := accept(path, info) + if accepted { + rtpdumpFiles[path] = segment + } + } + + return nil + }) + if err != nil { + return err + } + + for rtpdumpFile, segment := range rtpdumpFiles { + c := newRTPDump2WebMConverter(logger) + if err := c.ConvertFile(rtpdumpFile, fixDtx); err != nil { + c.logger.Error("Failed to convert %s: %v", rtpdumpFile, err) + continue + } + + switch c.recorder.(type) { + case *CursorWebmRecorder: + offset, exists := c.recorder.(*CursorWebmRecorder).StartOffset() + if exists { + segment.FFMpegOffset = offset + } + } + } + + return nil +} + +func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error { + c.logger.Info("Converting %s", inputFile) + + // Parse the RTP dump file + // Open the file + file, err := os.Open(inputFile) + if err != nil { + return fmt.Errorf("failed to open rtpdump file: %w", err) + } + defer file.Close() + + // Create standardized reader + reader, _, _ := rtpdump.NewReader(file) + c.reader = reader + + sdpContent, _ := readSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) + mType, _ := mimeType(sdpContent) + + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) + + switch mType { + case webrtc.MimeTypeAV1: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + case webrtc.MimeTypeVP9: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) + c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + case webrtc.MimeTypeH264: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) + case webrtc.MimeTypeVP8: + c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, releasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + case webrtc.MimeTypeOpus: + if fixDtx { + releasePacketHandler = samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler()) + } + c.sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, releasePacketHandler) + c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + default: + return fmt.Errorf("unsupported codec type: %s", mType) + } + if err != nil { + return fmt.Errorf("failed to create WebM recorder: %w", err) + } + defer c.recorder.Close() + + time.Sleep(1 * time.Second) + + // Convert and feed RTP packets + return c.feedPackets(reader) +} + +func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { + startTime := time.Now() + + i := 0 + for ; ; i++ { + packet, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return err + } else if packet.IsRTCP { + // _ = c.recorder.PushRtcpBuf(packet.Payload) + continue + } + + // Unmarshal the RTP packet from the raw payload + if c.sampleBuilder == nil { + _ = c.recorder.PushRtpBuf(packet.Payload) + } else { + // Unmarshal the RTP packet from the raw payload + rtpPacket := &rtp.Packet{} + if err := rtpPacket.Unmarshal(packet.Payload); err != nil { + c.logger.Warn("Failed to unmarshal RTP packet %d: %v", i, err) + continue + } + + // Push packet to samplebuilder for reordering + c.sampleBuilder.Push(rtpPacket) + } + + // time.Sleep(10 * time.Microsecond) + // Log progress + if i%10000 == 0 && i > 0 { + c.logger.Info("Processed %d packets", i) + } + } + + if c.sampleBuilder != nil { + c.sampleBuilder.Flush() + } + + duration := time.Since(startTime) + c.logger.Info("Finished feeding %d packets in %v", i, duration) + + // Allow some time for the recorder to finalize + time.Sleep(2 * time.Second) + + return nil +} + +func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp.Packet) { + return func(pkt *rtp.Packet) { + if c.lastPkt != nil { + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } + } + + c.lastPkt = pkt + + if e := c.recorder.OnRTP(pkt); e != nil { + c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) + } + } +} + +func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { + return func(pkt *rtp.Packet) { + pkt.SequenceNumber += c.inserted + + if c.lastPkt != nil { + if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { + c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } + + tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover + lastPktDuration := opusPacketDurationMs(c.lastPkt) + rtpDuration := uint32(lastPktDuration * 48) + + if rtpDuration == 0 { + rtpDuration = c.lastPktDuration + c.logger.Info("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } else { + c.lastPktDuration = rtpDuration + } + + if rtpDuration > 0 && tsDiff > rtpDuration { + + // Calculate how many packets we need to insert, taking care of packet losses + var toAdd uint16 + if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*rtpDuration != tsDiff { // TODO handle rollover + toAdd = uint16(tsDiff/rtpDuration) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) + } + + c.logger.Info("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", + toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + + for i := 1; i <= int(toAdd); i++ { + ins := c.lastPkt.Clone() + ins.Payload = ins.Payload[:1] // Keeping only TOC byte + ins.SequenceNumber += uint16(i) + ins.Timestamp += uint32(i) * rtpDuration + + c.logger.Debug("Writing inserted Packet %v", ins) + if e := c.recorder.OnRTP(ins); e != nil { + c.logger.Warn("Failed to record inserted RTP packet SeqNum: %d RtpTs: %d: %v", ins.SequenceNumber, ins.Timestamp, e) + } + } + + c.inserted += toAdd + pkt.SequenceNumber += toAdd + } + } + + c.lastPkt = pkt + + c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) + if e := c.recorder.OnRTP(pkt); e != nil { + c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) + } + } +} + +func opusPacketDurationMs(pkt *rtp.Packet) int { + // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + // | config |s|1|1|0|p| M | + // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + payload := pkt.Payload + if len(payload) < 1 { + return 0 + } + + toc := payload[0] + config := (toc >> 3) & 0x1F + c := toc & 0x03 + + // Calculate frame duration according to OPUS RFC 6716 table (use x10 factor) + // Frame duration is determined by the config value + var duration int + switch { + case config < 3: + // SILK-only NB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 3: + // SILK-only NB: 60 ms + duration = 600 + case config < 7: + // SILK-only MB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 7: + // SILK-only MB: 60 ms + duration = 600 + case config < 11: + // SILK-only WB: 10, 20, 40 ms + duration = 100 * (1 << (config & 0x03)) + case config == 11: + // SILK-only WB: 60 ms + duration = 600 + case config <= 13: + // Hybrid SWB: 10, 20 ms + duration = 100 * (1 << (config & 0x01)) + case config <= 15: + // Hybrid FB: 10, 20 ms + duration = 100 * (1 << (config & 0x01)) + case config <= 19: + // CELT-only NB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + case config <= 23: + // CELT-only WB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + case config <= 27: + // CELT-only SWB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + case config <= 31: + // CELT-only FB: 2.5, 5, 10, 20 ms + duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + default: + // MUST NOT HAPPEN + duration = 0 + } + + frameDuration := float32(duration) / 10 + + var frameCount float32 + switch c { + case 0: + frameCount = 1 + case 1, 2: + frameCount = 2 + case 3: + if len(payload) > 1 { + frameCount = float32(payload[1] & 0x3F) + } + } + + return int(frameDuration * frameCount) +} diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go b/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go new file mode 100644 index 0000000..78f5104 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go @@ -0,0 +1,319 @@ +package processing + +import ( + "bufio" + "context" + "fmt" + "io" + "math/rand" + "net" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/pion/rtcp" + "github.com/pion/rtp" +) + +type CursorWebmRecorder struct { + logger *getstream.DefaultLogger + outputPath string + conn *net.UDPConn + ffmpegCmd *exec.Cmd + stdin io.WriteCloser + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + + // Parsed from FFmpeg output: "Duration: N/A, start: , bitrate: N/A" + startOffsetMs int64 + hasStartOffset bool +} + +func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorWebmRecorder{ + logger: logger, + outputPath: outputPath, + ctx: ctx, + cancel: cancel, + } + + // Set up UDP connections + port := rand.Intn(10000) + 10000 + if err := r.setupConnections(port); err != nil { + cancel() + return nil, err + } + + // Start FFmpeg with codec detection + if err := r.startFFmpeg(outputPath, sdpContent, port); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +func (r *CursorWebmRecorder) setupConnections(port int) error { + // Setup connection + addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) + if err != nil { + return err + } + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + return err + } + r.conn = conn + + if e := r.conn.SetWriteBuffer(2048); e != nil { + r.logger.Error("Failed to set UDP write buffer: %v", e) + } + if e := r.conn.SetReadBuffer(2048); e != nil { + r.logger.Error("Failed to set UDP read buffer: %v", e) + } + + return nil +} + +func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port int) error { + + // Write SDP to a temporary file + sdpFile, err := os.CreateTemp("", "cursor_webm_*.sdp") + if err != nil { + return err + } + + updatedSdp := replaceSDP(sdpContent, port) + r.logger.Info("Using Sdp:\n%s\n", updatedSdp) + + if _, err := sdpFile.WriteString(updatedSdp); err != nil { + sdpFile.Close() + return err + } + sdpFile.Close() + + // Build FFmpeg command with optimized settings for single track recording + args := []string{ + "-threads", "1", + // "-loglevel", "debug", + "-protocol_whitelist", "file,udp,rtp", + "-buffer_size", "425984", + "-max_delay", "150000", + "-reorder_queue_size", "0", + "-i", sdpFile.Name(), + } + + //switch strings.ToLower(mimeType) { + //case "audio/opus": + // // For other codecs, use direct copy + args = append(args, "-c", "copy") + //default: + // // For other codecs, use direct copy + // args = append(args, "-c", "copy") + //} + //if isVP9 { + // // For VP9, avoid direct copy and use re-encoding with error resilience + // // This works around FFmpeg's experimental VP9 RTP support issues + // r.logger.Info("Detected VP9 codec, applying workarounds...") + // args = append(args, + // "-c:v", "libvpx-vp9", + // // "-error_resilience", "aggressive", + // "-err_detect", "ignore_err", + // "-fflags", "+genpts+igndts", + // "-avoid_negative_ts", "make_zero", + // // VP9-specific quality settings to handle corrupted frames + // "-crf", "30", + // "-row-mt", "1", + // "-frame-parallel", "1", + // ) + //} else if strings.Contains(strings.ToUpper(sdpContent), "AV1") { + // args = append(args, + // "-c:v", "libaom-av1", + // "-cpu-used", "8", + // "-usage", "realtime", + // ) + //} else if strings.Contains(strings.ToUpper(sdpContent), "OPUS") { + // args = append(args, "-fflags", "+genpts", "-use_wallclock_as_timestamps", "0", "-c:a", "copy") + //} else { + // // For other codecs, use direct copy + // args = append(args, "-c", "copy") + //} + + args = append(args, + "-y", + outputFilePath, + ) + + r.logger.Info("FFMpeg pipeline: %s", strings.Join(args, " ")) // Skip debug args for display + + r.ffmpegCmd = exec.Command("ffmpeg", args...) + + // Capture stdout/stderr to parse FFmpeg logs while mirroring to console + stdoutPipe, err := r.ffmpegCmd.StdoutPipe() + if err != nil { + return err + } + stderrPipe, err := r.ffmpegCmd.StderrPipe() + if err != nil { + return err + } + + // Create stdin pipe to send commands to FFmpeg + //var err error + r.stdin, err = r.ffmpegCmd.StdinPipe() + if err != nil { + fmt.Println("Error creating stdin pipe:", err) + } + + // Begin scanning output streams after process has started + go r.scanFFmpegOutput(stdoutPipe, false) + go r.scanFFmpegOutput(stderrPipe, true) + + // Start FFmpeg process + if err := r.ffmpegCmd.Start(); err != nil { + return err + } + + return nil +} + +// scanFFmpegOutput reads lines from FFmpeg output, mirrors to console, and extracts start offset. +func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { + scanner := bufio.NewScanner(reader) + re := regexp.MustCompile(`\bstart:\s*([0-9]+(?:\.[0-9]+)?)`) + for scanner.Scan() { + line := scanner.Text() + // Mirror output + if isStderr { + fmt.Fprintln(os.Stderr, line) + } else { + fmt.Fprintln(os.Stdout, line) + } + + // Try to extract the start value from those lines "Duration: N/A, start: 0.000000, bitrate: N/A" + if !strings.Contains(line, "Duration") || !strings.Contains(line, "bitrate") { + continue + } else if matches := re.FindStringSubmatch(line); len(matches) == 2 { + if v, parseErr := strconv.ParseFloat(matches[1], 64); parseErr == nil { + // Save only once + r.mu.Lock() + if !r.hasStartOffset { + r.startOffsetMs = int64(v * 1000) + r.hasStartOffset = true + r.logger.Info("Detected FFmpeg start offset: %.6f seconds", v) + } + r.mu.Unlock() + } + } + } + _ = scanner.Err() +} + +// StartOffset returns the parsed FFmpeg start offset in seconds and whether it was found. +func (r *CursorWebmRecorder) StartOffset() (int64, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.startOffsetMs, r.hasStartOffset +} + +func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { + // Marshal RTP packet + buf, err := packet.Marshal() + if err != nil { + return err + } + + return r.PushRtpBuf(buf) +} + +func (r *CursorWebmRecorder) PushRtpBuf(buf []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Send RTP packet over UDP + if r.conn != nil { + r.conn.SetWriteDeadline(time.Now().Add(1000 * time.Microsecond)) + _, err := r.conn.Write(buf) + if err != nil { + // return err) + //} + r.logger.Info("Wrote packet to %s - %v", r.conn.LocalAddr().String(), err) + } + } + return nil +} + +func (r *CursorWebmRecorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + // Cancel context to stop background goroutines + if r.cancel != nil { + r.cancel() + } + + r.logger.Info("Closing UPD connection...") + + // Close UDP connection by sending arbitrary RtcpBye (Ffmpeg is no able to end correctly) + if r.conn != nil { + buf, _ := rtcp.Goodbye{ + Sources: []uint32{1}, // fixed ssrc is ok + Reason: "bye", + }.Marshal() + _, _ = r.conn.Write(buf) + _ = r.conn.Close() + r.conn = nil + } + + r.logger.Info("UDP Connection closed...") + + time.Sleep(5 * time.Second) + + r.logger.Info("After sleep...") + + // Gracefully stop FFmpeg + if r.ffmpegCmd != nil && r.ffmpegCmd.Process != nil { + + // ✅ Gracefully stop FFmpeg by sending 'q' to stdin + //fmt.Println("Sending 'q' to FFmpeg...") + //_, _ = r.stdin.Write([]byte("q\n")) + //r.stdin.Close() + + // Send interrupt signal to FFmpeg process + r.logger.Info("Sending SIGTERM...") + + //if err := r.ffmpegCmd.Process.Signal(os.Interrupt); err != nil { + // // If interrupt fails, force kill + // r.ffmpegCmd.Process.Kill() + //} else { + + r.logger.Info("Waiting for SIGTERM...") + + // Wait for graceful exit with timeout + done := make(chan error, 1) + go func() { + done <- r.ffmpegCmd.Wait() + }() + + select { + case <-time.After(10 * time.Second): + r.logger.Info("Wait timetout for SIGTERM...") + + // Timeout, force kill + r.ffmpegCmd.Process.Kill() + case <-done: + r.logger.Info("Process exited succesfully SIGTERM...") + // Process exited gracefully + } + } + + return nil +} diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go b/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go new file mode 100644 index 0000000..0d7d70f --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go @@ -0,0 +1,176 @@ +package processing + +import ( + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "github.com/GetStream/getstream-go/v3" +) + +const TmpDir = "/tmp" + +type FileOffset struct { + Name string + Offset int64 +} + +func concatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { + // Write to a temporary file + tmpFile, err := os.CreateTemp(TmpDir, "concat_*.txt") + if err != nil { + return err + } + defer func() { + tmpFile.Close() + // _ = os.Remove(concatFile.Name()) + }() + + for _, file := range files { + if _, err := tmpFile.WriteString(fmt.Sprintf("file '%s'\n", file)); err != nil { + return err + } + } + + args := []string{} + args = append(args, "-f", "concat") + args = append(args, "-safe", "0") + args = append(args, "-i", tmpFile.Name()) + args = append(args, "-c", "copy") + args = append(args, outputPath) + return runFFMEPGCpmmand(args, logger) +} + +func muxFiles(fileName string, audioFile string, videoFile string, offsetMs float64, logger *getstream.DefaultLogger) error { + args := []string{} + + // Apply offset using itsoffset + // If offset is positive (video ahead), delay audio + // If offset is negative (audio ahead), delay video + if offsetMs != 0 { + offsetSeconds := offsetMs / 1000.0 + + if offsetMs > 0 { + // Video is ahead, delay audio + args = append(args, "-itsoffset", fmt.Sprintf("%.3f", offsetSeconds)) + args = append(args, "-i", audioFile) + args = append(args, "-i", videoFile) + } else { + args = append(args, "-i", audioFile) + args = append(args, "-itsoffset", fmt.Sprintf("%.3f", -offsetSeconds)) + args = append(args, "-i", videoFile) + } + } else { + args = append(args, "-i", audioFile) + args = append(args, "-i", videoFile) + } + + args = append(args, "-map", "0:a") + args = append(args, "-map", "1:v") + args = append(args, "-c", "copy") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func mixAudioFiles(fileName string, files []*FileOffset, logger *getstream.DefaultLogger) error { + var args []string + + var filterParts []string + var mixParts []string + + sort.Slice(files, func(i, j int) bool { + return files[i].Offset < files[j].Offset + }) + + var offsetToAdd int64 + for i, fo := range files { + args = append(args, "-i", fo.Name) + + if i == 0 { + offsetToAdd = -fo.Offset + } + offset := fo.Offset + offsetToAdd + + if offset > 0 { + // for stereo: offset|offset + label := fmt.Sprintf("a%d", i) + filterParts = append(filterParts, + fmt.Sprintf("[%d:a]adelay=%d|%d[%s]", i, offset, offset, label)) + mixParts = append(mixParts, fmt.Sprintf("[%s]", label)) + } else { + mixParts = append(mixParts, fmt.Sprintf("[%d:a]", i)) + } + } + + // Build amix filter + filter := strings.Join(filterParts, "; ") + if filter != "" { + filter += "; " + } + filter += strings.Join(mixParts, "") + + fmt.Sprintf("amix=inputs=%d:normalize=0", len(files)) + + args = append(args, "-filter_complex", filter) + args = append(args, "-c:a", "libopus") + args = append(args, "-b:a", "128k") + args = append(args, fileName) + + fmt.Println(strings.Join(args, " ")) + + return runFFMEPGCpmmand(args, logger) +} + +func generateSilence(fileName string, duration float64, logger *getstream.DefaultLogger) error { + args := []string{} + args = append(args, "-f", "lavfi") + args = append(args, "-t", fmt.Sprintf("%.3f", duration)) + args = append(args, "-i", "anullsrc=cl=stereo:r=48000") + args = append(args, "-c:a", "libopus") + args = append(args, "-b:a", "32k") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func generateBlackVideo(fileName, mimeType string, duration float64, width, height, frameRate int, logger *getstream.DefaultLogger) error { + var codecLib string + switch strings.ToLower(mimeType) { + case "video/vp8": + codecLib = "libvpx-vp9" + case "video/vp9": + codecLib = "libvpx-vp9" + case "video/h264": + codecLib = "libh264" + case "video/av1": + codecLib = "libav1" + } + + args := []string{} + args = append(args, "-f", "lavfi") + args = append(args, "-t", fmt.Sprintf("%.3f", duration)) + args = append(args, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", width, height, frameRate)) + args = append(args, "-c:v", codecLib) + args = append(args, "-b:v", "1M") + args = append(args, fileName) + + return runFFMEPGCpmmand(args, logger) +} + +func runFFMEPGCpmmand(args []string, logger *getstream.DefaultLogger) error { + cmd := exec.Command("ffmpeg", args...) + + // Capture output for debugging + output, err := cmd.CombinedOutput() + if err != nil { + logger.Error("FFmpeg command failed: %v", err) + logger.Error("FFmpeg output: %s", string(output)) + return fmt.Errorf("ffmpeg command failed: %w", err) + } + + logger.Info("Successfully ran ffmpeg: %s", args) + logger.Debug("FFmpeg output: %s", string(output)) + return nil +} diff --git a/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go b/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go new file mode 100644 index 0000000..06eed03 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go @@ -0,0 +1,473 @@ +package processing + +import ( + "context" + "encoding/binary" + "fmt" + "math/rand" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/GetStream/getstream-go/v3" + "github.com/pion/rtp" +) + +type CursorGstreamerWebmRecorder struct { + logger *getstream.DefaultLogger + outputPath string + rtpConn net.Conn + gstreamerCmd *exec.Cmd + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + port int + sdpFile *os.File + finalOutputPath string // Path for post-processed file with duration + tempOutputPath string // Path for temporary file before post-processing +} + +func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorGstreamerWebmRecorder, error) { + ctx, cancel := context.WithCancel(context.Background()) + + r := &CursorGstreamerWebmRecorder{ + logger: logger, + outputPath: outputPath, + ctx: ctx, + cancel: cancel, + } + + // Choose TCP listen port for GStreamer tcpserversrc + r.port = rand.Intn(10000) + 10000 + + // Start GStreamer with codec detection + if err := r.startGStreamer(sdpContent, outputPath); err != nil { + cancel() + return nil, err + } + + // Establish TCP client connection to the local tcpserversrc + if err := r.setupConnections(r.port); err != nil { + cancel() + return nil, err + } + + return r, nil +} + +func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { + // Setup TCP connection with retry to match GStreamer tcpserversrc readiness + address := "127.0.0.1:" + strconv.Itoa(port) + deadline := time.Now().Add(10 * time.Second) + var conn net.Conn + var err error + for { + conn, err = net.DialTimeout("tcp", address, 500*time.Millisecond) + if err == nil { + break + } + if time.Now().After(deadline) { + return fmt.Errorf("failed to connect to tcpserversrc at %s: %w", address, err) + } + time.Sleep(100 * time.Millisecond) + } + r.rtpConn = conn + return nil +} + +func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath string) error { + // Parse SDP to determine RTP caps for rtpstreamdepay + media, encodingName, payloadType, clockRate := parseRtpCapsFromSDP(sdpContent) + r.logger.Info("Starting TCP-based GStreamer pipeline (media=%s, encoding=%s, payload=%d, clock-rate=%d)", media, encodingName, payloadType, clockRate) + + // Determine codec from SDP content and build GStreamer arguments + isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") + isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") + isAV1 := strings.Contains(strings.ToUpper(sdpContent), "AV1") + isH264 := strings.Contains(strings.ToUpper(sdpContent), "H264") || strings.Contains(strings.ToUpper(sdpContent), "H.264") + isOpus := strings.Contains(strings.ToUpper(sdpContent), "OPUS") + + // Start with common GStreamer arguments optimized for RTP dump replay + args := []string{ + //"--gst-debug-level=3", + //"--gst-debug=tcpserversrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", + //"--gst-debug-no-color", + "-e", // Send EOS on interrupt for clean shutdown + } + // Source from TCP (RFC4571 framed) and depayload back to application/x-rtp + args = append(args, + "tcpserversrc", + "host=127.0.0.1", + fmt.Sprintf("port=%d", r.port), + "name=tcp_in", + "!", + "queue", + "max-size-buffers=0", + "max-size-bytes=268435456", + "max-size-time=0", + "leaky=0", + "!", + // Ensure rtpstreamdepay sink has caps + "application/x-rtp-stream", + "!", + "rtpstreamdepay", + "!", + fmt.Sprintf("application/x-rtp,media=%s,encoding-name=%s,clock-rate=%d,payload=%d", media, encodingName, clockRate, payloadType), + "!", + ) + + // Build pipeline based on codec with simplified RTP timestamp handling for dump replay + // + // Simplified approach for RTP dump replay: + // - rtpjitterbuffer: Basic packet reordering with minimal interference + // - latency=0: No artificial latency, process packets as they come + // - mode=none: Don't override timing, let depayloaders handle it + // - do-retransmission=false: No retransmission for dump replay + // - Remove identity sync to avoid timing conflicts + // + // This approach focuses on preserving original RTP timestamps without + // artificial buffering that can interfere with dump replay timing. + if false && isH264 { + r.logger.Info("Detected H.264 codec, building H.264 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtph264depay", "!", + "h264parse", "!", + "mp4mux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if false && isVP9 { + r.logger.Info("Detected VP9 codec, building VP9 pipeline with timestamp handling...") + args = append(args, + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", + "drop-on-latency=false", + "buffer-mode=slave", + "max-dropout-time=5000000000", + "max-reorder-delay=1000000000", + "!", + "rtpvp9depay", "!", + "vp9parse", "!", + "webmmux", + "writing-app=GStreamer-VP9", + "streamable=false", + "min-index-interval=2000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if isVP9 { + r.logger.Info("Detected VP9 codec, building VP9 pipeline with RTP timestamp handling...") + args = append(args, + + //// jitterbuffer for packet reordering and timestamp handling + "rtpjitterbuffer", + "name=jitterbuffer", + "mode=none", + "latency=0", // No artificial latency - process immediately + "do-lost=false", // Don't generate lost events for missing packets + "do-retransmission=false", // No retransmission for offline replay + "drop-on-latency=false", // Keep all packets even if late + "!", + // + // Depayload RTP to get VP9 frames + "rtpvp9depay", + "!", + + // Parse VP9 stream to ensure valid frame structure + "vp9parse", + "!", + + // Queue for buffering + "queue", + "!", + + // Mux into Matroska/WebM container + "webmmux", + "writing-app=GStreamer-VP9", + "streamable=false", + "min-index-interval=2000000000", + "!", + + // Write to file + "filesink", + fmt.Sprintf("location=%s", outputFilePath), + ) + + } else if false && isVP8 { + r.logger.Info("Detected VP8 codec, building VP8 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if false && isAV1 { + r.logger.Info("Detected AV1 codec, building AV1 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpav1depay", "!", + "av1parse", "!", + "webmmux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if false && isOpus { + r.logger.Info("Detected Opus codec, building Opus pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload=111", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpopusdepay", "!", + "opusparse", "!", + "webmmux", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } else if false { + // Default to VP8 if codec is not detected + r.logger.Info("Unknown or no codec detected, defaulting to VP8 pipeline with timestamp handling...") + args = append(args, + "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", + "rtpjitterbuffer", + "latency=0", + "mode=none", + "do-retransmission=false", "!", + "rtpvp8depay", "!", + "vp8parse", "!", + "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", + "filesink", fmt.Sprintf("location=%s", outputFilePath), + ) + } + + r.logger.Info("GStreamer pipeline: %s", strings.Join(args, " ")) // Skip debug args for display + + r.gstreamerCmd = exec.Command("gst-launch-1.0", args...) + // Redirect output for debugging + r.gstreamerCmd.Stdout = os.Stdout + r.gstreamerCmd.Stderr = os.Stderr + + // Start GStreamer process + if err := r.gstreamerCmd.Start(); err != nil { + return err + } + + r.logger.Info("GStreamer pipeline started with PID: %d", r.gstreamerCmd.Process.Pid) + + // Monitor the process in a goroutine + go func() { + if err := r.gstreamerCmd.Wait(); err != nil { + r.logger.Error("GStreamer process exited with error: %v", err) + } else { + r.logger.Info("GStreamer process exited normally") + } + }() + + return nil +} + +// parseRtpCapsFromSDP extracts basic RTP caps from an SDP for use with application/x-rtp caps +// Prioritizes video codecs (H264/VP9/VP8/AV1) over audio (OPUS) and parses payload/clock-rate +func parseRtpCapsFromSDP(sdp string) (media string, encodingName string, payload int, clockRate int) { + upper := strings.ToUpper(sdp) + + // Defaults + media = "video" + encodingName = "VP9" + payload = 96 + clockRate = 90000 + + // Select target encoding with priority: H264 > VP9 > VP8 > AV1 > OPUS (audio) + if strings.Contains(upper, "H264") || strings.Contains(upper, "H.264") { + encodingName = "H264" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "VP9") { + encodingName = "VP9" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "VP8") { + encodingName = "VP8" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "AV1") { + encodingName = "AV1" + media = "video" + clockRate = 90000 + } else if strings.Contains(upper, "OPUS") { + encodingName = "OPUS" + media = "audio" + clockRate = 48000 + } + + // Parse matching a=rtpmap for the chosen encoding to refine payload and clock + chosen := encodingName + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToLower(line), "a=rtpmap:") { + continue + } + // Example: a=rtpmap:96 VP9/90000 + after := strings.TrimSpace(line[len("a=rtpmap:"):]) + fields := strings.Fields(after) + if len(fields) < 2 { + continue + } + ptStr := fields[0] + codec := strings.ToUpper(fields[1]) + parts := strings.Split(codec, "/") + name := parts[0] + if name != chosen { + continue + } + if v, err := strconv.Atoi(ptStr); err == nil { + payload = v + } + if len(parts) >= 2 { + if v, err := strconv.Atoi(parts[1]); err == nil { + clockRate = v + } + } + break + } + + return +} + +func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { + // Marshal RTP packet + buf, err := packet.Marshal() + if err != nil { + return err + } + + return r.PushRtpBuf(buf) +} + +func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Send RTP packet over TCP using RFC4571 2-byte length prefix + if r.rtpConn != nil { + if len(buf) > 0xFFFF { + return fmt.Errorf("rtp packet too large for TCP framing: %d bytes", len(buf)) + } + header := make([]byte, 2) + binary.BigEndian.PutUint16(header, uint16(len(buf))) + if _, err := r.rtpConn.Write(header); err != nil { + r.logger.Warn("Failed to write RTP length header: %v", err) + return err + } + if _, err := r.rtpConn.Write(buf); err != nil { + r.logger.Warn("Failed to write RTP packet: %v", err) + return err + } + } + return nil +} + +func (r *CursorGstreamerWebmRecorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + r.logger.Info("Closing GStreamer WebM recorder...") + + r.logger.Info("Closing GStreamer WebM recorder2222...") + + // Cancel context to stop background goroutines + if r.cancel != nil { + r.cancel() + } + + // Close TCP connection + if r.rtpConn != nil { + r.logger.Info("Closing TCP connection...") + _ = r.rtpConn.Close() + r.rtpConn = nil + r.logger.Info("TCP connection closed") + } + + // Gracefully stop GStreamer + if r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil { + r.logger.Info("Stopping GStreamer process...") + + // Send EOS (End of Stream) signal to GStreamer + // GStreamer handles SIGINT gracefully and will finish writing the file + if err := r.gstreamerCmd.Process.Signal(os.Interrupt); err != nil { + r.logger.Error("Failed to send SIGINT to GStreamer: %v", err) + // If interrupt fails, force kill + r.gstreamerCmd.Process.Kill() + } else { + r.logger.Info("Sent SIGINT to GStreamer, waiting for graceful exit...") + + // Wait for graceful exit with timeout + done := make(chan error, 1) + go func() { + done <- r.gstreamerCmd.Wait() + }() + + select { + case <-time.After(15 * time.Second): + r.logger.Info("GStreamer exit timeout, force killing...") + // Timeout, force kill + r.gstreamerCmd.Process.Kill() + <-done // Wait for the kill to complete + case err := <-done: + if err != nil { + r.logger.Info("GStreamer exited with error: %v", err) + } else { + r.logger.Info("GStreamer exited gracefully") + } + } + } + } + + // Clean up temporary SDP file + if r.sdpFile != nil { + os.Remove(r.sdpFile.Name()) + r.sdpFile = nil + } + + // Post-process WebM to fix duration metadata if needed + if r.tempOutputPath != "" && r.finalOutputPath != "" { + r.logger.Info("Starting WebM duration post-processing...") + } + + r.logger.Info("GStreamer WebM recorder closed") + return nil +} + +// GetOutputPath returns the output file path (for compatibility) +func (r *CursorGstreamerWebmRecorder) GetOutputPath() string { + // Return final output path if post-processing is enabled, otherwise return original + if r.finalOutputPath != "" { + return r.finalOutputPath + } + return r.outputPath +} + +// IsRecording returns true if the recorder is currently active +func (r *CursorGstreamerWebmRecorder) IsRecording() bool { + r.mu.Lock() + defer r.mu.Unlock() + + return r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil +} diff --git a/pkg/cmd/raw-recording-tool/processing/sdp_tool.go b/pkg/cmd/raw-recording-tool/processing/sdp_tool.go new file mode 100644 index 0000000..ed61c08 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/sdp_tool.go @@ -0,0 +1,55 @@ +package processing + +import ( + "fmt" + "os" + "strings" + + "github.com/pion/webrtc/v4" +) + +func readSDP(sdpFilePath string) (string, error) { + content, err := os.ReadFile(sdpFilePath) + if err != nil { + return "", fmt.Errorf("failed to read SDP file %s: %w", sdpFilePath, err) + } + return string(content), nil +} + +func replaceSDP(sdpContent string, port int) string { + lines := strings.Split(sdpContent, "\n") + for i, line := range lines { + if strings.HasPrefix(line, "m=") { + // Parse the m= line: m= RTP/AVP + parts := strings.Fields(line) + if len(parts) >= 4 { + // Replace the port (second field) + parts[1] = fmt.Sprintf("%d", port) + lines[i] = strings.Join(parts, " ") + break + } + } + } + return strings.Join(lines, "\n") +} + +func mimeType(sdp string) (string, error) { + upper := strings.ToUpper(sdp) + if strings.Contains(upper, "VP9") { + return webrtc.MimeTypeVP9, nil + } + if strings.Contains(upper, "VP8") { + return webrtc.MimeTypeVP8, nil + } + if strings.Contains(upper, "AV1") { + return webrtc.MimeTypeAV1, nil + } + if strings.Contains(upper, "OPUS") { + return webrtc.MimeTypeOpus, nil + } + if strings.Contains(upper, "H264") { + return webrtc.MimeTypeH264, nil + } + + return "", fmt.Errorf("mimeType should be OPUS, VP8, VP9, AV1, H264") +} diff --git a/pkg/cmd/raw-recording-tool/processing/track_extractor.go b/pkg/cmd/raw-recording-tool/processing/track_extractor.go new file mode 100644 index 0000000..4aba07c --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/track_extractor.go @@ -0,0 +1,127 @@ +package processing + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GetStream/getstream-go/v3" + "github.com/pion/webrtc/v4" +) + +// Generic track extraction function that works for both audio and video +func ExtractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { + // Filter tracks to specified type only and apply hierarchical filtering + filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) + if len(filteredTracks) == 0 { + logger.Warn("No %s tracks found matching the filter criteria", trackType) + return nil + } + + logger.Info("Found %d %s tracks to extract", len(filteredTracks), trackType) + + // Extract and convert each track + for i, track := range filteredTracks { + logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(filteredTracks), track.TrackID) + + err := extractSingleTrackWithOptions(workingDir, track, outputDir, trackType, fillGaps, fixDtx, logger) + if err != nil { + logger.Error("Failed to extract %s track %s: %v", trackType, track.TrackID, err) + continue + } + } + + return nil +} + +func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { + accept := func(path string, info os.FileInfo) (*SegmentInfo, bool) { + for _, s := range track.Segments { + if strings.Contains(info.Name(), s.metadata.BaseFilename) { + if track.Codec == webrtc.MimeTypeH264 { + s.ContainerExt = Mp4 + } else { + s.ContainerExt = Webm + } + s.RtpDumpPath = path + s.SdpPath = strings.Replace(path, SuffixRtpDump, SuffixSdp, -1) + s.ContainerPath = strings.Replace(path, SuffixRtpDump, "."+s.ContainerExt, -1) + return s, true + } + } + return nil, false + } + + // Convert using the WebM converter + err := ConvertDirectory(inputPath, accept, fixDtx, logger) + if err != nil { + return fmt.Errorf("failed to convert %s track: %w", trackType, err) + } + + // Create segments with timing info and fill gaps + finalFile, err := processSegmentsWithGapFilling(track, trackType, outputDir, fillGaps, logger) + if err != nil { + return fmt.Errorf("failed to process segments with gap filling: %w", err) + } + + track.ConcatenatedContainerPath = finalFile + logger.Info("Successfully extracted %s track to: %s", trackType, finalFile) + return nil +} + +// processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file +func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { + // Build list of files to concatenate (with optional gap fillers) + var filesToConcat []string + for i, segment := range track.Segments { + // Add the segment file + filesToConcat = append(filesToConcat, segment.ContainerPath) + + // Add gap filler if requested and there's a gap before the next segment + if fillGaps && i < track.SegmentCount-1 { + nextSegment := track.Segments[i+1] + gapDuration := nextSegment.FFMpegOffset + firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) + + if gapDuration > 0 { // There's a gap + gapSeconds := float64(gapDuration) / 1000.0 + logger.Info("Detected %dms gap between segments, generating %s filler", gapDuration, trackType) + + // Create gap filler file + gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, segment.ContainerExt)) + + if trackType == "audio" { + err := generateSilence(gapFilePath, gapSeconds, logger) + if err != nil { + logger.Warn("Failed to generate silence, skipping gap: %v", err) + continue + } + } else if trackType == "video" { + // Use 720p quality as defaults + err := generateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) + if err != nil { + logger.Warn("Failed to generate black video, skipping gap: %v", err) + continue + } + } + + defer os.Remove(gapFilePath) + + filesToConcat = append(filesToConcat, gapFilePath) + } + } + } + + // Create final output file + finalName := fmt.Sprintf("%s_%s_%s_%s.%s", trackType, track.UserID, track.SessionID, track.TrackID, track.Segments[0].ContainerExt) + finalPath := filepath.Join(outputDir, finalName) + + // Concatenate all segments (with gap fillers if any) + err := concatFile(finalPath, filesToConcat, logger) + if err != nil { + return "", fmt.Errorf("failed to concatenate segments: %w", err) + } + + logger.Info("Successfully concatenated %d segments into %s (gap filled %t)", track.SegmentCount, finalPath, fillGaps) + return finalPath, nil +} From 20d42dca98b20441275cbe806dd2989ed82b43ad Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Mon, 19 Jan 2026 17:40:38 +0100 Subject: [PATCH 02/18] feat: update Video RawRecording CLI with cobra command --- pkg/cmd/raw-recording-tool/completion.go | 299 ------------------ pkg/cmd/raw-recording-tool/extract_audio.go | 185 ++++++----- pkg/cmd/raw-recording-tool/extract_video.go | 176 ++++++----- pkg/cmd/raw-recording-tool/list_tracks.go | 193 +++++------- pkg/cmd/raw-recording-tool/main.go | 325 -------------------- pkg/cmd/raw-recording-tool/mix_audio.go | 103 +++---- pkg/cmd/raw-recording-tool/mux_av.go | 184 ++++++----- pkg/cmd/raw-recording-tool/process_all.go | 176 ++++++----- pkg/cmd/raw-recording-tool/root.go | 294 ++++++++++++++++++ pkg/cmd/root/root.go | 2 + pkg/cmd/video/root.go | 16 + 11 files changed, 843 insertions(+), 1110 deletions(-) delete mode 100644 pkg/cmd/raw-recording-tool/completion.go delete mode 100644 pkg/cmd/raw-recording-tool/main.go create mode 100644 pkg/cmd/raw-recording-tool/root.go create mode 100644 pkg/cmd/video/root.go diff --git a/pkg/cmd/raw-recording-tool/completion.go b/pkg/cmd/raw-recording-tool/completion.go deleted file mode 100644 index 3ead091..0000000 --- a/pkg/cmd/raw-recording-tool/completion.go +++ /dev/null @@ -1,299 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -// generateCompletion generates shell completion scripts -func generateCompletion(shell string) { - switch shell { - case "bash": - generateBashCompletion() - case "zsh": - generateZshCompletion() - case "fish": - generateFishCompletion() - default: - _, _ = fmt.Fprintf(os.Stderr, "Unsupported shell: %s\n", shell) - _, _ = fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") - os.Exit(1) - } -} - -// generateBashCompletion generates bash completion script -func generateBashCompletion() { - script := `#!/bin/bash - -_raw_tools_completion() { - local cur prev words cword - _init_completion || return - - # Complete subcommands - if [[ $cword -eq 1 ]]; then - COMPREPLY=($(compgen -W "list-tracks extract-audio extract-video mux-av help" -- "$cur")) - return - fi - - local cmd="${words[1]}" - - case "$prev" in - --inputFile) - COMPREPLY=($(compgen -f -X "!*.zip" -- "$cur")) - return - ;; - --output) - COMPREPLY=($(compgen -d -- "$cur")) - return - ;; - --format) - case "$cmd" in - list-tracks) - COMPREPLY=($(compgen -W "table json completion users sessions tracks" -- "$cur")) - ;; - esac - return - ;; - --trackType) - COMPREPLY=($(compgen -W "audio video" -- "$cur")) - return - ;; - --userId|--sessionId|--trackId) - # Dynamic completion using list-tracks - if [[ -n "${_RAW_TOOLS_INPUT_FILE:-}" ]]; then - local completion_type="" - case "$prev" in - --userId) completion_type="users" ;; - --sessionId) completion_type="sessions" ;; - --trackId) completion_type="tracks" ;; - esac - if [[ -n "$completion_type" ]]; then - local values=$(raw-tools --inputFile "$_RAW_TOOLS_INPUT_FILE" --output /tmp list-tracks --format "$completion_type" 2>/dev/null) - COMPREPLY=($(compgen -W "$values" -- "$cur")) - fi - else - COMPREPLY=() - fi - return - ;; - esac - - # Complete global flags - local global_flags="--inputFile --inputS3 --output --verbose --help" - local cmd_flags="" - - case "$cmd" in - list-tracks) - cmd_flags="--format --trackType --completionType" - ;; - extract-audio|extract-video) - cmd_flags="--userId --sessionId --trackId --fill_gaps" - ;; - mux-av) - cmd_flags="--userId --sessionId --trackId --media" - ;; - mix-audio) - cmd_flags="" - ;; - esac - - COMPREPLY=($(compgen -W "$global_flags $cmd_flags" -- "$cur")) -} - -# Store input file for dynamic completion -_raw_tools_set_input_file() { - local i - for (( i=1; i < ${#COMP_WORDS[@]}; i++ )); do - if [[ "${COMP_WORDS[i]}" == "--inputFile" && i+1 < ${#COMP_WORDS[@]} ]]; then - export _RAW_TOOLS_INPUT_FILE="${COMP_WORDS[i+1]}" - break - fi - done -} - -# Hook to set input file before completion -complete -F _raw_tools_completion raw-tools - -# Wrapper to set input file -_raw_tools_wrapper() { - _raw_tools_set_input_file - _raw_tools_completion "$@" -} - -complete -F _raw_tools_wrapper raw-tools` - - fmt.Println(script) -} - -// generateZshCompletion generates zsh completion script -func generateZshCompletion() { - script := `#compdef raw-tools - -_raw_tools() { - local context state line - typeset -A opt_args - - _arguments -C \ - '1: :_raw_tools_commands' \ - '*:: :->args' - - case $state in - args) - case $words[1] in - list-tracks) - _raw_tools_list_tracks - ;; - extract-audio|extract-video) - _raw_tools_extract - ;; - mux-av) - _raw_tools_mux_av - ;; - esac - ;; - esac -} - -_raw_tools_commands() { - local commands=( - 'list-tracks:List all tracks with metadata' - 'extract-audio:Generate playable audio files' - 'extract-video:Generate playable video files' - 'mux-av:Mux audio and video tracks' - 'help:Show help' - ) - _describe 'commands' commands -} - -_raw_tools_global_args() { - _arguments \ - '--inputFile[Specify raw recording zip file]:file:_files -g "*.zip"' \ - '--inputS3[Specify raw recording zip file on S3]:s3path:' \ - '--output[Specify output directory]:directory:_directories' \ - '--verbose[Enable verbose logging]' \ - '--help[Show help]' -} - -_raw_tools_list_tracks() { - _arguments \ - '--format[Output format]:format:(table json completion users sessions tracks)' \ - '--trackType[Filter by track type]:type:(audio video)' \ - '--completionType[Completion type]:type:(users sessions tracks)' \ - '*: :_raw_tools_global_args' -} - -_raw_tools_extract() { - _arguments \ - '--userId[User ID filter]:userid:_raw_tools_complete_users' \ - '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ - '--trackId[Track ID filter]:trackid:_raw_tools_complete_tracks' \ - '--fill_gaps[Fill gaps with silence/black frames]' \ - '*: :_raw_tools_global_args' -} - -_raw_tools_mux_av() { - _arguments \ - '--userId[User ID filter]:userid:_raw_tools_complete_users' \ - '--sessionId[Session ID filter]:sessionid:_raw_tools_complete_sessions' \ - '--trackId[Track ID filter]:trackid:_raw_tools_complete_tracks' \ - '--media[Media type]:media:(user display both)' \ - '*: :_raw_tools_global_args' -} -// no mix-audio specific flags - -# Dynamic completion helpers -_raw_tools_complete_users() { - local input_file - for ((i=1; i <= $#words; i++)); do - if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then - input_file=$words[i+1] - break - fi - done - - if [[ -n "$input_file" ]]; then - local users=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format users 2>/dev/null)) - _wanted users expl 'user ID' compadd "$@" $users - else - _wanted users expl 'user ID' compadd "$@" - fi -} - -_raw_tools_complete_sessions() { - local input_file - for ((i=1; i <= $#words; i++)); do - if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then - input_file=$words[i+1] - break - fi - done - - if [[ -n "$input_file" ]]; then - local sessions=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format sessions 2>/dev/null)) - _wanted sessions expl 'session ID' compadd "$@" $sessions - else - _wanted sessions expl 'session ID' compadd "$@" - fi -} - -_raw_tools_complete_tracks() { - local input_file - for ((i=1; i <= $#words; i++)); do - if [[ $words[i] == "--inputFile" && i+1 <= $#words ]]; then - input_file=$words[i+1] - break - fi - done - - if [[ -n "$input_file" ]]; then - local tracks=($(raw-tools --inputFile "$input_file" --output /tmp list-tracks --format tracks 2>/dev/null)) - _wanted tracks expl 'track ID' compadd "$@" $tracks - else - _wanted tracks expl 'track ID' compadd "$@" - fi -} - -_raw_tools "$@"` - - fmt.Println(script) -} - -// generateFishCompletion generates fish completion script -func generateFishCompletion() { - script := `# Fish completion for raw-tools - -# Complete commands -complete -c raw-tools -f -n '__fish_use_subcommand' -a 'list-tracks' -d 'List all tracks with metadata' -complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-audio' -d 'Generate playable audio files' -complete -c raw-tools -f -n '__fish_use_subcommand' -a 'extract-video' -d 'Generate playable video files' -complete -c raw-tools -f -n '__fish_use_subcommand' -a 'mux-av' -d 'Mux audio and video tracks' -complete -c raw-tools -f -n '__fish_use_subcommand' -a 'help' -d 'Show help' - -# Global options -complete -c raw-tools -l inputFile -d 'Specify raw recording zip file' -r -F -complete -c raw-tools -l inputS3 -d 'Specify raw recording zip file on S3' -r -complete -c raw-tools -l output -d 'Specify output directory' -r -a '(__fish_complete_directories)' -complete -c raw-tools -l verbose -d 'Enable verbose logging' -complete -c raw-tools -l help -d 'Show help' - -# list-tracks specific options -complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l format -d 'Output format' -r -a 'table json completion users sessions tracks' -complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l trackType -d 'Filter by track type' -r -a 'audio video' -complete -c raw-tools -n '__fish_seen_subcommand_from list-tracks' -l completionType -d 'Completion type' -r -a 'users sessions tracks' - -# extract commands specific options -complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l userId -d 'User ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l sessionId -d 'Session ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l trackId -d 'Track ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from extract-audio extract-video' -l fill_gaps -d 'Fill gaps' - -# mux-av specific options -complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l userId -d 'User ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l sessionId -d 'Session ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l trackId -d 'Track ID filter' -r -complete -c raw-tools -n '__fish_seen_subcommand_from mux-av' -l media -d 'Media type' -r -a 'user display both' - -# mix-audio has no command-specific options` - - fmt.Println(script) -} diff --git a/pkg/cmd/raw-recording-tool/extract_audio.go b/pkg/cmd/raw-recording-tool/extract_audio.go index 67fa339..3ebd65b 100644 --- a/pkg/cmd/raw-recording-tool/extract_audio.go +++ b/pkg/cmd/raw-recording-tool/extract_audio.go @@ -1,116 +1,133 @@ -package main +package rawrecording import ( - "flag" "fmt" "os" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -type ExtractAudioArgs struct { - UserID string - SessionID string - TrackID string - FillGaps bool - FixDtx bool -} +func extractAudioCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "extract-audio", + Short: "Generate playable audio files from raw recording tracks", + Long: heredoc.Doc(` + Generate playable audio files from raw recording tracks. -type ExtractAudioProcess struct { - logger *getstream.DefaultLogger -} + Supports formats: webm, mp3, and others. + + Filters are mutually exclusive: you can only specify one of + --user-id, --session-id, or --track-id at a time. + `), + Example: heredoc.Doc(` + # Extract audio for all users (no filters) + $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out + + # Extract audio for specific user (all their tracks) + $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --user-id user123 + + # Extract audio for specific session + $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --session-id session456 + + # Extract a specific track + $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --track-id track1 + `), + RunE: runExtractAudio, + } + + fl := cmd.Flags() + fl.String("user-id", "", "Filter by user ID") + fl.String("session-id", "", "Filter by session ID") + fl.String("track-id", "", "Filter by track ID") + fl.Bool("fill-gaps", true, "Fill with silence when track was muted") + fl.Bool("fix-dtx", true, "Fix DTX shrink audio") + + // Register completions + _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) -func NewExtractAudioProcess(logger *getstream.DefaultLogger) *ExtractAudioProcess { - return &ExtractAudioProcess{logger: logger} + return cmd } -func (p *ExtractAudioProcess) runExtractAudio(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) - - // Parse command-specific flags - fs := flag.NewFlagSet("extract-audio", flag.ExitOnError) - extractAudioArgs := &ExtractAudioArgs{} - fs.StringVar(&extractAudioArgs.UserID, "userId", "", "Specify a userId (empty for all)") - fs.StringVar(&extractAudioArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") - fs.StringVar(&extractAudioArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - fs.BoolVar(&extractAudioArgs.FillGaps, "fill_gaps", true, "Fill with silence when track was muted (default true)") - fs.BoolVar(&extractAudioArgs.FixDtx, "fix_dtx", true, "Fix DTX shrink audio (default true)") - - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) +func runExtractAudio(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } + + // Validate global args (output is required for extract-audio) + if err := validateGlobalArgs(globalArgs, true); err != nil { + return err } + userID, _ := cmd.Flags().GetString("user-id") + sessionID, _ := cmd.Flags().GetString("session-id") + trackID, _ := cmd.Flags().GetString("track-id") + fillGaps, _ := cmd.Flags().GetBool("fill-gaps") + fixDtx, _ := cmd.Flags().GetBool("fix-dtx") + // Validate input arguments against actual recording data - metadata, err := validateInputArgs(globalArgs, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID) + metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) + return fmt.Errorf("validation error: %w", err) } - p.logger.Info("Starting extract-audio command") - p.printBanner(globalArgs, extractAudioArgs) + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting extract-audio command") + + // Print banner + printExtractAudioBanner(cmd, globalArgs, userID, sessionID, trackID, fillGaps, fixDtx) + + // Prepare working directory + workDir, cleanup, err := prepareWorkDir(globalArgs, logger) + if err != nil { + return err + } + defer cleanup() + globalArgs.WorkDir = workDir - // Implement extract audio functionality - if e := extractAudioTracks(globalArgs, extractAudioArgs, metadata, p.logger); e != nil { - p.logger.Error("Failed to extract audio: %v", e) + // Create output directory if it doesn't exist + if err := os.MkdirAll(globalArgs.Output, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) } - p.logger.Info("Extract audio command completed") + // Extract audio tracks + if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "audio", "both", fillGaps, fixDtx, logger); err != nil { + return fmt.Errorf("failed to extract audio: %w", err) + } + + logger.Info("Extract audio command completed") + return nil } -func (p *ExtractAudioProcess) printBanner(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs) { - fmt.Printf("Extract audio command with mutually exclusive filtering:\n") +func printExtractAudioBanner(cmd *cobra.Command, globalArgs *GlobalArgs, userID, sessionID, trackID string, fillGaps, fixDtx bool) { + cmd.Println("Extract audio command with mutually exclusive filtering:") if globalArgs.InputFile != "" { - fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + cmd.Printf(" Input file: %s\n", globalArgs.InputFile) } if globalArgs.InputDir != "" { - fmt.Printf(" Input directory: %s\n", globalArgs.InputDir) + cmd.Printf(" Input directory: %s\n", globalArgs.InputDir) } if globalArgs.InputS3 != "" { - fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + cmd.Printf(" Input S3: %s\n", globalArgs.InputS3) } - fmt.Printf(" Output directory: %s\n", globalArgs.Output) - fmt.Printf(" User ID filter: %s\n", extractAudioArgs.UserID) - fmt.Printf(" Session ID filter: %s\n", extractAudioArgs.SessionID) - fmt.Printf(" Track ID filter: %s\n", extractAudioArgs.TrackID) - - if extractAudioArgs.TrackID != "" { - fmt.Printf(" → Processing specific track '%s'\n", extractAudioArgs.TrackID) - } else if extractAudioArgs.SessionID != "" { - fmt.Printf(" → Processing all audio tracks for session '%s'\n", extractAudioArgs.SessionID) - } else if extractAudioArgs.UserID != "" { - fmt.Printf(" → Processing all audio tracks for user '%s'\n", extractAudioArgs.UserID) + cmd.Printf(" Output directory: %s\n", globalArgs.Output) + cmd.Printf(" User ID filter: %s\n", userID) + cmd.Printf(" Session ID filter: %s\n", sessionID) + cmd.Printf(" Track ID filter: %s\n", trackID) + + if trackID != "" { + cmd.Printf(" -> Processing specific track '%s'\n", trackID) + } else if sessionID != "" { + cmd.Printf(" -> Processing all audio tracks for session '%s'\n", sessionID) + } else if userID != "" { + cmd.Printf(" -> Processing all audio tracks for user '%s'\n", userID) } else { - fmt.Printf(" → Processing all audio tracks (no filters)\n") + cmd.Println(" -> Processing all audio tracks (no filters)") } - fmt.Printf(" Fill gaps: %t\n", extractAudioArgs.FillGaps) - fmt.Printf(" Fix DTX: %t\n", extractAudioArgs.FixDtx) -} - -func (p *ExtractAudioProcess) printUsage() { - fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-audio [command options]\n\n") - fmt.Fprintf(os.Stderr, "Generate playable audio files from raw recording tracks.\n") - fmt.Fprintf(os.Stderr, "Supports formats: webm, mp3, and others.\n\n") - fmt.Fprintf(os.Stderr, "Command Options (Mutually Exclusive Filters):\n") - fmt.Fprintf(os.Stderr, " --userId Filter by user ID\n") - fmt.Fprintf(os.Stderr, " --sessionId Filter by session ID\n") - fmt.Fprintf(os.Stderr, " --trackId Filter by track ID\n") - fmt.Fprintf(os.Stderr, " (specify at most one of the above)\n") - fmt.Fprintf(os.Stderr, " --fill_gaps Fix DTX shrink audio, fill with silence when muted\n\n") - fmt.Fprintf(os.Stderr, "Examples:\n") - fmt.Fprintf(os.Stderr, " # Extract audio for all users (no filters)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio\n\n") - fmt.Fprintf(os.Stderr, " # Extract audio for specific user (all their tracks)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --userId user123\n\n") - fmt.Fprintf(os.Stderr, " # Extract audio for specific session (all users in that session)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --sessionId session456\n\n") - fmt.Fprintf(os.Stderr, " # Extract a specific track\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-audio --trackId track1\n\n") - fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") -} - -func extractAudioTracks(globalArgs *GlobalArgs, extractAudioArgs *ExtractAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { - return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractAudioArgs.UserID, extractAudioArgs.SessionID, extractAudioArgs.TrackID, metadata, "audio", "both", extractAudioArgs.FillGaps, extractAudioArgs.FixDtx, logger) + cmd.Printf(" Fill gaps: %t\n", fillGaps) + cmd.Printf(" Fix DTX: %t\n", fixDtx) } diff --git a/pkg/cmd/raw-recording-tool/extract_video.go b/pkg/cmd/raw-recording-tool/extract_video.go index d6af5ac..42b5d82 100644 --- a/pkg/cmd/raw-recording-tool/extract_video.go +++ b/pkg/cmd/raw-recording-tool/extract_video.go @@ -1,114 +1,130 @@ -package main +package rawrecording import ( - "flag" "fmt" "os" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -type ExtractVideoArgs struct { - UserID string - SessionID string - TrackID string - FillGaps bool -} +func extractVideoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "extract-video", + Short: "Generate playable video files from raw recording tracks", + Long: heredoc.Doc(` + Generate playable video files from raw recording tracks. -type ExtractVideoProcess struct { - logger *getstream.DefaultLogger -} + Supports formats: webm, mp4, and others. -func NewExtractVideoProcess(logger *getstream.DefaultLogger) *ExtractVideoProcess { - return &ExtractVideoProcess{logger: logger} -} + Filters are mutually exclusive: you can only specify one of + --user-id, --session-id, or --track-id at a time. + `), + Example: heredoc.Doc(` + # Extract video for all users (no filters) + $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out + + # Extract video for specific user (all their tracks) + $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --user-id user123 + + # Extract video for specific session + $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --session-id session456 + + # Extract a specific track + $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --track-id track1 + `), + RunE: runExtractVideo, + } + + fl := cmd.Flags() + fl.String("user-id", "", "Filter by user ID") + fl.String("session-id", "", "Filter by session ID") + fl.String("track-id", "", "Filter by track ID") + fl.Bool("fill-gaps", true, "Fill with black frame when track was muted") + + // Register completions + _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) -func (p *ExtractVideoProcess) runExtractVideo(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) + return cmd +} - // Parse command-specific flags - fs := flag.NewFlagSet("extract-video", flag.ExitOnError) - extractVideoArgs := &ExtractVideoArgs{} - fs.StringVar(&extractVideoArgs.UserID, "userId", "", "Specify a userId (empty for all)") - fs.StringVar(&extractVideoArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") - fs.StringVar(&extractVideoArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - fs.BoolVar(&extractVideoArgs.FillGaps, "fill_gaps", true, "Fill with black frame when track was muted (default true)") +func runExtractVideo(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) + // Validate global args (output is required for extract-video) + if err := validateGlobalArgs(globalArgs, true); err != nil { + return err } + userID, _ := cmd.Flags().GetString("user-id") + sessionID, _ := cmd.Flags().GetString("session-id") + trackID, _ := cmd.Flags().GetString("track-id") + fillGaps, _ := cmd.Flags().GetBool("fill-gaps") + // Validate input arguments against actual recording data - metadata, err := validateInputArgs(globalArgs, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID) + metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) + return fmt.Errorf("validation error: %w", err) } - p.logger.Info("Starting extract-video command") - p.printBanner(globalArgs, extractVideoArgs) + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting extract-video command") + + // Print banner + printExtractVideoBanner(cmd, globalArgs, userID, sessionID, trackID, fillGaps) + + // Prepare working directory + workDir, cleanup, err := prepareWorkDir(globalArgs, logger) + if err != nil { + return err + } + defer cleanup() + globalArgs.WorkDir = workDir + + // Create output directory if it doesn't exist + if err := os.MkdirAll(globalArgs.Output, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } // Extract video tracks - if e := extractVideoTracks(globalArgs, extractVideoArgs, metadata, p.logger); e != nil { - p.logger.Error("Failed to extract video tracks: %v", e) - os.Exit(1) + if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "video", "both", fillGaps, false, logger); err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) } - p.logger.Info("Extract video command completed successfully") + logger.Info("Extract video command completed successfully") + return nil } -func (p *ExtractVideoProcess) printBanner(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs) { - fmt.Printf("Extract video command with mutually exclusive filtering:\n") +func printExtractVideoBanner(cmd *cobra.Command, globalArgs *GlobalArgs, userID, sessionID, trackID string, fillGaps bool) { + cmd.Println("Extract video command with mutually exclusive filtering:") if globalArgs.InputFile != "" { - fmt.Printf(" Input file: %s\n", globalArgs.InputFile) + cmd.Printf(" Input file: %s\n", globalArgs.InputFile) } if globalArgs.InputDir != "" { - fmt.Printf(" Input directory: %s\n", globalArgs.InputDir) + cmd.Printf(" Input directory: %s\n", globalArgs.InputDir) } if globalArgs.InputS3 != "" { - fmt.Printf(" Input S3: %s\n", globalArgs.InputS3) + cmd.Printf(" Input S3: %s\n", globalArgs.InputS3) } - fmt.Printf(" Output directory: %s\n", globalArgs.Output) - fmt.Printf(" User ID filter: %s\n", extractVideoArgs.UserID) - fmt.Printf(" Session ID filter: %s\n", extractVideoArgs.SessionID) - fmt.Printf(" Track ID filter: %s\n", extractVideoArgs.TrackID) - - if extractVideoArgs.TrackID != "" { - fmt.Printf(" → Processing specific track '%s'\n", extractVideoArgs.TrackID) - } else if extractVideoArgs.SessionID != "" { - fmt.Printf(" → Processing all video tracks for session '%s'\n", extractVideoArgs.SessionID) - } else if extractVideoArgs.UserID != "" { - fmt.Printf(" → Processing all video tracks for user '%s'\n", extractVideoArgs.UserID) + cmd.Printf(" Output directory: %s\n", globalArgs.Output) + cmd.Printf(" User ID filter: %s\n", userID) + cmd.Printf(" Session ID filter: %s\n", sessionID) + cmd.Printf(" Track ID filter: %s\n", trackID) + + if trackID != "" { + cmd.Printf(" -> Processing specific track '%s'\n", trackID) + } else if sessionID != "" { + cmd.Printf(" -> Processing all video tracks for session '%s'\n", sessionID) + } else if userID != "" { + cmd.Printf(" -> Processing all video tracks for user '%s'\n", userID) } else { - fmt.Printf(" → Processing all video tracks (no filters)\n") + cmd.Println(" -> Processing all video tracks (no filters)") } - fmt.Printf(" Fill gaps: %t\n", extractVideoArgs.FillGaps) -} - -func (p *ExtractVideoProcess) printUsage() { - fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] extract-video [command options]\n\n") - fmt.Fprintf(os.Stderr, "Generate playable video files from raw recording tracks.\n") - fmt.Fprintf(os.Stderr, "Supports formats: webm, mp4, and others.\n\n") - fmt.Fprintf(os.Stderr, "Command Options (Mutually Exclusive Filters):\n") - fmt.Fprintf(os.Stderr, " --userId Filter by user ID\n") - fmt.Fprintf(os.Stderr, " --sessionId Filter by session ID\n") - fmt.Fprintf(os.Stderr, " --trackId Filter by track ID\n") - fmt.Fprintf(os.Stderr, " (specify at most one of the above)\n") - fmt.Fprintf(os.Stderr, " --fill_gaps Fill with black frames when muted\n\n") - fmt.Fprintf(os.Stderr, "Examples:\n") - fmt.Fprintf(os.Stderr, " # Extract video for all users (no filters)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video\n\n") - fmt.Fprintf(os.Stderr, " # Extract video for specific user (all their tracks)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --userId user123\n\n") - fmt.Fprintf(os.Stderr, " # Extract video for specific session (all users in that session)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --sessionId session456\n\n") - fmt.Fprintf(os.Stderr, " # Extract a specific track\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip --output ./out extract-video --trackId track1\n\n") - fmt.Fprintf(os.Stderr, "Global Options: Use 'raw-tools --help' to see global options.\n") -} - -func extractVideoTracks(globalArgs *GlobalArgs, extractVideoArgs *ExtractVideoArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { - return processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, extractVideoArgs.UserID, extractVideoArgs.SessionID, extractVideoArgs.TrackID, metadata, "video", "both", extractVideoArgs.FillGaps, false, logger) + cmd.Printf(" Fill gaps: %t\n", fillGaps) } diff --git a/pkg/cmd/raw-recording-tool/list_tracks.go b/pkg/cmd/raw-recording-tool/list_tracks.go index 27e7e16..acbc844 100644 --- a/pkg/cmd/raw-recording-tool/list_tracks.go +++ b/pkg/cmd/raw-recording-tool/list_tracks.go @@ -1,49 +1,68 @@ -package main +package rawrecording import ( "encoding/json" - "flag" "fmt" - "os" "sort" "strings" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -type ListTracksArgs struct { - Format string // "table", "json", "completion", "users", "sessions", "tracks" - TrackType string // Filter by track type: "audio", "video", or "" for all - CompletionType string // For completion format: "users", "sessions", "tracks" -} +func listTracksCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list-tracks", + Short: "List all tracks in the raw recording with their metadata", + Long: heredoc.Doc(` + List all tracks in the raw recording with their metadata. -type ListTracksProcess struct { - logger *getstream.DefaultLogger -} + This command displays information about all audio and video tracks + in the recording, including user IDs, session IDs, track IDs, and codecs. -func NewListTracksProcess(logger *getstream.DefaultLogger) *ListTracksProcess { - return &ListTracksProcess{logger: logger} -} + Note: --output is optional for this command (only displays information). + `), + Example: heredoc.Doc(` + # List all tracks in table format + $ stream-cli video raw-recording list-tracks --input-file recording.zip -func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) + # Get JSON output for programmatic use + $ stream-cli video raw-recording list-tracks --input-file recording.zip --format json - // Parse command-specific flags - fs := flag.NewFlagSet("list-tracks", flag.ExitOnError) - listTracksArgs := &ListTracksArgs{} - fs.StringVar(&listTracksArgs.Format, "format", "table", "Output format: table, json, completion, users, sessions, tracks") - fs.StringVar(&listTracksArgs.TrackType, "trackType", "", "Filter by track type: audio, video") - fs.StringVar(&listTracksArgs.CompletionType, "completionType", "tracks", "For completion format: users, sessions, tracks") + # Get user IDs only + $ stream-cli video raw-recording list-tracks --input-file recording.zip --format users - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) + # Filter by track type + $ stream-cli video raw-recording list-tracks --input-file recording.zip --track-type audio + `), + RunE: runListTracks, } - // Setup logger - logger := setupLogger(globalArgs.Verbose) + fl := cmd.Flags() + fl.String("format", "table", "Output format: table, json, users, sessions, tracks, completion") + fl.String("track-type", "", "Filter by track type: audio, video") + fl.String("completion-type", "tracks", "For completion format: users, sessions, tracks") + + return cmd +} + +func runListTracks(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } + + // Validate global args (output is optional for list-tracks) + if err := validateGlobalArgs(globalArgs, false); err != nil { + return err + } + + format, _ := cmd.Flags().GetString("format") + trackType, _ := cmd.Flags().GetString("track-type") + completionType, _ := cmd.Flags().GetString("completion-type") + logger := setupLogger(globalArgs.Verbose) logger.Info("Starting list-tracks command") // Parse the recording metadata using efficient metadata-only approach @@ -53,52 +72,50 @@ func (p *ListTracksProcess) runListTracks(args []string, globalArgs *GlobalArgs) } else if globalArgs.InputDir != "" { inputPath = globalArgs.InputDir } else { - // TODO: Handle S3 input - return // For now, only support local files + return fmt.Errorf("S3 input not implemented yet") } - // Use efficient metadata-only parsing (optimized for list-tracks) parser := processing.NewMetadataParser(logger) metadata, err := parser.ParseMetadataOnly(inputPath) if err != nil { - logger.Error("Failed to parse recording: %v", err) + return fmt.Errorf("failed to parse recording: %w", err) } // Filter tracks if track type is specified - tracks := processing.FilterTracks(metadata.Tracks, "", "", "", listTracksArgs.TrackType, "") + tracks := processing.FilterTracks(metadata.Tracks, "", "", "", trackType, "") // Output in requested format - switch listTracksArgs.Format { + switch format { case "table": - p.printTracksTable(tracks) + printTracksTable(cmd, tracks) case "json": - p.printTracksJSON(metadata) + printTracksJSON(cmd, metadata) case "completion": - p.printCompletion(metadata, listTracksArgs.CompletionType) + printCompletion(cmd, metadata, completionType) case "users": - p.printUsers(metadata.UserIDs) + printUsers(cmd, metadata.UserIDs) case "sessions": - p.printSessions(metadata.Sessions) + printSessions(cmd, metadata.Sessions) case "tracks": - p.printTrackIDs(tracks) + printTrackIDs(cmd, tracks) default: - fmt.Fprintf(os.Stderr, "Unknown format: %s\n", listTracksArgs.Format) - os.Exit(1) + return fmt.Errorf("unknown format: %s", format) } logger.Info("List tracks command completed") + return nil } // printTracksTable prints tracks in a human-readable table format -func (p *ListTracksProcess) printTracksTable(tracks []*processing.TrackInfo) { +func printTracksTable(cmd *cobra.Command, tracks []*processing.TrackInfo) { if len(tracks) == 0 { - fmt.Println("No tracks found.") + cmd.Println("No tracks found.") return } // Print header - fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", "USER ID", "SESSION ID", "TRACK ID", "TYPE", "SCREENSHARE", "CODEC", "SEGMENTS") - fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", + cmd.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", "USER ID", "SESSION ID", "TRACK ID", "TYPE", "SCREENSHARE", "CODEC", "SEGMENTS") + cmd.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8s\n", strings.Repeat("-", 22), strings.Repeat("-", 38), strings.Repeat("-", 38), @@ -113,10 +130,10 @@ func (p *ListTracksProcess) printTracksTable(tracks []*processing.TrackInfo) { if track.IsScreenshare { screenshareStatus = "Yes" } - fmt.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8d\n", - p.truncateString(track.UserID, 22), - p.truncateString(track.SessionID, 38), - p.truncateString(track.TrackID, 38), + cmd.Printf("%-22s %-38s %-38s %-6s %-12s %-15s %-8d\n", + truncateString(track.UserID, 22), + truncateString(track.SessionID, 38), + truncateString(track.TrackID, 38), track.TrackType, screenshareStatus, track.Codec, @@ -125,7 +142,7 @@ func (p *ListTracksProcess) printTracksTable(tracks []*processing.TrackInfo) { } // truncateString truncates a string to a maximum length, adding "..." if needed -func (p *ListTracksProcess) truncateString(s string, maxLen int) string { +func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } @@ -133,54 +150,47 @@ func (p *ListTracksProcess) truncateString(s string, maxLen int) string { } // printTracksJSON prints the full metadata in JSON format -func (p *ListTracksProcess) printTracksJSON(metadata *processing.RecordingMetadata) { +func printTracksJSON(cmd *cobra.Command, metadata *processing.RecordingMetadata) { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { - fmt.Fprintf(os.Stderr, "Error marshaling JSON: %v\n", err) + cmd.PrintErrf("Error marshaling JSON: %v\n", err) return } - fmt.Println(string(data)) + cmd.Println(string(data)) } // printCompletion prints completion-friendly output -func (p *ListTracksProcess) printCompletion(metadata *processing.RecordingMetadata, completionType string) { +func printCompletion(cmd *cobra.Command, metadata *processing.RecordingMetadata, completionType string) { switch completionType { case "users": - p.printUsers(metadata.UserIDs) + printUsers(cmd, metadata.UserIDs) case "sessions": - p.printSessions(metadata.Sessions) + printSessions(cmd, metadata.Sessions) case "tracks": - trackIDs := make([]string, 0) - for _, track := range metadata.Tracks { - trackIDs = append(trackIDs, track.TrackID) - } - // Remove duplicates and sort - uniqueTrackIDs := p.removeDuplicates(trackIDs) - sort.Strings(uniqueTrackIDs) - p.printTrackIDs(metadata.Tracks) + printTrackIDs(cmd, metadata.Tracks) default: - fmt.Fprintf(os.Stderr, "Unknown completion type: %s\n", completionType) + cmd.PrintErrf("Unknown completion type: %s\n", completionType) } } // printUsers prints user IDs, one per line -func (p *ListTracksProcess) printUsers(userIDs []string) { +func printUsers(cmd *cobra.Command, userIDs []string) { sort.Strings(userIDs) for _, userID := range userIDs { - fmt.Println(userID) + cmd.Println(userID) } } // printSessions prints session IDs, one per line -func (p *ListTracksProcess) printSessions(sessions []string) { +func printSessions(cmd *cobra.Command, sessions []string) { sort.Strings(sessions) for _, session := range sessions { - fmt.Println(session) + cmd.Println(session) } } // printTrackIDs prints unique track IDs, one per line -func (p *ListTracksProcess) printTrackIDs(tracks []*processing.TrackInfo) { +func printTrackIDs(cmd *cobra.Command, tracks []*processing.TrackInfo) { trackIDs := make([]string, 0) seen := make(map[string]bool) @@ -193,45 +203,6 @@ func (p *ListTracksProcess) printTrackIDs(tracks []*processing.TrackInfo) { sort.Strings(trackIDs) for _, trackID := range trackIDs { - fmt.Println(trackID) + cmd.Println(trackID) } } - -// removeDuplicates removes duplicate strings from a slice -func (p *ListTracksProcess) removeDuplicates(input []string) []string { - keys := make(map[string]bool) - result := make([]string, 0) - - for _, item := range input { - if !keys[item] { - keys[item] = true - result = append(result, item) - } - } - - return result -} - -func (p *ListTracksProcess) printUsage() { - fmt.Fprintf(os.Stderr, "Usage: raw-tools [global options] list-tracks [command options]\n\n") - fmt.Fprintf(os.Stderr, "List all tracks in the raw recording with their metadata.\n") - fmt.Fprintf(os.Stderr, "Note: --output is optional for this command (only displays information).\n\n") - fmt.Fprintf(os.Stderr, "Command Options:\n") - fmt.Fprintf(os.Stderr, " --format Output format (default: table)\n") - fmt.Fprintf(os.Stderr, " table - Human readable table\n") - fmt.Fprintf(os.Stderr, " json - JSON format\n") - fmt.Fprintf(os.Stderr, " users - List of user IDs only\n") - fmt.Fprintf(os.Stderr, " sessions - List of session IDs only\n") - fmt.Fprintf(os.Stderr, " tracks - List of track IDs only\n") - fmt.Fprintf(os.Stderr, " completion - Shell completion format\n") - fmt.Fprintf(os.Stderr, " --trackType Filter by track type: audio, video\n") - fmt.Fprintf(os.Stderr, " --completionType For completion format: users, sessions, tracks\n\n") - fmt.Fprintf(os.Stderr, "Examples:\n") - fmt.Fprintf(os.Stderr, " # List all tracks in table format (no output directory needed)\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks\n\n") - fmt.Fprintf(os.Stderr, " # Get JSON output for programmatic use\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format json\n\n") - fmt.Fprintf(os.Stderr, " # Get user IDs for completion\n") - fmt.Fprintf(os.Stderr, " raw-tools --inputFile recording.zip list-tracks --format users\n") - fmt.Fprintf(os.Stderr, "\nGlobal Options: Use 'raw-tools --help' to see global options.\n") -} diff --git a/pkg/cmd/raw-recording-tool/main.go b/pkg/cmd/raw-recording-tool/main.go deleted file mode 100644 index 89ef516..0000000 --- a/pkg/cmd/raw-recording-tool/main.go +++ /dev/null @@ -1,325 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "os" - - "github.com/GetStream/getstream-go/v3" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" -) - -type GlobalArgs struct { - InputFile string - InputDir string - InputS3 string - Output string - Verbose bool - - WorkDir string -} - -func main() { - if len(os.Args) < 2 { - printGlobalUsage() - os.Exit(1) - } - - // Parse global flags first - globalArgs := &GlobalArgs{} - command, remainingArgs := parseGlobalFlags(os.Args[1:], globalArgs) - - if command == "" { - printGlobalUsage() - os.Exit(1) - } - - // Setup logger - logger := setupLogger(globalArgs.Verbose) - - switch command { - case "list-tracks": - p := NewListTracksProcess(logger) - p.runListTracks(remainingArgs, globalArgs) - case "completion": - runCompletion(remainingArgs) - case "help", "-h", "--help": - printGlobalUsage() - default: - if e := processCommand(command, globalArgs, remainingArgs, logger); e != nil { - logger.Error("Error processing command %s - %v", command, e) - os.Exit(1) - } - } -} - -func processCommand(command string, globalArgs *GlobalArgs, remainingArgs []string, logger *getstream.DefaultLogger) error { - // Extract to temp directory if needed (unified approach) - path := globalArgs.InputFile - if path == "" { - path = globalArgs.InputDir - } - - workingDir, cleanup, err := processing.ExtractToTempDir(path, logger) - if err != nil { - return fmt.Errorf("failed to prepare working directory: %w", err) - } - defer cleanup() - globalArgs.WorkDir = workingDir - - // Create output directory if it doesn't exist - if e := os.MkdirAll(globalArgs.Output, 0755); e != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - - switch command { - case "extract-audio": - p := NewExtractAudioProcess(logger) - p.runExtractAudio(remainingArgs, globalArgs) - case "extract-video": - p := NewExtractVideoProcess(logger) - p.runExtractVideo(remainingArgs, globalArgs) - case "mux-av": - p := NewMuxAudioVideoProcess(logger) - p.runMuxAV(remainingArgs, globalArgs) - case "mix-audio": - p := NewMixAudioProcess(logger) - p.runMixAudio(remainingArgs, globalArgs) - case "process-all": - p := NewProcessAllProcess(logger) - p.runProcessAll(remainingArgs, globalArgs) - default: - fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) - printGlobalUsage() - os.Exit(1) - } - - return nil -} - -// parseGlobalFlags parses global flags and returns the command and remaining args -func parseGlobalFlags(args []string, globalArgs *GlobalArgs) (string, []string) { - fs := flag.NewFlagSet("global", flag.ContinueOnError) - - fs.StringVar(&globalArgs.InputFile, "inputFile", "", "Specify raw recording zip file on file system") - fs.StringVar(&globalArgs.InputDir, "inputDir", "", "Specify raw recording directory on file system") - fs.StringVar(&globalArgs.InputS3, "inputS3", "", "Specify raw recording zip file on S3") - fs.StringVar(&globalArgs.Output, "output", "", "Specify an output directory") - fs.BoolVar(&globalArgs.Verbose, "verbose", false, "Enable verbose logging") - - // Find the command by looking for known commands - knownCommands := map[string]bool{ - "list-tracks": true, - "extract-audio": true, - "extract-video": true, - "mux-av": true, - "mix-audio": true, - "process-all": true, - "completion": true, - "help": true, - } - - commandIndex := -1 - for i, arg := range args { - if knownCommands[arg] { - commandIndex = i - break - } - } - - if commandIndex == -1 { - return "", nil - } - - // Parse global flags (everything before the command) - globalFlags := args[:commandIndex] - command := args[commandIndex] - remainingArgs := args[commandIndex+1:] - - err := fs.Parse(globalFlags) - if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing global flags: %v\n", err) - os.Exit(1) - } - - // Validate global arguments - if e := validateGlobalArgs(globalArgs, command); e != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", e) - printGlobalUsage() - os.Exit(1) - } - - return command, remainingArgs -} - -func setupLogger(verbose bool) *getstream.DefaultLogger { - var level getstream.LogLevel - if verbose { - level = getstream.LogLevelDebug - } else { - level = getstream.LogLevelInfo - } - logger := getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level) - return logger -} - -func validateGlobalArgs(globalArgs *GlobalArgs, command string) error { - if globalArgs.InputFile == "" && globalArgs.InputDir == "" && globalArgs.InputS3 == "" { - return fmt.Errorf("either --inputFile or --inputDir or --inputS3 must be specified") - } - - num := 0 - if globalArgs.InputFile != "" { - num++ - } - if globalArgs.InputDir != "" { - num++ - } - if globalArgs.InputS3 != "" { - num++ - } - if num > 1 { - return fmt.Errorf("--inputFile, --inputDir and --inputS3 are exclusive, only one is allowed") - } - - // --output is optional for list-tracks command (it only displays information) - if command != "list-tracks" && globalArgs.Output == "" { - return fmt.Errorf("--output directory must be specified") - } - - return nil -} - -// validateInputArgs validates input arguments using mutually exclusive logic -func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*processing.RecordingMetadata, error) { - // Count how many filters are specified - filtersCount := 0 - if userID != "" { - filtersCount++ - } - if sessionID != "" { - filtersCount++ - } - if trackID != "" { - filtersCount++ - } - - // Ensure filters are mutually exclusive - if filtersCount > 1 { - return nil, fmt.Errorf("only one filter can be specified at a time: --userId, --sessionId, and --trackId are mutually exclusive") - } - - var inputPath string - if globalArgs.InputFile != "" { - inputPath = globalArgs.InputFile - } else if globalArgs.InputDir != "" { - inputPath = globalArgs.InputDir - } else { - // TODO: Handle S3 validation - return nil, fmt.Errorf("Not implemented for now") - } - - // Parse metadata to validate the single specified argument - logger := setupLogger(false) // Use non-verbose for validation - parser := processing.NewMetadataParser(logger) - metadata, err := parser.ParseMetadataOnly(inputPath) - if err != nil { - return nil, fmt.Errorf("failed to parse recording for validation: %w", err) - } - - // If no filters specified, no validation needed - if filtersCount == 0 { - return metadata, nil - } - - // Validate the single specified filter - if trackID != "" { - found := false - for _, track := range metadata.Tracks { - if track.TrackID == trackID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("trackID '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) - } - } else if sessionID != "" { - found := false - for _, track := range metadata.Tracks { - if track.SessionID == sessionID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("sessionID '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) - } - } else if userID != "" { - found := false - for _, uid := range metadata.UserIDs { - if uid == userID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("userID '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) - } - } - - return metadata, nil -} - -func printGlobalUsage() { - fmt.Fprintf(os.Stderr, "Raw Recording Post Processing Tools\n\n") - fmt.Fprintf(os.Stderr, "Usage: %s [global options] [command options]\n\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Global Options:\n") - fmt.Fprintf(os.Stderr, " --inputFile Specify raw recording zip file on file system\n") - fmt.Fprintf(os.Stderr, " --inputS3 Specify raw recording zip file on S3\n") - fmt.Fprintf(os.Stderr, " --output Specify an output directory (optional for list-tracks)\n") - fmt.Fprintf(os.Stderr, " --verbose Enable verbose logging\n\n") - fmt.Fprintf(os.Stderr, "Commands:\n") - fmt.Fprintf(os.Stderr, " list-tracks Return list of userId - sessionId - trackId - trackType\n") - fmt.Fprintf(os.Stderr, " extract-audio Generate a playable audio file (webm, mp3, ...)\n") - fmt.Fprintf(os.Stderr, " extract-video Generate a playable video file (webm, mp4, ...)\n") - fmt.Fprintf(os.Stderr, " mux-av Mux audio and video tracks\n") - fmt.Fprintf(os.Stderr, " mix-audio Mix multiple audio tracks into one file (supports mutually exclusive filters)\n") - fmt.Fprintf(os.Stderr, " process-all Process audio, video, and mux (all-in-one)\n") - fmt.Fprintf(os.Stderr, " completion Generate shell completion scripts\n") - fmt.Fprintf(os.Stderr, " help Show this help message\n\n") - fmt.Fprintf(os.Stderr, "Examples:\n") - fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip list-tracks\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out extract-audio --userId user123\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s --inputFile recording.zip --output ./out mix-audio --sessionId session456\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s --verbose --inputFile recording.zip --output ./out mux-av --trackId track789\n\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Use '%s [global options] --help' for command-specific options.\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "\nCompletion Setup:\n") - fmt.Fprintf(os.Stderr, " # Bash\n") - fmt.Fprintf(os.Stderr, " source <(%s completion bash)\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " # Zsh\n") - fmt.Fprintf(os.Stderr, " source <(%s completion zsh)\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " # Fish\n") - fmt.Fprintf(os.Stderr, " %s completion fish | source\n", os.Args[0]) -} - -func printHelpIfAsked(args []string, fn func()) { - // Check for help flag before parsing - for _, arg := range args { - if arg == "--help" || arg == "-h" { - fn() - os.Exit(0) - } - } -} -func runCompletion(args []string) { - if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Usage: raw-tools completion \n") - fmt.Fprintf(os.Stderr, "Supported shells: bash, zsh, fish\n") - os.Exit(1) - } - - shell := args[0] - generateCompletion(shell) -} diff --git a/pkg/cmd/raw-recording-tool/mix_audio.go b/pkg/cmd/raw-recording-tool/mix_audio.go index fbcc903..dccbb5a 100644 --- a/pkg/cmd/raw-recording-tool/mix_audio.go +++ b/pkg/cmd/raw-recording-tool/mix_audio.go @@ -1,54 +1,72 @@ -package main +package rawrecording import ( "fmt" "os" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -// MixAudioArgs represents the arguments for the mix-audio command -type MixAudioArgs struct { - IncludeScreenShare bool -} +func mixAudioCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mix-audio", + Short: "Mix multiple audio tracks into one file", + Long: heredoc.Doc(` + Mix all audio tracks from multiple users/sessions into a single audio file + with proper timing synchronization (like a conference call recording). -type MixAudioProcess struct { - logger *getstream.DefaultLogger -} + Creates 'mixed_audio.webm' - a single audio file containing all mixed tracks + with proper timing synchronization based on the original recording timeline. + `), + Example: heredoc.Doc(` + # Mix all audio tracks from all users and sessions + $ stream-cli video raw-recording mix-audio --input-file recording.zip --output ./out + + # Mix with verbose logging + $ stream-cli video raw-recording mix-audio --input-file recording.zip --output ./out --verbose + `), + RunE: runMixAudio, + } -func NewMixAudioProcess(logger *getstream.DefaultLogger) *MixAudioProcess { - return &MixAudioProcess{logger: logger} + return cmd } -// runMixAudio handles the mix-audio command -func (p *MixAudioProcess) runMixAudio(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) +func runMixAudio(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } - mixAudioArgs := &MixAudioArgs{ - IncludeScreenShare: false, + // Validate global args (output is required for mix-audio) + if err := validateGlobalArgs(globalArgs, true); err != nil { + return err } // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, "", "", "") if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) + return fmt.Errorf("validation error: %w", err) } - p.logger.Info("Starting mix-audio command") + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting mix-audio command") - // Execute the mix-audio operation - if e := p.mixAllAudioTracks(globalArgs, mixAudioArgs, metadata, p.logger); e != nil { - p.logger.Error("Mix-audio failed: %v", e) - os.Exit(1) + // Prepare working directory + workDir, cleanup, err := prepareWorkDir(globalArgs, logger) + if err != nil { + return err } + defer cleanup() + globalArgs.WorkDir = workDir - p.logger.Info("Mix-audio command completed successfully") -} + // Create output directory if it doesn't exist + if err := os.MkdirAll(globalArgs.Output, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } -// mixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs *MixAudioArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + // Mix all audio tracks mixer := processing.NewAudioMixer(logger) mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, @@ -57,34 +75,7 @@ func (p *MixAudioProcess) mixAllAudioTracks(globalArgs *GlobalArgs, mixAudioArgs WithExtract: true, WithCleanup: false, }, metadata, logger) - return nil -} -// printMixAudioUsage prints the usage information for the mix-audio command -func (p *MixAudioProcess) printUsage() { - fmt.Println("Usage: raw-tools [global-options] mix-audio [options]") - fmt.Println() - fmt.Println("Mix all audio tracks from multiple users/sessions into a single audio file") - fmt.Println("with proper timing synchronization (like a conference call recording).") - fmt.Println() - fmt.Println("Options:") - fmt.Println(" --userId Filter by user ID (* for all users, default: *)") - fmt.Println(" --sessionId Filter by session ID (* for all sessions, default: *)") - fmt.Println(" --trackId Filter by track ID (* for all tracks, default: *)") - fmt.Println(" --no-fill-gaps Don't fill gaps with silence (not recommended for mixing)") - fmt.Println(" -h, --help Show this help message") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" # Mix all audio tracks from all users and sessions") - fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio") - fmt.Println() - fmt.Println(" # Mix audio tracks from a specific user") - fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --userId user123") - fmt.Println() - fmt.Println(" # Mix audio tracks from a specific session") - fmt.Println(" raw-tools --inputFile recording.tar.gz --output /tmp/mixed mix-audio --sessionId session456") - fmt.Println() - fmt.Println("Output:") - fmt.Println(" Creates 'mixed_audio.webm' - a single audio file containing all mixed tracks") - fmt.Println(" with proper timing synchronization based on the original recording timeline.") + logger.Info("Mix-audio command completed successfully") + return nil } diff --git a/pkg/cmd/raw-recording-tool/mux_av.go b/pkg/cmd/raw-recording-tool/mux_av.go index 1012d81..752879d 100644 --- a/pkg/cmd/raw-recording-tool/mux_av.go +++ b/pkg/cmd/raw-recording-tool/mux_av.go @@ -1,109 +1,137 @@ -package main +package rawrecording import ( - "flag" "fmt" "os" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -type MuxAVArgs struct { - UserID string - SessionID string - TrackID string - Media string // "user", "display", or "both" (default) -} - -type MuxAudioVideoProcess struct { - logger *getstream.DefaultLogger -} +func muxAVCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mux-av", + Short: "Mux audio and video tracks into a single file", + Long: heredoc.Doc(` + Mux audio and video tracks into a single file. + + This command combines audio and video tracks from the same + user/session into a single playable file. + + Filters are mutually exclusive: you can only specify one of + --user-id, --session-id, or --track-id at a time. + + Media filtering: + --media user Only mux user camera audio/video pairs + --media display Only mux display sharing audio/video pairs + --media both Mux both types (default) + `), + Example: heredoc.Doc(` + # Mux all tracks + $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out + + # Mux tracks for specific user + $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --user-id user123 + + # Mux only user camera tracks + $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --media user + + # Mux only display sharing tracks + $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --media display + `), + RunE: runMuxAV, + } -func NewMuxAudioVideoProcess(logger *getstream.DefaultLogger) *MuxAudioVideoProcess { - return &MuxAudioVideoProcess{logger: logger} + fl := cmd.Flags() + fl.String("user-id", "", "Filter by user ID") + fl.String("session-id", "", "Filter by session ID") + fl.String("track-id", "", "Filter by track ID") + fl.String("media", "both", "Filter by media type: 'user', 'display', or 'both'") + + // Register completions + _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) + _ = cmd.RegisterFlagCompletionFunc("media", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{"user", "display", "both"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd } -func (p *MuxAudioVideoProcess) runMuxAV(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) - - // Parse command-specific flags - fs := flag.NewFlagSet("mux-av", flag.ExitOnError) - muxAVArgs := &MuxAVArgs{} - fs.StringVar(&muxAVArgs.UserID, "userId", "", "Specify a userId (empty for all)") - fs.StringVar(&muxAVArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") - fs.StringVar(&muxAVArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") - fs.StringVar(&muxAVArgs.Media, "media", "both", "Filter by media type: 'user', 'display', or 'both'") +func runMuxAV(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) + // Validate global args (output is required for mux-av) + if err := validateGlobalArgs(globalArgs, true); err != nil { + return err } + userID, _ := cmd.Flags().GetString("user-id") + sessionID, _ := cmd.Flags().GetString("session-id") + trackID, _ := cmd.Flags().GetString("track-id") + media, _ := cmd.Flags().GetString("media") + // Validate input arguments against actual recording data - metadata, err := validateInputArgs(globalArgs, muxAVArgs.UserID, muxAVArgs.SessionID, muxAVArgs.TrackID) + metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) + return fmt.Errorf("validation error: %w", err) } - p.logger.Info("Starting mux-av command") - - // Display hierarchy information for user clarity - fmt.Printf("Mux audio and video command with hierarchical filtering:\n") - fmt.Printf(" Input file: %s\n", globalArgs.InputFile) - fmt.Printf(" Output directory: %s\n", globalArgs.Output) - fmt.Printf(" User ID filter: %s\n", muxAVArgs.UserID) - fmt.Printf(" Session ID filter: %s\n", muxAVArgs.SessionID) - fmt.Printf(" Track ID filter: %s\n", muxAVArgs.TrackID) - fmt.Printf(" Media filter: %s\n", muxAVArgs.Media) - - if muxAVArgs.TrackID != "" { - fmt.Printf(" → Processing specific track '%s'\n", muxAVArgs.TrackID) - } else if muxAVArgs.SessionID != "" { - fmt.Printf(" → Processing all tracks for session '%s'\n", muxAVArgs.SessionID) - } else if muxAVArgs.UserID != "" { - fmt.Printf(" → Processing all tracks for user '%s'\n", muxAVArgs.UserID) + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting mux-av command") + + // Print banner + cmd.Println("Mux audio and video command with hierarchical filtering:") + cmd.Printf(" Input file: %s\n", globalArgs.InputFile) + cmd.Printf(" Output directory: %s\n", globalArgs.Output) + cmd.Printf(" User ID filter: %s\n", userID) + cmd.Printf(" Session ID filter: %s\n", sessionID) + cmd.Printf(" Track ID filter: %s\n", trackID) + cmd.Printf(" Media filter: %s\n", media) + + if trackID != "" { + cmd.Printf(" -> Processing specific track '%s'\n", trackID) + } else if sessionID != "" { + cmd.Printf(" -> Processing all tracks for session '%s'\n", sessionID) + } else if userID != "" { + cmd.Printf(" -> Processing all tracks for user '%s'\n", userID) } else { - fmt.Printf(" → Processing all tracks (no filters)\n") + cmd.Println(" -> Processing all tracks (no filters)") } - // Extract and mux audio/video tracks - if err := p.muxAudioVideoTracks(globalArgs, muxAVArgs, metadata, p.logger); err != nil { - p.logger.Error("Failed to mux audio/video tracks: %v", err) - os.Exit(1) + // Prepare working directory + workDir, cleanup, err := prepareWorkDir(globalArgs, logger) + if err != nil { + return err } + defer cleanup() + globalArgs.WorkDir = workDir - p.logger.Info("Mux audio and video command completed successfully") -} - -func (p *MuxAudioVideoProcess) printUsage() { - fmt.Printf("Usage: raw-tools [global options] mux-av [options]\n") - fmt.Printf("\nMux audio and video tracks into a single file\n") - fmt.Printf("\nOptions:\n") - fmt.Printf(" --userId STRING Filter by user ID (mutually exclusive with --sessionId/--trackId)\n") - fmt.Printf(" --sessionId STRING Filter by session ID (mutually exclusive with --userId/--trackId)\n") - fmt.Printf(" --trackId STRING Filter by track ID (mutually exclusive with --userId/--sessionId)\n") - fmt.Printf(" --media STRING Filter by media type: 'user', 'display', or 'both' (default: \"both\")\n") - fmt.Printf("\nMedia Filtering:\n") - fmt.Printf(" --media user Only mux user camera audio/video pairs\n") - fmt.Printf(" --media display Only mux display sharing audio/video pairs\n") - fmt.Printf(" --media both Mux both types, but ensure consistent pairing (default)\n") -} + // Create output directory if it doesn't exist + if err := os.MkdirAll(globalArgs.Output, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } -func (p *MuxAudioVideoProcess) muxAudioVideoTracks(globalArgs *GlobalArgs, muxAVArgs *MuxAVArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { - muxer := processing.NewAudioVideoMuxer(p.logger) - if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + // Mux audio/video tracks + muxer := processing.NewAudioVideoMuxer(logger) + if err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, - UserID: muxAVArgs.UserID, - SessionID: muxAVArgs.SessionID, - TrackID: muxAVArgs.TrackID, - Media: muxAVArgs.Media, + UserID: userID, + SessionID: sessionID, + TrackID: trackID, + Media: media, WithExtract: true, WithCleanup: false, - }, metadata, logger); e != nil { - return e + }, metadata, logger); err != nil { + return fmt.Errorf("failed to mux audio/video tracks: %w", err) } + + logger.Info("Mux audio and video command completed successfully") return nil } diff --git a/pkg/cmd/raw-recording-tool/process_all.go b/pkg/cmd/raw-recording-tool/process_all.go index 17a6a2e..0ef0739 100644 --- a/pkg/cmd/raw-recording-tool/process_all.go +++ b/pkg/cmd/raw-recording-tool/process_all.go @@ -1,105 +1,125 @@ -package main +package rawrecording import ( - "flag" "fmt" "os" - "github.com/GetStream/getstream-go/v3" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" ) -type ProcessAllArgs struct { - UserID string - SessionID string - TrackID string -} +func processAllCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "process-all", + Short: "Process audio, video, and mux (all-in-one)", + Long: heredoc.Doc(` + Process audio, video, and mux them into combined files (all-in-one workflow). + + Outputs 3 files per session: audio WebM, video WebM, and muxed WebM. + Gap filling is always enabled for seamless playback. + + Filters are mutually exclusive: you can only specify one of + --user-id, --session-id, or --track-id at a time. + + Output files per session: + audio_{userId}_{sessionId}_{trackId}.webm - Audio-only file + video_{userId}_{sessionId}_{trackId}.webm - Video-only file + muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file + `), + Example: heredoc.Doc(` + # Process all tracks + $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out + + # Process tracks for specific user + $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out --user-id user123 + + # Process tracks for specific session + $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out --session-id session456 + `), + RunE: runProcessAll, + } -type ProcessAllProcess struct { - logger *getstream.DefaultLogger -} + fl := cmd.Flags() + fl.String("user-id", "", "Filter by user ID") + fl.String("session-id", "", "Filter by session ID") + fl.String("track-id", "", "Filter by track ID") -func NewProcessAllProcess(logger *getstream.DefaultLogger) *ProcessAllProcess { - return &ProcessAllProcess{logger: logger} -} + // Register completions + _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) -func (p *ProcessAllProcess) runProcessAll(args []string, globalArgs *GlobalArgs) { - printHelpIfAsked(args, p.printUsage) + return cmd +} - // Parse command-specific flags - fs := flag.NewFlagSet("process-all", flag.ExitOnError) - processAllArgs := &ProcessAllArgs{} - fs.StringVar(&processAllArgs.UserID, "userId", "", "Specify a userId (empty for all)") - fs.StringVar(&processAllArgs.SessionID, "sessionId", "", "Specify a sessionId (empty for all)") - fs.StringVar(&processAllArgs.TrackID, "trackId", "", "Specify a trackId (empty for all)") +func runProcessAll(cmd *cobra.Command, args []string) error { + globalArgs, err := getGlobalArgs(cmd) + if err != nil { + return err + } - if err := fs.Parse(args); err != nil { - fmt.Fprintf(os.Stderr, "Error parsing flags: %v\n", err) - os.Exit(1) + // Validate global args (output is required for process-all) + if err := validateGlobalArgs(globalArgs, true); err != nil { + return err } + userID, _ := cmd.Flags().GetString("user-id") + sessionID, _ := cmd.Flags().GetString("session-id") + trackID, _ := cmd.Flags().GetString("track-id") + // Validate input arguments against actual recording data - metadata, err := validateInputArgs(globalArgs, processAllArgs.UserID, processAllArgs.SessionID, processAllArgs.TrackID) + metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) + return fmt.Errorf("validation error: %w", err) } - p.logger.Info("Starting process-all command") - - // Display hierarchy information for user clarity - fmt.Printf("Process-all command (audio + video + mux) with hierarchical filtering:\n") - fmt.Printf(" Input file: %s\n", globalArgs.InputFile) - fmt.Printf(" Output directory: %s\n", globalArgs.Output) - fmt.Printf(" User ID filter: %s\n", processAllArgs.UserID) - fmt.Printf(" Session ID filter: %s\n", processAllArgs.SessionID) - fmt.Printf(" Track ID filter: %s\n", processAllArgs.TrackID) - fmt.Printf(" Gap filling: always enabled\n") - - if processAllArgs.TrackID != "" { - fmt.Printf(" → Processing specific track '%s'\n", processAllArgs.TrackID) - } else if processAllArgs.SessionID != "" { - fmt.Printf(" → Processing all tracks for session '%s'\n", processAllArgs.SessionID) - } else if processAllArgs.UserID != "" { - fmt.Printf(" → Processing all tracks for user '%s'\n", processAllArgs.UserID) + logger := setupLogger(globalArgs.Verbose) + logger.Info("Starting process-all command") + + // Print banner + cmd.Println("Process-all command (audio + video + mux) with hierarchical filtering:") + cmd.Printf(" Input file: %s\n", globalArgs.InputFile) + cmd.Printf(" Output directory: %s\n", globalArgs.Output) + cmd.Printf(" User ID filter: %s\n", userID) + cmd.Printf(" Session ID filter: %s\n", sessionID) + cmd.Printf(" Track ID filter: %s\n", trackID) + cmd.Println(" Gap filling: always enabled") + + if trackID != "" { + cmd.Printf(" -> Processing specific track '%s'\n", trackID) + } else if sessionID != "" { + cmd.Printf(" -> Processing all tracks for session '%s'\n", sessionID) + } else if userID != "" { + cmd.Printf(" -> Processing all tracks for user '%s'\n", userID) } else { - fmt.Printf(" → Processing all tracks (no filters)\n") + cmd.Println(" -> Processing all tracks (no filters)") } - // Process all tracks and mux them - if err := p.processAllTracks(globalArgs, processAllArgs, metadata, p.logger); err != nil { - p.logger.Error("Failed to process and mux tracks: %v", err) - os.Exit(1) + // Prepare working directory + workDir, cleanup, err := prepareWorkDir(globalArgs, logger) + if err != nil { + return err } + defer cleanup() + globalArgs.WorkDir = workDir - p.logger.Info("Process-all command completed successfully") -} - -func (p *ProcessAllProcess) printUsage() { - fmt.Printf("Usage: process-all [OPTIONS]\n") - fmt.Printf("\nProcess audio, video, and mux them into combined files (all-in-one workflow)\n") - fmt.Printf("Outputs 3 files per session: audio WebM, video WebM, and muxed WebM\n") - fmt.Printf("Gap filling is always enabled for seamless playback.\n") - fmt.Printf("\nOptions:\n") - fmt.Printf(" --userId STRING Specify a userId or * for all (default: \"*\")\n") - fmt.Printf(" --sessionId STRING Specify a sessionId or * for all (default: \"*\")\n") - fmt.Printf(" --trackId STRING Specify a trackId or * for all (default: \"*\")\n") - fmt.Printf("\nOutput files per session:\n") - fmt.Printf(" audio_{userId}_{sessionId}_{trackId}.webm - Audio-only file\n") - fmt.Printf(" video_{userId}_{sessionId}_{trackId}.webm - Video-only file\n") - fmt.Printf(" muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file\n") -} - -func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllArgs *ProcessAllArgs, metadata *processing.RecordingMetadata, logger *getstream.DefaultLogger) error { + // Create output directory if it doesn't exist + if err := os.MkdirAll(globalArgs.Output, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } - if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); e != nil { - return e + // Extract audio tracks + if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); err != nil { + return fmt.Errorf("failed to extract audio tracks: %w", err) } - if e := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); e != nil { - return e + // Extract video tracks + if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); err != nil { + return fmt.Errorf("failed to extract video tracks: %w", err) } + // Mix all audio tracks mixer := processing.NewAudioMixer(logger) mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, @@ -109,8 +129,9 @@ func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllA WithCleanup: false, }, metadata, logger) - muxer := processing.NewAudioVideoMuxer(p.logger) - if e := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + // Mux audio/video tracks + muxer := processing.NewAudioVideoMuxer(logger) + if err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, UserID: "", @@ -119,9 +140,10 @@ func (p *ProcessAllProcess) processAllTracks(globalArgs *GlobalArgs, processAllA Media: "", WithExtract: false, WithCleanup: false, - }, metadata, logger); e != nil { - return e + }, metadata, logger); err != nil { + return fmt.Errorf("failed to mux audio/video tracks: %w", err) } + logger.Info("Process-all command completed successfully") return nil } diff --git a/pkg/cmd/raw-recording-tool/root.go b/pkg/cmd/raw-recording-tool/root.go new file mode 100644 index 0000000..65a973f --- /dev/null +++ b/pkg/cmd/raw-recording-tool/root.go @@ -0,0 +1,294 @@ +package rawrecording + +import ( + "fmt" + "log" + "os" + + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/GetStream/getstream-go/v3" +) + +// GlobalArgs holds the global arguments shared across all subcommands +type GlobalArgs struct { + InputFile string + InputDir string + InputS3 string + Output string + Verbose bool + WorkDir string +} + +func NewRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "raw-recording", + Short: "Post-processing tools for raw video call recordings", + Long: heredoc.Doc(` + Post-processing tools for raw video call recordings. + + These commands allow you to extract, process, and mux audio/video + tracks from raw recording archives. + `), + Example: heredoc.Doc(` + # List all tracks in a recording + $ stream-cli video raw-recording list-tracks --input-file recording.zip + + # Extract audio tracks for a specific user + $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --user-id user123 + + # Mux audio and video tracks + $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out + `), + } + + // Persistent flags (global options available to all subcommands) + pf := cmd.PersistentFlags() + pf.String("input-file", "", "Raw recording zip file path") + pf.String("input-dir", "", "Raw recording directory path") + pf.String("input-s3", "", "Raw recording S3 path") + pf.String("output", "", "Output directory") + pf.Bool("verbose", false, "Enable verbose logging") + + // Add subcommands + cmd.AddCommand( + listTracksCmd(), + extractAudioCmd(), + extractVideoCmd(), + muxAVCmd(), + mixAudioCmd(), + processAllCmd(), + ) + + return cmd +} + +// getGlobalArgs extracts global arguments from cobra command flags +func getGlobalArgs(cmd *cobra.Command) (*GlobalArgs, error) { + inputFile, _ := cmd.Flags().GetString("input-file") + inputDir, _ := cmd.Flags().GetString("input-dir") + inputS3, _ := cmd.Flags().GetString("input-s3") + output, _ := cmd.Flags().GetString("output") + verbose, _ := cmd.Flags().GetBool("verbose") + + return &GlobalArgs{ + InputFile: inputFile, + InputDir: inputDir, + InputS3: inputS3, + Output: output, + Verbose: verbose, + }, nil +} + +// validateGlobalArgs validates global arguments +func validateGlobalArgs(globalArgs *GlobalArgs, requireOutput bool) error { + if globalArgs.InputFile == "" && globalArgs.InputDir == "" && globalArgs.InputS3 == "" { + return fmt.Errorf("either --input-file or --input-dir or --input-s3 must be specified") + } + + num := 0 + if globalArgs.InputFile != "" { + num++ + } + if globalArgs.InputDir != "" { + num++ + } + if globalArgs.InputS3 != "" { + num++ + } + if num > 1 { + return fmt.Errorf("--input-file, --input-dir and --input-s3 are exclusive, only one is allowed") + } + + if requireOutput && globalArgs.Output == "" { + return fmt.Errorf("--output directory must be specified") + } + + return nil +} + +// validateInputArgs validates input arguments using mutually exclusive logic +func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*processing.RecordingMetadata, error) { + // Count how many filters are specified + filtersCount := 0 + if userID != "" { + filtersCount++ + } + if sessionID != "" { + filtersCount++ + } + if trackID != "" { + filtersCount++ + } + + // Ensure filters are mutually exclusive + if filtersCount > 1 { + return nil, fmt.Errorf("only one filter can be specified at a time: --user-id, --session-id, and --track-id are mutually exclusive") + } + + var inputPath string + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else if globalArgs.InputDir != "" { + inputPath = globalArgs.InputDir + } else { + return nil, fmt.Errorf("S3 input not implemented yet") + } + + // Parse metadata to validate the single specified argument + logger := setupLogger(false) + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, fmt.Errorf("failed to parse recording for validation: %w", err) + } + + // If no filters specified, no validation needed + if filtersCount == 0 { + return metadata, nil + } + + // Validate the single specified filter + if trackID != "" { + found := false + for _, track := range metadata.Tracks { + if track.TrackID == trackID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("track-id '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) + } + } else if sessionID != "" { + found := false + for _, track := range metadata.Tracks { + if track.SessionID == sessionID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("session-id '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) + } + } else if userID != "" { + found := false + for _, uid := range metadata.UserIDs { + if uid == userID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("user-id '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) + } + } + + return metadata, nil +} + +// setupLogger creates a logger with the specified verbosity +func setupLogger(verbose bool) *getstream.DefaultLogger { + var level getstream.LogLevel + if verbose { + level = getstream.LogLevelDebug + } else { + level = getstream.LogLevelInfo + } + return getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level) +} + +// prepareWorkDir extracts the recording to a temp directory and returns the working directory +func prepareWorkDir(globalArgs *GlobalArgs, logger *getstream.DefaultLogger) (string, func(), error) { + path := globalArgs.InputFile + if path == "" { + path = globalArgs.InputDir + } + + workingDir, cleanup, err := processing.ExtractToTempDir(path, logger) + if err != nil { + return "", nil, fmt.Errorf("failed to prepare working directory: %w", err) + } + + return workingDir, cleanup, nil +} + +// completeUserIDs provides completion for user IDs +func completeUserIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + inputFile, _ := cmd.Flags().GetString("input-file") + inputDir, _ := cmd.Flags().GetString("input-dir") + + inputPath := inputFile + if inputPath == "" { + inputPath = inputDir + } + if inputPath == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + logger := setupLogger(false) + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return metadata.UserIDs, cobra.ShellCompDirectiveNoFileComp +} + +// completeSessionIDs provides completion for session IDs +func completeSessionIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + inputFile, _ := cmd.Flags().GetString("input-file") + inputDir, _ := cmd.Flags().GetString("input-dir") + + inputPath := inputFile + if inputPath == "" { + inputPath = inputDir + } + if inputPath == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + logger := setupLogger(false) + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + return metadata.Sessions, cobra.ShellCompDirectiveNoFileComp +} + +// completeTrackIDs provides completion for track IDs +func completeTrackIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + inputFile, _ := cmd.Flags().GetString("input-file") + inputDir, _ := cmd.Flags().GetString("input-dir") + + inputPath := inputFile + if inputPath == "" { + inputPath = inputDir + } + if inputPath == "" { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + logger := setupLogger(false) + parser := processing.NewMetadataParser(logger) + metadata, err := parser.ParseMetadataOnly(inputPath) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + trackIDs := make([]string, 0, len(metadata.Tracks)) + seen := make(map[string]bool) + for _, track := range metadata.Tracks { + if !seen[track.TrackID] { + trackIDs = append(trackIDs, track.TrackID) + seen[track.TrackID] = true + } + } + + return trackIDs, cobra.ShellCompDirectiveNoFileComp +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 4585dcc..7e96b65 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -8,6 +8,7 @@ import ( "github.com/GetStream/stream-cli/pkg/cmd/chat" cfgCmd "github.com/GetStream/stream-cli/pkg/cmd/config" + "github.com/GetStream/stream-cli/pkg/cmd/video" "github.com/GetStream/stream-cli/pkg/config" "github.com/GetStream/stream-cli/pkg/version" ) @@ -39,6 +40,7 @@ func NewCmd() *cobra.Command { root.AddCommand( cfgCmd.NewRootCmd(), chat.NewRootCmd(), + video.NewRootCmd(), ) cobra.OnInitialize(config.GetInitConfig(root, cfgPath)) diff --git a/pkg/cmd/video/root.go b/pkg/cmd/video/root.go new file mode 100644 index 0000000..2cf9bd1 --- /dev/null +++ b/pkg/cmd/video/root.go @@ -0,0 +1,16 @@ +package video + +import ( + "github.com/spf13/cobra" + + rawrecording "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool" +) + +func NewRootCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "video", + Short: "Video processing commands", + } + cmd.AddCommand(rawrecording.NewRootCmd()) + return cmd +} From 2d115d101105c562640c5624bc93a72c66c30f82 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Tue, 20 Jan 2026 09:46:16 +0100 Subject: [PATCH 03/18] feat: use constants string field --- pkg/cmd/raw-recording-tool/constants.go | 82 +++++++++++++++++++++ pkg/cmd/raw-recording-tool/extract_audio.go | 26 +++---- pkg/cmd/raw-recording-tool/extract_video.go | 22 +++--- pkg/cmd/raw-recording-tool/list_tracks.go | 12 +-- pkg/cmd/raw-recording-tool/mux_av.go | 26 +++---- pkg/cmd/raw-recording-tool/process_all.go | 18 ++--- pkg/cmd/raw-recording-tool/root.go | 46 ++++++------ 7 files changed, 157 insertions(+), 75 deletions(-) create mode 100644 pkg/cmd/raw-recording-tool/constants.go diff --git a/pkg/cmd/raw-recording-tool/constants.go b/pkg/cmd/raw-recording-tool/constants.go new file mode 100644 index 0000000..6af9627 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/constants.go @@ -0,0 +1,82 @@ +package rawrecording + +// Flag names for global/persistent flags +const ( + FlagInputFile = "input-file" + FlagInputDir = "input-dir" + FlagInputS3 = "input-s3" + FlagOutput = "output" + FlagVerbose = "verbose" +) + +// Flag names for filter flags (used across multiple commands) +const ( + FlagUserID = "user-id" + FlagSessionID = "session-id" + FlagTrackID = "track-id" +) + +// Flag names for processing options +const ( + FlagFillGaps = "fill-gaps" + FlagFixDtx = "fix-dtx" + FlagMedia = "media" +) + +// Flag names for list-tracks command +const ( + FlagFormat = "format" + FlagTrackType = "track-type" + FlagCompletionType = "completion-type" +) + +// Flag descriptions for global/persistent flags +const ( + DescInputFile = "Raw recording zip file path" + DescInputDir = "Raw recording directory path" + DescInputS3 = "Raw recording S3 path" + DescOutput = "Output directory" + DescVerbose = "Enable verbose logging" +) + +// Flag descriptions for filter flags +const ( + DescUserID = "Filter by user ID" + DescSessionID = "Filter by session ID" + DescTrackID = "Filter by track ID" +) + +// Flag descriptions for processing options +const ( + DescFillGapsAudio = "Fill with silence when track was muted" + DescFillGapsVideo = "Fill with black frame when track was muted" + DescFixDtx = "Fix DTX shrink audio" + DescMedia = "Filter by media type: 'user', 'display', or 'both'" +) + +// Flag descriptions for list-tracks command +const ( + DescFormat = "Output format: table, json, users, sessions, tracks, completion" + DescTrackType = "Filter by track type: audio, video" + DescCompletionType = "For completion format: users, sessions, tracks" +) + +// Default values +const ( + DefaultFormat = "table" + DefaultCompletionType = "tracks" + DefaultMedia = "both" +) + +// Media type values +const ( + MediaUser = "user" + MediaDisplay = "display" + MediaBoth = "both" +) + +// Track type values +const ( + TrackTypeAudio = "audio" + TrackTypeVideo = "video" +) diff --git a/pkg/cmd/raw-recording-tool/extract_audio.go b/pkg/cmd/raw-recording-tool/extract_audio.go index 3ebd65b..b93cbe5 100644 --- a/pkg/cmd/raw-recording-tool/extract_audio.go +++ b/pkg/cmd/raw-recording-tool/extract_audio.go @@ -38,16 +38,16 @@ func extractAudioCmd() *cobra.Command { } fl := cmd.Flags() - fl.String("user-id", "", "Filter by user ID") - fl.String("session-id", "", "Filter by session ID") - fl.String("track-id", "", "Filter by track ID") - fl.Bool("fill-gaps", true, "Fill with silence when track was muted") - fl.Bool("fix-dtx", true, "Fix DTX shrink audio") + fl.String(FlagUserID, "", DescUserID) + fl.String(FlagSessionID, "", DescSessionID) + fl.String(FlagTrackID, "", DescTrackID) + fl.Bool(FlagFillGaps, true, DescFillGapsAudio) + fl.Bool(FlagFixDtx, true, DescFixDtx) // Register completions - _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) - _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) - _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagUserID, completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagSessionID, completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagTrackID, completeTrackIDs) return cmd } @@ -63,11 +63,11 @@ func runExtractAudio(cmd *cobra.Command, args []string) error { return err } - userID, _ := cmd.Flags().GetString("user-id") - sessionID, _ := cmd.Flags().GetString("session-id") - trackID, _ := cmd.Flags().GetString("track-id") - fillGaps, _ := cmd.Flags().GetBool("fill-gaps") - fixDtx, _ := cmd.Flags().GetBool("fix-dtx") + userID, _ := cmd.Flags().GetString(FlagUserID) + sessionID, _ := cmd.Flags().GetString(FlagSessionID) + trackID, _ := cmd.Flags().GetString(FlagTrackID) + fillGaps, _ := cmd.Flags().GetBool(FlagFillGaps) + fixDtx, _ := cmd.Flags().GetBool(FlagFixDtx) // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) diff --git a/pkg/cmd/raw-recording-tool/extract_video.go b/pkg/cmd/raw-recording-tool/extract_video.go index 42b5d82..1709e0e 100644 --- a/pkg/cmd/raw-recording-tool/extract_video.go +++ b/pkg/cmd/raw-recording-tool/extract_video.go @@ -38,15 +38,15 @@ func extractVideoCmd() *cobra.Command { } fl := cmd.Flags() - fl.String("user-id", "", "Filter by user ID") - fl.String("session-id", "", "Filter by session ID") - fl.String("track-id", "", "Filter by track ID") - fl.Bool("fill-gaps", true, "Fill with black frame when track was muted") + fl.String(FlagUserID, "", DescUserID) + fl.String(FlagSessionID, "", DescSessionID) + fl.String(FlagTrackID, "", DescTrackID) + fl.Bool(FlagFillGaps, true, DescFillGapsVideo) // Register completions - _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) - _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) - _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagUserID, completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagSessionID, completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagTrackID, completeTrackIDs) return cmd } @@ -62,10 +62,10 @@ func runExtractVideo(cmd *cobra.Command, args []string) error { return err } - userID, _ := cmd.Flags().GetString("user-id") - sessionID, _ := cmd.Flags().GetString("session-id") - trackID, _ := cmd.Flags().GetString("track-id") - fillGaps, _ := cmd.Flags().GetBool("fill-gaps") + userID, _ := cmd.Flags().GetString(FlagUserID) + sessionID, _ := cmd.Flags().GetString(FlagSessionID) + trackID, _ := cmd.Flags().GetString(FlagTrackID) + fillGaps, _ := cmd.Flags().GetBool(FlagFillGaps) // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) diff --git a/pkg/cmd/raw-recording-tool/list_tracks.go b/pkg/cmd/raw-recording-tool/list_tracks.go index acbc844..a7e80ed 100644 --- a/pkg/cmd/raw-recording-tool/list_tracks.go +++ b/pkg/cmd/raw-recording-tool/list_tracks.go @@ -40,9 +40,9 @@ func listTracksCmd() *cobra.Command { } fl := cmd.Flags() - fl.String("format", "table", "Output format: table, json, users, sessions, tracks, completion") - fl.String("track-type", "", "Filter by track type: audio, video") - fl.String("completion-type", "tracks", "For completion format: users, sessions, tracks") + fl.String(FlagFormat, DefaultFormat, DescFormat) + fl.String(FlagTrackType, "", DescTrackType) + fl.String(FlagCompletionType, DefaultCompletionType, DescCompletionType) return cmd } @@ -58,9 +58,9 @@ func runListTracks(cmd *cobra.Command, args []string) error { return err } - format, _ := cmd.Flags().GetString("format") - trackType, _ := cmd.Flags().GetString("track-type") - completionType, _ := cmd.Flags().GetString("completion-type") + format, _ := cmd.Flags().GetString(FlagFormat) + trackType, _ := cmd.Flags().GetString(FlagTrackType) + completionType, _ := cmd.Flags().GetString(FlagCompletionType) logger := setupLogger(globalArgs.Verbose) logger.Info("Starting list-tracks command") diff --git a/pkg/cmd/raw-recording-tool/mux_av.go b/pkg/cmd/raw-recording-tool/mux_av.go index 752879d..1c49b4e 100644 --- a/pkg/cmd/raw-recording-tool/mux_av.go +++ b/pkg/cmd/raw-recording-tool/mux_av.go @@ -44,17 +44,17 @@ func muxAVCmd() *cobra.Command { } fl := cmd.Flags() - fl.String("user-id", "", "Filter by user ID") - fl.String("session-id", "", "Filter by session ID") - fl.String("track-id", "", "Filter by track ID") - fl.String("media", "both", "Filter by media type: 'user', 'display', or 'both'") + fl.String(FlagUserID, "", DescUserID) + fl.String(FlagSessionID, "", DescSessionID) + fl.String(FlagTrackID, "", DescTrackID) + fl.String(FlagMedia, DefaultMedia, DescMedia) // Register completions - _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) - _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) - _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) - _ = cmd.RegisterFlagCompletionFunc("media", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"user", "display", "both"}, cobra.ShellCompDirectiveNoFileComp + _ = cmd.RegisterFlagCompletionFunc(FlagUserID, completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagSessionID, completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagTrackID, completeTrackIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagMedia, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{MediaUser, MediaDisplay, MediaBoth}, cobra.ShellCompDirectiveNoFileComp }) return cmd @@ -71,10 +71,10 @@ func runMuxAV(cmd *cobra.Command, args []string) error { return err } - userID, _ := cmd.Flags().GetString("user-id") - sessionID, _ := cmd.Flags().GetString("session-id") - trackID, _ := cmd.Flags().GetString("track-id") - media, _ := cmd.Flags().GetString("media") + userID, _ := cmd.Flags().GetString(FlagUserID) + sessionID, _ := cmd.Flags().GetString(FlagSessionID) + trackID, _ := cmd.Flags().GetString(FlagTrackID) + media, _ := cmd.Flags().GetString(FlagMedia) // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) diff --git a/pkg/cmd/raw-recording-tool/process_all.go b/pkg/cmd/raw-recording-tool/process_all.go index 0ef0739..071eca1 100644 --- a/pkg/cmd/raw-recording-tool/process_all.go +++ b/pkg/cmd/raw-recording-tool/process_all.go @@ -41,14 +41,14 @@ func processAllCmd() *cobra.Command { } fl := cmd.Flags() - fl.String("user-id", "", "Filter by user ID") - fl.String("session-id", "", "Filter by session ID") - fl.String("track-id", "", "Filter by track ID") + fl.String(FlagUserID, "", DescUserID) + fl.String(FlagSessionID, "", DescSessionID) + fl.String(FlagTrackID, "", DescTrackID) // Register completions - _ = cmd.RegisterFlagCompletionFunc("user-id", completeUserIDs) - _ = cmd.RegisterFlagCompletionFunc("session-id", completeSessionIDs) - _ = cmd.RegisterFlagCompletionFunc("track-id", completeTrackIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagUserID, completeUserIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagSessionID, completeSessionIDs) + _ = cmd.RegisterFlagCompletionFunc(FlagTrackID, completeTrackIDs) return cmd } @@ -64,9 +64,9 @@ func runProcessAll(cmd *cobra.Command, args []string) error { return err } - userID, _ := cmd.Flags().GetString("user-id") - sessionID, _ := cmd.Flags().GetString("session-id") - trackID, _ := cmd.Flags().GetString("track-id") + userID, _ := cmd.Flags().GetString(FlagUserID) + sessionID, _ := cmd.Flags().GetString(FlagSessionID) + trackID, _ := cmd.Flags().GetString(FlagTrackID) // Validate input arguments against actual recording data metadata, err := validateInputArgs(globalArgs, userID, sessionID, trackID) diff --git a/pkg/cmd/raw-recording-tool/root.go b/pkg/cmd/raw-recording-tool/root.go index 65a973f..2d26e02 100644 --- a/pkg/cmd/raw-recording-tool/root.go +++ b/pkg/cmd/raw-recording-tool/root.go @@ -46,11 +46,11 @@ func NewRootCmd() *cobra.Command { // Persistent flags (global options available to all subcommands) pf := cmd.PersistentFlags() - pf.String("input-file", "", "Raw recording zip file path") - pf.String("input-dir", "", "Raw recording directory path") - pf.String("input-s3", "", "Raw recording S3 path") - pf.String("output", "", "Output directory") - pf.Bool("verbose", false, "Enable verbose logging") + pf.String(FlagInputFile, "", DescInputFile) + pf.String(FlagInputDir, "", DescInputDir) + pf.String(FlagInputS3, "", DescInputS3) + pf.String(FlagOutput, "", DescOutput) + pf.Bool(FlagVerbose, false, DescVerbose) // Add subcommands cmd.AddCommand( @@ -67,11 +67,11 @@ func NewRootCmd() *cobra.Command { // getGlobalArgs extracts global arguments from cobra command flags func getGlobalArgs(cmd *cobra.Command) (*GlobalArgs, error) { - inputFile, _ := cmd.Flags().GetString("input-file") - inputDir, _ := cmd.Flags().GetString("input-dir") - inputS3, _ := cmd.Flags().GetString("input-s3") - output, _ := cmd.Flags().GetString("output") - verbose, _ := cmd.Flags().GetBool("verbose") + inputFile, _ := cmd.Flags().GetString(FlagInputFile) + inputDir, _ := cmd.Flags().GetString(FlagInputDir) + inputS3, _ := cmd.Flags().GetString(FlagInputS3) + output, _ := cmd.Flags().GetString(FlagOutput) + verbose, _ := cmd.Flags().GetBool(FlagVerbose) return &GlobalArgs{ InputFile: inputFile, @@ -85,7 +85,7 @@ func getGlobalArgs(cmd *cobra.Command) (*GlobalArgs, error) { // validateGlobalArgs validates global arguments func validateGlobalArgs(globalArgs *GlobalArgs, requireOutput bool) error { if globalArgs.InputFile == "" && globalArgs.InputDir == "" && globalArgs.InputS3 == "" { - return fmt.Errorf("either --input-file or --input-dir or --input-s3 must be specified") + return fmt.Errorf("either --%s or --%s or --%s must be specified", FlagInputFile, FlagInputDir, FlagInputS3) } num := 0 @@ -99,11 +99,11 @@ func validateGlobalArgs(globalArgs *GlobalArgs, requireOutput bool) error { num++ } if num > 1 { - return fmt.Errorf("--input-file, --input-dir and --input-s3 are exclusive, only one is allowed") + return fmt.Errorf("--%s, --%s and --%s are exclusive, only one is allowed", FlagInputFile, FlagInputDir, FlagInputS3) } if requireOutput && globalArgs.Output == "" { - return fmt.Errorf("--output directory must be specified") + return fmt.Errorf("--%s directory must be specified", FlagOutput) } return nil @@ -125,7 +125,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string // Ensure filters are mutually exclusive if filtersCount > 1 { - return nil, fmt.Errorf("only one filter can be specified at a time: --user-id, --session-id, and --track-id are mutually exclusive") + return nil, fmt.Errorf("only one filter can be specified at a time: --%s, --%s, and --%s are mutually exclusive", FlagUserID, FlagSessionID, FlagTrackID) } var inputPath string @@ -160,7 +160,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return nil, fmt.Errorf("track-id '%s' not found in recording. Use 'list-tracks --format tracks' to see available track IDs", trackID) + return nil, fmt.Errorf("%s '%s' not found in recording. Use 'list-tracks --%s tracks' to see available track IDs", FlagTrackID, trackID, FlagFormat) } } else if sessionID != "" { found := false @@ -171,7 +171,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return nil, fmt.Errorf("session-id '%s' not found in recording. Use 'list-tracks --format sessions' to see available session IDs", sessionID) + return nil, fmt.Errorf("%s '%s' not found in recording. Use 'list-tracks --%s sessions' to see available session IDs", FlagSessionID, sessionID, FlagFormat) } } else if userID != "" { found := false @@ -182,7 +182,7 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } } if !found { - return nil, fmt.Errorf("user-id '%s' not found in recording. Use 'list-tracks --format users' to see available user IDs", userID) + return nil, fmt.Errorf("%s '%s' not found in recording. Use 'list-tracks --%s users' to see available user IDs", FlagUserID, userID, FlagFormat) } } @@ -217,8 +217,8 @@ func prepareWorkDir(globalArgs *GlobalArgs, logger *getstream.DefaultLogger) (st // completeUserIDs provides completion for user IDs func completeUserIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - inputFile, _ := cmd.Flags().GetString("input-file") - inputDir, _ := cmd.Flags().GetString("input-dir") + inputFile, _ := cmd.Flags().GetString(FlagInputFile) + inputDir, _ := cmd.Flags().GetString(FlagInputDir) inputPath := inputFile if inputPath == "" { @@ -240,8 +240,8 @@ func completeUserIDs(cmd *cobra.Command, args []string, toComplete string) ([]st // completeSessionIDs provides completion for session IDs func completeSessionIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - inputFile, _ := cmd.Flags().GetString("input-file") - inputDir, _ := cmd.Flags().GetString("input-dir") + inputFile, _ := cmd.Flags().GetString(FlagInputFile) + inputDir, _ := cmd.Flags().GetString(FlagInputDir) inputPath := inputFile if inputPath == "" { @@ -263,8 +263,8 @@ func completeSessionIDs(cmd *cobra.Command, args []string, toComplete string) ([ // completeTrackIDs provides completion for track IDs func completeTrackIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - inputFile, _ := cmd.Flags().GetString("input-file") - inputDir, _ := cmd.Flags().GetString("input-dir") + inputFile, _ := cmd.Flags().GetString(FlagInputFile) + inputDir, _ := cmd.Flags().GetString(FlagInputDir) inputPath := inputFile if inputPath == "" { From e5b84b6608f22f091c98b34a7f4895992780b788 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 22 Jan 2026 15:58:12 +0100 Subject: [PATCH 04/18] feat: update with ProcessingLogger adapter --- pkg/cmd/raw-recording-tool/extract_audio.go | 13 +- pkg/cmd/raw-recording-tool/extract_video.go | 13 +- pkg/cmd/raw-recording-tool/mix_audio.go | 2 +- pkg/cmd/raw-recording-tool/mux_av.go | 6 +- pkg/cmd/raw-recording-tool/process_all.go | 33 +- .../processing/archive_input.go | 6 +- .../processing/archive_json.go | 18 +- .../processing/archive_metadata.go | 93 ++-- .../processing/audio_mixer.go | 114 +++-- .../processing/audio_video_muxer.go | 162 ++++--- .../processing/constants.go | 17 +- .../processing/container_converter.go | 235 ++++----- .../processing/ffmpeg_converter.go | 181 +++---- .../processing/ffmpeg_helper.go | 194 ++++---- .../processing/gstreamer_converter.go | 452 +++++------------- .../processing/logger_adapter.go | 47 ++ .../processing/track_extractor.go | 214 ++++++--- pkg/cmd/raw-recording-tool/root.go | 6 +- 18 files changed, 950 insertions(+), 856 deletions(-) create mode 100644 pkg/cmd/raw-recording-tool/processing/logger_adapter.go diff --git a/pkg/cmd/raw-recording-tool/extract_audio.go b/pkg/cmd/raw-recording-tool/extract_audio.go index b93cbe5..1a99493 100644 --- a/pkg/cmd/raw-recording-tool/extract_audio.go +++ b/pkg/cmd/raw-recording-tool/extract_audio.go @@ -95,7 +95,18 @@ func runExtractAudio(cmd *cobra.Command, args []string) error { } // Extract audio tracks - if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "audio", "both", fillGaps, fixDtx, logger); err != nil { + extractor := processing.NewTrackExtractor(logger) + if _, err := extractor.ExtractTracks(&processing.TrackExtractorConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: userID, + SessionID: sessionID, + TrackID: trackID, + TrackKind: TrackTypeAudio, + MediaType: "both", + FillGap: fillGaps, + FillDtx: fixDtx, + }, metadata); err != nil { return fmt.Errorf("failed to extract audio: %w", err) } diff --git a/pkg/cmd/raw-recording-tool/extract_video.go b/pkg/cmd/raw-recording-tool/extract_video.go index 1709e0e..ab1a93e 100644 --- a/pkg/cmd/raw-recording-tool/extract_video.go +++ b/pkg/cmd/raw-recording-tool/extract_video.go @@ -93,7 +93,18 @@ func runExtractVideo(cmd *cobra.Command, args []string) error { } // Extract video tracks - if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, userID, sessionID, trackID, metadata, "video", "both", fillGaps, false, logger); err != nil { + extractor := processing.NewTrackExtractor(logger) + if _, err := extractor.ExtractTracks(&processing.TrackExtractorConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: userID, + SessionID: sessionID, + TrackID: trackID, + TrackKind: TrackTypeVideo, + MediaType: "both", + FillGap: fillGaps, + FillDtx: false, + }, metadata); err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } diff --git a/pkg/cmd/raw-recording-tool/mix_audio.go b/pkg/cmd/raw-recording-tool/mix_audio.go index dccbb5a..a6b77f7 100644 --- a/pkg/cmd/raw-recording-tool/mix_audio.go +++ b/pkg/cmd/raw-recording-tool/mix_audio.go @@ -74,7 +74,7 @@ func runMixAudio(cmd *cobra.Command, args []string) error { WithScreenshare: false, WithExtract: true, WithCleanup: false, - }, metadata, logger) + }, metadata) logger.Info("Mix-audio command completed successfully") return nil diff --git a/pkg/cmd/raw-recording-tool/mux_av.go b/pkg/cmd/raw-recording-tool/mux_av.go index 1c49b4e..f6571b2 100644 --- a/pkg/cmd/raw-recording-tool/mux_av.go +++ b/pkg/cmd/raw-recording-tool/mux_av.go @@ -119,16 +119,16 @@ func runMuxAV(cmd *cobra.Command, args []string) error { // Mux audio/video tracks muxer := processing.NewAudioVideoMuxer(logger) - if err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + if _, err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, UserID: userID, SessionID: sessionID, TrackID: trackID, - Media: media, + MediaType: media, WithExtract: true, WithCleanup: false, - }, metadata, logger); err != nil { + }, metadata); err != nil { return fmt.Errorf("failed to mux audio/video tracks: %w", err) } diff --git a/pkg/cmd/raw-recording-tool/process_all.go b/pkg/cmd/raw-recording-tool/process_all.go index 071eca1..d66b0d1 100644 --- a/pkg/cmd/raw-recording-tool/process_all.go +++ b/pkg/cmd/raw-recording-tool/process_all.go @@ -110,12 +110,33 @@ func runProcessAll(cmd *cobra.Command, args []string) error { } // Extract audio tracks - if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "audio", "both", true, true, logger); err != nil { + extractor := processing.NewTrackExtractor(logger) + if _, err := extractor.ExtractTracks(&processing.TrackExtractorConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: "", + SessionID: "", + TrackID: "", + TrackKind: TrackTypeAudio, + MediaType: "both", + FillGap: true, + FillDtx: true, + }, metadata); err != nil { return fmt.Errorf("failed to extract audio tracks: %w", err) } // Extract video tracks - if err := processing.ExtractTracks(globalArgs.WorkDir, globalArgs.Output, "", "", "", metadata, "video", "both", true, true, logger); err != nil { + if _, err := extractor.ExtractTracks(&processing.TrackExtractorConfig{ + WorkDir: globalArgs.WorkDir, + OutputDir: globalArgs.Output, + UserID: "", + SessionID: "", + TrackID: "", + TrackKind: TrackTypeVideo, + MediaType: "both", + FillGap: true, + FillDtx: true, + }, metadata); err != nil { return fmt.Errorf("failed to extract video tracks: %w", err) } @@ -127,20 +148,20 @@ func runProcessAll(cmd *cobra.Command, args []string) error { WithScreenshare: false, WithExtract: false, WithCleanup: false, - }, metadata, logger) + }, metadata) // Mux audio/video tracks muxer := processing.NewAudioVideoMuxer(logger) - if err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ + if _, err := muxer.MuxAudioVideoTracks(&processing.AudioVideoMuxerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, UserID: "", SessionID: "", TrackID: "", - Media: "", + MediaType: "", WithExtract: false, WithCleanup: false, - }, metadata, logger); err != nil { + }, metadata); err != nil { return fmt.Errorf("failed to mux audio/video tracks: %w", err) } diff --git a/pkg/cmd/raw-recording-tool/processing/archive_input.go b/pkg/cmd/raw-recording-tool/processing/archive_input.go index 5f61d8d..491b525 100644 --- a/pkg/cmd/raw-recording-tool/processing/archive_input.go +++ b/pkg/cmd/raw-recording-tool/processing/archive_input.go @@ -8,13 +8,11 @@ import ( "os" "path/filepath" "strings" - - "github.com/GetStream/getstream-go/v3" ) // extractToTempDir extracts archive to temp directory or returns the directory path // Returns: (workingDir, cleanupFunc, error) -func ExtractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string, func(), error) { +func ExtractToTempDir(inputPath string, logger *ProcessingLogger) (string, func(), error) { // If it's already a directory, just return it if stat, err := os.Stat(inputPath); err == nil && stat.IsDir() { logger.Debug("Input is already a directory: %s", inputPath) @@ -48,7 +46,7 @@ func ExtractToTempDir(inputPath string, logger *getstream.DefaultLogger) (string } // extractTarGzToDir extracts a tar.gz file to the specified directory -func extractTarGzToDir(tarGzPath, destDir string, logger *getstream.DefaultLogger) error { +func extractTarGzToDir(tarGzPath, destDir string, logger *ProcessingLogger) error { file, err := os.Open(tarGzPath) if err != nil { return fmt.Errorf("failed to open tar.gz file: %w", err) diff --git a/pkg/cmd/raw-recording-tool/processing/archive_json.go b/pkg/cmd/raw-recording-tool/processing/archive_json.go index 85dfc81..3718890 100644 --- a/pkg/cmd/raw-recording-tool/processing/archive_json.go +++ b/pkg/cmd/raw-recording-tool/processing/archive_json.go @@ -1,8 +1,14 @@ package processing +import "time" + type SessionTimingMetadata struct { - ParticipantID string `json:"participant_id"` - UserSessionID string `json:"user_session_id"` + CallType string `json:"call_type"` + CallID string `json:"call_id"` + CallSessionID string `json:"call_session_id"` + CallStartTime time.Time `json:"call_start_time"` + ParticipantID string `json:"participant_id"` + UserSessionID string `json:"user_session_id"` Segments struct { Audio []*SegmentMetadata `json:"audio"` Video []*SegmentMetadata `json:"video"` @@ -27,4 +33,12 @@ type SegmentMetadata struct { FirstRtcpNtpTimestamp int64 `json:"first_rtcp_ntp_timestamp,omitempty"` LastRtcpRtpTimestamp uint32 `json:"last_rtcp_rtp_timestamp,omitempty"` LastRtcpNtpTimestamp int64 `json:"last_rtcp_ntp_timestamp,omitempty"` + + FirstKeyFrameOffsetMs *int64 `json:"first_key_frame_offset_ms,omitempty"` + MaxFrameDimension *SegmentFrameDimension `json:"max_frame_dimension,omitempty"` +} + +type SegmentFrameDimension struct { + Width uint32 `json:"width,omitempty"` + Height uint32 `json:"height,omitempty"` } diff --git a/pkg/cmd/raw-recording-tool/processing/archive_metadata.go b/pkg/cmd/raw-recording-tool/processing/archive_metadata.go index f1ae828..2535eb3 100644 --- a/pkg/cmd/raw-recording-tool/processing/archive_metadata.go +++ b/pkg/cmd/raw-recording-tool/processing/archive_metadata.go @@ -10,22 +10,28 @@ import ( "path/filepath" "sort" "strings" - - "github.com/GetStream/getstream-go/v3" + "time" ) // TrackInfo represents a single track with its metadata (deduplicated across segments) type TrackInfo struct { - UserID string `json:"userId"` // participant_id from timing metadata - SessionID string `json:"sessionId"` // user_session_id from timing metadata - TrackID string `json:"trackId"` // track_id from segment - TrackType string `json:"trackType"` // "audio" or "video" (cleaned from TRACK_TYPE_*) - IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track - Codec string `json:"codec"` // codec info - SegmentCount int `json:"segmentCount"` // number of segments for this track - Segments []*SegmentInfo `json:"segments"` // list of filenames (for JSON output only) - - ConcatenatedContainerPath string + CallType string `json:"callType"` // call_type from timing metadata + CallID string `json:"callId"` // call_id from timing metadata + CallSessionID string `json:"callSessionId"` // call_session_id from timing metadata + CallStartTime time.Time `json:"callStartTime"` // call_start_time from timing metadata + UserID string `json:"userId"` // participant_id from timing metadata + SessionID string `json:"sessionId"` // user_session_id from timing metadata + TrackID string `json:"trackId"` // track_id from segment + TrackType string `json:"trackType"` // track_type from segment + TrackKind string `json:"trackKind"` // "audio" or "video" (cleaned from TRACK_TYPE_*) + IsScreenshare bool `json:"isScreenshare"` // true if this is a screenshare track + Codec string `json:"codec"` // codec info + SegmentCount int `json:"segmentCount"` // number of segments for this track + TrackStartTime time.Time `json:"trackStartTime"` // first_rtp_unix_timestamp from segment + TrackEndTime time.Time `json:"trackEndTime"` // last_rtp_unix_timestamp from segment + Segments []*SegmentInfo `json:"segments"` // list of filenames (for JSON output only) + + ConcatenatedTrackFileInfo *TrackFileInfo } type SegmentInfo struct { @@ -35,7 +41,13 @@ type SegmentInfo struct { SdpPath string ContainerPath string ContainerExt string - FFMpegOffset int64 +} + +type TrackFileInfo struct { + Name string + StartAt time.Time + EndAt time.Time + MaxFrameDimension SegmentFrameDimension } // RecordingMetadata contains all tracks and session information @@ -47,11 +59,11 @@ type RecordingMetadata struct { // MetadataParser handles parsing of raw recording files type MetadataParser struct { - logger *getstream.DefaultLogger + logger *ProcessingLogger } // NewMetadataParser creates a new metadata parser -func NewMetadataParser(logger *getstream.DefaultLogger) *MetadataParser { +func NewMetadataParser(logger *ProcessingLogger) *MetadataParser { return &MetadataParser{ logger: logger, } @@ -197,27 +209,42 @@ func (p *MetadataParser) parseTimingMetadataFile(data []byte) ([]*TrackInfo, err // Use a map to deduplicate tracks by unique key trackMap := make(map[string]*TrackInfo) - processSegment := func(segment *SegmentMetadata, trackType string) { + processSegment := func(segment *SegmentMetadata, trackKind string) { key := fmt.Sprintf("%s|%s|%s|%s", sessionMetadata.ParticipantID, sessionMetadata.UserSessionID, segment.TrackID, - trackType) + trackKind) if existingTrack, exists := trackMap[key]; exists { existingTrack.Segments = append(existingTrack.Segments, &SegmentInfo{metadata: segment}) existingTrack.SegmentCount++ + + ts, te := time.UnixMilli(segment.FirstRtpUnixTimestamp), time.UnixMilli(segment.LastRtpUnixTimestamp) + if ts.Before(existingTrack.TrackStartTime) { + existingTrack.TrackStartTime = ts + } + if te.After(existingTrack.TrackEndTime) { + existingTrack.TrackEndTime = te + } } else { // Create new track track := &TrackInfo{ - UserID: sessionMetadata.ParticipantID, - SessionID: sessionMetadata.UserSessionID, - TrackID: segment.TrackID, - TrackType: p.cleanTrackType(segment.TrackType), - IsScreenshare: p.isScreenshareTrack(segment.TrackType), - Codec: segment.Codec, - SegmentCount: 1, - Segments: []*SegmentInfo{{metadata: segment}}, + CallType: sessionMetadata.CallType, + CallID: sessionMetadata.CallID, + CallSessionID: sessionMetadata.CallSessionID, + CallStartTime: sessionMetadata.CallStartTime, + UserID: sessionMetadata.ParticipantID, + SessionID: sessionMetadata.UserSessionID, + TrackID: segment.TrackID, + TrackType: segment.TrackType, + TrackKind: p.cleanTrackType(segment.TrackType), + IsScreenshare: p.isScreenshareTrack(segment.TrackType), + Codec: segment.Codec, + SegmentCount: 1, + TrackStartTime: time.UnixMilli(segment.FirstRtpUnixTimestamp), + TrackEndTime: time.UnixMilli(segment.LastRtpUnixTimestamp), + Segments: []*SegmentInfo{{metadata: segment}}, } trackMap[key] = track } @@ -254,9 +281,9 @@ func (p *MetadataParser) isScreenshareTrack(trackType string) bool { func (p *MetadataParser) cleanTrackType(trackType string) string { switch trackType { case "TRACK_TYPE_AUDIO", "TRACK_TYPE_SCREEN_SHARE_AUDIO": - return "audio" + return trackKindAudio case "TRACK_TYPE_VIDEO", "TRACK_TYPE_SCREEN_SHARE": - return "video" + return trackKindVideo default: return strings.ToLower(trackType) } @@ -298,20 +325,20 @@ func (p *MetadataParser) extractUniqueSessions(tracks []*TrackInfo) []string { // Only one filter (userID, sessionID, or trackID) can be specified at a time // Empty values are ignored, specific values must match // If all are empty, all tracks are returned -func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackType, mediaFilter string) []*TrackInfo { +func FilterTracks(tracks []*TrackInfo, userID, sessionID, trackID, trackKind, mediaType string) []*TrackInfo { filtered := make([]*TrackInfo, 0) for _, track := range tracks { - if trackType != "" && track.TrackType != trackType { - continue // Skip tracks with wrong TrackType + if trackKind != "" && track.TrackKind != trackKind { + continue // Skip tracks with wrong trackKind } // Apply media type filtering if specified - if mediaFilter != "" && mediaFilter != "both" { - if mediaFilter == "user" && track.IsScreenshare { + if mediaType != "" && mediaType != mediaTypeBoth { + if mediaType == mediaTypeUser && track.IsScreenshare { continue // Skip display tracks when only user requested } - if mediaFilter == "display" && !track.IsScreenshare { + if mediaType == mediaTypeDisplay && !track.IsScreenshare { continue // Skip user tracks when only display requested } } diff --git a/pkg/cmd/raw-recording-tool/processing/audio_mixer.go b/pkg/cmd/raw-recording-tool/processing/audio_mixer.go index b69751f..f502b92 100644 --- a/pkg/cmd/raw-recording-tool/processing/audio_mixer.go +++ b/pkg/cmd/raw-recording-tool/processing/audio_mixer.go @@ -2,90 +2,133 @@ package processing import ( "fmt" + "os" "path/filepath" + "slices" +) - "github.com/GetStream/getstream-go/v3" +const ( + FormatMp3 = "mp3" + FormatWeba = "weba" + FormatWebm = "webm" + FormatMka = "mka" + FormatMkv = "mkv" + DefaultFormat = FormatMkv ) +var supportedFormats = [5]string{FormatMp3, FormatWeba, FormatWebm, FormatMka, FormatMkv} + type AudioMixerConfig struct { WorkDir string OutputDir string + Format string WithScreenshare bool - WithExtract bool - WithCleanup bool + + WithExtract bool + WithCleanup bool } type AudioMixer struct { - logger *getstream.DefaultLogger + logger *ProcessingLogger } -func NewAudioMixer(logger *getstream.DefaultLogger) *AudioMixer { +func NewAudioMixer(logger *ProcessingLogger) *AudioMixer { return &AudioMixer{logger: logger} } // MixAllAudioTracks orchestrates the entire audio mixing workflow using existing extraction logic -func (p *AudioMixer) MixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *AudioMixer) MixAllAudioTracks(config *AudioMixerConfig, metadata *RecordingMetadata) (*string, error) { + p.overrideConfig(config) + // Step 1: Extract all matching audio tracks using existing ExtractTracks function - logger.Info("Step 1/2: Extracting all matching audio tracks...") + p.logger.Info("Extracting all matching audio tracks...") if config.WithExtract { - mediaFilter := "user" - if config.WithScreenshare { - mediaFilter = "both" + mediaType := "" + if !config.WithScreenshare { + mediaType = "user" } - if err := ExtractTracks(config.WorkDir, config.OutputDir, "", "", "", metadata, "audio", mediaFilter, true, true, logger); err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) + cfg := &TrackExtractorConfig{ + WorkDir: config.WorkDir, + OutputDir: config.OutputDir, + UserID: "", + SessionID: "", + TrackID: "", + TrackKind: trackKindAudio, + MediaType: mediaType, + FillDtx: true, + FillGap: true, + + Cleanup: config.WithCleanup, + } + + extractor := NewTrackExtractor(p.logger) + if _, err := extractor.ExtractTracks(cfg, metadata); err != nil { + return nil, fmt.Errorf("failed to extract audio tracks: %w", err) } } - fileOffsetMap := p.offset(metadata, config.WithScreenshare, logger) - if len(fileOffsetMap) == 0 { - return fmt.Errorf("no audio files were extracted - check your filter criteria") + fileOffsets := p.offset(metadata, config.WithScreenshare) + if len(fileOffsets) == 0 { + p.logger.Warn("No audio tracks found") + return nil, nil } - logger.Info("Found %d extracted audio files to mix", len(fileOffsetMap)) + p.logger.Info("Found %d extracted audio files to mix", len(fileOffsets)) + + //// Clean up individual audio files (optional) + if config.WithCleanup { + defer func(offsets *[]*FileOffset) { + for _, fileOffset := range *offsets { + p.logger.Info("Cleaning up temporary file: %s", fileOffset.Name) + if err := os.Remove(fileOffset.Name); err != nil { + p.logger.Warn("Failed to clean up temporary file %s: %v", fileOffset.Name, err) + } + } + }(&fileOffsets) + } // Step 3: Mix all discovered audio files using existing webm.mixAudioFiles - outputFile := filepath.Join(config.OutputDir, "mixed_audio.webm") + outputFile := p.buildFilename(config, metadata) - err := mixAudioFiles(outputFile, fileOffsetMap, logger) + err := runFFmpegCommand(generateMixAudioFilesArguments(outputFile, config.Format, fileOffsets), p.logger) if err != nil { - return fmt.Errorf("failed to mix audio files: %w", err) + return nil, fmt.Errorf("failed to mix audio files: %w", err) } - logger.Info("Successfully created mixed audio file: %s", outputFile) + p.logger.Info("Successfully created mixed audio file: %s", outputFile) - //// Clean up individual audio files (optional) - //for _, audioFile := range audioFiles { - // if err := os.Remove(audioFile.FilePath); err != nil { - // logger.Warn("Failed to clean up temporary file %s: %v", audioFile.FilePath, err) - // } - //} + return &outputFile, nil +} - return nil +func (p *AudioMixer) overrideConfig(config *AudioMixerConfig) { + if !slices.Contains(supportedFormats[:], config.Format) { + p.logger.Warn("Audio format %s not supported, fallback to default %s", config.Format, DefaultFormat) + config.Format = DefaultFormat + } } -func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, logger *getstream.DefaultLogger) []*FileOffset { +func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool) []*FileOffset { var offsets []*FileOffset var firstTrack *TrackInfo for _, t := range metadata.Tracks { - if t.TrackType == "audio" && (!t.IsScreenshare || withScreenshare) { + if t.TrackKind == trackKindAudio && (!t.IsScreenshare || withScreenshare) { if firstTrack == nil { firstTrack = t offsets = append(offsets, &FileOffset{ - Name: t.ConcatenatedContainerPath, + Name: t.ConcatenatedTrackFileInfo.Name, Offset: 0, // Will be sorted later and rearranged }) } else { - offset, err := calculateSyncOffsetFromFiles(t, firstTrack, logger) + offset, err := calculateSyncOffsetFromFiles(t, firstTrack) if err != nil { - logger.Warn("Failed to calculate sync offset for audio tracks: %v", err) + p.logger.Warn("Failed to calculate sync offset for audio tracks: %v", err) continue } offsets = append(offsets, &FileOffset{ - Name: t.ConcatenatedContainerPath, + Name: t.ConcatenatedTrackFileInfo.Name, Offset: offset, }) } @@ -94,3 +137,8 @@ func (p *AudioMixer) offset(metadata *RecordingMetadata, withScreenshare bool, l return offsets } + +func (p *AudioMixer) buildFilename(config *AudioMixerConfig, metadata *RecordingMetadata) string { + tr := metadata.Tracks[0] + return filepath.Join(config.OutputDir, fmt.Sprintf("composite_%s_%s_%s_%d.%s", tr.CallType, tr.CallID, trackKindAudio, tr.CallStartTime.UTC().UnixMilli(), config.Format)) +} diff --git a/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go b/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go index 20ef319..0b686ae 100644 --- a/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go +++ b/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go @@ -2,10 +2,9 @@ package processing import ( "fmt" + "os" "path/filepath" - "strings" - - "github.com/GetStream/getstream-go/v3" + "time" ) type AudioVideoMuxerConfig struct { @@ -14,65 +13,81 @@ type AudioVideoMuxerConfig struct { UserID string SessionID string TrackID string - Media string + MediaType string WithExtract bool WithCleanup bool } type AudioVideoMuxer struct { - logger *getstream.DefaultLogger + logger *ProcessingLogger } -func NewAudioVideoMuxer(logger *getstream.DefaultLogger) *AudioVideoMuxer { +func NewAudioVideoMuxer(logger *ProcessingLogger) *AudioVideoMuxer { return &AudioVideoMuxer{logger: logger} } -func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata, logger *getstream.DefaultLogger) error { +func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, metadata *RecordingMetadata) ([]*TrackFileInfo, error) { if config.WithExtract { - // Extract audio tracks with gap filling enabled - logger.Info("Extracting audio tracks with gap filling...") - err := ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "audio", config.Media, true, true, logger) - if err != nil { - return fmt.Errorf("failed to extract audio tracks: %w", err) + cfg := &TrackExtractorConfig{ + WorkDir: config.WorkDir, + OutputDir: config.OutputDir, + UserID: config.UserID, + SessionID: config.SessionID, + TrackID: config.TrackID, + TrackKind: "", + MediaType: config.MediaType, + FillGap: true, + FillDtx: true, + + Cleanup: config.WithCleanup, } - // Extract video tracks with gap filling enabled - logger.Info("Extracting video tracks with gap filling...") - err = ExtractTracks(config.WorkDir, config.OutputDir, config.UserID, config.SessionID, config.TrackID, metadata, "video", config.Media, true, true, logger) + extractor := NewTrackExtractor(p.logger) + + // Extract tracks with gap filling enabled + p.logger.Info("Extracting tracks with gap filling...") + _, err := extractor.ExtractTracks(cfg, metadata) if err != nil { - return fmt.Errorf("failed to extract video tracks: %w", err) + return nil, fmt.Errorf("failed to extract audio tracks: %w", err) } } - // Group files by media type for proper pairing - pairedTracks := p.groupFilesByMediaType(metadata) + var infos []*TrackFileInfo // Group files by media type for proper pairing + pairedTracks := p.groupFilesByMediaType(config, metadata) + for audioTrack, videoTrack := range pairedTracks { - //logger.Info("Muxing %d user audio/video pairs", len(userAudio)) - err := p.muxTrackPairs(audioTrack, videoTrack, config.OutputDir, logger) + // logger.Infof("Muxing %d user audio/video pairs", len(userAudio)) + info, err := p.muxTrackPairs(audioTrack, videoTrack, config) if err != nil { - logger.Error("Failed to mux user tracks: %v", err) + p.logger.Error("Failed to mux user tracks: %v", err) } + infos = append(infos, info) } - return nil + return infos, nil } // calculateSyncOffsetFromFiles calculates sync offset between audio and video files using metadata -func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo, logger *getstream.DefaultLogger) (int64, error) { +func calculateSyncOffsetFromFiles(audioTrack, videoTrack *TrackInfo) (int64, error) { // Calculate offset: positive means video starts before audio - audioTs := audioTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) - videoTs := videoTrack.Segments[0].FFMpegOffset + firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) - offset := audioTs - videoTs + audioOffset, videoOffset := int64(0), int64(0) + if audioTrack.Segments[0].metadata.FirstKeyFrameOffsetMs != nil { + audioOffset = *audioTrack.Segments[0].metadata.FirstKeyFrameOffsetMs + } + if videoTrack.Segments[0].metadata.FirstKeyFrameOffsetMs != nil { + videoOffset = *videoTrack.Segments[0].metadata.FirstKeyFrameOffsetMs + } - logger.Info(fmt.Sprintf("Calculated sync offset: audio_start=%v, audio_ts=%v, video_start=%v, video_ts=%v, offset=%d", - audioTrack.Segments[0].metadata.FirstRtpUnixTimestamp, audioTs, videoTrack.Segments[0].metadata.FirstRtpUnixTimestamp, videoTs, offset)) + audioTs := audioOffset + firstPacketNtpTimestamp(audioTrack.Segments[0].metadata) + videoTs := videoOffset + firstPacketNtpTimestamp(videoTrack.Segments[0].metadata) + offset := audioTs - videoTs return offset, nil } // groupFilesByMediaType groups audio and video files by media type (user vs display) -func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map[*TrackInfo]*TrackInfo { +func (p *AudioVideoMuxer) groupFilesByMediaType(config *AudioVideoMuxerConfig, metadata *RecordingMetadata) map[*TrackInfo]*TrackInfo { pairedTracks := make(map[*TrackInfo]*TrackInfo) matches := func(audio *TrackInfo, video *TrackInfo) bool { @@ -81,10 +96,11 @@ func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map audio.IsScreenshare == video.IsScreenshare } - for _, at := range metadata.Tracks { - if at.TrackType == "audio" { - for _, vt := range metadata.Tracks { - if vt.TrackType == "video" && matches(at, vt) { + filteredTracks := FilterTracks(metadata.Tracks, config.UserID, config.SessionID, config.TrackID, "", config.MediaType) + for _, at := range filteredTracks { + if at.TrackKind == trackKindAudio { + for _, vt := range filteredTracks { + if vt.TrackKind == trackKindVideo && matches(at, vt) { pairedTracks[at] = vt break } @@ -96,56 +112,74 @@ func (p *AudioVideoMuxer) groupFilesByMediaType(metadata *RecordingMetadata) map } // muxTrackPairs muxes audio/video pairs of the same media type -func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, outputDir string, logger *getstream.DefaultLogger) error { +func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, config *AudioVideoMuxerConfig) (*TrackFileInfo, error) { // Calculate sync offset using segment timing information - offset, err := calculateSyncOffsetFromFiles(audio, video, logger) + offset, err := calculateSyncOffsetFromFiles(audio, video) if err != nil { - logger.Warn("Failed to calculate sync offset, using 0: %v", err) + p.logger.Warn("Failed to calculate sync offset, using 0: %v", err) offset = 0 } // Generate output filename with media type indicator - outputFile := p.generateMediaAwareMuxedFilename(audio, video, outputDir) + outputFile := p.buildFilename(config.OutputDir, video) - audioFile := audio.ConcatenatedContainerPath - videoFile := video.ConcatenatedContainerPath + audioFile := audio.ConcatenatedTrackFileInfo.Name + videoFile := video.ConcatenatedTrackFileInfo.Name // Mux the audio and video files - logger.Info("Muxing %s + %s → %s (offset: %dms)", + p.logger.Debug("Muxing %s + %s → %s (offset: %dms)", filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) - err = muxFiles(outputFile, audioFile, videoFile, float64(offset), logger) + err = runFFmpegCommand(generateMuxFilesArguments(outputFile, audioFile, videoFile, float64(offset)), p.logger) if err != nil { - logger.Error("Failed to mux %s + %s: %v", audioFile, videoFile, err) - return err + p.logger.Errorf("Failed to mux %s + %s: %v", audioFile, videoFile, err) + return nil, err } - logger.Info("Successfully created muxed file: %s", outputFile) + p.logger.Info("Successfully created muxed file: %s", outputFile) // Clean up individual track files to avoid clutter - //os.Remove(audioFile) - //os.Remove(videoFile) - //} - // - //if len(audioFiles) != len(videoFiles) { - // logger.Warn("Mismatched %s track counts: %d audio, %d video", mediaTypeName, len(audioFiles), len(videoFiles)) - //} - - return nil -} + if config.WithCleanup { + defer func() { + for _, file := range []string{audioFile, videoFile} { + p.logger.Info("Cleaning up temporary file: %s", file) + if err := os.Remove(file); err != nil { + p.logger.Warn("Failed to clean up temporary file %s: %v", file, err) + } + } + }() + } -// generateMediaAwareMuxedFilename creates output filename that indicates media type -func (p *AudioVideoMuxer) generateMediaAwareMuxedFilename(audioFile, videoFile *TrackInfo, outputDir string) string { - audioBase := filepath.Base(audioFile.Segments[0].ContainerPath) - audioBase = strings.TrimSuffix(audioBase, "."+audioFile.Segments[0].ContainerExt) + return &TrackFileInfo{ + Name: outputFile, + StartAt: p.getTime(audio.ConcatenatedTrackFileInfo.StartAt, video.ConcatenatedTrackFileInfo.StartAt, true), + EndAt: p.getTime(audio.ConcatenatedTrackFileInfo.EndAt, video.ConcatenatedTrackFileInfo.EndAt, false), + MaxFrameDimension: video.ConcatenatedTrackFileInfo.MaxFrameDimension, + }, nil +} - // Replace "audio_" with "muxed_{mediaType}_" to create output name - var muxedName string - if audioFile.IsScreenshare { - muxedName = strings.Replace(audioBase, "audio_", "muxed_display_", 1) + "." + videoFile.Segments[0].ContainerExt +func (p *AudioVideoMuxer) getTime(d1, d2 time.Time, first bool) time.Time { + if d1.Before(d2) { + if first { + return d1 + } else { + return d2 + } } else { - muxedName = strings.Replace(audioBase, "audio_", "muxed_", 1) + "." + videoFile.Segments[0].ContainerExt + if first { + return d2 + } else { + return d1 + } + } +} + +// buildFilename creates output filename that indicates media type +func (p *AudioVideoMuxer) buildFilename(outputDir string, track *TrackInfo) string { + media := "audio_video" + if track.IsScreenshare { + media = "shared_" + media } - return filepath.Join(outputDir, muxedName) + return filepath.Join(outputDir, fmt.Sprintf("individual_%s_%s_%s_%s_%s_%d.%s", track.CallType, track.CallID, track.UserID, track.SessionID, media, track.CallStartTime.UnixMilli(), track.Segments[0].ContainerExt)) } diff --git a/pkg/cmd/raw-recording-tool/processing/constants.go b/pkg/cmd/raw-recording-tool/processing/constants.go index 5d1f595..0c66b01 100644 --- a/pkg/cmd/raw-recording-tool/processing/constants.go +++ b/pkg/cmd/raw-recording-tool/processing/constants.go @@ -1,15 +1,16 @@ package processing const ( - RtpDump = "rtpdump" - SuffixRtpDump = "." + RtpDump + trackKindAudio = "audio" + trackKindVideo = "video" - Sdp = "sdp" - SuffixSdp = "." + Sdp + mediaTypeUser = "user" + mediaTypeDisplay = "display" + mediaTypeBoth = "both" - Webm = "webm" - SuffixWebm = "." + Webm + suffixRtpDump = ".rtpdump" + suffixSdp = ".sdp" - Mp4 = "mp4" - SuffixMp4 = "." + Mp4 + mkvExtension = "mkv" + mkvSuffix = "." + mkvExtension ) diff --git a/pkg/cmd/raw-recording-tool/processing/container_converter.go b/pkg/cmd/raw-recording-tool/processing/container_converter.go index e52ea8d..fee75be 100644 --- a/pkg/cmd/raw-recording-tool/processing/container_converter.go +++ b/pkg/cmd/raw-recording-tool/processing/container_converter.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/GetStream/getstream-go/v3" "github.com/pion/rtp" "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" @@ -17,18 +16,23 @@ import ( "github.com/pion/webrtc/v4/pkg/media/samplebuilder" ) -const audioMaxLate = 200 // 4sec -const videoMaxLate = 1000 // 4sec +const ( + audioMaxLate = 200 // 4sec + videoMaxLate = 1000 // 4sec +) type RTPDump2WebMConverter struct { - logger *getstream.DefaultLogger + logger *ProcessingLogger reader *rtpdump.Reader recorder WebmRecorder sampleBuilder *samplebuilder.SampleBuilder + firstPkt *rtp.Packet lastPkt *rtp.Packet lastPktDuration uint32 - inserted uint16 + dtxInserted uint64 + + totalFrames int } type WebmRecorder interface { @@ -37,13 +41,13 @@ type WebmRecorder interface { Close() error } -func newRTPDump2WebMConverter(logger *getstream.DefaultLogger) *RTPDump2WebMConverter { +func newRTPDump2WebMConverter(logger *ProcessingLogger) *RTPDump2WebMConverter { return &RTPDump2WebMConverter{ logger: logger, } } -func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) (*SegmentInfo, bool), fixDtx bool, logger *getstream.DefaultLogger) error { +func ConvertDirectory(directory string, accept func(path string, info os.FileInfo) (*SegmentInfo, bool), fixDtx bool, logger *ProcessingLogger) error { rtpdumpFiles := make(map[string]*SegmentInfo) // Walk through directory to find .rtpdump files @@ -52,7 +56,7 @@ func ConvertDirectory(directory string, accept func(path string, info os.FileInf return err } - if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), SuffixRtpDump) { + if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), suffixRtpDump) { segment, accepted := accept(path, info) if accepted { rtpdumpFiles[path] = segment @@ -65,27 +69,19 @@ func ConvertDirectory(directory string, accept func(path string, info os.FileInf return err } - for rtpdumpFile, segment := range rtpdumpFiles { + for rtpdumpFile := range rtpdumpFiles { c := newRTPDump2WebMConverter(logger) if err := c.ConvertFile(rtpdumpFile, fixDtx); err != nil { - c.logger.Error("Failed to convert %s: %v", rtpdumpFile, err) + c.logger.Errorf("Failed to convert %s: %v", rtpdumpFile, err) continue } - - switch c.recorder.(type) { - case *CursorWebmRecorder: - offset, exists := c.recorder.(*CursorWebmRecorder).StartOffset() - if exists { - segment.FFMpegOffset = offset - } - } } return nil } func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error { - c.logger.Info("Converting %s", inputFile) + c.logger.Debugf("Converting %s", inputFile) // Parse the RTP dump file // Open the file @@ -99,30 +95,31 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error reader, _, _ := rtpdump.NewReader(file) c.reader = reader - sdpContent, _ := readSDP(strings.Replace(inputFile, SuffixRtpDump, SuffixSdp, 1)) + sdpContent, _ := readSDP(strings.Replace(inputFile, suffixRtpDump, suffixSdp, 1)) mType, _ := mimeType(sdpContent) - - releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) + _, suffix := outputFormatForMimeType(mType) switch mType { case webrtc.MimeTypeAV1: + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.AV1Depacketizer{}, 90000, releasePacketHandler) - c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + c.recorder, err = NewGstreamerConverter(strings.Replace(inputFile, suffixRtpDump, suffix, 1), sdpContent, c.logger) case webrtc.MimeTypeVP9: + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP9Packet{}, 90000, releasePacketHandler) - c.recorder, err = NewCursorGstreamerWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + c.recorder, err = NewGstreamerConverter(strings.Replace(inputFile, suffixRtpDump, suffix, 1), sdpContent, c.logger) case webrtc.MimeTypeH264: + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.H264Packet{}, 90000, releasePacketHandler) - c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixMp4, 1), sdpContent, c.logger) + c.recorder, err = NewGstreamerConverter(strings.Replace(inputFile, suffixRtpDump, suffix, 1), sdpContent, c.logger) case webrtc.MimeTypeVP8: + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildDefaultReleasePacketHandler()) c.sampleBuilder = samplebuilder.New(videoMaxLate, &codecs.VP8Packet{}, 90000, releasePacketHandler) - c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + c.recorder, err = NewGstreamerConverter(strings.Replace(inputFile, suffixRtpDump, suffix, 1), sdpContent, c.logger) case webrtc.MimeTypeOpus: - if fixDtx { - releasePacketHandler = samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler()) - } + releasePacketHandler := samplebuilder.WithPacketReleaseHandler(c.buildOpusReleasePacketHandler(fixDtx)) c.sampleBuilder = samplebuilder.New(audioMaxLate, &codecs.OpusPacket{}, 48000, releasePacketHandler) - c.recorder, err = NewCursorWebmRecorder(strings.Replace(inputFile, SuffixRtpDump, SuffixWebm, 1), sdpContent, c.logger) + c.recorder, err = NewGstreamerConverter(strings.Replace(inputFile, suffixRtpDump, suffix, 1), sdpContent, c.logger) default: return fmt.Errorf("unsupported codec type: %s", mType) } @@ -131,16 +128,14 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error } defer c.recorder.Close() - time.Sleep(1 * time.Second) - // Convert and feed RTP packets - return c.feedPackets(reader) + return c.feedPackets(mType, reader) } -func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { +func (c *RTPDump2WebMConverter) feedPackets(mType string, reader *rtpdump.Reader) error { startTime := time.Now() - i := 0 + i := uint64(0) for ; ; i++ { packet, err := reader.Next() if errors.Is(err, io.EOF) { @@ -153,24 +148,18 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { } // Unmarshal the RTP packet from the raw payload - if c.sampleBuilder == nil { - _ = c.recorder.PushRtpBuf(packet.Payload) - } else { - // Unmarshal the RTP packet from the raw payload - rtpPacket := &rtp.Packet{} - if err := rtpPacket.Unmarshal(packet.Payload); err != nil { - c.logger.Warn("Failed to unmarshal RTP packet %d: %v", i, err) - continue - } - - // Push packet to samplebuilder for reordering - c.sampleBuilder.Push(rtpPacket) + rtpPacket := &rtp.Packet{} + if err := rtpPacket.Unmarshal(packet.Payload); err != nil { + c.logger.Warnf("Failed to unmarshal RTP packet %d: %v", i, err) + continue } - // time.Sleep(10 * time.Microsecond) + // Push packet to samplebuilder for reordering + c.sampleBuilder.Push(rtpPacket) + // Log progress if i%10000 == 0 && i > 0 { - c.logger.Info("Processed %d packets", i) + c.logger.Debugf("Processed %d packets", i) } } @@ -178,88 +167,99 @@ func (c *RTPDump2WebMConverter) feedPackets(reader *rtpdump.Reader) error { c.sampleBuilder.Flush() } - duration := time.Since(startTime) - c.logger.Info("Finished feeding %d packets in %v", i, duration) + duration := time.Since(startTime).Round(time.Millisecond) - // Allow some time for the recorder to finalize - time.Sleep(2 * time.Second) + c.logger.Infof("Finished feeding %d packets (%d dtxInserted, %d real) (frames: %d total, codec: %s) in %v ", i+c.dtxInserted, c.dtxInserted, i, c.totalFrames, mType, duration) return nil } func (c *RTPDump2WebMConverter) buildDefaultReleasePacketHandler() func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { + if pkt.Marker { + c.totalFrames++ + } + if c.lastPkt != nil { if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { - c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + c.logger.Infof("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) } } c.lastPkt = pkt if e := c.recorder.OnRTP(pkt); e != nil { - c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) + c.logger.Warnf("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) } } } -func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler() func(pkt *rtp.Packet) { +func (c *RTPDump2WebMConverter) buildOpusReleasePacketHandler(fixDtx bool) func(pkt *rtp.Packet) { return func(pkt *rtp.Packet) { - pkt.SequenceNumber += c.inserted + pkt.SequenceNumber += uint16(c.dtxInserted) if c.lastPkt != nil { if pkt.SequenceNumber-c.lastPkt.SequenceNumber > 1 { - c.logger.Info("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + c.logger.Infof("Missing Packet Detected, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) } - tsDiff := pkt.Timestamp - c.lastPkt.Timestamp // TODO handle rollover - lastPktDuration := opusPacketDurationMs(c.lastPkt) - rtpDuration := uint32(lastPktDuration * 48) + if fixDtx { + tsDiff := c.timestampDiff(pkt.Timestamp, c.lastPkt.Timestamp) + lastPktDuration := opusPacketDurationMs(c.lastPkt) + rtpDuration := uint32(lastPktDuration * 48) - if rtpDuration == 0 { - rtpDuration = c.lastPktDuration - c.logger.Info("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) - } else { - c.lastPktDuration = rtpDuration - } + if rtpDuration == 0 { + rtpDuration = c.lastPktDuration + c.logger.Infof("LastPacket with no duration, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + } else { + c.lastPktDuration = rtpDuration + } - if rtpDuration > 0 && tsDiff > rtpDuration { + if rtpDuration > 0 && tsDiff > rtpDuration { - // Calculate how many packets we need to insert, taking care of packet losses - var toAdd uint16 - if uint32(pkt.SequenceNumber-c.lastPkt.SequenceNumber)*rtpDuration != tsDiff { // TODO handle rollover - toAdd = uint16(tsDiff/rtpDuration) - (pkt.SequenceNumber - c.lastPkt.SequenceNumber) - } + // Calculate how many packets we need to insert, taking care of packet losses + var toAdd uint16 + if uint32(c.sequenceNumberDiff(pkt.SequenceNumber, c.lastPkt.SequenceNumber))*rtpDuration != tsDiff { + toAdd = uint16(tsDiff/rtpDuration) - c.sequenceNumberDiff(pkt.SequenceNumber, c.lastPkt.SequenceNumber) + } - c.logger.Info("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", - toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) + c.logger.Debugf("Gap detected, inserting %d packets tsDiff %d, Previous SeqNum: %d RtpTs: %d - Last SeqNum: %d RtpTs: %d", + toAdd, tsDiff, c.lastPkt.SequenceNumber, c.lastPkt.Timestamp, pkt.SequenceNumber, pkt.Timestamp) - for i := 1; i <= int(toAdd); i++ { - ins := c.lastPkt.Clone() - ins.Payload = ins.Payload[:1] // Keeping only TOC byte - ins.SequenceNumber += uint16(i) - ins.Timestamp += uint32(i) * rtpDuration + for i := 1; i <= int(toAdd); i++ { + ins := c.lastPkt.Clone() + ins.Payload = ins.Payload[:1] // Keeping only TOC byte + ins.SequenceNumber += uint16(i) + ins.Timestamp += uint32(i) * rtpDuration - c.logger.Debug("Writing inserted Packet %v", ins) - if e := c.recorder.OnRTP(ins); e != nil { - c.logger.Warn("Failed to record inserted RTP packet SeqNum: %d RtpTs: %d: %v", ins.SequenceNumber, ins.Timestamp, e) + c.logger.Debugf("Writing dtxInserted Packet %v", ins) + if e := c.recorder.OnRTP(ins); e != nil { + c.logger.Warnf("Failed to record dtxInserted RTP packet SeqNum: %d RtpTs: %d: %v", ins.SequenceNumber, ins.Timestamp, e) + } } - } - c.inserted += toAdd - pkt.SequenceNumber += toAdd + c.dtxInserted += uint64(toAdd) + pkt.SequenceNumber += toAdd + } } } c.lastPkt = pkt - c.logger.Debug("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) + c.logger.Debugf("Writing real Packet Last SeqNum: %d RtpTs: %d", pkt.SequenceNumber, pkt.Timestamp) if e := c.recorder.OnRTP(pkt); e != nil { - c.logger.Warn("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) + c.logger.Warnf("Failed to record RTP packet SeqNum: %d RtpTs: %d: %v", pkt.SequenceNumber, pkt.Timestamp, e) } } } +func getMaxFrameDimension(f1, f2 SegmentFrameDimension) SegmentFrameDimension { + if f1.Width*f1.Height > f2.Width*f2.Height { + return f1 + } + return f2 +} + func opusPacketDurationMs(pkt *rtp.Packet) int { // 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 // +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ @@ -276,62 +276,79 @@ func opusPacketDurationMs(pkt *rtp.Packet) int { // Calculate frame duration according to OPUS RFC 6716 table (use x10 factor) // Frame duration is determined by the config value - var duration int + duration := opusFrameDurationFactor10(config) + frameDuration := float32(duration) / 10 + frameCount := opusFrameCount(c, payload) + + return int(frameDuration * float32(frameCount)) +} + +func opusFrameDurationFactor10(config byte) int { switch { case config < 3: // SILK-only NB: 10, 20, 40 ms - duration = 100 * (1 << (config & 0x03)) + return 100 * (1 << (config & 0x03)) case config == 3: // SILK-only NB: 60 ms - duration = 600 + return 600 case config < 7: // SILK-only MB: 10, 20, 40 ms - duration = 100 * (1 << (config & 0x03)) + return 100 * (1 << (config & 0x03)) case config == 7: // SILK-only MB: 60 ms - duration = 600 + return 600 case config < 11: // SILK-only WB: 10, 20, 40 ms - duration = 100 * (1 << (config & 0x03)) + return 100 * (1 << (config & 0x03)) case config == 11: // SILK-only WB: 60 ms - duration = 600 + return 600 case config <= 13: // Hybrid SWB: 10, 20 ms - duration = 100 * (1 << (config & 0x01)) + return 100 * (1 << (config & 0x01)) case config <= 15: // Hybrid FB: 10, 20 ms - duration = 100 * (1 << (config & 0x01)) + return 100 * (1 << (config & 0x01)) case config <= 19: // CELT-only NB: 2.5, 5, 10, 20 ms - duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + return 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math case config <= 23: // CELT-only WB: 2.5, 5, 10, 20 ms - duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + return 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math case config <= 27: // CELT-only SWB: 2.5, 5, 10, 20 ms - duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + return 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math case config <= 31: // CELT-only FB: 2.5, 5, 10, 20 ms - duration = 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math + return 25 * (1 << (config & 0x03)) // 2.5ms * 10 for integer math default: // MUST NOT HAPPEN - duration = 0 + return 0 } +} - frameDuration := float32(duration) / 10 - - var frameCount float32 +func opusFrameCount(c byte, payload []byte) int { switch c { case 0: - frameCount = 1 + return 1 case 1, 2: - frameCount = 2 + return 2 case 3: if len(payload) > 1 { - frameCount = float32(payload[1] & 0x3F) + return int(payload[1] & 0x3F) } } + return 0 +} + +func (c *RTPDump2WebMConverter) offset(pts, fts uint32) int64 { + return int64(c.timestampDiff(pts, fts) / 90) +} + +func (c *RTPDump2WebMConverter) timestampDiff(pts, fts uint32) uint32 { + return pts - fts +} - return int(frameDuration * frameCount) +func (c *RTPDump2WebMConverter) sequenceNumberDiff(psq, fsq uint16) uint16 { + return psq - fsq } diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go b/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go index 78f5104..e36fc29 100644 --- a/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go +++ b/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go @@ -15,13 +15,12 @@ import ( "sync" "time" - "github.com/GetStream/getstream-go/v3" "github.com/pion/rtcp" "github.com/pion/rtp" ) -type CursorWebmRecorder struct { - logger *getstream.DefaultLogger +type FfmpegConverter struct { + logger *ProcessingLogger outputPath string conn *net.UDPConn ffmpegCmd *exec.Cmd @@ -29,39 +28,37 @@ type CursorWebmRecorder struct { mu sync.Mutex ctx context.Context cancel context.CancelFunc + sdpFile *os.File // Parsed from FFmpeg output: "Duration: N/A, start: , bitrate: N/A" startOffsetMs int64 hasStartOffset bool } -func NewCursorWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorWebmRecorder, error) { - ctx, cancel := context.WithCancel(context.Background()) - - r := &CursorWebmRecorder{ +func NewFfmpegConverter(outputPath, sdpContent string, logger *ProcessingLogger) (*FfmpegConverter, error) { + r := &FfmpegConverter{ logger: logger, outputPath: outputPath, - ctx: ctx, - cancel: cancel, } // Set up UDP connections port := rand.Intn(10000) + 10000 if err := r.setupConnections(port); err != nil { - cancel() return nil, err } // Start FFmpeg with codec detection if err := r.startFFmpeg(outputPath, sdpContent, port); err != nil { - cancel() + r.conn.Close() return nil, err } + time.Sleep(2 * time.Second) // Wait for udp socket opened + return r, nil } -func (r *CursorWebmRecorder) setupConnections(port int) error { +func (r *FfmpegConverter) setupConnections(port int) error { // Setup connection addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) if err != nil { @@ -73,86 +70,38 @@ func (r *CursorWebmRecorder) setupConnections(port int) error { } r.conn = conn - if e := r.conn.SetWriteBuffer(2048); e != nil { - r.logger.Error("Failed to set UDP write buffer: %v", e) + if e := r.conn.SetWriteBuffer(2 * 1024); e != nil { + r.logger.Errorf("Failed to set UDP write buffer: %v", e) } - if e := r.conn.SetReadBuffer(2048); e != nil { - r.logger.Error("Failed to set UDP read buffer: %v", e) + if e := r.conn.SetReadBuffer(10 * 1024); e != nil { + r.logger.Errorf("Failed to set UDP read buffer: %v", e) } return nil } -func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port int) error { +func (r *FfmpegConverter) startFFmpeg(outputFilePath, sdpContent string, port int) error { // Write SDP to a temporary file sdpFile, err := os.CreateTemp("", "cursor_webm_*.sdp") if err != nil { return err } + r.sdpFile = sdpFile updatedSdp := replaceSDP(sdpContent, port) - r.logger.Info("Using Sdp:\n%s\n", updatedSdp) + r.logger.Infof("Using Sdp:\n%s\n", updatedSdp) - if _, err := sdpFile.WriteString(updatedSdp); err != nil { - sdpFile.Close() - return err + if _, e := r.sdpFile.WriteString(updatedSdp); e != nil { + r.sdpFile.Close() + return e } - sdpFile.Close() + r.sdpFile.Close() // Build FFmpeg command with optimized settings for single track recording - args := []string{ - "-threads", "1", - // "-loglevel", "debug", - "-protocol_whitelist", "file,udp,rtp", - "-buffer_size", "425984", - "-max_delay", "150000", - "-reorder_queue_size", "0", - "-i", sdpFile.Name(), - } + args := r.generateArgs(sdpFile.Name(), outputFilePath) - //switch strings.ToLower(mimeType) { - //case "audio/opus": - // // For other codecs, use direct copy - args = append(args, "-c", "copy") - //default: - // // For other codecs, use direct copy - // args = append(args, "-c", "copy") - //} - //if isVP9 { - // // For VP9, avoid direct copy and use re-encoding with error resilience - // // This works around FFmpeg's experimental VP9 RTP support issues - // r.logger.Info("Detected VP9 codec, applying workarounds...") - // args = append(args, - // "-c:v", "libvpx-vp9", - // // "-error_resilience", "aggressive", - // "-err_detect", "ignore_err", - // "-fflags", "+genpts+igndts", - // "-avoid_negative_ts", "make_zero", - // // VP9-specific quality settings to handle corrupted frames - // "-crf", "30", - // "-row-mt", "1", - // "-frame-parallel", "1", - // ) - //} else if strings.Contains(strings.ToUpper(sdpContent), "AV1") { - // args = append(args, - // "-c:v", "libaom-av1", - // "-cpu-used", "8", - // "-usage", "realtime", - // ) - //} else if strings.Contains(strings.ToUpper(sdpContent), "OPUS") { - // args = append(args, "-fflags", "+genpts", "-use_wallclock_as_timestamps", "0", "-c:a", "copy") - //} else { - // // For other codecs, use direct copy - // args = append(args, "-c", "copy") - //} - - args = append(args, - "-y", - outputFilePath, - ) - - r.logger.Info("FFMpeg pipeline: %s", strings.Join(args, " ")) // Skip debug args for display + r.logger.Infof("FFMpeg pipeline: %s", strings.Join(args, " ")) // Skip debug args for display r.ffmpegCmd = exec.Command("ffmpeg", args...) @@ -178,24 +127,39 @@ func (r *CursorWebmRecorder) startFFmpeg(outputFilePath, sdpContent string, port go r.scanFFmpegOutput(stderrPipe, true) // Start FFmpeg process - if err := r.ffmpegCmd.Start(); err != nil { - return err + if e := r.ffmpegCmd.Start(); e != nil { + return e } return nil } +func (r *FfmpegConverter) generateArgs(sdp, output string) []string { + // Build FFmpeg command with optimized settings for single track recording + var args []string + args = append(args, "-hide_banner") + args = append(args, "-threads", "1") + args = append(args, "-protocol_whitelist", "file,udp,rtp") + args = append(args, "-buffer_size", "10000000") + args = append(args, "-max_delay", "1000000") + args = append(args, "-reorder_queue_size", "0") + args = append(args, "-i", sdp) + args = append(args, "-c", "copy") + args = append(args, "-y", output) + return args +} + // scanFFmpegOutput reads lines from FFmpeg output, mirrors to console, and extracts start offset. -func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { +func (r *FfmpegConverter) scanFFmpegOutput(reader io.Reader, isStderr bool) { scanner := bufio.NewScanner(reader) re := regexp.MustCompile(`\bstart:\s*([0-9]+(?:\.[0-9]+)?)`) for scanner.Scan() { line := scanner.Text() // Mirror output if isStderr { - fmt.Fprintln(os.Stderr, line) + _, _ = fmt.Fprintln(os.Stderr, line) } else { - fmt.Fprintln(os.Stdout, line) + _, _ = fmt.Fprintln(os.Stdout, line) } // Try to extract the start value from those lines "Duration: N/A, start: 0.000000, bitrate: N/A" @@ -208,7 +172,7 @@ func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { if !r.hasStartOffset { r.startOffsetMs = int64(v * 1000) r.hasStartOffset = true - r.logger.Info("Detected FFmpeg start offset: %.6f seconds", v) + r.logger.Infof("Detected FFmpeg start offset: %d ms", r.startOffsetMs) } r.mu.Unlock() } @@ -218,13 +182,13 @@ func (r *CursorWebmRecorder) scanFFmpegOutput(reader io.Reader, isStderr bool) { } // StartOffset returns the parsed FFmpeg start offset in seconds and whether it was found. -func (r *CursorWebmRecorder) StartOffset() (int64, bool) { +func (r *FfmpegConverter) StartOffset() (int64, bool) { r.mu.Lock() defer r.mu.Unlock() return r.startOffsetMs, r.hasStartOffset } -func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { +func (r *FfmpegConverter) OnRTP(packet *rtp.Packet) error { // Marshal RTP packet buf, err := packet.Marshal() if err != nil { @@ -234,24 +198,22 @@ func (r *CursorWebmRecorder) OnRTP(packet *rtp.Packet) error { return r.PushRtpBuf(buf) } -func (r *CursorWebmRecorder) PushRtpBuf(buf []byte) error { +func (r *FfmpegConverter) PushRtpBuf(buf []byte) error { r.mu.Lock() defer r.mu.Unlock() // Send RTP packet over UDP if r.conn != nil { - r.conn.SetWriteDeadline(time.Now().Add(1000 * time.Microsecond)) _, err := r.conn.Write(buf) if err != nil { - // return err) - //} - r.logger.Info("Wrote packet to %s - %v", r.conn.LocalAddr().String(), err) + r.logger.Warnf("Failed to write RTP packet: %v", err) } + return err } return nil } -func (r *CursorWebmRecorder) Close() error { +func (r *FfmpegConverter) Close() error { r.mu.Lock() defer r.mu.Unlock() @@ -260,9 +222,9 @@ func (r *CursorWebmRecorder) Close() error { r.cancel() } - r.logger.Info("Closing UPD connection...") + r.logger.Infof("Closing UPD connection and wait for FFMpeg termination...") - // Close UDP connection by sending arbitrary RtcpBye (Ffmpeg is no able to end correctly) + // Close UDP connection by sending arbitrary RtcpBye (Ffmpeg is now able to end correctly) if r.conn != nil { buf, _ := rtcp.Goodbye{ Sources: []uint32{1}, // fixed ssrc is ok @@ -273,30 +235,8 @@ func (r *CursorWebmRecorder) Close() error { r.conn = nil } - r.logger.Info("UDP Connection closed...") - - time.Sleep(5 * time.Second) - - r.logger.Info("After sleep...") - - // Gracefully stop FFmpeg + // Gracefully wait for FFmpeg termination if r.ffmpegCmd != nil && r.ffmpegCmd.Process != nil { - - // ✅ Gracefully stop FFmpeg by sending 'q' to stdin - //fmt.Println("Sending 'q' to FFmpeg...") - //_, _ = r.stdin.Write([]byte("q\n")) - //r.stdin.Close() - - // Send interrupt signal to FFmpeg process - r.logger.Info("Sending SIGTERM...") - - //if err := r.ffmpegCmd.Process.Signal(os.Interrupt); err != nil { - // // If interrupt fails, force kill - // r.ffmpegCmd.Process.Kill() - //} else { - - r.logger.Info("Waiting for SIGTERM...") - // Wait for graceful exit with timeout done := make(chan error, 1) go func() { @@ -304,16 +244,23 @@ func (r *CursorWebmRecorder) Close() error { }() select { - case <-time.After(10 * time.Second): - r.logger.Info("Wait timetout for SIGTERM...") + case <-time.After(5 * time.Second): + r.logger.Warnf("FFMpeg Process termination timeout...") // Timeout, force kill - r.ffmpegCmd.Process.Kill() + if e := r.ffmpegCmd.Process.Kill(); e != nil { + r.logger.Errorf("FFMpeg Process errored while killing: %v", e) + } case <-done: - r.logger.Info("Process exited succesfully SIGTERM...") - // Process exited gracefully + r.logger.Infof("FFMpeg Process exited succesfully...") } } + // Clean up temporary SDP file + if r.sdpFile != nil { + _ = os.Remove(r.sdpFile.Name()) + r.sdpFile = nil + } + return nil } diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go b/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go index 0d7d70f..ab68f25 100644 --- a/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go +++ b/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go @@ -2,49 +2,29 @@ package processing import ( "fmt" - "os" "os/exec" "sort" "strings" - - "github.com/GetStream/getstream-go/v3" + "time" ) -const TmpDir = "/tmp" - type FileOffset struct { Name string Offset int64 } -func concatFile(outputPath string, files []string, logger *getstream.DefaultLogger) error { - // Write to a temporary file - tmpFile, err := os.CreateTemp(TmpDir, "concat_*.txt") - if err != nil { - return err - } - defer func() { - tmpFile.Close() - // _ = os.Remove(concatFile.Name()) - }() - - for _, file := range files { - if _, err := tmpFile.WriteString(fmt.Sprintf("file '%s'\n", file)); err != nil { - return err - } - } - - args := []string{} +func generateConcatFileArguments(outputPath, concatPath string) ([]string, error) { + args := defaultArgs() args = append(args, "-f", "concat") args = append(args, "-safe", "0") - args = append(args, "-i", tmpFile.Name()) + args = append(args, "-i", concatPath) args = append(args, "-c", "copy") - args = append(args, outputPath) - return runFFMEPGCpmmand(args, logger) + args = append(args, "-y", outputPath) + return args, nil } -func muxFiles(fileName string, audioFile string, videoFile string, offsetMs float64, logger *getstream.DefaultLogger) error { - args := []string{} +func generateMuxFilesArguments(fileName string, audioFile string, videoFile string, offsetMs float64) []string { + args := defaultArgs() // Apply offset using itsoffset // If offset is positive (video ahead), delay audio @@ -70,16 +50,14 @@ func muxFiles(fileName string, audioFile string, videoFile string, offsetMs floa args = append(args, "-map", "0:a") args = append(args, "-map", "1:v") args = append(args, "-c", "copy") - args = append(args, fileName) - - return runFFMEPGCpmmand(args, logger) + args = append(args, "-y", fileName) + return args } -func mixAudioFiles(fileName string, files []*FileOffset, logger *getstream.DefaultLogger) error { - var args []string - +func generateMixAudioFilesArguments(fileName, format string, files []*FileOffset) []string { var filterParts []string var mixParts []string + args := defaultArgs() sort.Slice(files, func(i, j int) bool { return files[i].Offset < files[j].Offset @@ -89,88 +67,140 @@ func mixAudioFiles(fileName string, files []*FileOffset, logger *getstream.Defau for i, fo := range files { args = append(args, "-i", fo.Name) - if i == 0 { - offsetToAdd = -fo.Offset + if len(files) > 1 { + if i == 0 { + offsetToAdd = -fo.Offset + } + offset := fo.Offset + offsetToAdd + + if offset > 0 { + // for stereo: offset|offset + label := fmt.Sprintf("a%d", i) + filterParts = append(filterParts, + fmt.Sprintf("[%d:a]adelay=%d|%d[%s]", i, offset, offset, label)) + mixParts = append(mixParts, fmt.Sprintf("[%s]", label)) + } else { + mixParts = append(mixParts, fmt.Sprintf("[%d:a]", i)) + } } - offset := fo.Offset + offsetToAdd - - if offset > 0 { - // for stereo: offset|offset - label := fmt.Sprintf("a%d", i) - filterParts = append(filterParts, - fmt.Sprintf("[%d:a]adelay=%d|%d[%s]", i, offset, offset, label)) - mixParts = append(mixParts, fmt.Sprintf("[%s]", label)) - } else { - mixParts = append(mixParts, fmt.Sprintf("[%d:a]", i)) + } + + if len(files) > 1 { + // Build amix filter + filter := strings.Join(filterParts, "; ") + if filter != "" { + filter += "; " } + filter += strings.Join(mixParts, "") + + fmt.Sprintf("amix=inputs=%d:normalize=0", len(files)) + + args = append(args, "-filter_complex", filter) } - // Build amix filter - filter := strings.Join(filterParts, "; ") - if filter != "" { - filter += "; " + audioLib := audioLibForExtension(format) + mkvAudioLib := audioLibForExtension(FormatMkv) + // Copy is enough in case of webm, weba, mka, mkv when len == 1 + if audioLib != mkvAudioLib || len(files) > 1 { + args = append(args, "-c:a", audioLibForExtension(format)) + args = append(args, "-b:a", "128k") + } else { + args = append(args, "-c", "copy") } - filter += strings.Join(mixParts, "") + - fmt.Sprintf("amix=inputs=%d:normalize=0", len(files)) - args = append(args, "-filter_complex", filter) - args = append(args, "-c:a", "libopus") - args = append(args, "-b:a", "128k") - args = append(args, fileName) + if format == FormatWeba { + args = append(args, "-f", "webm") + } + + args = append(args, "-y", fileName) fmt.Println(strings.Join(args, " ")) + return args +} - return runFFMEPGCpmmand(args, logger) +func audioLibForExtension(str string) string { + switch str { + case FormatMp3: + return "libmp3lame" + case FormatWeba, FormatWebm, FormatMkv, FormatMka: + return "libopus" + default: + return "libopus" + } } -func generateSilence(fileName string, duration float64, logger *getstream.DefaultLogger) error { - args := []string{} +func generateSilenceArguments(fileName string, duration float64) []string { + args := defaultArgs() args = append(args, "-f", "lavfi") args = append(args, "-t", fmt.Sprintf("%.3f", duration)) args = append(args, "-i", "anullsrc=cl=stereo:r=48000") args = append(args, "-c:a", "libopus") args = append(args, "-b:a", "32k") - args = append(args, fileName) + args = append(args, "-y", fileName) + return args +} - return runFFMEPGCpmmand(args, logger) +func generateBlackVideoArguments(fileName, mimeType string, duration float64, width, height, frameRate int) []string { + args := defaultArgs() + args = append(args, "-f", "lavfi") + args = append(args, "-t", fmt.Sprintf("%.3f", duration)) + args = append(args, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", width, height, frameRate)) + args = append(args, "-c:v", videoLibForMimeType(mimeType)) + + if strings.ToLower(mimeType) == "video/h264" { + args = append(args, "-preset", "ultrafast") + } else { + args = append(args, "-b:v", "0") + args = append(args, "-cpu-used", "8") + } + + args = append(args, "-crf", "45") + args = append(args, "-y", fileName) + return args } -func generateBlackVideo(fileName, mimeType string, duration float64, width, height, frameRate int, logger *getstream.DefaultLogger) error { - var codecLib string - switch strings.ToLower(mimeType) { +func videoLibForMimeType(str string) string { + switch strings.ToLower(str) { case "video/vp8": - codecLib = "libvpx-vp9" + return "libvpx" case "video/vp9": - codecLib = "libvpx-vp9" + return "libvpx-vp9" case "video/h264": - codecLib = "libh264" + return "libx264" case "video/av1": - codecLib = "libav1" + return "libaom-av1" + default: + return "libvpx" } +} - args := []string{} - args = append(args, "-f", "lavfi") - args = append(args, "-t", fmt.Sprintf("%.3f", duration)) - args = append(args, "-i", fmt.Sprintf("color=c=black:s=%dx%d:r=%d", width, height, frameRate)) - args = append(args, "-c:v", codecLib) - args = append(args, "-b:v", "1M") - args = append(args, fileName) +func outputFormatForMimeType(str string) (extension, suffix string) { + extension = mkvExtension + suffix = mkvSuffix + return +} - return runFFMEPGCpmmand(args, logger) +func defaultArgs() []string { + var args []string + args = append(args, "-hide_banner") + args = append(args, "-threads", "1") + args = append(args, "-filter_threads", "1") + return args } -func runFFMEPGCpmmand(args []string, logger *getstream.DefaultLogger) error { +func runFFmpegCommand(args []string, logger *ProcessingLogger) error { + startAt := time.Now() cmd := exec.Command("ffmpeg", args...) // Capture output for debugging output, err := cmd.CombinedOutput() + logger.Infof("FFmpeg process pid<%d> with args: %s", cmd.Process.Pid, args) + logger.Infof("FFmpeg process pid<%d> output:\n%s", cmd.Process.Pid, string(output)) + if err != nil { - logger.Error("FFmpeg command failed: %v", err) - logger.Error("FFmpeg output: %s", string(output)) - return fmt.Errorf("ffmpeg command failed: %w", err) + logger.Errorf("FFmpeg process pid<%d> failed: %v", cmd.Process.Pid, err) + return fmt.Errorf("FFmpeg process pid<%d> failed in %s: %w", cmd.Process.Pid, time.Now().Sub(startAt).Round(time.Millisecond), err) } - logger.Info("Successfully ran ffmpeg: %s", args) - logger.Debug("FFmpeg output: %s", string(output)) + logger.Infof("FFmpeg process pid<%d> ended successfully in %s", cmd.Process.Pid, time.Now().Sub(startAt).Round(time.Millisecond)) return nil } diff --git a/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go b/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go index 06eed03..7e97356 100644 --- a/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go +++ b/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go @@ -13,28 +13,25 @@ import ( "sync" "time" - "github.com/GetStream/getstream-go/v3" "github.com/pion/rtp" ) -type CursorGstreamerWebmRecorder struct { - logger *getstream.DefaultLogger - outputPath string - rtpConn net.Conn - gstreamerCmd *exec.Cmd - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - port int - sdpFile *os.File - finalOutputPath string // Path for post-processed file with duration - tempOutputPath string // Path for temporary file before post-processing +type GstreamerConverter struct { + logger *ProcessingLogger + outputPath string + rtpConn net.Conn + gstreamerCmd *exec.Cmd + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + port int + startAt time.Time } -func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getstream.DefaultLogger) (*CursorGstreamerWebmRecorder, error) { +func NewGstreamerConverter(outputPath, sdpContent string, logger *ProcessingLogger) (*GstreamerConverter, error) { ctx, cancel := context.WithCancel(context.Background()) - r := &CursorGstreamerWebmRecorder{ + r := &GstreamerConverter{ logger: logger, outputPath: outputPath, ctx: ctx, @@ -59,7 +56,7 @@ func NewCursorGstreamerWebmRecorder(outputPath, sdpContent string, logger *getst return r, nil } -func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { +func (r *GstreamerConverter) setupConnections(port int) error { // Setup TCP connection with retry to match GStreamer tcpserversrc readiness address := "127.0.0.1:" + strconv.Itoa(port) deadline := time.Now().Add(10 * time.Second) @@ -73,192 +70,21 @@ func (r *CursorGstreamerWebmRecorder) setupConnections(port int) error { if time.Now().After(deadline) { return fmt.Errorf("failed to connect to tcpserversrc at %s: %w", address, err) } - time.Sleep(100 * time.Millisecond) + time.Sleep(50 * time.Millisecond) } r.rtpConn = conn return nil } -func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath string) error { - // Parse SDP to determine RTP caps for rtpstreamdepay - media, encodingName, payloadType, clockRate := parseRtpCapsFromSDP(sdpContent) - r.logger.Info("Starting TCP-based GStreamer pipeline (media=%s, encoding=%s, payload=%d, clock-rate=%d)", media, encodingName, payloadType, clockRate) - - // Determine codec from SDP content and build GStreamer arguments - isVP9 := strings.Contains(strings.ToUpper(sdpContent), "VP9") - isVP8 := strings.Contains(strings.ToUpper(sdpContent), "VP8") - isAV1 := strings.Contains(strings.ToUpper(sdpContent), "AV1") - isH264 := strings.Contains(strings.ToUpper(sdpContent), "H264") || strings.Contains(strings.ToUpper(sdpContent), "H.264") - isOpus := strings.Contains(strings.ToUpper(sdpContent), "OPUS") +func (r *GstreamerConverter) startGStreamer(sdpContent, outputFilePath string) error { + r.startAt = time.Now() // Start with common GStreamer arguments optimized for RTP dump replay - args := []string{ - //"--gst-debug-level=3", - //"--gst-debug=tcpserversrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,vp9*:5", - //"--gst-debug-no-color", - "-e", // Send EOS on interrupt for clean shutdown - } - // Source from TCP (RFC4571 framed) and depayload back to application/x-rtp - args = append(args, - "tcpserversrc", - "host=127.0.0.1", - fmt.Sprintf("port=%d", r.port), - "name=tcp_in", - "!", - "queue", - "max-size-buffers=0", - "max-size-bytes=268435456", - "max-size-time=0", - "leaky=0", - "!", - // Ensure rtpstreamdepay sink has caps - "application/x-rtp-stream", - "!", - "rtpstreamdepay", - "!", - fmt.Sprintf("application/x-rtp,media=%s,encoding-name=%s,clock-rate=%d,payload=%d", media, encodingName, clockRate, payloadType), - "!", - ) - - // Build pipeline based on codec with simplified RTP timestamp handling for dump replay - // - // Simplified approach for RTP dump replay: - // - rtpjitterbuffer: Basic packet reordering with minimal interference - // - latency=0: No artificial latency, process packets as they come - // - mode=none: Don't override timing, let depayloaders handle it - // - do-retransmission=false: No retransmission for dump replay - // - Remove identity sync to avoid timing conflicts - // - // This approach focuses on preserving original RTP timestamps without - // artificial buffering that can interfere with dump replay timing. - if false && isH264 { - r.logger.Info("Detected H.264 codec, building H.264 pipeline with timestamp handling...") - args = append(args, - "application/x-rtp,media=video,encoding-name=H264,clock-rate=90000", "!", - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", "!", - "rtph264depay", "!", - "h264parse", "!", - "mp4mux", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if false && isVP9 { - r.logger.Info("Detected VP9 codec, building VP9 pipeline with timestamp handling...") - args = append(args, - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", - "drop-on-latency=false", - "buffer-mode=slave", - "max-dropout-time=5000000000", - "max-reorder-delay=1000000000", - "!", - "rtpvp9depay", "!", - "vp9parse", "!", - "webmmux", - "writing-app=GStreamer-VP9", - "streamable=false", - "min-index-interval=2000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if isVP9 { - r.logger.Info("Detected VP9 codec, building VP9 pipeline with RTP timestamp handling...") - args = append(args, - - //// jitterbuffer for packet reordering and timestamp handling - "rtpjitterbuffer", - "name=jitterbuffer", - "mode=none", - "latency=0", // No artificial latency - process immediately - "do-lost=false", // Don't generate lost events for missing packets - "do-retransmission=false", // No retransmission for offline replay - "drop-on-latency=false", // Keep all packets even if late - "!", - // - // Depayload RTP to get VP9 frames - "rtpvp9depay", - "!", - - // Parse VP9 stream to ensure valid frame structure - "vp9parse", - "!", - - // Queue for buffering - "queue", - "!", - - // Mux into Matroska/WebM container - "webmmux", - "writing-app=GStreamer-VP9", - "streamable=false", - "min-index-interval=2000000000", - "!", - - // Write to file - "filesink", - fmt.Sprintf("location=%s", outputFilePath), - ) - - } else if false && isVP8 { - r.logger.Info("Detected VP8 codec, building VP8 pipeline with timestamp handling...") - args = append(args, - "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", "!", - "rtpvp8depay", "!", - "vp8parse", "!", - "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if false && isAV1 { - r.logger.Info("Detected AV1 codec, building AV1 pipeline with timestamp handling...") - args = append(args, - "application/x-rtp,media=video,encoding-name=AV1,clock-rate=90000", "!", - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", "!", - "rtpav1depay", "!", - "av1parse", "!", - "webmmux", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if false && isOpus { - r.logger.Info("Detected Opus codec, building Opus pipeline with timestamp handling...") - args = append(args, - "application/x-rtp,media=audio,encoding-name=OPUS,clock-rate=48000,payload=111", "!", - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", "!", - "rtpopusdepay", "!", - "opusparse", "!", - "webmmux", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) - } else if false { - // Default to VP8 if codec is not detected - r.logger.Info("Unknown or no codec detected, defaulting to VP8 pipeline with timestamp handling...") - args = append(args, - "application/x-rtp,media=video,encoding-name=VP8,clock-rate=90000", "!", - "rtpjitterbuffer", - "latency=0", - "mode=none", - "do-retransmission=false", "!", - "rtpvp8depay", "!", - "vp8parse", "!", - "webmmux", "writing-app=GStreamer", "streamable=false", "min-index-interval=2000000000", "!", - "filesink", fmt.Sprintf("location=%s", outputFilePath), - ) + args, err := r.generateArgs(sdpContent, outputFilePath) + if err != nil { + return err } - r.logger.Info("GStreamer pipeline: %s", strings.Join(args, " ")) // Skip debug args for display - r.gstreamerCmd = exec.Command("gst-launch-1.0", args...) // Redirect output for debugging r.gstreamerCmd.Stdout = os.Stdout @@ -269,89 +95,100 @@ func (r *CursorGstreamerWebmRecorder) startGStreamer(sdpContent, outputFilePath return err } - r.logger.Info("GStreamer pipeline started with PID: %d", r.gstreamerCmd.Process.Pid) - - // Monitor the process in a goroutine - go func() { - if err := r.gstreamerCmd.Wait(); err != nil { - r.logger.Error("GStreamer process exited with error: %v", err) - } else { - r.logger.Info("GStreamer process exited normally") - } - }() + r.logger.Infof("GStreamer process pid<%d> with pipeline: %s", r.gstreamerCmd.Process.Pid, strings.Join(args, " ")) return nil } -// parseRtpCapsFromSDP extracts basic RTP caps from an SDP for use with application/x-rtp caps -// Prioritizes video codecs (H264/VP9/VP8/AV1) over audio (OPUS) and parses payload/clock-rate -func parseRtpCapsFromSDP(sdp string) (media string, encodingName string, payload int, clockRate int) { - upper := strings.ToUpper(sdp) - - // Defaults - media = "video" - encodingName = "VP9" - payload = 96 - clockRate = 90000 - - // Select target encoding with priority: H264 > VP9 > VP8 > AV1 > OPUS (audio) - if strings.Contains(upper, "H264") || strings.Contains(upper, "H.264") { - encodingName = "H264" - media = "video" - clockRate = 90000 - } else if strings.Contains(upper, "VP9") { - encodingName = "VP9" - media = "video" - clockRate = 90000 - } else if strings.Contains(upper, "VP8") { - encodingName = "VP8" - media = "video" - clockRate = 90000 - } else if strings.Contains(upper, "AV1") { - encodingName = "AV1" - media = "video" - clockRate = 90000 - } else if strings.Contains(upper, "OPUS") { - encodingName = "OPUS" - media = "audio" - clockRate = 48000 +func (r *GstreamerConverter) generateArgs(sdpContent, outputFilePath string) ([]string, error) { + // Parse SDP to determine RTP caps for rtpstreamdepay + media, encodingName, payloadType, clockRate, err := parseRtpCapsFromSDP(sdpContent) + if err != nil { + return nil, err } - // Parse matching a=rtpmap for the chosen encoding to refine payload and clock - chosen := encodingName - for _, line := range strings.Split(sdp, "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(strings.ToLower(line), "a=rtpmap:") { - continue - } - // Example: a=rtpmap:96 VP9/90000 - after := strings.TrimSpace(line[len("a=rtpmap:"):]) - fields := strings.Fields(after) - if len(fields) < 2 { - continue - } - ptStr := fields[0] - codec := strings.ToUpper(fields[1]) - parts := strings.Split(codec, "/") - name := parts[0] - if name != chosen { - continue - } - if v, err := strconv.Atoi(ptStr); err == nil { - payload = v - } - if len(parts) >= 2 { - if v, err := strconv.Atoi(parts[1]); err == nil { - clockRate = v + // Start with common GStreamer arguments optimized for RTP dump replay + args := []string{} + args = append(args, "-e") + // args = append(args, "--gst-debug-level=3") + // args = append(args, "--gst-debug=tcpserversrc:5,rtp*:5,webm*:5,identity:5,jitterbuffer:5,av1*:5") + // args = append(args, "--gst-debug-no-color") + args = append(args, "tcpserversrc", "host=127.0.0.1", fmt.Sprintf("port=%d", r.port), "!") + args = append(args, "application/x-rtp-stream", "!") + args = append(args, "rtpstreamdepay", "!") + args = append(args, fmt.Sprintf("application/x-rtp,media=%s,encoding-name=%s,clock-rate=%s,payload=%s", media, encodingName, clockRate, payloadType), "!") + + // Simplified approach for RTP dump replay: + // - rtpjitterbuffer: Basic packet reordering with minimal interference + // - mode=none: Don't override timing, let depayloaders handle it + // - latency=0: No artificial latency, process packets as they come + // - do-retransmission=false: No retransmission for dump replay + args = append(args, "rtpjitterbuffer", "mode=none", "latency=0", "do-lost=false", "do-retransmission=false", "drop-on-latency=false", "!") + + switch encodingName { + case "VP9", "AV1", "H264": + args = append(args, fmt.Sprintf("rtp%sdepay", strings.ToLower(encodingName)), "!") + args = append(args, fmt.Sprintf("%sparse", strings.ToLower(encodingName)), "!") + case "OPUS", "VP8": + args = append(args, fmt.Sprintf("rtp%sdepay", strings.ToLower(encodingName)), "!") + default: + return nil, fmt.Errorf("unsupported encoding: %s", encodingName) + } + + args = append(args, "matroskamux", "streamable=false", "!") + args = append(args, "filesink", fmt.Sprintf("location=%s", outputFilePath)) + + return args, nil +} + +func parseRtpCapsFromSDP(sdp string) (media string, encodingName string, payload string, clockRate string, err error) { + // Expect one m= line and one a=rtpmap line; return error if missing or malformed + mLineFound := false + rtpmapLineFound := false + for _, raw := range strings.Split(sdp, "\n") { + //line := strings.TrimSpace(raw) + lower := strings.ToLower(raw) + if strings.HasPrefix(lower, "m=") { + mLineFound = true + // Format: m= ... + fields := strings.Fields(lower) + if len(fields) >= 1 { + media = strings.TrimPrefix(fields[0], "m=") + } else { + err = fmt.Errorf("invalid m= line: %s", lower) + return + } + } else if strings.HasPrefix(lower, "a=rtpmap:") { + rtpmapLineFound = true + + // Format: a=rtpmap: /[/channels] + after := strings.TrimSpace(lower[len("a=rtpmap:"):]) + fields := strings.Fields(after) + if len(fields) >= 2 { + payload = fields[0] + codec := strings.ToUpper(fields[1]) + parts := strings.Split(codec, "/") + if len(parts) >= 2 { + encodingName = parts[0] + clockRate = parts[1] + } else { + err = fmt.Errorf("invalid a=rtpmap: %s", lower) + return + } + } else { + err = fmt.Errorf("invalid a=rtpmap: %s", lower) + return } } - break } + if !mLineFound || !rtpmapLineFound { + err = fmt.Errorf("Invalid SDP m= or a=rtpmap lines not found: \n%s", sdp) + } return } -func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { +func (r *GstreamerConverter) OnRTP(packet *rtp.Packet) error { // Marshal RTP packet buf, err := packet.Marshal() if err != nil { @@ -361,7 +198,7 @@ func (r *CursorGstreamerWebmRecorder) OnRTP(packet *rtp.Packet) error { return r.PushRtpBuf(buf) } -func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { +func (r *GstreamerConverter) PushRtpBuf(buf []byte) error { r.mu.Lock() defer r.mu.Unlock() @@ -373,24 +210,22 @@ func (r *CursorGstreamerWebmRecorder) PushRtpBuf(buf []byte) error { header := make([]byte, 2) binary.BigEndian.PutUint16(header, uint16(len(buf))) if _, err := r.rtpConn.Write(header); err != nil { - r.logger.Warn("Failed to write RTP length header: %v", err) + r.logger.Warnf("Failed to write RTP length header: %v", err) return err } if _, err := r.rtpConn.Write(buf); err != nil { - r.logger.Warn("Failed to write RTP packet: %v", err) + r.logger.Warnf("Failed to write RTP packet: %v", err) return err } } return nil } -func (r *CursorGstreamerWebmRecorder) Close() error { +func (r *GstreamerConverter) Close() error { r.mu.Lock() defer r.mu.Unlock() - r.logger.Info("Closing GStreamer WebM recorder...") - - r.logger.Info("Closing GStreamer WebM recorder2222...") + r.logger.Infof("GStreamer process pid<%d> Closing TCP connection and wait for termination...", r.gstreamerCmd.Process.Pid) // Cancel context to stop background goroutines if r.cancel != nil { @@ -399,75 +234,30 @@ func (r *CursorGstreamerWebmRecorder) Close() error { // Close TCP connection if r.rtpConn != nil { - r.logger.Info("Closing TCP connection...") _ = r.rtpConn.Close() r.rtpConn = nil - r.logger.Info("TCP connection closed") } - // Gracefully stop GStreamer + // Gracefully wait for FFmpeg termination if r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil { - r.logger.Info("Stopping GStreamer process...") - - // Send EOS (End of Stream) signal to GStreamer - // GStreamer handles SIGINT gracefully and will finish writing the file - if err := r.gstreamerCmd.Process.Signal(os.Interrupt); err != nil { - r.logger.Error("Failed to send SIGINT to GStreamer: %v", err) - // If interrupt fails, force kill - r.gstreamerCmd.Process.Kill() - } else { - r.logger.Info("Sent SIGINT to GStreamer, waiting for graceful exit...") - - // Wait for graceful exit with timeout - done := make(chan error, 1) - go func() { - done <- r.gstreamerCmd.Wait() - }() - - select { - case <-time.After(15 * time.Second): - r.logger.Info("GStreamer exit timeout, force killing...") - // Timeout, force kill - r.gstreamerCmd.Process.Kill() - <-done // Wait for the kill to complete - case err := <-done: - if err != nil { - r.logger.Info("GStreamer exited with error: %v", err) - } else { - r.logger.Info("GStreamer exited gracefully") - } + // Wait for graceful exit with timeout + done := make(chan error, 1) + go func() { + done <- r.gstreamerCmd.Wait() + }() + + select { + case <-time.After(5 * time.Second): + r.logger.Warnf("GStreamer process pid<%d> termination timeout in %s...", r.gstreamerCmd.Process.Pid, time.Now().Sub(r.startAt).Round(time.Millisecond)) + + // Timeout, force kill + if e := r.gstreamerCmd.Process.Kill(); e != nil { + r.logger.Errorf("GStreamer process pid<%d> errored while killing: %v", r.gstreamerCmd.Process.Pid, e) } + case <-done: + r.logger.Infof("GStreamer process pid<%d> exited succesfully in %s...", r.gstreamerCmd.Process.Pid, time.Now().Sub(r.startAt).Round(time.Millisecond)) } } - // Clean up temporary SDP file - if r.sdpFile != nil { - os.Remove(r.sdpFile.Name()) - r.sdpFile = nil - } - - // Post-process WebM to fix duration metadata if needed - if r.tempOutputPath != "" && r.finalOutputPath != "" { - r.logger.Info("Starting WebM duration post-processing...") - } - - r.logger.Info("GStreamer WebM recorder closed") return nil } - -// GetOutputPath returns the output file path (for compatibility) -func (r *CursorGstreamerWebmRecorder) GetOutputPath() string { - // Return final output path if post-processing is enabled, otherwise return original - if r.finalOutputPath != "" { - return r.finalOutputPath - } - return r.outputPath -} - -// IsRecording returns true if the recorder is currently active -func (r *CursorGstreamerWebmRecorder) IsRecording() bool { - r.mu.Lock() - defer r.mu.Unlock() - - return r.gstreamerCmd != nil && r.gstreamerCmd.Process != nil -} diff --git a/pkg/cmd/raw-recording-tool/processing/logger_adapter.go b/pkg/cmd/raw-recording-tool/processing/logger_adapter.go new file mode 100644 index 0000000..0754b70 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/processing/logger_adapter.go @@ -0,0 +1,47 @@ +package processing + +import ( + "github.com/GetStream/getstream-go/v3" +) + +type ProcessingLogger struct { + logger *getstream.DefaultLogger +} + +func NewRawToolLogger(logger *getstream.DefaultLogger) *ProcessingLogger { + return &ProcessingLogger{ + logger: logger, + } +} + +func (l *ProcessingLogger) Debug(format string, args ...interface{}) { + l.logger.Debug(format, args...) +} + +func (l *ProcessingLogger) Debugf(format string, args ...interface{}) { + l.logger.Debug(format, args...) +} + +func (l *ProcessingLogger) Info(format string, args ...interface{}) { + l.logger.Info(format, args...) +} + +func (l *ProcessingLogger) Infof(format string, args ...interface{}) { + l.logger.Info(format, args...) +} + +func (l *ProcessingLogger) Warn(format string, args ...interface{}) { + l.logger.Warn(format, args...) +} + +func (l *ProcessingLogger) Warnf(format string, args ...interface{}) { + l.logger.Warn(format, args...) +} + +func (l *ProcessingLogger) Error(format string, args ...interface{}) { + l.logger.Error(format, args...) +} + +func (l *ProcessingLogger) Errorf(format string, args ...interface{}) { + l.logger.Error(format, args...) +} diff --git a/pkg/cmd/raw-recording-tool/processing/track_extractor.go b/pkg/cmd/raw-recording-tool/processing/track_extractor.go index 4aba07c..bb04528 100644 --- a/pkg/cmd/raw-recording-tool/processing/track_extractor.go +++ b/pkg/cmd/raw-recording-tool/processing/track_extractor.go @@ -5,48 +5,73 @@ import ( "os" "path/filepath" "strings" - - "github.com/GetStream/getstream-go/v3" - "github.com/pion/webrtc/v4" + "time" ) +const blackVideoFps = 5 + +type TrackExtractorConfig struct { + WorkDir string + OutputDir string + UserID string + SessionID string + TrackID string + TrackKind string + MediaType string + FillGap bool + FillDtx bool + + Cleanup bool +} + +type TrackExtractor struct { + logger *ProcessingLogger +} + +func NewTrackExtractor(logger *ProcessingLogger) *TrackExtractor { + return &TrackExtractor{logger: logger} +} + // Generic track extraction function that works for both audio and video -func ExtractTracks(workingDir, outputDir, userID, sessionID, trackID string, metadata *RecordingMetadata, trackType, mediaFilter string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { +func (p *TrackExtractor) ExtractTracks(config *TrackExtractorConfig, metadata *RecordingMetadata) ([]*TrackFileInfo, error) { // Filter tracks to specified type only and apply hierarchical filtering - filteredTracks := FilterTracks(metadata.Tracks, userID, sessionID, trackID, trackType, mediaFilter) + filteredTracks := FilterTracks(metadata.Tracks, config.UserID, config.SessionID, config.TrackID, config.TrackKind, config.MediaType) if len(filteredTracks) == 0 { - logger.Warn("No %s tracks found matching the filter criteria", trackType) - return nil + p.logger.Warnf("No %s tracks found matching the filter criteria", config.TrackKind) + return nil, nil } - logger.Info("Found %d %s tracks to extract", len(filteredTracks), trackType) + p.logger.Infof("Found %d %s tracks to extract", len(filteredTracks), config.TrackKind) // Extract and convert each track + var infos []*TrackFileInfo for i, track := range filteredTracks { - logger.Info("Processing %s track %d/%d: %s", trackType, i+1, len(filteredTracks), track.TrackID) + p.logger.Debugf("Processing %s track %d/%d: %s", config.TrackKind, i+1, len(filteredTracks), track.TrackID) - err := extractSingleTrackWithOptions(workingDir, track, outputDir, trackType, fillGaps, fixDtx, logger) + info, err := p.extractSingleTrackWithOptions(config, track) if err != nil { - logger.Error("Failed to extract %s track %s: %v", trackType, track.TrackID, err) + p.logger.Errorf("Failed to extract %s track %s: %v", config.TrackKind, track.TrackID, err) continue } + if info != nil { + infos = append(infos, info) + } } - return nil + return infos, nil } -func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir string, trackType string, fillGaps, fixDtx bool, logger *getstream.DefaultLogger) error { +func (p *TrackExtractor) extractSingleTrackWithOptions(config *TrackExtractorConfig, track *TrackInfo) (*TrackFileInfo, error) { accept := func(path string, info os.FileInfo) (*SegmentInfo, bool) { for _, s := range track.Segments { if strings.Contains(info.Name(), s.metadata.BaseFilename) { - if track.Codec == webrtc.MimeTypeH264 { - s.ContainerExt = Mp4 - } else { - s.ContainerExt = Webm - } - s.RtpDumpPath = path - s.SdpPath = strings.Replace(path, SuffixRtpDump, SuffixSdp, -1) - s.ContainerPath = strings.Replace(path, SuffixRtpDump, "."+s.ContainerExt, -1) + extension, suffix := outputFormatForMimeType(track.Codec) + abs, _ := filepath.Abs(path) + + s.RtpDumpPath = abs + s.SdpPath = strings.Replace(abs, suffixRtpDump, suffixSdp, -1) + s.ContainerExt = extension + s.ContainerPath = strings.Replace(abs, suffixRtpDump, suffix, -1) return s, true } } @@ -54,74 +79,147 @@ func extractSingleTrackWithOptions(inputPath string, track *TrackInfo, outputDir } // Convert using the WebM converter - err := ConvertDirectory(inputPath, accept, fixDtx, logger) + err := ConvertDirectory(config.WorkDir, accept, config.FillDtx, p.logger) if err != nil { - return fmt.Errorf("failed to convert %s track: %w", trackType, err) + return nil, fmt.Errorf("failed to convert %s track: %w", track.TrackKind, err) } // Create segments with timing info and fill gaps - finalFile, err := processSegmentsWithGapFilling(track, trackType, outputDir, fillGaps, logger) + finalFileInfo, err := p.processSegmentsWithGapFilling(config, track) if err != nil { - return fmt.Errorf("failed to process segments with gap filling: %w", err) + return nil, fmt.Errorf("failed to process segments with gap filling: %w", err) } - track.ConcatenatedContainerPath = finalFile - logger.Info("Successfully extracted %s track to: %s", trackType, finalFile) - return nil + track.ConcatenatedTrackFileInfo = finalFileInfo + p.logger.Infof("Successfully extracted %s track to: %s", track.TrackKind, finalFileInfo.Name) + return finalFileInfo, nil } // processSegmentsWithGapFilling processes webm segments, fills gaps if requested, and concatenates into final file -func processSegmentsWithGapFilling(track *TrackInfo, trackType string, outputDir string, fillGaps bool, logger *getstream.DefaultLogger) (string, error) { +func (p *TrackExtractor) processSegmentsWithGapFilling(config *TrackExtractorConfig, track *TrackInfo) (*TrackFileInfo, error) { // Build list of files to concatenate (with optional gap fillers) - var filesToConcat []string + var cleanupFiles []string + concatFile, err := os.Create(p.buildConcatFilename(config.OutputDir, track)) + if err != nil { + return nil, err + } + cleanupFiles = append(cleanupFiles, concatFile.Name()) + + // If enabled, cleanUp all working files (segment mkv, silence or black frame files and concat.txt) + if config.Cleanup { + defer func(files *[]string) { + for _, file := range *files { + p.logger.Infof("Cleaning up temporary file: %s", file) + if err := os.Remove(file); err != nil { + p.logger.Warnf("Failed to clean up temporary file %s: %v", file, err) + } + } + }(&cleanupFiles) + } + defer concatFile.Close() + for i, segment := range track.Segments { - // Add the segment file - filesToConcat = append(filesToConcat, segment.ContainerPath) + if _, e := concatFile.WriteString(fmt.Sprintf("file '%s'\n", segment.ContainerPath)); e != nil { + return nil, e + } + cleanupFiles = append(cleanupFiles, segment.ContainerPath) // Add gap filler if requested and there's a gap before the next segment - if fillGaps && i < track.SegmentCount-1 { + if config.FillGap && i < track.SegmentCount-1 { nextSegment := track.Segments[i+1] - gapDuration := nextSegment.FFMpegOffset + firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) + offset := int64(0) + if nextSegment.metadata.FirstKeyFrameOffsetMs != nil { + offset = *nextSegment.metadata.FirstKeyFrameOffsetMs + } + gapDuration := offset + firstPacketNtpTimestamp(nextSegment.metadata) - lastPacketNtpTimestamp(segment.metadata) if gapDuration > 0 { // There's a gap gapSeconds := float64(gapDuration) / 1000.0 - logger.Info("Detected %dms gap between segments, generating %s filler", gapDuration, trackType) + p.logger.Infof("Detected %dms gap between segments, generating %s filler", gapDuration, track.TrackKind) // Create gap filler file - gapFilePath := filepath.Join(outputDir, fmt.Sprintf("gap_%s_%d.%s", trackType, i, segment.ContainerExt)) - - if trackType == "audio" { - err := generateSilence(gapFilePath, gapSeconds, logger) - if err != nil { - logger.Warn("Failed to generate silence, skipping gap: %v", err) - continue - } - } else if trackType == "video" { - // Use 720p quality as defaults - err := generateBlackVideo(gapFilePath, track.Codec, gapSeconds, 1280, 720, 30, logger) - if err != nil { - logger.Warn("Failed to generate black video, skipping gap: %v", err) - continue - } + gapFilePath := p.buildGapFilename(config.OutputDir, track, i) + + var args []string + if track.TrackKind == trackKindVideo { + args = generateBlackVideoArguments(gapFilePath, track.Codec, gapSeconds, 1280, 720, blackVideoFps) + } else { + args = generateSilenceArguments(gapFilePath, gapSeconds) } - defer os.Remove(gapFilePath) + if e := runFFmpegCommand(args, p.logger); e != nil { + p.logger.Warnf("Failed to generate %s gap, skipping: %v", track.TrackKind, e) + continue + } + cleanupFiles = append(cleanupFiles, gapFilePath) + + absPath, err := filepath.Abs(gapFilePath) + if err != nil { + return nil, err + } - filesToConcat = append(filesToConcat, gapFilePath) + if _, e := concatFile.WriteString(fmt.Sprintf("file '%s'\n", absPath)); e != nil { + return nil, e + } } } } // Create final output file - finalName := fmt.Sprintf("%s_%s_%s_%s.%s", trackType, track.UserID, track.SessionID, track.TrackID, track.Segments[0].ContainerExt) - finalPath := filepath.Join(outputDir, finalName) + finalPath := p.buildFilename(config.OutputDir, track) // Concatenate all segments (with gap fillers if any) - err := concatFile(finalPath, filesToConcat, logger) + args, err := generateConcatFileArguments(finalPath, concatFile.Name()) if err != nil { - return "", fmt.Errorf("failed to concatenate segments: %w", err) + return nil, fmt.Errorf("failed to generate ffmpeg arguments: %w", err) + } + + err = runFFmpegCommand(args, p.logger) + if err != nil { + return nil, fmt.Errorf("failed to concatenate segments: %w", err) + } + + p.logger.Debugf("Successfully concatenated %d segments into %s (gap filled %t)", track.SegmentCount, finalPath, config.FillGap) + + var ts, te int64 + if len(track.Segments) > 0 { + ts = track.Segments[0].metadata.FirstRtpUnixTimestamp + te = track.Segments[len(track.Segments)-1].metadata.LastRtpUnixTimestamp + } + return &TrackFileInfo{ + Name: finalPath, + StartAt: time.UnixMilli(ts), + EndAt: time.UnixMilli(te), + MaxFrameDimension: p.getMaxFrameDimension(track), + }, nil +} + +func (p *TrackExtractor) getMaxFrameDimension(track *TrackInfo) SegmentFrameDimension { + frameDimension := SegmentFrameDimension{} + if track.TrackKind == trackKindVideo { + for _, segment := range track.Segments { + if segment.metadata.MaxFrameDimension != nil { + frameDimension = getMaxFrameDimension(*segment.metadata.MaxFrameDimension, frameDimension) + } + } } + return frameDimension +} + +// buildDefaultFilename creates output filename that indicates media type +func (p *TrackExtractor) buildFilename(outputDir string, track *TrackInfo) string { + media := track.TrackKind + "_only" + if track.IsScreenshare { + media = "shared_" + media + } + + return filepath.Join(outputDir, fmt.Sprintf("individual_%s_%s_%s_%s_%s_%d.%s", track.CallType, track.CallID, track.UserID, track.SessionID, media, track.CallStartTime.UnixMilli(), track.Segments[0].ContainerExt)) +} + +func (p *TrackExtractor) buildGapFilename(outputDir string, track *TrackInfo, i int) string { + return filepath.Join(outputDir, fmt.Sprintf("gap_%s_%s_%s_%d_%d.%s", track.UserID, track.SessionID, track.TrackKind, track.CallStartTime.UnixMilli(), i, track.Segments[i].ContainerExt)) +} - logger.Info("Successfully concatenated %d segments into %s (gap filled %t)", track.SegmentCount, finalPath, fillGaps) - return finalPath, nil +func (p *TrackExtractor) buildConcatFilename(outputDir string, track *TrackInfo) string { + return filepath.Join(outputDir, fmt.Sprintf("concat_%s_%s_%s_%d.txt", track.UserID, track.SessionID, track.TrackKind, track.CallStartTime.UnixMilli())) } diff --git a/pkg/cmd/raw-recording-tool/root.go b/pkg/cmd/raw-recording-tool/root.go index 2d26e02..e96a6c8 100644 --- a/pkg/cmd/raw-recording-tool/root.go +++ b/pkg/cmd/raw-recording-tool/root.go @@ -190,18 +190,18 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string } // setupLogger creates a logger with the specified verbosity -func setupLogger(verbose bool) *getstream.DefaultLogger { +func setupLogger(verbose bool) *processing.ProcessingLogger { var level getstream.LogLevel if verbose { level = getstream.LogLevelDebug } else { level = getstream.LogLevelInfo } - return getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level) + return processing.NewRawToolLogger(getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level)) } // prepareWorkDir extracts the recording to a temp directory and returns the working directory -func prepareWorkDir(globalArgs *GlobalArgs, logger *getstream.DefaultLogger) (string, func(), error) { +func prepareWorkDir(globalArgs *GlobalArgs, logger *processing.ProcessingLogger) (string, func(), error) { path := globalArgs.InputFile if path == "" { path = globalArgs.InputDir From e870f6589e9bdd89fac75548f160410d597d75c3 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 23 Jan 2026 16:33:51 +0100 Subject: [PATCH 05/18] feat: from s3 with https protocol OK --- go.mod | 23 +- go.sum | 38 +++ pkg/cmd/raw-recording-tool/constants.go | 5 +- pkg/cmd/raw-recording-tool/list_tracks.go | 13 +- pkg/cmd/raw-recording-tool/root.go | 61 +++- pkg/cmd/raw-recording-tool/s3_downloader.go | 351 ++++++++++++++++++++ 6 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 pkg/cmd/raw-recording-tool/s3_downloader.go diff --git a/go.mod b/go.mod index 1bb7d44..f73e0f9 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,16 @@ module github.com/GetStream/stream-cli -go 1.22 +go 1.23 + +toolchain go1.24.4 require ( github.com/AlecAivazis/survey/v2 v2.3.4 github.com/GetStream/getstream-go/v3 v3.7.0 github.com/GetStream/stream-chat-go/v5 v5.8.1 github.com/MakeNowJust/heredoc v1.0.0 + github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 github.com/cheynewallace/tabby v1.1.1 github.com/gizak/termui/v3 v3.1.0 github.com/gorilla/websocket v1.5.0 @@ -19,6 +23,23 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/pion/datachannel v1.6.0 // indirect diff --git a/go.sum b/go.sum index c310063..d9e8bbd 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,44 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 h1:C2dUPSnEpy4voWFIq3JNd8gN0Y5vYGDo44eUE58a/p8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cheynewallace/tabby v1.1.1 h1:JvUR8waht4Y0S3JF17G6Vhyt+FRhnqVCkk8l4YrOU54= github.com/cheynewallace/tabby v1.1.1/go.mod h1:Pba/6cUL8uYqvOc9RkyvFbHGrQ9wShyrn6/S/1OYVys= diff --git a/pkg/cmd/raw-recording-tool/constants.go b/pkg/cmd/raw-recording-tool/constants.go index 6af9627..28c5757 100644 --- a/pkg/cmd/raw-recording-tool/constants.go +++ b/pkg/cmd/raw-recording-tool/constants.go @@ -7,6 +7,7 @@ const ( FlagInputS3 = "input-s3" FlagOutput = "output" FlagVerbose = "verbose" + FlagCacheDir = "cache-dir" ) // Flag names for filter flags (used across multiple commands) @@ -34,9 +35,10 @@ const ( const ( DescInputFile = "Raw recording zip file path" DescInputDir = "Raw recording directory path" - DescInputS3 = "Raw recording S3 path" + DescInputS3 = "Raw recording S3 URL (s3://bucket/path or presigned HTTPS URL)" DescOutput = "Output directory" DescVerbose = "Enable verbose logging" + DescCacheDir = "Cache directory for S3 downloads" ) // Flag descriptions for filter flags @@ -66,6 +68,7 @@ const ( DefaultFormat = "table" DefaultCompletionType = "tracks" DefaultMedia = "both" + DefaultCacheSubdir = "stream-cli/raw-recordings" ) // Media type values diff --git a/pkg/cmd/raw-recording-tool/list_tracks.go b/pkg/cmd/raw-recording-tool/list_tracks.go index a7e80ed..aae76fa 100644 --- a/pkg/cmd/raw-recording-tool/list_tracks.go +++ b/pkg/cmd/raw-recording-tool/list_tracks.go @@ -1,6 +1,7 @@ package rawrecording import ( + "context" "encoding/json" "fmt" "sort" @@ -65,14 +66,10 @@ func runListTracks(cmd *cobra.Command, args []string) error { logger := setupLogger(globalArgs.Verbose) logger.Info("Starting list-tracks command") - // Parse the recording metadata using efficient metadata-only approach - var inputPath string - if globalArgs.InputFile != "" { - inputPath = globalArgs.InputFile - } else if globalArgs.InputDir != "" { - inputPath = globalArgs.InputDir - } else { - return fmt.Errorf("S3 input not implemented yet") + // Resolve input path (download from S3 if needed) + inputPath, err := resolveInputPath(context.Background(), globalArgs) + if err != nil { + return err } parser := processing.NewMetadataParser(logger) diff --git a/pkg/cmd/raw-recording-tool/root.go b/pkg/cmd/raw-recording-tool/root.go index e96a6c8..f5897e8 100644 --- a/pkg/cmd/raw-recording-tool/root.go +++ b/pkg/cmd/raw-recording-tool/root.go @@ -1,6 +1,7 @@ package rawrecording import ( + "context" "fmt" "log" "os" @@ -19,7 +20,11 @@ type GlobalArgs struct { InputS3 string Output string Verbose bool + CacheDir string WorkDir string + + // resolvedInputPath is the local path to the input (after S3 download if needed) + resolvedInputPath string } func NewRootCmd() *cobra.Command { @@ -51,6 +56,7 @@ func NewRootCmd() *cobra.Command { pf.String(FlagInputS3, "", DescInputS3) pf.String(FlagOutput, "", DescOutput) pf.Bool(FlagVerbose, false, DescVerbose) + pf.String(FlagCacheDir, "", DescCacheDir) // Add subcommands cmd.AddCommand( @@ -72,6 +78,12 @@ func getGlobalArgs(cmd *cobra.Command) (*GlobalArgs, error) { inputS3, _ := cmd.Flags().GetString(FlagInputS3) output, _ := cmd.Flags().GetString(FlagOutput) verbose, _ := cmd.Flags().GetBool(FlagVerbose) + cacheDir, _ := cmd.Flags().GetString(FlagCacheDir) + + // Use default cache directory if not specified + if cacheDir == "" { + cacheDir = GetDefaultCacheDir() + } return &GlobalArgs{ InputFile: inputFile, @@ -79,6 +91,7 @@ func getGlobalArgs(cmd *cobra.Command) (*GlobalArgs, error) { InputS3: inputS3, Output: output, Verbose: verbose, + CacheDir: cacheDir, }, nil } @@ -109,6 +122,36 @@ func validateGlobalArgs(globalArgs *GlobalArgs, requireOutput bool) error { return nil } +// resolveInputPath resolves the input to a local path, downloading from S3 if necessary +func resolveInputPath(ctx context.Context, globalArgs *GlobalArgs) (string, error) { + // If already resolved, return cached path + if globalArgs.resolvedInputPath != "" { + return globalArgs.resolvedInputPath, nil + } + + var inputPath string + + if globalArgs.InputFile != "" { + inputPath = globalArgs.InputFile + } else if globalArgs.InputDir != "" { + inputPath = globalArgs.InputDir + } else if globalArgs.InputS3 != "" { + // Download from S3 (with caching) + downloader := NewS3Downloader(globalArgs.CacheDir, globalArgs.Verbose) + downloadedPath, err := downloader.Download(ctx, globalArgs.InputS3) + if err != nil { + return "", fmt.Errorf("failed to download from S3: %w", err) + } + inputPath = downloadedPath + } else { + return "", fmt.Errorf("no input specified") + } + + // Cache the resolved path + globalArgs.resolvedInputPath = inputPath + return inputPath, nil +} + // validateInputArgs validates input arguments using mutually exclusive logic func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string) (*processing.RecordingMetadata, error) { // Count how many filters are specified @@ -128,13 +171,10 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string return nil, fmt.Errorf("only one filter can be specified at a time: --%s, --%s, and --%s are mutually exclusive", FlagUserID, FlagSessionID, FlagTrackID) } - var inputPath string - if globalArgs.InputFile != "" { - inputPath = globalArgs.InputFile - } else if globalArgs.InputDir != "" { - inputPath = globalArgs.InputDir - } else { - return nil, fmt.Errorf("S3 input not implemented yet") + // Resolve input path (download from S3 if needed) + inputPath, err := resolveInputPath(context.Background(), globalArgs) + if err != nil { + return nil, err } // Parse metadata to validate the single specified argument @@ -202,9 +242,10 @@ func setupLogger(verbose bool) *processing.ProcessingLogger { // prepareWorkDir extracts the recording to a temp directory and returns the working directory func prepareWorkDir(globalArgs *GlobalArgs, logger *processing.ProcessingLogger) (string, func(), error) { - path := globalArgs.InputFile - if path == "" { - path = globalArgs.InputDir + // Resolve input path (download from S3 if needed) + path, err := resolveInputPath(context.Background(), globalArgs) + if err != nil { + return "", nil, err } workingDir, cleanup, err := processing.ExtractToTempDir(path, logger) diff --git a/pkg/cmd/raw-recording-tool/s3_downloader.go b/pkg/cmd/raw-recording-tool/s3_downloader.go new file mode 100644 index 0000000..b997e27 --- /dev/null +++ b/pkg/cmd/raw-recording-tool/s3_downloader.go @@ -0,0 +1,351 @@ +package rawrecording + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// S3Downloader handles downloading files from S3 with caching +type S3Downloader struct { + cacheDir string + verbose bool +} + +// CacheMetadata stores information about a cached file +type CacheMetadata struct { + ETag string `json:"etag"` + OriginalURL string `json:"original_url"` + LastModified string `json:"last_modified,omitempty"` +} + +// NewS3Downloader creates a new S3Downloader +func NewS3Downloader(cacheDir string, verbose bool) *S3Downloader { + return &S3Downloader{ + cacheDir: cacheDir, + verbose: verbose, + } +} + +// Download downloads a file from S3 or presigned URL, using cache if available +// Returns the local file path to the downloaded file +func (d *S3Downloader) Download(ctx context.Context, inputURL string) (string, error) { + // Ensure cache directory exists + if err := os.MkdirAll(d.cacheDir, 0755); err != nil { + return "", fmt.Errorf("failed to create cache directory: %w", err) + } + + // Generate cache key from URL + cacheKey := d.generateCacheKey(inputURL) + cachedFilePath := filepath.Join(d.cacheDir, cacheKey+".tar.gz") + metadataPath := filepath.Join(d.cacheDir, cacheKey+".meta.json") + + // Check if file is already cached + if d.isCacheValid(ctx, inputURL, cachedFilePath, metadataPath) { + if d.verbose { + fmt.Printf("Using cached file: %s\n", cachedFilePath) + } + return cachedFilePath, nil + } + + // Download the file + if d.verbose { + fmt.Printf("Downloading from: %s\n", d.sanitizeURLForLog(inputURL)) + } + + var etag string + var err error + + if isS3URL(inputURL) { + etag, err = d.downloadFromS3(ctx, inputURL, cachedFilePath) + } else { + etag, err = d.downloadFromPresignedURL(ctx, inputURL, cachedFilePath) + } + + if err != nil { + return "", err + } + + // Save cache metadata + metadata := CacheMetadata{ + ETag: etag, + OriginalURL: d.hashURL(inputURL), // Store hash instead of URL for privacy + } + if err := d.saveCacheMetadata(metadataPath, &metadata); err != nil { + // Log but don't fail - download succeeded + if d.verbose { + fmt.Printf("Warning: failed to save cache metadata: %v\n", err) + } + } + + if d.verbose { + fmt.Printf("Downloaded to: %s\n", cachedFilePath) + } + + return cachedFilePath, nil +} + +// generateCacheKey creates a unique cache key from the URL +func (d *S3Downloader) generateCacheKey(inputURL string) string { + return d.hashURL(inputURL) +} + +// hashURL creates a SHA256 hash of the URL +func (d *S3Downloader) hashURL(inputURL string) string { + // For presigned URLs, we only hash the base path (without query params) + // This allows the same file to be cached even if the signature changes + baseURL := inputURL + if u, err := url.Parse(inputURL); err == nil && !isS3URL(inputURL) { + baseURL = u.Scheme + "://" + u.Host + u.Path + } + + hash := sha256.Sum256([]byte(baseURL)) + return hex.EncodeToString(hash[:])[:16] // Use first 16 chars +} + +// sanitizeURLForLog removes sensitive query parameters from URL for logging +func (d *S3Downloader) sanitizeURLForLog(inputURL string) string { + if isS3URL(inputURL) { + return inputURL + } + u, err := url.Parse(inputURL) + if err != nil { + return "[invalid URL]" + } + return u.Scheme + "://" + u.Host + u.Path + "?[signature hidden]" +} + +// isS3URL checks if the URL is an s3:// URL +func isS3URL(inputURL string) bool { + return strings.HasPrefix(inputURL, "s3://") +} + +// parseS3URL parses an s3:// URL into bucket and key +func parseS3URL(inputURL string) (bucket, key string, err error) { + if !isS3URL(inputURL) { + return "", "", fmt.Errorf("not an S3 URL: %s", inputURL) + } + + // Remove s3:// prefix + path := strings.TrimPrefix(inputURL, "s3://") + + // Split into bucket and key + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + return "", "", fmt.Errorf("invalid S3 URL format, expected s3://bucket/key: %s", inputURL) + } + + return parts[0], parts[1], nil +} + +// isCacheValid checks if the cached file is still valid +func (d *S3Downloader) isCacheValid(ctx context.Context, inputURL, cachedFilePath, metadataPath string) bool { + // Check if cached file exists + if _, err := os.Stat(cachedFilePath); os.IsNotExist(err) { + return false + } + + // Check if metadata exists + metadata, err := d.loadCacheMetadata(metadataPath) + if err != nil { + return false + } + + // Verify URL hash matches + if metadata.OriginalURL != d.hashURL(inputURL) { + return false + } + + // Get current ETag from remote + var remoteETag string + if isS3URL(inputURL) { + remoteETag, err = d.getS3ETag(ctx, inputURL) + } else { + remoteETag, err = d.getPresignedURLETag(ctx, inputURL) + } + + if err != nil { + if d.verbose { + fmt.Printf("Warning: failed to get remote ETag, will re-download: %v\n", err) + } + return false + } + + // Compare ETags + return metadata.ETag == remoteETag +} + +// getS3ETag gets the ETag for an S3 object +func (d *S3Downloader) getS3ETag(ctx context.Context, inputURL string) (string, error) { + bucket, key, err := parseS3URL(inputURL) + if err != nil { + return "", err + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return "", fmt.Errorf("failed to load AWS config: %w", err) + } + + client := s3.NewFromConfig(cfg) + result, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: &bucket, + Key: &key, + }) + if err != nil { + return "", fmt.Errorf("failed to get S3 object metadata: %w", err) + } + + if result.ETag != nil { + return *result.ETag, nil + } + return "", nil +} + +// getPresignedURLETag gets the ETag for a presigned URL via HEAD request +func (d *S3Downloader) getPresignedURLETag(ctx context.Context, inputURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodHead, inputURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create HEAD request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute HEAD request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HEAD request failed with status: %d", resp.StatusCode) + } + + return resp.Header.Get("ETag"), nil +} + +// downloadFromS3 downloads a file from S3 using the AWS SDK +func (d *S3Downloader) downloadFromS3(ctx context.Context, inputURL, destPath string) (string, error) { + bucket, key, err := parseS3URL(inputURL) + if err != nil { + return "", err + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return "", fmt.Errorf("failed to load AWS config: %w", err) + } + + client := s3.NewFromConfig(cfg) + result, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &bucket, + Key: &key, + }) + if err != nil { + return "", fmt.Errorf("failed to download from S3: %w", err) + } + defer result.Body.Close() + + // Create destination file + file, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("failed to create destination file: %w", err) + } + defer file.Close() + + // Copy content + if _, err := io.Copy(file, result.Body); err != nil { + os.Remove(destPath) // Clean up partial file + return "", fmt.Errorf("failed to write file: %w", err) + } + + var etag string + if result.ETag != nil { + etag = *result.ETag + } + + return etag, nil +} + +// downloadFromPresignedURL downloads a file from a presigned URL +func (d *S3Downloader) downloadFromPresignedURL(ctx context.Context, inputURL, destPath string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, inputURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create GET request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to execute GET request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed with status: %d", resp.StatusCode) + } + + // Create destination file + file, err := os.Create(destPath) + if err != nil { + return "", fmt.Errorf("failed to create destination file: %w", err) + } + defer file.Close() + + // Copy content + if _, err := io.Copy(file, resp.Body); err != nil { + os.Remove(destPath) // Clean up partial file + return "", fmt.Errorf("failed to write file: %w", err) + } + + return resp.Header.Get("ETag"), nil +} + +// loadCacheMetadata loads cache metadata from a JSON file +func (d *S3Downloader) loadCacheMetadata(path string) (*CacheMetadata, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var metadata CacheMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, err + } + + return &metadata, nil +} + +// saveCacheMetadata saves cache metadata to a JSON file +func (d *S3Downloader) saveCacheMetadata(path string, metadata *CacheMetadata) error { + data, err := json.MarshalIndent(metadata, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// GetDefaultCacheDir returns the default cache directory +func GetDefaultCacheDir() string { + // Try user cache directory first + if cacheDir, err := os.UserCacheDir(); err == nil { + return filepath.Join(cacheDir, DefaultCacheSubdir) + } + + // Fallback to home directory + if homeDir, err := os.UserHomeDir(); err == nil { + return filepath.Join(homeDir, ".cache", DefaultCacheSubdir) + } + + // Last resort: temp directory + return filepath.Join(os.TempDir(), DefaultCacheSubdir) +} From 17e7ee1e16eb2104862c8bd2daa0016a055b4e22 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 23 Jan 2026 16:39:51 +0100 Subject: [PATCH 06/18] feat: from s3 with s3 protocol OK --- go.mod | 1 + go.sum | 2 + pkg/cmd/raw-recording-tool/s3_downloader.go | 45 ++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index f73e0f9..b782b0f 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/GetStream/stream-chat-go/v5 v5.8.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/aws/aws-sdk-go-v2/config v1.32.7 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1 github.com/cheynewallace/tabby v1.1.1 github.com/gizak/termui/v3 v3.1.0 diff --git a/go.sum b/go.sum index d9e8bbd..88001c4 100644 --- a/go.sum +++ b/go.sum @@ -58,6 +58,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUT github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0 h1:pQZGI0qQXeCHZHMeWzhwPu+4jkWrdrIb2dgpG4OKmco= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.21.0/go.mod h1:XGq5kImVqQT4HUNbbG+0Y8O74URsPNH7CGPg1s1HW5E= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= diff --git a/pkg/cmd/raw-recording-tool/s3_downloader.go b/pkg/cmd/raw-recording-tool/s3_downloader.go index b997e27..c9d8543 100644 --- a/pkg/cmd/raw-recording-tool/s3_downloader.go +++ b/pkg/cmd/raw-recording-tool/s3_downloader.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/aws/aws-sdk-go-v2/config" + s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" ) @@ -149,6 +150,40 @@ func parseS3URL(inputURL string) (bucket, key string, err error) { return parts[0], parts[1], nil } +// getS3ClientForBucket creates an S3 client configured for the bucket's region +func (d *S3Downloader) getS3ClientForBucket(ctx context.Context, bucket string) (*s3.Client, error) { + // First, load the default config + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + // Create a client to detect the bucket region + client := s3.NewFromConfig(cfg) + + // Get the actual bucket region + region, err := s3manager.GetBucketRegion(ctx, client, bucket) + if err != nil { + // If we can't detect the region, return the default client + if d.verbose { + fmt.Printf("Warning: could not detect bucket region, using default: %v\n", err) + } + return client, nil + } + + if d.verbose { + fmt.Printf("Detected bucket region: %s\n", region) + } + + // Reload config with the correct region + cfg, err = config.LoadDefaultConfig(ctx, config.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config with region %s: %w", region, err) + } + + return s3.NewFromConfig(cfg), nil +} + // isCacheValid checks if the cached file is still valid func (d *S3Downloader) isCacheValid(ctx context.Context, inputURL, cachedFilePath, metadataPath string) bool { // Check if cached file exists @@ -193,12 +228,11 @@ func (d *S3Downloader) getS3ETag(ctx context.Context, inputURL string) (string, return "", err } - cfg, err := config.LoadDefaultConfig(ctx) + client, err := d.getS3ClientForBucket(ctx, bucket) if err != nil { - return "", fmt.Errorf("failed to load AWS config: %w", err) + return "", err } - client := s3.NewFromConfig(cfg) result, err := client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: &bucket, Key: &key, @@ -240,12 +274,11 @@ func (d *S3Downloader) downloadFromS3(ctx context.Context, inputURL, destPath st return "", err } - cfg, err := config.LoadDefaultConfig(ctx) + client, err := d.getS3ClientForBucket(ctx, bucket) if err != nil { - return "", fmt.Errorf("failed to load AWS config: %w", err) + return "", err } - client := s3.NewFromConfig(cfg) result, err := client.GetObject(ctx, &s3.GetObjectInput{ Bucket: &bucket, Key: &key, From 3d322e3faa844861399614ba5962b4cd4efba274 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Wed, 28 Jan 2026 08:48:27 +0100 Subject: [PATCH 07/18] feat: remove FFMpeg converter --- .../processing/ffmpeg_converter.go | 266 ------------------ 1 file changed, 266 deletions(-) delete mode 100644 pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go b/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go deleted file mode 100644 index e36fc29..0000000 --- a/pkg/cmd/raw-recording-tool/processing/ffmpeg_converter.go +++ /dev/null @@ -1,266 +0,0 @@ -package processing - -import ( - "bufio" - "context" - "fmt" - "io" - "math/rand" - "net" - "os" - "os/exec" - "regexp" - "strconv" - "strings" - "sync" - "time" - - "github.com/pion/rtcp" - "github.com/pion/rtp" -) - -type FfmpegConverter struct { - logger *ProcessingLogger - outputPath string - conn *net.UDPConn - ffmpegCmd *exec.Cmd - stdin io.WriteCloser - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - sdpFile *os.File - - // Parsed from FFmpeg output: "Duration: N/A, start: , bitrate: N/A" - startOffsetMs int64 - hasStartOffset bool -} - -func NewFfmpegConverter(outputPath, sdpContent string, logger *ProcessingLogger) (*FfmpegConverter, error) { - r := &FfmpegConverter{ - logger: logger, - outputPath: outputPath, - } - - // Set up UDP connections - port := rand.Intn(10000) + 10000 - if err := r.setupConnections(port); err != nil { - return nil, err - } - - // Start FFmpeg with codec detection - if err := r.startFFmpeg(outputPath, sdpContent, port); err != nil { - r.conn.Close() - return nil, err - } - - time.Sleep(2 * time.Second) // Wait for udp socket opened - - return r, nil -} - -func (r *FfmpegConverter) setupConnections(port int) error { - // Setup connection - addr, err := net.ResolveUDPAddr("udp", "127.0.0.1:"+strconv.Itoa(port)) - if err != nil { - return err - } - conn, err := net.DialUDP("udp", nil, addr) - if err != nil { - return err - } - r.conn = conn - - if e := r.conn.SetWriteBuffer(2 * 1024); e != nil { - r.logger.Errorf("Failed to set UDP write buffer: %v", e) - } - if e := r.conn.SetReadBuffer(10 * 1024); e != nil { - r.logger.Errorf("Failed to set UDP read buffer: %v", e) - } - - return nil -} - -func (r *FfmpegConverter) startFFmpeg(outputFilePath, sdpContent string, port int) error { - - // Write SDP to a temporary file - sdpFile, err := os.CreateTemp("", "cursor_webm_*.sdp") - if err != nil { - return err - } - r.sdpFile = sdpFile - - updatedSdp := replaceSDP(sdpContent, port) - r.logger.Infof("Using Sdp:\n%s\n", updatedSdp) - - if _, e := r.sdpFile.WriteString(updatedSdp); e != nil { - r.sdpFile.Close() - return e - } - r.sdpFile.Close() - - // Build FFmpeg command with optimized settings for single track recording - args := r.generateArgs(sdpFile.Name(), outputFilePath) - - r.logger.Infof("FFMpeg pipeline: %s", strings.Join(args, " ")) // Skip debug args for display - - r.ffmpegCmd = exec.Command("ffmpeg", args...) - - // Capture stdout/stderr to parse FFmpeg logs while mirroring to console - stdoutPipe, err := r.ffmpegCmd.StdoutPipe() - if err != nil { - return err - } - stderrPipe, err := r.ffmpegCmd.StderrPipe() - if err != nil { - return err - } - - // Create stdin pipe to send commands to FFmpeg - //var err error - r.stdin, err = r.ffmpegCmd.StdinPipe() - if err != nil { - fmt.Println("Error creating stdin pipe:", err) - } - - // Begin scanning output streams after process has started - go r.scanFFmpegOutput(stdoutPipe, false) - go r.scanFFmpegOutput(stderrPipe, true) - - // Start FFmpeg process - if e := r.ffmpegCmd.Start(); e != nil { - return e - } - - return nil -} - -func (r *FfmpegConverter) generateArgs(sdp, output string) []string { - // Build FFmpeg command with optimized settings for single track recording - var args []string - args = append(args, "-hide_banner") - args = append(args, "-threads", "1") - args = append(args, "-protocol_whitelist", "file,udp,rtp") - args = append(args, "-buffer_size", "10000000") - args = append(args, "-max_delay", "1000000") - args = append(args, "-reorder_queue_size", "0") - args = append(args, "-i", sdp) - args = append(args, "-c", "copy") - args = append(args, "-y", output) - return args -} - -// scanFFmpegOutput reads lines from FFmpeg output, mirrors to console, and extracts start offset. -func (r *FfmpegConverter) scanFFmpegOutput(reader io.Reader, isStderr bool) { - scanner := bufio.NewScanner(reader) - re := regexp.MustCompile(`\bstart:\s*([0-9]+(?:\.[0-9]+)?)`) - for scanner.Scan() { - line := scanner.Text() - // Mirror output - if isStderr { - _, _ = fmt.Fprintln(os.Stderr, line) - } else { - _, _ = fmt.Fprintln(os.Stdout, line) - } - - // Try to extract the start value from those lines "Duration: N/A, start: 0.000000, bitrate: N/A" - if !strings.Contains(line, "Duration") || !strings.Contains(line, "bitrate") { - continue - } else if matches := re.FindStringSubmatch(line); len(matches) == 2 { - if v, parseErr := strconv.ParseFloat(matches[1], 64); parseErr == nil { - // Save only once - r.mu.Lock() - if !r.hasStartOffset { - r.startOffsetMs = int64(v * 1000) - r.hasStartOffset = true - r.logger.Infof("Detected FFmpeg start offset: %d ms", r.startOffsetMs) - } - r.mu.Unlock() - } - } - } - _ = scanner.Err() -} - -// StartOffset returns the parsed FFmpeg start offset in seconds and whether it was found. -func (r *FfmpegConverter) StartOffset() (int64, bool) { - r.mu.Lock() - defer r.mu.Unlock() - return r.startOffsetMs, r.hasStartOffset -} - -func (r *FfmpegConverter) OnRTP(packet *rtp.Packet) error { - // Marshal RTP packet - buf, err := packet.Marshal() - if err != nil { - return err - } - - return r.PushRtpBuf(buf) -} - -func (r *FfmpegConverter) PushRtpBuf(buf []byte) error { - r.mu.Lock() - defer r.mu.Unlock() - - // Send RTP packet over UDP - if r.conn != nil { - _, err := r.conn.Write(buf) - if err != nil { - r.logger.Warnf("Failed to write RTP packet: %v", err) - } - return err - } - return nil -} - -func (r *FfmpegConverter) Close() error { - r.mu.Lock() - defer r.mu.Unlock() - - // Cancel context to stop background goroutines - if r.cancel != nil { - r.cancel() - } - - r.logger.Infof("Closing UPD connection and wait for FFMpeg termination...") - - // Close UDP connection by sending arbitrary RtcpBye (Ffmpeg is now able to end correctly) - if r.conn != nil { - buf, _ := rtcp.Goodbye{ - Sources: []uint32{1}, // fixed ssrc is ok - Reason: "bye", - }.Marshal() - _, _ = r.conn.Write(buf) - _ = r.conn.Close() - r.conn = nil - } - - // Gracefully wait for FFmpeg termination - if r.ffmpegCmd != nil && r.ffmpegCmd.Process != nil { - // Wait for graceful exit with timeout - done := make(chan error, 1) - go func() { - done <- r.ffmpegCmd.Wait() - }() - - select { - case <-time.After(5 * time.Second): - r.logger.Warnf("FFMpeg Process termination timeout...") - - // Timeout, force kill - if e := r.ffmpegCmd.Process.Kill(); e != nil { - r.logger.Errorf("FFMpeg Process errored while killing: %v", e) - } - case <-done: - r.logger.Infof("FFMpeg Process exited succesfully...") - } - } - - // Clean up temporary SDP file - if r.sdpFile != nil { - _ = os.Remove(r.sdpFile.Name()) - r.sdpFile = nil - } - - return nil -} From d382bf682eeffba9696164425620e9c37d25bd1f Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Wed, 28 Jan 2026 09:33:26 +0100 Subject: [PATCH 08/18] feat: move to raw-recording --- pkg/cmd/{raw-recording-tool => raw-recording}/README.md | 0 pkg/cmd/{raw-recording-tool => raw-recording}/constants.go | 0 pkg/cmd/{raw-recording-tool => raw-recording}/extract_audio.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/extract_video.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/list_tracks.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/mix_audio.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/mux_av.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/process_all.go | 2 +- .../processing/archive_input.go | 0 .../processing/archive_json.go | 0 .../processing/archive_metadata.go | 0 .../processing/audio_mixer.go | 0 .../processing/audio_video_muxer.go | 0 .../processing/constants.go | 0 .../processing/container_converter.go | 0 .../processing/ffmpeg_helper.go | 0 .../processing/gstreamer_converter.go | 0 .../processing/logger_adapter.go | 0 .../processing/sdp_tool.go | 0 .../processing/track_extractor.go | 0 pkg/cmd/{raw-recording-tool => raw-recording}/root.go | 2 +- pkg/cmd/{raw-recording-tool => raw-recording}/s3_downloader.go | 0 pkg/cmd/video/root.go | 2 +- 23 files changed, 8 insertions(+), 8 deletions(-) rename pkg/cmd/{raw-recording-tool => raw-recording}/README.md (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/constants.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/extract_audio.go (98%) rename pkg/cmd/{raw-recording-tool => raw-recording}/extract_video.go (98%) rename pkg/cmd/{raw-recording-tool => raw-recording}/list_tracks.go (98%) rename pkg/cmd/{raw-recording-tool => raw-recording}/mix_audio.go (96%) rename pkg/cmd/{raw-recording-tool => raw-recording}/mux_av.go (98%) rename pkg/cmd/{raw-recording-tool => raw-recording}/process_all.go (98%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/archive_input.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/archive_json.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/archive_metadata.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/audio_mixer.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/audio_video_muxer.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/constants.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/container_converter.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/ffmpeg_helper.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/gstreamer_converter.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/logger_adapter.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/sdp_tool.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/processing/track_extractor.go (100%) rename pkg/cmd/{raw-recording-tool => raw-recording}/root.go (99%) rename pkg/cmd/{raw-recording-tool => raw-recording}/s3_downloader.go (100%) diff --git a/pkg/cmd/raw-recording-tool/README.md b/pkg/cmd/raw-recording/README.md similarity index 100% rename from pkg/cmd/raw-recording-tool/README.md rename to pkg/cmd/raw-recording/README.md diff --git a/pkg/cmd/raw-recording-tool/constants.go b/pkg/cmd/raw-recording/constants.go similarity index 100% rename from pkg/cmd/raw-recording-tool/constants.go rename to pkg/cmd/raw-recording/constants.go diff --git a/pkg/cmd/raw-recording-tool/extract_audio.go b/pkg/cmd/raw-recording/extract_audio.go similarity index 98% rename from pkg/cmd/raw-recording-tool/extract_audio.go rename to pkg/cmd/raw-recording/extract_audio.go index 1a99493..5338097 100644 --- a/pkg/cmd/raw-recording-tool/extract_audio.go +++ b/pkg/cmd/raw-recording/extract_audio.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/extract_video.go b/pkg/cmd/raw-recording/extract_video.go similarity index 98% rename from pkg/cmd/raw-recording-tool/extract_video.go rename to pkg/cmd/raw-recording/extract_video.go index ab1a93e..9a8e2a7 100644 --- a/pkg/cmd/raw-recording-tool/extract_video.go +++ b/pkg/cmd/raw-recording/extract_video.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/list_tracks.go b/pkg/cmd/raw-recording/list_tracks.go similarity index 98% rename from pkg/cmd/raw-recording-tool/list_tracks.go rename to pkg/cmd/raw-recording/list_tracks.go index aae76fa..524c6fd 100644 --- a/pkg/cmd/raw-recording-tool/list_tracks.go +++ b/pkg/cmd/raw-recording/list_tracks.go @@ -7,7 +7,7 @@ import ( "sort" "strings" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/mix_audio.go b/pkg/cmd/raw-recording/mix_audio.go similarity index 96% rename from pkg/cmd/raw-recording-tool/mix_audio.go rename to pkg/cmd/raw-recording/mix_audio.go index a6b77f7..bd0e131 100644 --- a/pkg/cmd/raw-recording-tool/mix_audio.go +++ b/pkg/cmd/raw-recording/mix_audio.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/mux_av.go b/pkg/cmd/raw-recording/mux_av.go similarity index 98% rename from pkg/cmd/raw-recording-tool/mux_av.go rename to pkg/cmd/raw-recording/mux_av.go index f6571b2..9e1c9a3 100644 --- a/pkg/cmd/raw-recording-tool/mux_av.go +++ b/pkg/cmd/raw-recording/mux_av.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/process_all.go b/pkg/cmd/raw-recording/process_all.go similarity index 98% rename from pkg/cmd/raw-recording-tool/process_all.go rename to pkg/cmd/raw-recording/process_all.go index d66b0d1..f3dbff8 100644 --- a/pkg/cmd/raw-recording-tool/process_all.go +++ b/pkg/cmd/raw-recording/process_all.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" ) diff --git a/pkg/cmd/raw-recording-tool/processing/archive_input.go b/pkg/cmd/raw-recording/processing/archive_input.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/archive_input.go rename to pkg/cmd/raw-recording/processing/archive_input.go diff --git a/pkg/cmd/raw-recording-tool/processing/archive_json.go b/pkg/cmd/raw-recording/processing/archive_json.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/archive_json.go rename to pkg/cmd/raw-recording/processing/archive_json.go diff --git a/pkg/cmd/raw-recording-tool/processing/archive_metadata.go b/pkg/cmd/raw-recording/processing/archive_metadata.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/archive_metadata.go rename to pkg/cmd/raw-recording/processing/archive_metadata.go diff --git a/pkg/cmd/raw-recording-tool/processing/audio_mixer.go b/pkg/cmd/raw-recording/processing/audio_mixer.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/audio_mixer.go rename to pkg/cmd/raw-recording/processing/audio_mixer.go diff --git a/pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go b/pkg/cmd/raw-recording/processing/audio_video_muxer.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/audio_video_muxer.go rename to pkg/cmd/raw-recording/processing/audio_video_muxer.go diff --git a/pkg/cmd/raw-recording-tool/processing/constants.go b/pkg/cmd/raw-recording/processing/constants.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/constants.go rename to pkg/cmd/raw-recording/processing/constants.go diff --git a/pkg/cmd/raw-recording-tool/processing/container_converter.go b/pkg/cmd/raw-recording/processing/container_converter.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/container_converter.go rename to pkg/cmd/raw-recording/processing/container_converter.go diff --git a/pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go b/pkg/cmd/raw-recording/processing/ffmpeg_helper.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/ffmpeg_helper.go rename to pkg/cmd/raw-recording/processing/ffmpeg_helper.go diff --git a/pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go b/pkg/cmd/raw-recording/processing/gstreamer_converter.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/gstreamer_converter.go rename to pkg/cmd/raw-recording/processing/gstreamer_converter.go diff --git a/pkg/cmd/raw-recording-tool/processing/logger_adapter.go b/pkg/cmd/raw-recording/processing/logger_adapter.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/logger_adapter.go rename to pkg/cmd/raw-recording/processing/logger_adapter.go diff --git a/pkg/cmd/raw-recording-tool/processing/sdp_tool.go b/pkg/cmd/raw-recording/processing/sdp_tool.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/sdp_tool.go rename to pkg/cmd/raw-recording/processing/sdp_tool.go diff --git a/pkg/cmd/raw-recording-tool/processing/track_extractor.go b/pkg/cmd/raw-recording/processing/track_extractor.go similarity index 100% rename from pkg/cmd/raw-recording-tool/processing/track_extractor.go rename to pkg/cmd/raw-recording/processing/track_extractor.go diff --git a/pkg/cmd/raw-recording-tool/root.go b/pkg/cmd/raw-recording/root.go similarity index 99% rename from pkg/cmd/raw-recording-tool/root.go rename to pkg/cmd/raw-recording/root.go index f5897e8..3acd25a 100644 --- a/pkg/cmd/raw-recording-tool/root.go +++ b/pkg/cmd/raw-recording/root.go @@ -6,7 +6,7 @@ import ( "log" "os" - "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool/processing" + "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" diff --git a/pkg/cmd/raw-recording-tool/s3_downloader.go b/pkg/cmd/raw-recording/s3_downloader.go similarity index 100% rename from pkg/cmd/raw-recording-tool/s3_downloader.go rename to pkg/cmd/raw-recording/s3_downloader.go diff --git a/pkg/cmd/video/root.go b/pkg/cmd/video/root.go index 2cf9bd1..304ea8a 100644 --- a/pkg/cmd/video/root.go +++ b/pkg/cmd/video/root.go @@ -3,7 +3,7 @@ package video import ( "github.com/spf13/cobra" - rawrecording "github.com/GetStream/stream-cli/pkg/cmd/raw-recording-tool" + rawrecording "github.com/GetStream/stream-cli/pkg/cmd/raw-recording" ) func NewRootCmd() *cobra.Command { From 0134c85c3740170aad02e9ff229885375d3afada Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Wed, 28 Jan 2026 09:50:35 +0100 Subject: [PATCH 09/18] feat: update README.md --- pkg/cmd/raw-recording/README.md | 463 ++++++++++++-------------------- 1 file changed, 166 insertions(+), 297 deletions(-) diff --git a/pkg/cmd/raw-recording/README.md b/pkg/cmd/raw-recording/README.md index 725a86d..3b9cad4 100644 --- a/pkg/cmd/raw-recording/README.md +++ b/pkg/cmd/raw-recording/README.md @@ -1,6 +1,6 @@ -# Raw-Tools CLI +# Raw Recording CLI -Post-processing tools for raw video call recordings with intelligent completion, validation, and advanced audio/video processing. +Post-processing tools for Stream Video raw call recordings. Extract, process, and combine audio/video tracks from raw recording archives. ## Features @@ -9,420 +9,289 @@ Post-processing tools for raw video call recordings with intelligent completion, - **Validation**: Automatic validation of user inputs against available data - **Multiple Formats**: Support for different output formats (table, JSON, completion) - **Advanced Processing**: Extract, mux, mix and process audio/video with gap filling -- **Hybrid Architecture**: Optimized performance for different use cases +- **S3 Support**: Download recordings directly from S3 or presigned URLs with caching ## Commands -### `list-tracks` - Discovery & Completion Hub +### `list-tracks` - Discovery & Exploration -The `list-tracks` command serves as both a discovery tool and completion engine for other commands. +The `list-tracks` command shows all tracks in a recording with their metadata. ```bash -# Basic usage - see all tracks in table format (no --output needed) -raw-tools --inputFile recording.tar.gz list-tracks +# List all tracks in table format +stream-cli video raw-recording list-tracks --input-file recording.tar.gz # Get JSON output for programmatic use -raw-tools --inputFile recording.tar.gz list-tracks --format json +stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format json -# Get completion-friendly lists -raw-tools --inputFile recording.tar.gz list-tracks --format users -raw-tools --inputFile recording.tar.gz list-tracks --format sessions -raw-tools --inputFile recording.tar.gz list-tracks --format tracks +# Get user IDs only +stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format users + +# Get session IDs only +stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format sessions + +# Get track IDs only +stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format tracks + +# Filter by track type +stream-cli video raw-recording list-tracks --input-file recording.tar.gz --track-type audio ``` **Options:** - `--format ` - Output format: `table` (default), `json`, `users`, `sessions`, `tracks`, `completion` -- `--trackType ` - Filter by track type: `audio`, `video` (optional) -- `-h, --help` - Show help message +- `--track-type ` - Filter by track type: `audio`, `video` **Output Formats:** - `table` - Human-readable table with screenshare detection (default) - `json` - Full metadata in JSON format for scripting - `users` - List of user IDs only (for shell scripts) -- `sessions` - List of session IDs only (for automation) +- `sessions` - List of session IDs only (for automation) - `tracks` - List of track IDs only (for filtering) -- `completion` - Shell completion format ### `extract-audio` - Extract Audio Tracks -Extract and convert individual audio tracks from raw recordings to WebM format. +Extract and convert audio tracks from raw recordings to playable MKV format. ```bash # Extract audio for all users -raw-tools --inputFile recording.zip --output ./output extract-audio +stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out -# Extract audio for specific user with gap filling -raw-tools --inputFile recording.zip --output ./output extract-audio --userId user123 --fill_gaps +# Extract audio for specific user +stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --user-id user123 # Extract audio for specific session -raw-tools --inputFile recording.zip --output ./output extract-audio --sessionId session456 +stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --session-id session456 -# Extract specific track only -raw-tools --inputFile recording.zip --output ./output extract-audio --trackId track789 +# Extract a specific track +stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --track-id track789 + +# Disable gap filling +stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --fill-gaps=false ``` **Options:** -- `--userId ` - Filter by user ID (returns all tracks for that user) -- `--sessionId ` - Filter by session ID (returns all tracks for that session) -- `--trackId ` - Filter by track ID (returns only that specific track) -- **Note**: These filters are mutually exclusive - only one can be specified at a time -- `--fill_gaps` - Fill temporal gaps between segments with silence (recommended for playback) -- `-h, --help` - Show help message - -**Mutually Exclusive Filtering:** -- Only one filter can be specified at a time: `--userId`, `--sessionId`, or `--trackId` -- `--trackId` returns exactly one track (the specified track) -- `--sessionId` returns all tracks for that session (multiple tracks possible) -- `--userId` returns all tracks for that user (multiple tracks possible) -- If no filter is specified, all tracks are processed +- `--user-id ` - Filter by user ID (all tracks for that user) +- `--session-id ` - Filter by session ID (all tracks for that session) +- `--track-id ` - Filter by track ID (specific track only) +- `--fill-gaps` - Fill temporal gaps with silence when track was muted (default: true) +- `--fix-dtx` - Fix DTX (Discontinuous Transmission) shrink audio (default: true) + +**Note**: Filters are mutually exclusive - only one of `--user-id`, `--session-id`, or `--track-id` can be specified at a time. ### `extract-video` - Extract Video Tracks -Extract and convert individual video tracks from raw recordings to WebM format. +Extract and convert video tracks from raw recordings to playable MKV format. ```bash # Extract video for all users -raw-tools --inputFile recording.zip --output ./output extract-video +stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out + +# Extract video for specific user +stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --user-id user123 -# Extract video for specific user with black frame filling -raw-tools --inputFile recording.zip --output ./output extract-video --userId user123 --fill_gaps +# Extract video for specific session +stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --session-id session456 -# Extract screenshare video only -raw-tools --inputFile recording.zip --output ./output extract-video --userId user456 --fill_gaps +# Extract a specific track +stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --track-id track789 + +# Disable gap filling +stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --fill-gaps=false ``` **Options:** -- `--userId ` - Filter by user ID (returns all tracks for that user) -- `--sessionId ` - Filter by session ID (returns all tracks for that session) -- `--trackId ` - Filter by track ID (returns only that specific track) -- **Note**: These filters are mutually exclusive - only one can be specified at a time -- `--fill_gaps` - Fill temporal gaps between segments with black frames (recommended for playback) -- `-h, --help` - Show help message +- `--user-id ` - Filter by user ID (all tracks for that user) +- `--session-id ` - Filter by session ID (all tracks for that session) +- `--track-id ` - Filter by track ID (specific track only) +- `--fill-gaps` - Fill temporal gaps with black frames when track was muted (default: true) -**Video Processing:** -- Supports regular camera video and screenshare video -- Automatically detects and preserves video codec (VP8, VP9, H264, AV1) -- Gap filling generates black frames matching original video dimensions and framerate +**Note**: Filters are mutually exclusive - only one of `--user-id`, `--session-id`, or `--track-id` can be specified at a time. -### `mux-av` - Mux Audio/Video +### `mux-av` - Combine Audio and Video -Combine individual audio and video tracks with proper synchronization and timing offsets. +Combine audio and video tracks into synchronized files. ```bash -# Mux audio/video for all users -raw-tools --inputFile recording.zip --output ./output mux-av +# Mux all tracks +stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out -# Mux for specific user with proper sync -raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 +# Mux tracks for specific user +stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --user-id user123 -# Mux for specific session -raw-tools --inputFile recording.zip --output ./output mux-av --sessionId session456 +# Mux only user camera tracks (not screenshare) +stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --media user -# Mux specific tracks with precise control -raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --sessionId session456 +# Mux only display/screenshare tracks +stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --media display ``` **Options:** -- `--userId ` - Filter by user ID (returns all tracks for that user) -- `--sessionId ` - Filter by session ID (returns all tracks for that session) -- `--trackId ` - Filter by track ID (returns only that specific track) -- **Note**: These filters are mutually exclusive - only one can be specified at a time -- `--media ` - Filter by media type: `user` (camera/microphone), `display` (screen sharing), or `both` (default) -- `-h, --help` - Show help message - -**Features:** -- Automatic timing synchronization between audio and video using RTCP timestamps -- Gap filling for seamless playback (always enabled for muxing) -- Single combined WebM output per user/session combination -- Intelligent offset calculation for perfect A/V sync -- Supports all video codecs (VP8, VP9, H264, AV1) with Opus audio -- Media type filtering ensures consistent pairing (user camera ↔ user microphone, display sharing ↔ display audio) - -**Media Type Examples:** -```bash -# Mux only user camera/microphone tracks -raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media user - -# Mux only display sharing tracks -raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media display +- `--user-id ` - Filter by user ID +- `--session-id ` - Filter by session ID +- `--track-id ` - Filter by track ID +- `--media ` - Filter by media type: `user` (camera/microphone), `display` (screenshare), or `both` (default) -# Mux both types with proper pairing (default) -raw-tools --inputFile recording.zip --output ./output mux-av --userId user123 --media both -``` +**Note**: Filters are mutually exclusive. ### `mix-audio` - Mix Multiple Audio Tracks -Mix audio from multiple users/sessions into a single synchronized audio file, perfect for conference call reconstruction. +Mix audio from multiple users/sessions into a single synchronized audio file. ```bash -# Mix audio from all users (full conference call) -raw-tools --inputFile recording.zip --output ./output mix-audio - -# Mix audio from specific user across all sessions -raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 - -# Mix audio from specific session (all users in that session) -raw-tools --inputFile recording.zip --output ./output mix-audio --sessionId session456 +# Mix all audio tracks from all users +stream-cli video raw-recording mix-audio --input-file recording.tar.gz --output ./out -# Mix specific tracks with fine control -raw-tools --inputFile recording.zip --output ./output mix-audio --userId user123 --sessionId session456 +# Mix with verbose logging +stream-cli video raw-recording mix-audio --input-file recording.tar.gz --output ./out --verbose ``` -**Options:** -- `--userId ` - Filter by user ID (returns all tracks for that user) -- `--sessionId ` - Filter by session ID (returns all tracks for that session) -- `--trackId ` - Filter by track ID (returns only that specific track) -- **Note**: These filters are mutually exclusive - only one can be specified at a time -- `--no-fill-gaps` - Disable gap filling (not recommended for mixing, gaps enabled by default) -- `-h, --help` - Show help message - -**Perfect for:** -- Conference call audio reconstruction with proper timing -- Multi-participant audio analysis and review -- Creating complete session audio timelines -- Audio synchronization testing and validation -- Podcast-style recordings from video calls - -**Advanced Mixing:** -- Uses FFmpeg adelay and amix filters for professional-quality mixing -- Automatic timing offset calculation based on segment metadata -- Gap filling with silence maintains temporal relationships -- Output: Single `mixed_audio.webm` file with all tracks properly synchronized +Creates `composite_{callType}_{callId}_audio_{timestamp}.mkv` with all tracks properly synchronized based on original timing. ### `process-all` - Complete Workflow -Execute audio extraction, video extraction, and muxing in a single command - the all-in-one solution. +Execute audio extraction, video extraction, and muxing in a single command. ```bash -# Process everything for all users -raw-tools --inputFile recording.zip --output ./output process-all - -# Process everything for specific user -raw-tools --inputFile recording.zip --output ./output process-all --userId user123 +# Process all tracks +stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out -# Process specific session with all participants -raw-tools --inputFile recording.zip --output ./output process-all --sessionId session456 +# Process tracks for specific user +stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out --user-id user123 -# Process specific tracks with full workflow -raw-tools --inputFile recording.zip --output ./output process-all --userId user123 --sessionId session456 +# Process tracks for specific session +stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out --session-id session456 ``` **Options:** -- `--userId ` - Filter by user ID (returns all tracks for that user) -- `--sessionId ` - Filter by session ID (returns all tracks for that session) -- `--trackId ` - Filter by track ID (returns only that specific track) -- **Note**: These filters are mutually exclusive - only one can be specified at a time -- `-h, --help` - Show help message - -**Workflow Steps:** -1. **Audio Extraction** - Extracts all matching audio tracks with gap filling enabled -2. **Video Extraction** - Extracts all matching video tracks with gap filling enabled -3. **Audio/Video Muxing** - Combines corresponding audio and video tracks with sync - -**Outputs:** -- Individual audio tracks (WebM format): `audio_userId_sessionId_trackId.webm` -- Individual video tracks (WebM format): `video_userId_sessionId_trackId.webm` -- Combined audio/video files (WebM format): `muxed_userId_sessionId_combined.webm` -- All files include gap filling for seamless playback -- Perfect for bulk processing and automated workflows - -## Completion Workflow Architecture - -### 1. Discovery Phase -```bash -# First, explore what's in your recording -raw-tools --inputFile recording.zip list-tracks - -# Example output with screenshare detection: -# USER ID SESSION ID TRACK ID TYPE SCREENSHARE CODEC SEGMENTS -# -------------------- -------------------- -------------------- ------- ------------ --------------- -------- -# user_abc123 session_xyz789 track_001 audio No audio/opus 3 -# user_abc123 session_xyz789 track_002 video No video/VP8 2 -# user_def456 session_xyz789 track_003 video Yes video/VP8 1 -``` +- `--user-id ` - Filter by user ID +- `--session-id ` - Filter by session ID +- `--track-id ` - Filter by track ID -### 2. Shell Completion Setup +**Output files:** +- `individual_{callType}_{callId}_{userId}_{sessionId}_audio_only_{timestamp}.mkv` - Audio-only files +- `individual_{callType}_{callId}_{userId}_{sessionId}_video_only_{timestamp}.mkv` - Video-only files +- `individual_{callType}_{callId}_{userId}_{sessionId}_audio_video_{timestamp}.mkv` - Combined audio+video files +- `composite_{callType}_{callId}_audio_{timestamp}.mkv` - Mixed audio from all participants -```bash -# Install completion for your shell -source <(raw-tools completion bash) # Bash -source <(raw-tools completion zsh) # Zsh -raw-tools completion fish | source # Fish -``` +## Global Options -### 3. Dynamic Completion in Action +These options are available for all commands: -With completion enabled, the CLI will: -- **Auto-complete commands** and flags -- **Dynamically suggest user IDs** from the actual recording -- **Validate inputs** against available data -- **Provide helpful error messages** with discovery hints +- `--input-file ` - Path to raw recording tar.gz archive +- `--input-dir ` - Path to extracted raw recording directory +- `--input-s3 ` - S3 URL (`s3://bucket/path`) or presigned HTTPS URL +- `--output ` - Output directory (required for most commands) +- `--verbose` - Enable verbose logging +- `--cache-dir ` - Cache directory for S3 downloads -```bash -# Tab completion will suggest actual user IDs from your recording -raw-tools --inputFile recording.zip --output ./out extract-audio --userId -# Shows: user_abc123 user_def456 - -# Invalid inputs show helpful errors -raw-tools --inputFile recording.zip --output ./out extract-audio --userId invalid_user -# Error: userID 'invalid_user' not found in recording. Available users: user_abc123, user_def456 -# Tip: Use 'raw-tools --inputFile recording.zip --output ./out list-tracks --format users' to see available user IDs -``` +**Input options**: Only one of `--input-file`, `--input-dir`, or `--input-s3` can be specified. + +## S3 Support -### 4. Programmatic Integration +Download recordings directly from S3: ```bash -# Get user IDs for scripts -USERS=$(raw-tools --inputFile recording.zip list-tracks --format users) +# Using S3 URL (requires AWS credentials) +stream-cli video raw-recording list-tracks --input-s3 s3://mybucket/recordings/call.tar.gz -# Process each user -for user in $USERS; do - raw-tools --inputFile recording.zip --output ./output extract-audio --userId "$user" --fill_gaps -done +# Using presigned HTTPS URL +stream-cli video raw-recording list-tracks --input-s3 "https://mybucket.s3.amazonaws.com/recordings/call.tar.gz?..." -# Get JSON metadata for complex processing -raw-tools --inputFile recording.zip list-tracks --format json > metadata.json +# S3 downloads are cached locally to avoid re-downloading +stream-cli video raw-recording process-all --input-s3 s3://mybucket/call.tar.gz --output ./out ``` ## Workflow Examples -### Example 1: Extract Audio for Each Participant +### Extract Audio for Each Participant ```bash # 1. Discover participants -raw-tools --inputFile call.zip list-tracks --format users +stream-cli video raw-recording list-tracks --input-file call.tar.gz --format users # 2. Extract each participant's audio -for user in $(raw-tools --inputFile call.zip list-tracks --format users); do +for user in $(stream-cli video raw-recording list-tracks --input-file call.tar.gz --format users); do echo "Extracting audio for user: $user" - raw-tools --inputFile call.zip --output ./extracted extract-audio --userId "$user" --fill_gaps + stream-cli video raw-recording extract-audio --input-file call.tar.gz --output ./extracted --user-id "$user" done ``` -### Example 2: Quality Check Before Processing +### Conference Call Audio Mixing ```bash -# 1. Get full metadata overview -raw-tools --inputFile recording.zip list-tracks --format json > recording_info.json - -# 2. Check track counts -audio_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType audio --format json | jq '.tracks | length') -video_tracks=$(raw-tools --inputFile recording.zip list-tracks --trackType video --format json | jq '.tracks | length') +# Mix all participants into single audio file +stream-cli video raw-recording mix-audio --input-file conference.tar.gz --output ./mixed -echo "Found $audio_tracks audio tracks and $video_tracks video tracks" - -# 3. Process only if we have both audio and video -if [ "$audio_tracks" -gt 0 ] && [ "$video_tracks" -gt 0 ]; then - raw-tools --inputFile recording.zip --output ./output mux-av -fi +# Create session-by-session mixed audio +for session in $(stream-cli video raw-recording list-tracks --input-file conference.tar.gz --format sessions); do + stream-cli video raw-recording mix-audio --input-file conference.tar.gz --output "./mixed/$session" +done ``` -### Example 3: Conference Call Audio Mixing +### Complete Processing Pipeline ```bash -# 1. Mix all participants into single audio file -raw-tools --inputFile conference.zip --output ./mixed mix-audio - -# 2. Mix specific users for focused conversation (individual commands) -raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user1 -raw-tools --inputFile conference.zip --output ./mixed mix-audio --userId user2 - -# 3. Create session-by-session mixed audio -for session in $(raw-tools --inputFile conference.zip list-tracks --format sessions); do - raw-tools --inputFile conference.zip --output "./mixed/$session" mix-audio --sessionId "$session" -done +# All-in-one processing +stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./complete + +# Results: +# - ./complete/individual_*_audio_only_*.mkv (individual audio tracks) +# - ./complete/individual_*_video_only_*.mkv (individual video tracks) +# - ./complete/individual_*_audio_video_*.mkv (combined A/V tracks) +# - ./complete/composite_*_audio_*.mkv (mixed audio) ``` -### Example 4: Complete Processing Pipeline +## Dependencies -```bash -# All-in-one processing for the entire recording -raw-tools --inputFile recording.zip --output ./complete process-all +### FFmpeg -# Results in: -# - ./complete/audio_*.webm (individual audio tracks) -# - ./complete/video_*.webm (individual video tracks) -# - ./complete/muxed_*.webm (combined A/V tracks) -``` +Required for media processing and conversion. Must be compiled with the following libraries: -### Example 5: Session-Based Processing +- `libopus` - Opus audio codec +- `libvpx` - VP8/VP9 video codecs +- `libx264` - H.264 video codec +- `libaom` - AV1 video codec (libaom-av1) +- `libmp3lame` - MP3 audio codec (optional, for MP3 output) +**macOS:** ```bash -# 1. Process each session separately -for session in $(raw-tools --inputFile recording.zip list-tracks --format sessions); do - echo "Processing session: $session" - - # Extract all audio from this session - raw-tools --inputFile recording.zip --output "./output/$session" extract-audio --sessionId "$session" --fill_gaps - - # Extract all video from this session - raw-tools --inputFile recording.zip --output "./output/$session" extract-video --sessionId "$session" --fill_gaps - - # Mux audio/video for this session - raw-tools --inputFile recording.zip --output "./output/$session" mux-av --sessionId "$session" -done +brew install ffmpeg ``` -## Architecture & Performance - -### Hybrid Processing Architecture - -The tool uses an intelligent hybrid approach optimized for different use cases: - -**Fast Metadata Reading (`list-tracks`):** -- Direct tar.gz parsing for metadata-only operations -- Skips extraction of large media files (.rtpdump/.sdp) -- 10-50x faster than full extraction for discovery workflows - -**Full Processing (extraction commands):** -- Complete archive extraction to temporary directories -- Access to all media files for conversion and processing -- Unified processing pipeline for reliability +**Ubuntu/Debian:** +```bash +sudo apt install ffmpeg +``` -### Command Categories +### GStreamer -1. **Discovery Commands** (`list-tracks`) - - Optimized for speed and shell completion - - Minimal resource usage - - Instant metadata access +Required for RTP dump to container conversion. Install GStreamer 1.0 with the following plugin packages: -2. **Processing Commands** (`extract-*`, `mix-*`, `mux-*`, `process-all`) - - Full archive extraction and processing - - Complete media file access - - Advanced audio/video operations +**macOS:** +```bash +brew install gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly +``` -3. **Utility Commands** (`completion`, `help`) - - Shell integration and documentation +**Ubuntu/Debian:** +```bash +sudo apt install gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly +``` -## Benefits of the Architecture +**Required GStreamer plugins:** +- `gst-plugins-base` - Core elements (tcpserversrc, filesink) +- `gst-plugins-good` - RTP plugins (rtpjitterbuffer, rtpvp8depay, rtpvp9depay, rtpopusdepay) +- `gst-plugins-bad` - Additional codecs (rtpav1depay, av1parse, matroskamux) +- `gst-plugins-ugly` - H.264 support (rtph264depay, h264parse) -1. **Discoverability**: No need to guess user IDs, session IDs, or track IDs -2. **Performance**: Optimized operations for different use cases -3. **Validation**: Immediate feedback if specified IDs don't exist -4. **Efficiency**: Tab completion speeds up command construction -5. **Reliability**: Prevents typos and invalid commands -6. **Scriptability**: Programmatic access to metadata for automated workflows -7. **User Experience**: Helpful error messages with actionable suggestions -8. **Advanced Processing**: Conference call reconstruction and analysis capabilities +### AWS Credentials (Optional) -## File Structure +Required for S3 URL support (`s3://...`). Not needed for presigned HTTPS URLs. -``` -cmd/raw-tools/ -├── main.go # Main CLI entry point and routing -├── metadata.go # Shared metadata parsing and filtering (hybrid architecture) -├── completion.go # Shell completion scripts generation -├── list_tracks.go # Discovery and completion command (optimized) -├── extract_audio.go # Audio extraction with validation -├── extract_video.go # Video extraction with validation -├── extract_track.go # Generic extraction logic (shared) -├── mix_audio.go # Multi-user audio mixing -├── mux_av.go # Audio/video synchronization and muxing -├── process_all.go # All-in-one processing workflow -└── README.md # This documentation -``` +Configure via: +- Environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` +- AWS credentials file: `~/.aws/credentials` +- IAM role (when running on AWS infrastructure) -## Dependencies +### Go -- **FFmpeg**: Required for media processing and conversion -- **Go 1.19+**: For building the CLI tool +Go 1.19+ required for building the CLI tool from source. From 18f821c8150dc7ad9974d9d1fb8b412a5f2b9cb9 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Wed, 28 Jan 2026 10:18:56 +0100 Subject: [PATCH 10/18] feat: update docs texts --- pkg/cmd/raw-recording/constants.go | 6 +++--- pkg/cmd/raw-recording/extract_audio.go | 10 +++++----- pkg/cmd/raw-recording/extract_video.go | 10 +++++----- pkg/cmd/raw-recording/list_tracks.go | 8 ++++---- pkg/cmd/raw-recording/mix_audio.go | 10 ++++++---- pkg/cmd/raw-recording/mux_av.go | 8 ++++---- pkg/cmd/raw-recording/process_all.go | 19 ++++++++++--------- pkg/cmd/raw-recording/root.go | 6 +++--- 8 files changed, 40 insertions(+), 37 deletions(-) diff --git a/pkg/cmd/raw-recording/constants.go b/pkg/cmd/raw-recording/constants.go index 28c5757..3e22861 100644 --- a/pkg/cmd/raw-recording/constants.go +++ b/pkg/cmd/raw-recording/constants.go @@ -33,8 +33,8 @@ const ( // Flag descriptions for global/persistent flags const ( - DescInputFile = "Raw recording zip file path" - DescInputDir = "Raw recording directory path" + DescInputFile = "Raw recording tar.gz archive path" + DescInputDir = "Raw recording extracted directory path" DescInputS3 = "Raw recording S3 URL (s3://bucket/path or presigned HTTPS URL)" DescOutput = "Output directory" DescVerbose = "Enable verbose logging" @@ -52,7 +52,7 @@ const ( const ( DescFillGapsAudio = "Fill with silence when track was muted" DescFillGapsVideo = "Fill with black frame when track was muted" - DescFixDtx = "Fix DTX shrink audio" + DescFixDtx = "Restore original audio duration by filling DTX silence gaps (required for A/V sync)" DescMedia = "Filter by media type: 'user', 'display', or 'both'" ) diff --git a/pkg/cmd/raw-recording/extract_audio.go b/pkg/cmd/raw-recording/extract_audio.go index 5338097..93734a8 100644 --- a/pkg/cmd/raw-recording/extract_audio.go +++ b/pkg/cmd/raw-recording/extract_audio.go @@ -16,23 +16,23 @@ func extractAudioCmd() *cobra.Command { Long: heredoc.Doc(` Generate playable audio files from raw recording tracks. - Supports formats: webm, mp3, and others. + Output format: MKV container with Opus audio codec. Filters are mutually exclusive: you can only specify one of --user-id, --session-id, or --track-id at a time. `), Example: heredoc.Doc(` # Extract audio for all users (no filters) - $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out + $ stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out # Extract audio for specific user (all their tracks) - $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --user-id user123 + $ stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --user-id user123 # Extract audio for specific session - $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --session-id session456 + $ stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --session-id session456 # Extract a specific track - $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --track-id track1 + $ stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --track-id track1 `), RunE: runExtractAudio, } diff --git a/pkg/cmd/raw-recording/extract_video.go b/pkg/cmd/raw-recording/extract_video.go index 9a8e2a7..bc10b2f 100644 --- a/pkg/cmd/raw-recording/extract_video.go +++ b/pkg/cmd/raw-recording/extract_video.go @@ -16,23 +16,23 @@ func extractVideoCmd() *cobra.Command { Long: heredoc.Doc(` Generate playable video files from raw recording tracks. - Supports formats: webm, mp4, and others. + Output format: MKV container with original video codec (VP8, VP9, H264, or AV1). Filters are mutually exclusive: you can only specify one of --user-id, --session-id, or --track-id at a time. `), Example: heredoc.Doc(` # Extract video for all users (no filters) - $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out + $ stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out # Extract video for specific user (all their tracks) - $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --user-id user123 + $ stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --user-id user123 # Extract video for specific session - $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --session-id session456 + $ stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --session-id session456 # Extract a specific track - $ stream-cli video raw-recording extract-video --input-file recording.zip --output ./out --track-id track1 + $ stream-cli video raw-recording extract-video --input-file recording.tar.gz --output ./out --track-id track1 `), RunE: runExtractVideo, } diff --git a/pkg/cmd/raw-recording/list_tracks.go b/pkg/cmd/raw-recording/list_tracks.go index 524c6fd..586de4f 100644 --- a/pkg/cmd/raw-recording/list_tracks.go +++ b/pkg/cmd/raw-recording/list_tracks.go @@ -26,16 +26,16 @@ func listTracksCmd() *cobra.Command { `), Example: heredoc.Doc(` # List all tracks in table format - $ stream-cli video raw-recording list-tracks --input-file recording.zip + $ stream-cli video raw-recording list-tracks --input-file recording.tar.gz # Get JSON output for programmatic use - $ stream-cli video raw-recording list-tracks --input-file recording.zip --format json + $ stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format json # Get user IDs only - $ stream-cli video raw-recording list-tracks --input-file recording.zip --format users + $ stream-cli video raw-recording list-tracks --input-file recording.tar.gz --format users # Filter by track type - $ stream-cli video raw-recording list-tracks --input-file recording.zip --track-type audio + $ stream-cli video raw-recording list-tracks --input-file recording.tar.gz --track-type audio `), RunE: runListTracks, } diff --git a/pkg/cmd/raw-recording/mix_audio.go b/pkg/cmd/raw-recording/mix_audio.go index bd0e131..fbbd632 100644 --- a/pkg/cmd/raw-recording/mix_audio.go +++ b/pkg/cmd/raw-recording/mix_audio.go @@ -17,15 +17,17 @@ func mixAudioCmd() *cobra.Command { Mix all audio tracks from multiple users/sessions into a single audio file with proper timing synchronization (like a conference call recording). - Creates 'mixed_audio.webm' - a single audio file containing all mixed tracks - with proper timing synchronization based on the original recording timeline. + Creates a composite audio file (MKV format by default) containing all mixed + tracks with proper timing synchronization based on the original recording timeline. + + Output: composite_{callType}_{callId}_audio_{timestamp}.mkv `), Example: heredoc.Doc(` # Mix all audio tracks from all users and sessions - $ stream-cli video raw-recording mix-audio --input-file recording.zip --output ./out + $ stream-cli video raw-recording mix-audio --input-file recording.tar.gz --output ./out # Mix with verbose logging - $ stream-cli video raw-recording mix-audio --input-file recording.zip --output ./out --verbose + $ stream-cli video raw-recording mix-audio --input-file recording.tar.gz --output ./out --verbose `), RunE: runMixAudio, } diff --git a/pkg/cmd/raw-recording/mux_av.go b/pkg/cmd/raw-recording/mux_av.go index 9e1c9a3..9a87383 100644 --- a/pkg/cmd/raw-recording/mux_av.go +++ b/pkg/cmd/raw-recording/mux_av.go @@ -29,16 +29,16 @@ func muxAVCmd() *cobra.Command { `), Example: heredoc.Doc(` # Mux all tracks - $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out + $ stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out # Mux tracks for specific user - $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --user-id user123 + $ stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --user-id user123 # Mux only user camera tracks - $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --media user + $ stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --media user # Mux only display sharing tracks - $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out --media display + $ stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out --media display `), RunE: runMuxAV, } diff --git a/pkg/cmd/raw-recording/process_all.go b/pkg/cmd/raw-recording/process_all.go index f3dbff8..ead2e4c 100644 --- a/pkg/cmd/raw-recording/process_all.go +++ b/pkg/cmd/raw-recording/process_all.go @@ -16,26 +16,27 @@ func processAllCmd() *cobra.Command { Long: heredoc.Doc(` Process audio, video, and mux them into combined files (all-in-one workflow). - Outputs 3 files per session: audio WebM, video WebM, and muxed WebM. - Gap filling is always enabled for seamless playback. + Outputs multiple MKV files: individual audio/video tracks, muxed A/V, and mixed audio. + Gap filling and DTX fix are always enabled for seamless playback and proper A/V sync. Filters are mutually exclusive: you can only specify one of --user-id, --session-id, or --track-id at a time. - Output files per session: - audio_{userId}_{sessionId}_{trackId}.webm - Audio-only file - video_{userId}_{sessionId}_{trackId}.webm - Video-only file - muxed_{userId}_{sessionId}_{trackId}.webm - Combined audio+video file + Output files: + individual_{callType}_{callId}_{userId}_{sessionId}_audio_only_{timestamp}.mkv + individual_{callType}_{callId}_{userId}_{sessionId}_video_only_{timestamp}.mkv + individual_{callType}_{callId}_{userId}_{sessionId}_audio_video_{timestamp}.mkv + composite_{callType}_{callId}_audio_{timestamp}.mkv `), Example: heredoc.Doc(` # Process all tracks - $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out + $ stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out # Process tracks for specific user - $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out --user-id user123 + $ stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out --user-id user123 # Process tracks for specific session - $ stream-cli video raw-recording process-all --input-file recording.zip --output ./out --session-id session456 + $ stream-cli video raw-recording process-all --input-file recording.tar.gz --output ./out --session-id session456 `), RunE: runProcessAll, } diff --git a/pkg/cmd/raw-recording/root.go b/pkg/cmd/raw-recording/root.go index 3acd25a..532c6eb 100644 --- a/pkg/cmd/raw-recording/root.go +++ b/pkg/cmd/raw-recording/root.go @@ -39,13 +39,13 @@ func NewRootCmd() *cobra.Command { `), Example: heredoc.Doc(` # List all tracks in a recording - $ stream-cli video raw-recording list-tracks --input-file recording.zip + $ stream-cli video raw-recording list-tracks --input-file recording.tar.gz # Extract audio tracks for a specific user - $ stream-cli video raw-recording extract-audio --input-file recording.zip --output ./out --user-id user123 + $ stream-cli video raw-recording extract-audio --input-file recording.tar.gz --output ./out --user-id user123 # Mux audio and video tracks - $ stream-cli video raw-recording mux-av --input-file recording.zip --output ./out + $ stream-cli video raw-recording mux-av --input-file recording.tar.gz --output ./out `), } From c27c8e3500bc76ae21992e65e49916d4748604de Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Wed, 28 Jan 2026 13:06:25 +0100 Subject: [PATCH 11/18] feat: update with latest changes --- .../processing/archive_metadata.go | 19 ++++++++++--------- .../processing/audio_video_muxer.go | 16 +++++++++------- .../processing/track_extractor.go | 14 ++++++++++++-- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/pkg/cmd/raw-recording/processing/archive_metadata.go b/pkg/cmd/raw-recording/processing/archive_metadata.go index 2535eb3..5bee550 100644 --- a/pkg/cmd/raw-recording/processing/archive_metadata.go +++ b/pkg/cmd/raw-recording/processing/archive_metadata.go @@ -48,6 +48,8 @@ type TrackFileInfo struct { StartAt time.Time EndAt time.Time MaxFrameDimension SegmentFrameDimension + AudioTrack *TrackInfo + VideoTrack *TrackInfo } // RecordingMetadata contains all tracks and session information @@ -100,17 +102,17 @@ func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, err } if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), "_timing_metadata.json") { - p.logger.Debug("Processing metadata file: %s", path) + p.logger.Debugf("Processing metadata file: %s", path) data, err := os.ReadFile(path) if err != nil { - p.logger.Warn("Failed to read metadata file %s: %v", path, err) + p.logger.Warnf("Failed to read metadata file %s: %v", path, err) return nil } tracks, err := p.parseTimingMetadataFile(data) if err != nil { - p.logger.Warn("Failed to parse metadata file %s: %v", path, err) + p.logger.Warnf("Failed to parse metadata file %s: %v", path, err) return nil } @@ -119,7 +121,6 @@ func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, err return nil }) - if err != nil { return nil, fmt.Errorf("failed to process directory: %w", err) } @@ -134,7 +135,7 @@ func (p *MetadataParser) parseDirectory(dirPath string) (*RecordingMetadata, err // parseMetadataOnlyFromTarGz efficiently extracts only timing metadata from tar.gz files // This is optimized for list-tracks - only reads JSON files, skips all .rtpdump/.sdp files func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*RecordingMetadata, error) { - p.logger.Debug("Reading metadata directly from tar.gz (efficient mode): %s", tarGzPath) + p.logger.Debugf("Reading metadata directly from tar.gz (efficient mode): %s", tarGzPath) file, err := os.Open(tarGzPath) if err != nil { @@ -169,17 +170,17 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin // Only process timing metadata JSON files (skip all .rtpdump/.sdp files) if strings.HasSuffix(strings.ToLower(header.Name), "_timing_metadata.json") { - p.logger.Debug("Processing metadata file: %s", header.Name) + p.logger.Debugf("Processing metadata file: %s", header.Name) data, err := io.ReadAll(tarReader) if err != nil { - p.logger.Warn("Failed to read metadata file %s: %v", header.Name, err) + p.logger.Warnf("Failed to read metadata file %s: %v", header.Name, err) continue } tracks, err := p.parseTimingMetadataFile(data) if err != nil { - p.logger.Warn("Failed to parse metadata file %s: %v", header.Name, err) + p.logger.Warnf("Failed to parse metadata file %s: %v", header.Name, err) continue } @@ -189,7 +190,7 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin // Skip all other files (.rtpdump, .sdp, etc.) - huge efficiency gain! } - p.logger.Debug("Efficiently read %d metadata files from archive (skipped all media data files)", filesRead) + p.logger.Debugf("Efficiently read %d metadata files from archive (skipped all media data files)", filesRead) // Extract unique user IDs and sessions metadata.UserIDs = p.extractUniqueUserIDs(metadata.Tracks) diff --git a/pkg/cmd/raw-recording/processing/audio_video_muxer.go b/pkg/cmd/raw-recording/processing/audio_video_muxer.go index 0b686ae..8365bbe 100644 --- a/pkg/cmd/raw-recording/processing/audio_video_muxer.go +++ b/pkg/cmd/raw-recording/processing/audio_video_muxer.go @@ -46,7 +46,7 @@ func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, met extractor := NewTrackExtractor(p.logger) // Extract tracks with gap filling enabled - p.logger.Info("Extracting tracks with gap filling...") + p.logger.Infof("Extracting tracks with gap filling...") _, err := extractor.ExtractTracks(cfg, metadata) if err != nil { return nil, fmt.Errorf("failed to extract audio tracks: %w", err) @@ -60,7 +60,7 @@ func (p *AudioVideoMuxer) MuxAudioVideoTracks(config *AudioVideoMuxerConfig, met // logger.Infof("Muxing %d user audio/video pairs", len(userAudio)) info, err := p.muxTrackPairs(audioTrack, videoTrack, config) if err != nil { - p.logger.Error("Failed to mux user tracks: %v", err) + p.logger.Errorf("Failed to mux user tracks: %v", err) } infos = append(infos, info) } @@ -116,7 +116,7 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, config *AudioVi // Calculate sync offset using segment timing information offset, err := calculateSyncOffsetFromFiles(audio, video) if err != nil { - p.logger.Warn("Failed to calculate sync offset, using 0: %v", err) + p.logger.Warnf("Failed to calculate sync offset, using 0: %v", err) offset = 0 } @@ -127,7 +127,7 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, config *AudioVi videoFile := video.ConcatenatedTrackFileInfo.Name // Mux the audio and video files - p.logger.Debug("Muxing %s + %s → %s (offset: %dms)", + p.logger.Debugf("Muxing %s + %s → %s (offset: %dms)", filepath.Base(audioFile), filepath.Base(videoFile), filepath.Base(outputFile), offset) err = runFFmpegCommand(generateMuxFilesArguments(outputFile, audioFile, videoFile, float64(offset)), p.logger) @@ -136,15 +136,15 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, config *AudioVi return nil, err } - p.logger.Info("Successfully created muxed file: %s", outputFile) + p.logger.Infof("Successfully created muxed file: %s", outputFile) // Clean up individual track files to avoid clutter if config.WithCleanup { defer func() { for _, file := range []string{audioFile, videoFile} { - p.logger.Info("Cleaning up temporary file: %s", file) + p.logger.Infof("Cleaning up temporary file: %s", file) if err := os.Remove(file); err != nil { - p.logger.Warn("Failed to clean up temporary file %s: %v", file, err) + p.logger.Warnf("Failed to clean up temporary file %s: %v", file, err) } } }() @@ -155,6 +155,8 @@ func (p *AudioVideoMuxer) muxTrackPairs(audio, video *TrackInfo, config *AudioVi StartAt: p.getTime(audio.ConcatenatedTrackFileInfo.StartAt, video.ConcatenatedTrackFileInfo.StartAt, true), EndAt: p.getTime(audio.ConcatenatedTrackFileInfo.EndAt, video.ConcatenatedTrackFileInfo.EndAt, false), MaxFrameDimension: video.ConcatenatedTrackFileInfo.MaxFrameDimension, + AudioTrack: audio, + VideoTrack: video, }, nil } diff --git a/pkg/cmd/raw-recording/processing/track_extractor.go b/pkg/cmd/raw-recording/processing/track_extractor.go index bb04528..533d8ce 100644 --- a/pkg/cmd/raw-recording/processing/track_extractor.go +++ b/pkg/cmd/raw-recording/processing/track_extractor.go @@ -46,11 +46,11 @@ func (p *TrackExtractor) ExtractTracks(config *TrackExtractorConfig, metadata *R // Extract and convert each track var infos []*TrackFileInfo for i, track := range filteredTracks { - p.logger.Debugf("Processing %s track %d/%d: %s", config.TrackKind, i+1, len(filteredTracks), track.TrackID) + p.logger.Debugf("Processing %s track %d/%d: %s", track.TrackKind, i+1, len(filteredTracks), track.TrackID) info, err := p.extractSingleTrackWithOptions(config, track) if err != nil { - p.logger.Errorf("Failed to extract %s track %s: %v", config.TrackKind, track.TrackID, err) + p.logger.Errorf("Failed to extract %s track %s: %v", track.TrackKind, track.TrackID, err) continue } if info != nil { @@ -186,11 +186,21 @@ func (p *TrackExtractor) processSegmentsWithGapFilling(config *TrackExtractorCon ts = track.Segments[0].metadata.FirstRtpUnixTimestamp te = track.Segments[len(track.Segments)-1].metadata.LastRtpUnixTimestamp } + + var audioTrack, videoTrack *TrackInfo + switch track.TrackKind { + case trackKindAudio: + audioTrack = track + case trackKindVideo: + videoTrack = track + } return &TrackFileInfo{ Name: finalPath, StartAt: time.UnixMilli(ts), EndAt: time.UnixMilli(te), MaxFrameDimension: p.getMaxFrameDimension(track), + AudioTrack: audioTrack, + VideoTrack: videoTrack, }, nil } From 2ec6fc18e39c40e1df41e254afb44681807db395 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 29 Jan 2026 10:21:45 +0100 Subject: [PATCH 12/18] feat: remove getstream.Logger deps --- go.mod | 4 +- go.sum | 6 --- .../processing/logger_adapter.go | 45 ++++++++++++++----- pkg/cmd/raw-recording/root.go | 11 ++--- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index b782b0f..ee009c3 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ toolchain go1.24.4 require ( github.com/AlecAivazis/survey/v2 v2.3.4 - github.com/GetStream/getstream-go/v3 v3.7.0 github.com/GetStream/stream-chat-go/v5 v5.8.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/aws/aws-sdk-go-v2/config v1.32.7 @@ -15,7 +14,6 @@ require ( github.com/cheynewallace/tabby v1.1.1 github.com/gizak/termui/v3 v3.1.0 github.com/gorilla/websocket v1.5.0 - github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.0 github.com/pion/webrtc/v4 v4.2.3 github.com/spf13/cobra v1.4.0 @@ -41,7 +39,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/pion/datachannel v1.6.0 // indirect github.com/pion/dtls/v3 v3.0.10 // indirect @@ -50,6 +47,7 @@ require ( github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect + github.com/pion/rtcp v1.2.16 // indirect github.com/pion/sctp v1.9.2 // indirect github.com/pion/sdp/v3 v3.0.17 // indirect github.com/pion/srtp/v3 v3.0.10 // indirect diff --git a/go.sum b/go.sum index 88001c4..7dbe48c 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,6 @@ github.com/AlecAivazis/survey/v2 v2.3.4 h1:pchTU9rsLUSvWEl2Aq9Pv3k0IE2fkqtGxazsk github.com/AlecAivazis/survey/v2 v2.3.4/go.mod h1:hrV6Y/kQCLhIZXGcriDCUBtB3wnN7156gMXJ3+b23xM= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GetStream/getstream-go/v3 v3.7.0 h1:GzpyJ1lUacTHAIGyh0mMrP7f0hd1bQ/UEU1nyrNC9tk= -github.com/GetStream/getstream-go/v3 v3.7.0/go.mod h1:myW37DwbXEM2DrQy578MsWZcJzw/wjFLF3iPm71wwgg= github.com/GetStream/stream-chat-go/v5 v5.8.1 h1:nO3pfa4p4o6KEZOAXaaII3bhdrMrfT2zs6VduchuJws= github.com/GetStream/stream-chat-go/v5 v5.8.1/go.mod h1:ET7NyUYplNy8+tyliin6Q3kKwbd/+FHQWMAW6zucisY= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -124,8 +122,6 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -196,8 +192,6 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= diff --git a/pkg/cmd/raw-recording/processing/logger_adapter.go b/pkg/cmd/raw-recording/processing/logger_adapter.go index 0754b70..f0c9343 100644 --- a/pkg/cmd/raw-recording/processing/logger_adapter.go +++ b/pkg/cmd/raw-recording/processing/logger_adapter.go @@ -1,47 +1,68 @@ package processing import ( - "github.com/GetStream/getstream-go/v3" + "fmt" + "io" +) + +// LogLevel represents the severity level of a log message +type LogLevel int + +const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError ) type ProcessingLogger struct { - logger *getstream.DefaultLogger + writer io.Writer + level LogLevel } -func NewRawToolLogger(logger *getstream.DefaultLogger) *ProcessingLogger { +func NewProcessingLogger(writer io.Writer, level LogLevel) *ProcessingLogger { return &ProcessingLogger{ - logger: logger, + writer: writer, + level: level, + } +} + +func (l *ProcessingLogger) log(level LogLevel, prefix, format string, args ...interface{}) { + if level < l.level { + return } + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(l.writer, "%s %s\n", prefix, msg) } func (l *ProcessingLogger) Debug(format string, args ...interface{}) { - l.logger.Debug(format, args...) + l.log(LogLevelDebug, "[DEBUG]", format, args...) } func (l *ProcessingLogger) Debugf(format string, args ...interface{}) { - l.logger.Debug(format, args...) + l.log(LogLevelDebug, "[DEBUG]", format, args...) } func (l *ProcessingLogger) Info(format string, args ...interface{}) { - l.logger.Info(format, args...) + l.log(LogLevelInfo, "[INFO]", format, args...) } func (l *ProcessingLogger) Infof(format string, args ...interface{}) { - l.logger.Info(format, args...) + l.log(LogLevelInfo, "[INFO]", format, args...) } func (l *ProcessingLogger) Warn(format string, args ...interface{}) { - l.logger.Warn(format, args...) + l.log(LogLevelWarn, "[WARN]", format, args...) } func (l *ProcessingLogger) Warnf(format string, args ...interface{}) { - l.logger.Warn(format, args...) + l.log(LogLevelWarn, "[WARN]", format, args...) } func (l *ProcessingLogger) Error(format string, args ...interface{}) { - l.logger.Error(format, args...) + l.log(LogLevelError, "[ERROR]", format, args...) } func (l *ProcessingLogger) Errorf(format string, args ...interface{}) { - l.logger.Error(format, args...) + l.log(LogLevelError, "[ERROR]", format, args...) } diff --git a/pkg/cmd/raw-recording/root.go b/pkg/cmd/raw-recording/root.go index 532c6eb..0f45a85 100644 --- a/pkg/cmd/raw-recording/root.go +++ b/pkg/cmd/raw-recording/root.go @@ -3,14 +3,11 @@ package rawrecording import ( "context" "fmt" - "log" "os" "github.com/GetStream/stream-cli/pkg/cmd/raw-recording/processing" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" - - "github.com/GetStream/getstream-go/v3" ) // GlobalArgs holds the global arguments shared across all subcommands @@ -231,13 +228,13 @@ func validateInputArgs(globalArgs *GlobalArgs, userID, sessionID, trackID string // setupLogger creates a logger with the specified verbosity func setupLogger(verbose bool) *processing.ProcessingLogger { - var level getstream.LogLevel + var level processing.LogLevel if verbose { - level = getstream.LogLevelDebug + level = processing.LogLevelDebug } else { - level = getstream.LogLevelInfo + level = processing.LogLevelInfo } - return processing.NewRawToolLogger(getstream.NewDefaultLogger(os.Stderr, "", log.LstdFlags, level)) + return processing.NewProcessingLogger(os.Stderr, level) } // prepareWorkDir extracts the recording to a temp directory and returns the working directory From 3e0a93719088e0a2a1a7786a3956f1d8bf6255a3 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Thu, 29 Jan 2026 11:12:29 +0100 Subject: [PATCH 13/18] feat: include audio screenshare in mixed audio --- pkg/cmd/raw-recording/mix_audio.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/raw-recording/mix_audio.go b/pkg/cmd/raw-recording/mix_audio.go index fbbd632..7763c44 100644 --- a/pkg/cmd/raw-recording/mix_audio.go +++ b/pkg/cmd/raw-recording/mix_audio.go @@ -73,7 +73,7 @@ func runMixAudio(cmd *cobra.Command, args []string) error { mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, - WithScreenshare: false, + WithScreenshare: true, WithExtract: true, WithCleanup: false, }, metadata) From 2c3f4b478a59017ac1db91cf4d66c3d96fd909bc Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 30 Jan 2026 11:21:13 +0100 Subject: [PATCH 14/18] feat: fix lint --- pkg/cmd/raw-recording/processing/container_converter.go | 2 +- pkg/cmd/raw-recording/processing/sdp_tool.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/raw-recording/processing/container_converter.go b/pkg/cmd/raw-recording/processing/container_converter.go index fee75be..29ee00c 100644 --- a/pkg/cmd/raw-recording/processing/container_converter.go +++ b/pkg/cmd/raw-recording/processing/container_converter.go @@ -11,7 +11,7 @@ import ( "github.com/pion/rtp" "github.com/pion/rtp/codecs" - "github.com/pion/webrtc/v4" + webrtc "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media/rtpdump" "github.com/pion/webrtc/v4/pkg/media/samplebuilder" ) diff --git a/pkg/cmd/raw-recording/processing/sdp_tool.go b/pkg/cmd/raw-recording/processing/sdp_tool.go index ed61c08..5496d7c 100644 --- a/pkg/cmd/raw-recording/processing/sdp_tool.go +++ b/pkg/cmd/raw-recording/processing/sdp_tool.go @@ -5,7 +5,7 @@ import ( "os" "strings" - "github.com/pion/webrtc/v4" + webrtc "github.com/pion/webrtc/v4" ) func readSDP(sdpFilePath string) (string, error) { From 7f17786a6e3f8f162b22c33605b30d37c39cce33 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 30 Jan 2026 12:59:22 +0100 Subject: [PATCH 15/18] feat: fix lint --- pkg/cmd/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 2d37d64..cb2bcc9 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -6,7 +6,7 @@ import ( "net/url" "text/tabwriter" - "github.com/AlecAivazis/survey/v2" + survey "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cheynewallace/tabby" "github.com/spf13/cobra" From febbbd394777551d4dad1d3afc4ea6e4f81257e9 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 30 Jan 2026 13:34:56 +0100 Subject: [PATCH 16/18] feat: test linter --- .golangci.yml | 2 +- Makefile | 2 +- pkg/cmd/chat/channel/channel.go | 2 +- pkg/cmd/raw-recording/mix_audio.go | 2 +- pkg/cmd/raw-recording/process_all.go | 2 +- .../processing/container_converter.go | 5 ----- .../raw-recording/processing/ffmpeg_helper.go | 4 ++-- .../processing/gstreamer_converter.go | 4 ++-- pkg/cmd/raw-recording/processing/sdp_tool.go | 17 ----------------- test/helpers.go | 2 -- 10 files changed, 9 insertions(+), 33 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 257373b..f570d73 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,5 @@ run: - go: '1.19' + go: '1.23' deadline: 210s timeout: 10m skip-dirs: diff --git a/Makefile b/Makefile index d9b1d48..4fbca0a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = stream-cli -GOLANGCI_VERSION = 1.55.2 +GOLANGCI_VERSION = 1.62.2 GOLANGCI = .bin/golangci/$(GOLANGCI_VERSION)/golangci-lint $(GOLANGCI): @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(dir $(GOLANGCI)) v$(GOLANGCI_VERSION) diff --git a/pkg/cmd/chat/channel/channel.go b/pkg/cmd/chat/channel/channel.go index 3887ad0..8437d0f 100644 --- a/pkg/cmd/chat/channel/channel.go +++ b/pkg/cmd/chat/channel/channel.go @@ -551,7 +551,7 @@ func hideCmd() *cobra.Command { return err } - cmd.Printf("Successfully hid channel for " + userID + "\n") + cmd.Printf("Successfully hid channel for %s\n", userID) return nil }, } diff --git a/pkg/cmd/raw-recording/mix_audio.go b/pkg/cmd/raw-recording/mix_audio.go index 7763c44..0a3f601 100644 --- a/pkg/cmd/raw-recording/mix_audio.go +++ b/pkg/cmd/raw-recording/mix_audio.go @@ -70,7 +70,7 @@ func runMixAudio(cmd *cobra.Command, args []string) error { // Mix all audio tracks mixer := processing.NewAudioMixer(logger) - mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ + _, _ = mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, WithScreenshare: true, diff --git a/pkg/cmd/raw-recording/process_all.go b/pkg/cmd/raw-recording/process_all.go index ead2e4c..192aead 100644 --- a/pkg/cmd/raw-recording/process_all.go +++ b/pkg/cmd/raw-recording/process_all.go @@ -143,7 +143,7 @@ func runProcessAll(cmd *cobra.Command, args []string) error { // Mix all audio tracks mixer := processing.NewAudioMixer(logger) - mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ + _, _ = mixer.MixAllAudioTracks(&processing.AudioMixerConfig{ WorkDir: globalArgs.WorkDir, OutputDir: globalArgs.Output, WithScreenshare: false, diff --git a/pkg/cmd/raw-recording/processing/container_converter.go b/pkg/cmd/raw-recording/processing/container_converter.go index 29ee00c..56ca110 100644 --- a/pkg/cmd/raw-recording/processing/container_converter.go +++ b/pkg/cmd/raw-recording/processing/container_converter.go @@ -27,7 +27,6 @@ type RTPDump2WebMConverter struct { recorder WebmRecorder sampleBuilder *samplebuilder.SampleBuilder - firstPkt *rtp.Packet lastPkt *rtp.Packet lastPktDuration uint32 dtxInserted uint64 @@ -341,10 +340,6 @@ func opusFrameCount(c byte, payload []byte) int { return 0 } -func (c *RTPDump2WebMConverter) offset(pts, fts uint32) int64 { - return int64(c.timestampDiff(pts, fts) / 90) -} - func (c *RTPDump2WebMConverter) timestampDiff(pts, fts uint32) uint32 { return pts - fts } diff --git a/pkg/cmd/raw-recording/processing/ffmpeg_helper.go b/pkg/cmd/raw-recording/processing/ffmpeg_helper.go index ab68f25..96a064c 100644 --- a/pkg/cmd/raw-recording/processing/ffmpeg_helper.go +++ b/pkg/cmd/raw-recording/processing/ffmpeg_helper.go @@ -198,9 +198,9 @@ func runFFmpegCommand(args []string, logger *ProcessingLogger) error { if err != nil { logger.Errorf("FFmpeg process pid<%d> failed: %v", cmd.Process.Pid, err) - return fmt.Errorf("FFmpeg process pid<%d> failed in %s: %w", cmd.Process.Pid, time.Now().Sub(startAt).Round(time.Millisecond), err) + return fmt.Errorf("FFmpeg process pid<%d> failed in %s: %w", cmd.Process.Pid, time.Since(startAt).Round(time.Millisecond), err) } - logger.Infof("FFmpeg process pid<%d> ended successfully in %s", cmd.Process.Pid, time.Now().Sub(startAt).Round(time.Millisecond)) + logger.Infof("FFmpeg process pid<%d> ended successfully in %s", cmd.Process.Pid, time.Since(startAt).Round(time.Millisecond)) return nil } diff --git a/pkg/cmd/raw-recording/processing/gstreamer_converter.go b/pkg/cmd/raw-recording/processing/gstreamer_converter.go index 7e97356..f5bd3f7 100644 --- a/pkg/cmd/raw-recording/processing/gstreamer_converter.go +++ b/pkg/cmd/raw-recording/processing/gstreamer_converter.go @@ -248,14 +248,14 @@ func (r *GstreamerConverter) Close() error { select { case <-time.After(5 * time.Second): - r.logger.Warnf("GStreamer process pid<%d> termination timeout in %s...", r.gstreamerCmd.Process.Pid, time.Now().Sub(r.startAt).Round(time.Millisecond)) + r.logger.Warnf("GStreamer process pid<%d> termination timeout in %s...", r.gstreamerCmd.Process.Pid, time.Since(r.startAt).Round(time.Millisecond)) // Timeout, force kill if e := r.gstreamerCmd.Process.Kill(); e != nil { r.logger.Errorf("GStreamer process pid<%d> errored while killing: %v", r.gstreamerCmd.Process.Pid, e) } case <-done: - r.logger.Infof("GStreamer process pid<%d> exited succesfully in %s...", r.gstreamerCmd.Process.Pid, time.Now().Sub(r.startAt).Round(time.Millisecond)) + r.logger.Infof("GStreamer process pid<%d> exited succesfully in %s...", r.gstreamerCmd.Process.Pid, time.Since(r.startAt).Round(time.Millisecond)) } } diff --git a/pkg/cmd/raw-recording/processing/sdp_tool.go b/pkg/cmd/raw-recording/processing/sdp_tool.go index 5496d7c..5e8b01d 100644 --- a/pkg/cmd/raw-recording/processing/sdp_tool.go +++ b/pkg/cmd/raw-recording/processing/sdp_tool.go @@ -16,23 +16,6 @@ func readSDP(sdpFilePath string) (string, error) { return string(content), nil } -func replaceSDP(sdpContent string, port int) string { - lines := strings.Split(sdpContent, "\n") - for i, line := range lines { - if strings.HasPrefix(line, "m=") { - // Parse the m= line: m= RTP/AVP - parts := strings.Fields(line) - if len(parts) >= 4 { - // Replace the port (second field) - parts[1] = fmt.Sprintf("%d", port) - lines[i] = strings.Join(parts, " ") - break - } - } - } - return strings.Join(lines, "\n") -} - func mimeType(sdp string) (string, error) { upper := strings.ToUpper(sdp) if strings.Contains(upper, "VP9") { diff --git a/test/helpers.go b/test/helpers.go index bbdb649..d4650f1 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -6,7 +6,6 @@ import ( "math/rand" "os" "testing" - "time" stream "github.com/GetStream/stream-chat-go/v5" "github.com/spf13/cobra" @@ -91,7 +90,6 @@ func DeleteMessage(id string) { } func RandomString(n int) string { - rand.Seed(time.Now().UnixNano()) bytes := make([]byte, n) for i := 0; i < n; i++ { bytes[i] = byte(65 + rand.Intn(25)) // A=65 and Z = 65+25 From 1b9a36916a17af16e6c3a6dfe47eac5e34a489ae Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 30 Jan 2026 13:36:39 +0100 Subject: [PATCH 17/18] feat: remove linter deprecation --- .golangci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f570d73..5eee92a 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,10 +2,12 @@ run: go: '1.23' deadline: 210s timeout: 10m - skip-dirs: + +issues: + exclude-dirs: - mocks - '.*_mock' - skip-files: + exclude-files: - '.*_mock.go' - ".*\\.pb\\.go$" From 6fa3c25cd9d284d5051fd8efb5aa4583dd83fcd5 Mon Sep 17 00:00:00 2001 From: Raphael Reynaud Date: Fri, 30 Jan 2026 14:12:16 +0100 Subject: [PATCH 18/18] feat: upgrade linter and fix lint --- .golangci.yml | 5 ++-- Makefile | 2 +- go.mod | 4 +--- pkg/cmd/chat/imports/imports.go | 12 +++++++--- .../chat/imports/validator/validator_test.go | 4 +++- pkg/cmd/chat/utils/fileupload.go | 4 +++- .../raw-recording/processing/archive_input.go | 12 ++++++---- .../processing/archive_metadata.go | 8 +++++-- .../processing/container_converter.go | 8 +++++-- .../processing/gstreamer_converter.go | 2 +- .../processing/logger_adapter.go | 2 +- .../processing/track_extractor.go | 12 ++++++---- pkg/cmd/raw-recording/s3_downloader.go | 24 +++++++++++++------ pkg/config/config.go | 2 +- pkg/config/config_test.go | 4 ++-- 15 files changed, 69 insertions(+), 36 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 5eee92a..150c7e5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,7 @@ +version: "2" + run: - go: '1.23' - deadline: 210s + go: '1.24' timeout: 10m issues: diff --git a/Makefile b/Makefile index 4fbca0a..a4c5484 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ NAME = stream-cli -GOLANGCI_VERSION = 1.62.2 +GOLANGCI_VERSION = 2.8.0 GOLANGCI = .bin/golangci/$(GOLANGCI_VERSION)/golangci-lint $(GOLANGCI): @curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(dir $(GOLANGCI)) v$(GOLANGCI_VERSION) diff --git a/go.mod b/go.mod index ee009c3..8f2b506 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/GetStream/stream-cli -go 1.23 - -toolchain go1.24.4 +go 1.24 require ( github.com/AlecAivazis/survey/v2 v2.3.4 diff --git a/pkg/cmd/chat/imports/imports.go b/pkg/cmd/chat/imports/imports.go index eb8a805..40b2eea 100644 --- a/pkg/cmd/chat/imports/imports.go +++ b/pkg/cmd/chat/imports/imports.go @@ -30,7 +30,9 @@ func validateFile(cmd *cobra.Command, c *stream.Client, filename string) (*valid if err != nil { return nil, err } - defer reader.Close() + defer func() { + _ = reader.Close() + }() rolesResp, err := c.Permissions().ListRoles(cmd.Context()) if err != nil { @@ -85,7 +87,9 @@ func uploadToS3(ctx context.Context, filename, url string) error { if err != nil { return err } - defer data.Close() + defer func() { + _ = data.Close() + }() stat, err := data.Stat() if err != nil { @@ -103,7 +107,9 @@ func uploadToS3(ctx context.Context, filename, url string) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() return nil } diff --git a/pkg/cmd/chat/imports/validator/validator_test.go b/pkg/cmd/chat/imports/validator/validator_test.go index 6136b72..11f319a 100644 --- a/pkg/cmd/chat/imports/validator/validator_test.go +++ b/pkg/cmd/chat/imports/validator/validator_test.go @@ -113,7 +113,9 @@ func TestValidator_Validate(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f, err := os.Open("testdata/" + tt.filename) require.NoError(t, err) - defer f.Close() + defer func() { + _ = f.Close() + }() var options []Options if tt.lighterChanIDValidation { diff --git a/pkg/cmd/chat/utils/fileupload.go b/pkg/cmd/chat/utils/fileupload.go index 5246df7..66dd753 100644 --- a/pkg/cmd/chat/utils/fileupload.go +++ b/pkg/cmd/chat/utils/fileupload.go @@ -28,7 +28,9 @@ func uploadFile(c *stream.Client, cmd *cobra.Command, uploadtype uploadType, chT if err != nil { return "", err } - defer file.Close() + defer func() { + _ = file.Close() + }() req := stream.SendFileRequest{ User: &stream.User{ID: userID}, diff --git a/pkg/cmd/raw-recording/processing/archive_input.go b/pkg/cmd/raw-recording/processing/archive_input.go index 491b525..b2a0369 100644 --- a/pkg/cmd/raw-recording/processing/archive_input.go +++ b/pkg/cmd/raw-recording/processing/archive_input.go @@ -29,7 +29,7 @@ func ExtractToTempDir(inputPath string, logger *ProcessingLogger) (string, func( } cleanup := func() { - os.RemoveAll(tempDir) + _ = os.RemoveAll(tempDir) } err = extractTarGzToDir(inputPath, tempDir, logger) @@ -51,13 +51,17 @@ func extractTarGzToDir(tarGzPath, destDir string, logger *ProcessingLogger) erro if err != nil { return fmt.Errorf("failed to open tar.gz file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() gzReader, err := gzip.NewReader(file) if err != nil { return fmt.Errorf("failed to create gzip reader: %w", err) } - defer gzReader.Close() + defer func() { + _ = gzReader.Close() + }() tarReader := tar.NewReader(gzReader) @@ -90,7 +94,7 @@ func extractTarGzToDir(tarGzPath, destDir string, logger *ProcessingLogger) erro } _, err = io.Copy(outFile, tarReader) - outFile.Close() + _ = outFile.Close() if err != nil { return fmt.Errorf("failed to extract file %s: %w", destPath, err) } diff --git a/pkg/cmd/raw-recording/processing/archive_metadata.go b/pkg/cmd/raw-recording/processing/archive_metadata.go index 5bee550..3d63a5e 100644 --- a/pkg/cmd/raw-recording/processing/archive_metadata.go +++ b/pkg/cmd/raw-recording/processing/archive_metadata.go @@ -141,13 +141,17 @@ func (p *MetadataParser) parseMetadataOnlyFromTarGz(tarGzPath string) (*Recordin if err != nil { return nil, fmt.Errorf("failed to open tar.gz file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() gzReader, err := gzip.NewReader(file) if err != nil { return nil, fmt.Errorf("failed to create gzip reader: %w", err) } - defer gzReader.Close() + defer func() { + _ = gzReader.Close() + }() tarReader := tar.NewReader(gzReader) diff --git a/pkg/cmd/raw-recording/processing/container_converter.go b/pkg/cmd/raw-recording/processing/container_converter.go index 56ca110..f3bcd51 100644 --- a/pkg/cmd/raw-recording/processing/container_converter.go +++ b/pkg/cmd/raw-recording/processing/container_converter.go @@ -88,7 +88,9 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error if err != nil { return fmt.Errorf("failed to open rtpdump file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() // Create standardized reader reader, _, _ := rtpdump.NewReader(file) @@ -125,7 +127,9 @@ func (c *RTPDump2WebMConverter) ConvertFile(inputFile string, fixDtx bool) error if err != nil { return fmt.Errorf("failed to create WebM recorder: %w", err) } - defer c.recorder.Close() + defer func() { + _ = c.recorder.Close() + }() // Convert and feed RTP packets return c.feedPackets(mType, reader) diff --git a/pkg/cmd/raw-recording/processing/gstreamer_converter.go b/pkg/cmd/raw-recording/processing/gstreamer_converter.go index f5bd3f7..5d064e8 100644 --- a/pkg/cmd/raw-recording/processing/gstreamer_converter.go +++ b/pkg/cmd/raw-recording/processing/gstreamer_converter.go @@ -183,7 +183,7 @@ func parseRtpCapsFromSDP(sdp string) (media string, encodingName string, payload } if !mLineFound || !rtpmapLineFound { - err = fmt.Errorf("Invalid SDP m= or a=rtpmap lines not found: \n%s", sdp) + err = fmt.Errorf("invalid SDP m= or a=rtpmap lines not found: \n%s", sdp) } return } diff --git a/pkg/cmd/raw-recording/processing/logger_adapter.go b/pkg/cmd/raw-recording/processing/logger_adapter.go index f0c9343..ff5234f 100644 --- a/pkg/cmd/raw-recording/processing/logger_adapter.go +++ b/pkg/cmd/raw-recording/processing/logger_adapter.go @@ -32,7 +32,7 @@ func (l *ProcessingLogger) log(level LogLevel, prefix, format string, args ...in return } msg := fmt.Sprintf(format, args...) - fmt.Fprintf(l.writer, "%s %s\n", prefix, msg) + _, _ = fmt.Fprintf(l.writer, "%s %s\n", prefix, msg) } func (l *ProcessingLogger) Debug(format string, args ...interface{}) { diff --git a/pkg/cmd/raw-recording/processing/track_extractor.go b/pkg/cmd/raw-recording/processing/track_extractor.go index 533d8ce..5dcf547 100644 --- a/pkg/cmd/raw-recording/processing/track_extractor.go +++ b/pkg/cmd/raw-recording/processing/track_extractor.go @@ -69,9 +69,9 @@ func (p *TrackExtractor) extractSingleTrackWithOptions(config *TrackExtractorCon abs, _ := filepath.Abs(path) s.RtpDumpPath = abs - s.SdpPath = strings.Replace(abs, suffixRtpDump, suffixSdp, -1) + s.SdpPath = strings.ReplaceAll(abs, suffixRtpDump, suffixSdp) s.ContainerExt = extension - s.ContainerPath = strings.Replace(abs, suffixRtpDump, suffix, -1) + s.ContainerPath = strings.ReplaceAll(abs, suffixRtpDump, suffix) return s, true } } @@ -116,10 +116,12 @@ func (p *TrackExtractor) processSegmentsWithGapFilling(config *TrackExtractorCon } }(&cleanupFiles) } - defer concatFile.Close() + defer func() { + _ = concatFile.Close() + }() for i, segment := range track.Segments { - if _, e := concatFile.WriteString(fmt.Sprintf("file '%s'\n", segment.ContainerPath)); e != nil { + if _, e := fmt.Fprintf(concatFile, "file '%s'\n", segment.ContainerPath); e != nil { return nil, e } cleanupFiles = append(cleanupFiles, segment.ContainerPath) @@ -158,7 +160,7 @@ func (p *TrackExtractor) processSegmentsWithGapFilling(config *TrackExtractorCon return nil, err } - if _, e := concatFile.WriteString(fmt.Sprintf("file '%s'\n", absPath)); e != nil { + if _, e := fmt.Fprintf(concatFile, "file '%s'\n", absPath); e != nil { return nil, e } } diff --git a/pkg/cmd/raw-recording/s3_downloader.go b/pkg/cmd/raw-recording/s3_downloader.go index c9d8543..ddc52a1 100644 --- a/pkg/cmd/raw-recording/s3_downloader.go +++ b/pkg/cmd/raw-recording/s3_downloader.go @@ -258,7 +258,9 @@ func (d *S3Downloader) getPresignedURLETag(ctx context.Context, inputURL string) if err != nil { return "", fmt.Errorf("failed to execute HEAD request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HEAD request failed with status: %d", resp.StatusCode) @@ -286,18 +288,22 @@ func (d *S3Downloader) downloadFromS3(ctx context.Context, inputURL, destPath st if err != nil { return "", fmt.Errorf("failed to download from S3: %w", err) } - defer result.Body.Close() + defer func() { + _ = result.Body.Close() + }() // Create destination file file, err := os.Create(destPath) if err != nil { return "", fmt.Errorf("failed to create destination file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() // Copy content if _, err := io.Copy(file, result.Body); err != nil { - os.Remove(destPath) // Clean up partial file + _ = os.Remove(destPath) // Clean up partial file return "", fmt.Errorf("failed to write file: %w", err) } @@ -320,7 +326,9 @@ func (d *S3Downloader) downloadFromPresignedURL(ctx context.Context, inputURL, d if err != nil { return "", fmt.Errorf("failed to execute GET request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("download failed with status: %d", resp.StatusCode) @@ -331,11 +339,13 @@ func (d *S3Downloader) downloadFromPresignedURL(ctx context.Context, inputURL, d if err != nil { return "", fmt.Errorf("failed to create destination file: %w", err) } - defer file.Close() + defer func() { + _ = file.Close() + }() // Copy content if _, err := io.Copy(file, resp.Body); err != nil { - os.Remove(destPath) // Clean up partial file + _ = os.Remove(destPath) // Clean up partial file return "", fmt.Errorf("failed to write file: %w", err) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 823276f..01363b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -196,7 +196,7 @@ func GetInitConfig(cmd *cobra.Command, cfgPath *string) func() { os.Exit(1) } - f.Close() + _ = f.Close() } if err != nil { cmd.PrintErr(err) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7799bd5..074c381 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -188,8 +188,8 @@ default: test2 } func getNormalizedString(s string) string { - noSpace := strings.Replace(s, " ", "", -1) - noNewLine := strings.Replace(noSpace, "\n", "", -1) + noSpace := strings.ReplaceAll(s, " ", "") + noNewLine := strings.ReplaceAll(noSpace, "\n", "") return strings.TrimSpace(noNewLine) }