Compare commits
65 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
add511e3dd | ||
|
7afa84505e | ||
|
4a863a3212 | ||
|
98d93ad286 | ||
|
5f026be769 | ||
|
d35dedb15b | ||
|
e49bbb6800 | ||
|
c022c18a7f | ||
|
b147da6d9b | ||
|
e113d55044 | ||
|
0df42511ce | ||
|
f853a5cced | ||
|
781d535d38 | ||
|
f3a5b802b8 | ||
|
6646402273 | ||
|
f6c87c4568 | ||
|
2f980acbb3 | ||
|
b8389eceb0 | ||
|
f2b9e9af75 | ||
|
8eca4b0e27 | ||
|
b05ae25809 | ||
|
52b0616d5f | ||
|
55e04e0249 | ||
|
df9724afa7 | ||
|
579dfeef22 | ||
|
5f6b6fa3a9 | ||
|
c4bc32b3e8 | ||
|
f7f9843c4b | ||
|
b69e1ecf86 | ||
|
524dafd800 | ||
|
b257f456ba | ||
|
045498a2ce | ||
|
08a5690d30 | ||
|
d332a78af1 | ||
|
f791125c02 | ||
|
30da888184 | ||
|
2fbf2176cf | ||
|
af4410c4cf | ||
|
18d5ef3db1 | ||
|
1e759b6f42 | ||
|
3523a1a34e | ||
|
5735b2d73a | ||
|
0fbbe25e1b | ||
|
1c742426ed | ||
|
e778c3c443 | ||
|
e14cfdee85 | ||
|
266a9307d2 | ||
|
cd2c339c10 | ||
|
54cfe3a55f | ||
|
87f6786387 | ||
|
2eeb809e6e | ||
|
522be621ee | ||
|
6952516204 | ||
|
ba356137c3 | ||
|
b05bc6f98b | ||
|
34f18b0fbd | ||
|
5bf19f92b0 | ||
|
e4438baa65 | ||
|
036366a875 | ||
|
d3a6d6acdb | ||
|
cddcb0eb4d | ||
|
7edb975b8e | ||
|
3f25458b03 | ||
|
be0408a296 | ||
|
2cde04728a |
@ -10,11 +10,15 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ludeeus/action-shellcheck@2.0.0
|
||||
build:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- lint
|
||||
steps:
|
||||
@ -35,10 +39,10 @@ jobs:
|
||||
ffmpeg-version: release
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go 1.24.1
|
||||
- name: Setup Go 1.24.2
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.1'
|
||||
go-version: '1.24.2'
|
||||
cache: false
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
@ -67,10 +71,10 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Go 1.24.1
|
||||
- name: Setup Go 1.24.2
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.1'
|
||||
go-version: '1.24.2'
|
||||
- name: install OS dependencies
|
||||
run: |
|
||||
sudo apt-get -y update && \
|
||||
@ -83,3 +87,4 @@ jobs:
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
89
.github/workflows/codeql.yml
vendored
Normal file
89
.github/workflows/codeql.yml
vendored
Normal file
@ -0,0 +1,89 @@
|
||||
name: ci-scan
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
schedule:
|
||||
- cron: '36 23 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
@ -26,9 +26,26 @@ archives:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
brews:
|
||||
- name: octoplex
|
||||
description: "Octoplex is a live video restreamer for the terminal."
|
||||
homepage: "https://github.com/rfwatson/octoplex"
|
||||
repository:
|
||||
owner: rfwatson
|
||||
name: homebrew-octoplex
|
||||
token: "{{ .Env.HOMEBREW_TOKEN }}"
|
||||
install: |
|
||||
bin.install "octoplex"
|
||||
test: |
|
||||
system "#{bin}/octoplex -h"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: rfwatson
|
||||
name: octoplex
|
||||
|
||||
changelog:
|
||||
use: github
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^doc:"
|
||||
|
43
CONTRIBUTING.md
Normal file
43
CONTRIBUTING.md
Normal file
@ -0,0 +1,43 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for contributing to Octoplex!
|
||||
|
||||
## Development
|
||||
|
||||
### Mise
|
||||
|
||||
Octoplex uses [mise](https://mise.jdx.dev/installing-mise.html) as a task
|
||||
runner and environment management tool.
|
||||
|
||||
Once installed, you can run common development tasks easily:
|
||||
|
||||
Command|Shortcut|Description
|
||||
---|---|---
|
||||
`mise run test`|`mise run t`|Run unit tests
|
||||
`mise run test_integration`|`mise run ti`|Run integration tests
|
||||
`mise run lint`|`mise run l`|Run linter
|
||||
`mise run format`|`mise run f`|Run formatter
|
||||
`mise run generate_mocks`|`mise run m`|Re-generate mocks
|
||||
|
||||
### Tests
|
||||
|
||||
#### Integration tests
|
||||
|
||||
The integration tests (mostly in `/internal/app/integration_test.go`) attempt
|
||||
to exercise the entire app, including launching containers and rendering the
|
||||
terminal output.
|
||||
|
||||
Sometimes they can be flaky. Always ensure there are no stale Docker containers
|
||||
present from previous runs, and that nothing is listening or attempting to
|
||||
broadcast to localhost:1935 or localhost:1936.
|
||||
|
||||
## Opening a pull request
|
||||
|
||||
Pull requests are welcome, but please propose significant changes in a
|
||||
[discussion](https://github.com/rfwatson/octoplex/discussions) first.
|
||||
|
||||
1. Fork the repo
|
||||
2. Make your changes, including test coverage
|
||||
3. Push the changes to a branch
|
||||
4. Ensure the branch is passing
|
||||
5. Open a pull request
|
153
README.md
153
README.md
@ -1,6 +1,153 @@
|
||||
# Octoplex :octopus:
|
||||
|
||||
Octoplex multiplexes RTMP streams to multiple destinations from the
|
||||
comfort of your terminal.
|
||||

|
||||

|
||||

|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||

|
||||
Octoplex is a live video restreamer for the terminal.
|
||||
|
||||
* Restream RTMP/RTMPS to unlimited destinations
|
||||
* Broadcast using OBS and other standard tools
|
||||
* Add and remove destinations while streaming
|
||||
* Automatic reconnections
|
||||
* Terminal user interface with real-time container metrics and health status
|
||||
* Built on FFmpeg, Docker and other proven free software
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
+------------------+ +-------------------+
|
||||
| OBS | ----> | Octoplex |
|
||||
| (Video Capture) | RTMP | |
|
||||
+------------------+ +-------------------+
|
||||
|
|
||||
| Restream to multiple destinations
|
||||
v
|
||||
+------------+ +------------+ +------------+ +--------------+
|
||||
| Twitch.tv | | YouTube | | Facebook | | Other |
|
||||
+------------+ +------------+ +------------+ | Destinations |
|
||||
+--------------+
|
||||
```
|
||||
|
||||
## Asciicast :video_camera:
|
||||
|
||||
[](https://asciinema.org/a/Es8hpa6rq82ov7cDM6bZTVyCT)
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker Engine
|
||||
|
||||
First, make sure Docker Engine is installed. Octoplex uses Docker to manage
|
||||
FFmpeg and other streaming tools.
|
||||
|
||||
Linux: See https://docs.docker.com/engine/install/.
|
||||
|
||||
MacOS: https://docs.docker.com/desktop/setup/install/mac-install/
|
||||
|
||||
### Octoplex
|
||||
|
||||
#### Homebrew
|
||||
|
||||
Octoplex can be installed using Homebrew on MacOS or Linux.
|
||||
|
||||
```
|
||||
$ brew tap rfwatson/octoplex
|
||||
$ brew install octoplex
|
||||
```
|
||||
|
||||
#### From Github
|
||||
|
||||
Alternatively, grab the latest build for your platform from the [releases page](https://github.com/rfwatson/octoplex/releases).
|
||||
|
||||
Unarchive the `octoplex` binary and copy it somewhere in your $PATH.
|
||||
|
||||
## Usage
|
||||
|
||||
Launch the `octoplex` binary.
|
||||
|
||||
```
|
||||
$ octoplex
|
||||
```
|
||||
|
||||
### Connecting with OBS
|
||||
|
||||
To connect with OBS, configure it to stream to `rtmp://localhost:1935/live`.
|
||||
|
||||

|
||||
|
||||
### Subcommands
|
||||
|
||||
Subcommand|Description
|
||||
---|---
|
||||
None|Launch the terminal user interface
|
||||
`print-config`|Echo the path to the configuration file to STDOUT
|
||||
`edit-config`|Edit the configuration file in $EDITOR
|
||||
`version`|Print the version
|
||||
`help`|Print help screen
|
||||
|
||||
### Configuration file
|
||||
|
||||
Octoplex stores configuration state in a simple YAML file. (See [above](#subcommands) for its location.)
|
||||
|
||||
Sample configuration:
|
||||
|
||||
```yaml
|
||||
logfile:
|
||||
enabled: true # defaults to false
|
||||
path: /path/to/logfile # defaults to $XDG_STATE_HOME/octoplex/octoplex.log
|
||||
sources:
|
||||
mediaServer:
|
||||
streamKey: live # defaults to "live"
|
||||
host: rtmp.example.com # defaults to "localhost"
|
||||
tls: # optional. If RTMPS is enabled, defaults to a
|
||||
cert: /etc/mycert.pem # self-signed keypair corresponding to the host
|
||||
key: /etc/mykey.pem # key.
|
||||
rtmp:
|
||||
enabled: true # defaults to false
|
||||
ip: 127.0.0.1 # defaults to 127.0.0.1
|
||||
port: 1935 # defaults to 1935
|
||||
rtmps:
|
||||
enabled: true # defaults to false
|
||||
ip: 0.0.0.0 # defaults to 127.0.0.1
|
||||
port: 1936 # defaults to 1936
|
||||
destinations:
|
||||
- name: YouTube # Destination name, used only for display
|
||||
url: rtmp://rtmp.youtube.com/12345 # Destination URL with stream key
|
||||
- name: Twitch.tv
|
||||
url: rtmp://rtmp.youtube.com/12345
|
||||
# other destinations here
|
||||
```
|
||||
|
||||
:information_source: It is also possible to add and remove destinations directly from the
|
||||
terminal user interface.
|
||||
|
||||
:warning: `sources.mediaServer.rtmp.ip` must be set to a valid IP address if
|
||||
you want to accept connections from other hosts. Leave it blank to bind only to
|
||||
localhost (`127.0.0.1`) or use `0.0.0.0` to bind to all network interfaces.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Bug reports
|
||||
|
||||
Open bug reports [on GitHub](https://github.com/rfwatson/octoplex/issues/new).
|
||||
|
||||
### Pull requests
|
||||
|
||||
Pull requests are welcome.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Octoplex is built on and/or makes use of other free and open source software,
|
||||
most notably:
|
||||
|
||||
Name|License|URL
|
||||
---|---|---
|
||||
Docker|`Apache 2.0`|[GitHub](https://github.com/moby/moby/tree/master/client)
|
||||
FFmpeg|`LGPL`|[Website](https://www.ffmpeg.org/legal.html)
|
||||
MediaMTX|`MIT`|[GitHub](https://github.com/bluenviron/mediamtx)
|
||||
tview|`MIT`|[GitHub](https://github.com/rivo/tview)
|
||||
|
||||
## Licence
|
||||
|
||||
Octoplex is released under the [AGPL v3](https://github.com/rfwatson/octoplex/blob/main/LICENSE) license.
|
||||
|
11
SECURITY.md
Normal file
11
SECURITY.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Security
|
||||
|
||||
## Supported versions
|
||||
|
||||
Octoplex is currently alpha software. Security updates will be targeted to the
|
||||
`main` branch.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Please report any vulnerability privately through GitHub:
|
||||
https://github.com/rfwatson/octoplex/security
|
BIN
assets/obs1.png
Normal file
BIN
assets/obs1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
@ -1,11 +0,0 @@
|
||||
FROM bluenviron/mediamtx:latest AS mediamtx
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache \
|
||||
bash \
|
||||
curl
|
||||
|
||||
COPY --from=mediamtx /mediamtx /usr/bin/mediamtx
|
||||
|
||||
CMD ["/usr/bin/mediamtx"]
|
23
go.mod
23
go.mod
@ -3,11 +3,12 @@ module git.netflux.io/rob/octoplex
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.0.1+incompatible
|
||||
github.com/docker/docker v28.0.4+incompatible
|
||||
github.com/docker/go-connections v0.5.0
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/opencontainers/image-spec v1.1.1
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
||||
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/testcontainers/testcontainers-go v0.35.0
|
||||
golang.design/x/clipboard v0.7.0
|
||||
@ -87,18 +88,18 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de // indirect
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/image v0.26.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/term v0.30.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/term v0.31.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
||||
|
48
go.sum
48
go.sum
@ -26,8 +26,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
|
||||
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v28.0.4+incompatible h1:JNNkBctYKurkw6FrHfKqY0nKIDf5nrbxjVBtS+cdcok=
|
||||
github.com/docker/docker v28.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
@ -125,8 +125,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
||||
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922 h1:SMyqkaRfpE8ZQUSRTZKO3uN84xov++OGa+e3NCksaQw=
|
||||
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@ -219,16 +219,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
||||
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394 h1:bFYqOIMdeiCEdzPJkLiOoMDzW/v3tjW4AA/RmUZYsL8=
|
||||
golang.org/x/exp/shiny v0.0.0-20250305212735-054e65f0b394/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de h1:WuckfUoaRGJfaQTPZvlmcaQwg4Xj9oS2cvvh3dUqpDo=
|
||||
golang.org/x/mobile v0.0.0-20250305212854-3a7bc9f8a4de/go.mod h1:/IZuixag1ELW37+FftdmIt59/3esqpAWM/QqWtf7HUI=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
|
||||
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
|
||||
golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7 h1:8MGTx39304caZ/OMsjPfuxUoDGI2tRas92F5x97tIYc=
|
||||
golang.org/x/mobile v0.0.0-20250408133729-978277e7eaf7/go.mod h1:ftACcHgQ7vaOnQbHOHvXt9Y6bEPHrs5Ovk67ClwrPJA=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@ -249,8 +249,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -260,8 +260,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@ -283,8 +283,8 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@ -294,8 +294,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@ -305,8 +305,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@ -317,8 +317,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
@ -5,19 +5,21 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/config"
|
||||
"git.netflux.io/rob/octoplex/internal/container"
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
"git.netflux.io/rob/octoplex/internal/mediaserver"
|
||||
"git.netflux.io/rob/octoplex/internal/multiplexer"
|
||||
"git.netflux.io/rob/octoplex/internal/replicator"
|
||||
"git.netflux.io/rob/octoplex/internal/terminal"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// RunParams holds the parameters for running the application.
|
||||
type RunParams struct {
|
||||
Config config.Config
|
||||
ConfigService *config.Service
|
||||
DockerClient container.DockerClient
|
||||
Screen *terminal.Screen // Screen may be nil.
|
||||
ClipboardAvailable bool
|
||||
@ -28,9 +30,20 @@ type RunParams struct {
|
||||
|
||||
// Run starts the application, and blocks until it exits.
|
||||
func Run(ctx context.Context, params RunParams) error {
|
||||
state := newStateFromRunParams(params)
|
||||
logger := params.Logger
|
||||
// cfg is the current configuration of the application, as reflected in the
|
||||
// config file.
|
||||
cfg := params.ConfigService.Current()
|
||||
|
||||
// state is the current state of the application, as reflected in the UI.
|
||||
state := new(domain.AppState)
|
||||
applyConfig(cfg, state)
|
||||
|
||||
// Ensure there is at least one active source.
|
||||
if !cfg.Sources.MediaServer.RTMP.Enabled && !cfg.Sources.MediaServer.RTMPS.Enabled {
|
||||
return errors.New("config: either sources.mediaServer.rtmp.enabled or sources.mediaServer.rtmps.enabled must be set")
|
||||
}
|
||||
|
||||
logger := params.Logger
|
||||
ui, err := terminal.StartUI(ctx, terminal.StartParams{
|
||||
Screen: params.Screen,
|
||||
ClipboardAvailable: params.ClipboardAvailable,
|
||||
@ -43,8 +56,31 @@ func Run(ctx context.Context, params RunParams) error {
|
||||
}
|
||||
defer ui.Close()
|
||||
|
||||
// emptyUI is a dummy function that sets the UI state to an empty state, and
|
||||
// re-renders the screen.
|
||||
//
|
||||
// This is a workaround for a weird interaction between tview and
|
||||
// tcell.SimulationScreen which leads to newly-added pages not rendering if
|
||||
// the UI is not re-rendered for a second time.
|
||||
// It is only needed for integration tests when rendering modals before the
|
||||
// main loop starts. It would be nice to remove this but the risk/impact on
|
||||
// non-test code is pretty low.
|
||||
emptyUI := func() { ui.SetState(domain.AppState{}) }
|
||||
|
||||
containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
|
||||
if err != nil {
|
||||
err = fmt.Errorf("create container client: %w", err)
|
||||
|
||||
var errString string
|
||||
if client.IsErrConnectionFailed(err) {
|
||||
errString = "Could not connect to Docker. Is Docker installed and running?"
|
||||
} else {
|
||||
errString = err.Error()
|
||||
}
|
||||
ui.ShowFatalErrorModal(errString)
|
||||
|
||||
emptyUI()
|
||||
<-ui.C()
|
||||
return err
|
||||
}
|
||||
defer containerClient.Close()
|
||||
@ -52,48 +88,64 @@ func Run(ctx context.Context, params RunParams) error {
|
||||
updateUI := func() { ui.SetState(*state) }
|
||||
updateUI()
|
||||
|
||||
// TODO: check for unused networks.
|
||||
if exists, err := containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil {
|
||||
return fmt.Errorf("check existing containers: %w", err)
|
||||
} else if exists {
|
||||
if ui.ShowStartupCheckModal() {
|
||||
if err = containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil {
|
||||
return fmt.Errorf("remove existing containers: %w", err)
|
||||
}
|
||||
if err = containerClient.RemoveUnusedNetworks(ctx); err != nil {
|
||||
return fmt.Errorf("remove unused networks: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ui.AllowQuit()
|
||||
|
||||
// While RTMP is the only source, it doesn't make sense to disable it.
|
||||
if !params.Config.Sources.RTMP.Enabled {
|
||||
return errors.New("config: sources.rtmp.enabled must be set to true")
|
||||
var tlsCertPath, tlsKeyPath string
|
||||
if cfg.Sources.MediaServer.TLS != nil {
|
||||
tlsCertPath = cfg.Sources.MediaServer.TLS.CertPath
|
||||
tlsKeyPath = cfg.Sources.MediaServer.TLS.KeyPath
|
||||
}
|
||||
|
||||
srv := mediaserver.StartActor(ctx, mediaserver.StartActorParams{
|
||||
StreamKey: mediaserver.StreamKey(params.Config.Sources.RTMP.StreamKey),
|
||||
srv, err := mediaserver.NewActor(ctx, mediaserver.NewActorParams{
|
||||
RTMPAddr: buildNetAddr(cfg.Sources.MediaServer.RTMP),
|
||||
RTMPSAddr: buildNetAddr(cfg.Sources.MediaServer.RTMPS),
|
||||
Host: cfg.Sources.MediaServer.Host,
|
||||
TLSCertPath: tlsCertPath,
|
||||
TLSKeyPath: tlsKeyPath,
|
||||
StreamKey: mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey),
|
||||
ContainerClient: containerClient,
|
||||
Logger: logger.With("component", "mediaserver"),
|
||||
})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("create mediaserver: %w", err)
|
||||
ui.ShowFatalErrorModal(err.Error())
|
||||
emptyUI()
|
||||
<-ui.C()
|
||||
return err
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
mp := multiplexer.NewActor(ctx, multiplexer.NewActorParams{
|
||||
SourceURL: srv.State().RTMPInternalURL,
|
||||
// Set the RTMP and RTMPS URLs in the UI, which are only known after the
|
||||
// MediaServer is available.
|
||||
ui.SetRTMPURLs(srv.RTMPURL(), srv.RTMPSURL())
|
||||
|
||||
repl := replicator.StartActor(ctx, replicator.StartActorParams{
|
||||
SourceURL: srv.RTMPInternalURL(),
|
||||
ContainerClient: containerClient,
|
||||
Logger: logger.With("component", "multiplexer"),
|
||||
Logger: logger.With("component", "replicator"),
|
||||
})
|
||||
defer mp.Close()
|
||||
defer repl.Close()
|
||||
|
||||
const uiUpdateInterval = time.Second
|
||||
uiUpdateT := time.NewTicker(uiUpdateInterval)
|
||||
defer uiUpdateT.Stop()
|
||||
|
||||
startupCheckC := doStartupCheck(ctx, containerClient, ui.ShowStartupCheckModal)
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-startupCheckC:
|
||||
if errors.Is(err, errStartupCheckUserQuit) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("startup check: %w", err)
|
||||
} else {
|
||||
startupCheckC = nil
|
||||
|
||||
if err = srv.Start(ctx); err != nil {
|
||||
return fmt.Errorf("start mediaserver: %w", err)
|
||||
}
|
||||
}
|
||||
case <-params.ConfigService.C():
|
||||
// No-op, config updates are handled synchronously for now.
|
||||
case cmd, ok := <-ui.C():
|
||||
if !ok {
|
||||
// TODO: keep UI open until all containers have closed
|
||||
@ -103,11 +155,44 @@ func Run(ctx context.Context, params RunParams) error {
|
||||
|
||||
logger.Debug("Command received", "cmd", cmd.Name())
|
||||
switch c := cmd.(type) {
|
||||
case terminal.CommandStartDestination:
|
||||
mp.StartDestination(c.URL)
|
||||
case terminal.CommandStopDestination:
|
||||
mp.StopDestination(c.URL)
|
||||
case terminal.CommandQuit:
|
||||
case domain.CommandAddDestination:
|
||||
newCfg := cfg
|
||||
newCfg.Destinations = append(newCfg.Destinations, config.Destination{
|
||||
Name: c.DestinationName,
|
||||
URL: c.URL,
|
||||
})
|
||||
if err := params.ConfigService.SetConfig(newCfg); err != nil {
|
||||
logger.Error("Config update failed", "err", err)
|
||||
ui.ConfigUpdateFailed(err)
|
||||
continue
|
||||
}
|
||||
cfg = newCfg
|
||||
handleConfigUpdate(cfg, state, ui)
|
||||
ui.DestinationAdded()
|
||||
case domain.CommandRemoveDestination:
|
||||
repl.StopDestination(c.URL) // no-op if not live
|
||||
newCfg := cfg
|
||||
newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool {
|
||||
return dest.URL == c.URL
|
||||
})
|
||||
if err := params.ConfigService.SetConfig(newCfg); err != nil {
|
||||
logger.Error("Config update failed", "err", err)
|
||||
ui.ConfigUpdateFailed(err)
|
||||
continue
|
||||
}
|
||||
cfg = newCfg
|
||||
handleConfigUpdate(cfg, state, ui)
|
||||
ui.DestinationRemoved()
|
||||
case domain.CommandStartDestination:
|
||||
if !state.Source.Live {
|
||||
ui.ShowSourceNotLiveModal()
|
||||
continue
|
||||
}
|
||||
|
||||
repl.StartDestination(c.URL)
|
||||
case domain.CommandStopDestination:
|
||||
repl.StopDestination(c.URL)
|
||||
case domain.CommandQuit:
|
||||
return nil
|
||||
}
|
||||
case <-uiUpdateT.C:
|
||||
@ -116,12 +201,12 @@ func Run(ctx context.Context, params RunParams) error {
|
||||
logger.Debug("Server state received", "state", serverState)
|
||||
applyServerState(serverState, state)
|
||||
updateUI()
|
||||
case mpState := <-mp.C():
|
||||
logger.Debug("Multiplexer state received", "state", mpState)
|
||||
destErrors := applyMultiplexerState(mpState, state)
|
||||
case replState := <-repl.C():
|
||||
logger.Debug("Replicator state received", "state", replState)
|
||||
destErrors := applyReplicatorState(replState, state)
|
||||
|
||||
for _, destError := range destErrors {
|
||||
handleDestError(destError, mp, ui)
|
||||
handleDestError(destError, repl, ui)
|
||||
}
|
||||
|
||||
updateUI()
|
||||
@ -129,6 +214,12 @@ func Run(ctx context.Context, params RunParams) error {
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfigUpdate applies the config to the app state, and updates the UI.
|
||||
func handleConfigUpdate(cfg config.Config, appState *domain.AppState, ui *terminal.UI) {
|
||||
applyConfig(cfg, appState)
|
||||
ui.SetState(*appState)
|
||||
}
|
||||
|
||||
// applyServerState applies the current server state to the app state.
|
||||
func applyServerState(serverState domain.Source, appState *domain.AppState) {
|
||||
appState.Source = serverState
|
||||
@ -142,29 +233,29 @@ type destinationError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// applyMultiplexerState applies the current multiplexer state to the app state.
|
||||
// applyReplicatorState applies the current replicator state to the app state.
|
||||
//
|
||||
// It returns a list of destination errors that should be displayed to the user.
|
||||
func applyMultiplexerState(mpState multiplexer.State, appState *domain.AppState) []destinationError {
|
||||
func applyReplicatorState(replState replicator.State, appState *domain.AppState) []destinationError {
|
||||
var errorsToDisplay []destinationError
|
||||
|
||||
for i := range appState.Destinations {
|
||||
dest := &appState.Destinations[i]
|
||||
|
||||
if dest.URL != mpState.URL {
|
||||
if dest.URL != replState.URL {
|
||||
continue
|
||||
}
|
||||
|
||||
if dest.Container.Err == nil && mpState.Container.Err != nil {
|
||||
if dest.Container.Err == nil && replState.Container.Err != nil {
|
||||
errorsToDisplay = append(errorsToDisplay, destinationError{
|
||||
name: dest.Name,
|
||||
url: dest.URL,
|
||||
err: mpState.Container.Err,
|
||||
err: replState.Container.Err,
|
||||
})
|
||||
}
|
||||
|
||||
dest.Container = mpState.Container
|
||||
dest.Status = mpState.Status
|
||||
dest.Container = replState.Container
|
||||
dest.Status = replState.Status
|
||||
|
||||
break
|
||||
}
|
||||
@ -173,23 +264,82 @@ func applyMultiplexerState(mpState multiplexer.State, appState *domain.AppState)
|
||||
}
|
||||
|
||||
// handleDestError displays a modal to the user, and stops the destination.
|
||||
func handleDestError(destError destinationError, mp *multiplexer.Actor, ui *terminal.UI) {
|
||||
func handleDestError(destError destinationError, repl *replicator.Actor, ui *terminal.UI) {
|
||||
ui.ShowDestinationErrorModal(destError.name, destError.err)
|
||||
|
||||
mp.StopDestination(destError.url)
|
||||
repl.StopDestination(destError.url)
|
||||
}
|
||||
|
||||
// newStateFromRunParams creates a new app state from the run parameters.
|
||||
func newStateFromRunParams(params RunParams) *domain.AppState {
|
||||
var state domain.AppState
|
||||
// applyConfig applies the config to the app state. For now we only set the
|
||||
// destinations.
|
||||
func applyConfig(cfg config.Config, appState *domain.AppState) {
|
||||
appState.Destinations = resolveDestinations(appState.Destinations, cfg.Destinations)
|
||||
}
|
||||
|
||||
state.Destinations = make([]domain.Destination, 0, len(params.Config.Destinations))
|
||||
for _, dest := range params.Config.Destinations {
|
||||
state.Destinations = append(state.Destinations, domain.Destination{
|
||||
Name: dest.Name,
|
||||
URL: dest.URL,
|
||||
// resolveDestinations merges the current destinations with newly configured
|
||||
// destinations.
|
||||
func resolveDestinations(destinations []domain.Destination, inDestinations []config.Destination) []domain.Destination {
|
||||
destinations = slices.DeleteFunc(destinations, func(dest domain.Destination) bool {
|
||||
return !slices.ContainsFunc(inDestinations, func(inDest config.Destination) bool {
|
||||
return inDest.URL == dest.URL
|
||||
})
|
||||
})
|
||||
|
||||
for i, inDest := range inDestinations {
|
||||
if i < len(destinations) && destinations[i].URL == inDest.URL {
|
||||
continue
|
||||
}
|
||||
|
||||
destinations = slices.Insert(destinations, i, domain.Destination{
|
||||
Name: inDest.Name,
|
||||
URL: inDest.URL,
|
||||
})
|
||||
}
|
||||
|
||||
return &state
|
||||
return destinations[:len(inDestinations)]
|
||||
}
|
||||
|
||||
var errStartupCheckUserQuit = errors.New("user quit startup check modal")
|
||||
|
||||
// doStartupCheck performs a startup check to see if there are any existing app
|
||||
// containers.
|
||||
//
|
||||
// It returns a channel that will be closed, possibly after receiving an error.
|
||||
// If the error is non-nil the app must not be started. If the error is
|
||||
// [errStartupCheckUserQuit], the user voluntarily quit the startup check
|
||||
// modal.
|
||||
func doStartupCheck(ctx context.Context, containerClient *container.Client, showModal func() bool) <-chan error {
|
||||
ch := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
if exists, err := containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil {
|
||||
ch <- fmt.Errorf("check existing containers: %w", err)
|
||||
} else if exists {
|
||||
if showModal() {
|
||||
if err = containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil {
|
||||
ch <- fmt.Errorf("remove existing containers: %w", err)
|
||||
return
|
||||
}
|
||||
if err = containerClient.RemoveUnusedNetworks(ctx); err != nil {
|
||||
ch <- fmt.Errorf("remove unused networks: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ch <- errStartupCheckUserQuit
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// buildNetAddr builds a [mediaserver.OptionalNetAddr] from the config.
|
||||
func buildNetAddr(src config.RTMPSource) mediaserver.OptionalNetAddr {
|
||||
if !src.Enabled {
|
||||
return mediaserver.OptionalNetAddr{Enabled: false}
|
||||
}
|
||||
|
||||
return mediaserver.OptionalNetAddr{Enabled: true, NetAddr: domain.NetAddr(src.NetAddr)}
|
||||
}
|
||||
|
71
internal/app/app_test.go
Normal file
71
internal/app/app_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/config"
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveDestinations(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
in []config.Destination
|
||||
existing []domain.Destination
|
||||
want []domain.Destination
|
||||
}{
|
||||
{
|
||||
name: "nil slices",
|
||||
existing: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "empty slices",
|
||||
existing: []domain.Destination{},
|
||||
want: []domain.Destination{},
|
||||
},
|
||||
{
|
||||
name: "identical slices",
|
||||
in: []config.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
want: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
},
|
||||
{
|
||||
name: "adding a new destination",
|
||||
in: []config.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}},
|
||||
want: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
},
|
||||
{
|
||||
name: "removing a destination",
|
||||
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
},
|
||||
{
|
||||
name: "switching order, two items",
|
||||
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}},
|
||||
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}},
|
||||
},
|
||||
{
|
||||
name: "switching order, several items",
|
||||
in: []config.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}},
|
||||
want: []domain.Destination{{URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
},
|
||||
{
|
||||
name: "removing all destinations",
|
||||
in: []config.Destination{},
|
||||
existing: []domain.Destination{{URL: "rtmp://rtmp.youtube.com/live"}, {URL: "rtmp://rtmp.twitch.tv/live"}, {URL: "rtmp://rtmp.facebook.com/live"}, {URL: "rtmp://rtmp.tiktok.com/live"}},
|
||||
want: []domain.Destination{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, resolveDestinations(tc.existing, tc.in))
|
||||
})
|
||||
}
|
||||
}
|
204
internal/app/integration_helpers_test.go
Normal file
204
internal/app/integration_helpers_test.go
Normal file
@ -0,0 +1,204 @@
|
||||
//go:build integration
|
||||
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/app"
|
||||
"git.netflux.io/rob/octoplex/internal/config"
|
||||
"git.netflux.io/rob/octoplex/internal/container"
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
"git.netflux.io/rob/octoplex/internal/terminal"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
)
|
||||
|
||||
func buildAppParams(
|
||||
t *testing.T,
|
||||
configService *config.Service,
|
||||
dockerClient container.DockerClient,
|
||||
screen tcell.SimulationScreen,
|
||||
screenCaptureC chan<- terminal.ScreenCapture,
|
||||
logger *slog.Logger,
|
||||
) app.RunParams {
|
||||
t.Helper()
|
||||
|
||||
return app.RunParams{
|
||||
ConfigService: configService,
|
||||
DockerClient: dockerClient,
|
||||
Screen: &terminal.Screen{
|
||||
Screen: screen,
|
||||
Width: 180,
|
||||
Height: 25,
|
||||
CaptureC: screenCaptureC,
|
||||
},
|
||||
ClipboardAvailable: false,
|
||||
BuildInfo: domain.BuildInfo{Version: "0.0.1", GoVersion: "go1.16.3"},
|
||||
Logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func setupSimulationScreen(t *testing.T) (tcell.SimulationScreen, chan<- terminal.ScreenCapture, func() []string) {
|
||||
t.Helper()
|
||||
|
||||
// Fetching the screen contents is tricky at this level of the test pyramid,
|
||||
// because we need to:
|
||||
//
|
||||
// 1. Somehow capture the screen contents, which is only available via the
|
||||
// tcell.SimulationScreen, and...
|
||||
// 2. Do so without triggering data races.
|
||||
//
|
||||
// We can achieve this by passing a channel into the terminal actor, which
|
||||
// will send screen captures after each render. This can be stored locally
|
||||
// and asserted against when needed.
|
||||
var (
|
||||
screenCells []tcell.SimCell
|
||||
screenWidth int
|
||||
screenMu sync.Mutex
|
||||
)
|
||||
|
||||
getContents := func() []string {
|
||||
screenMu.Lock()
|
||||
defer screenMu.Unlock()
|
||||
|
||||
var lines []string
|
||||
for n, _ := range screenCells {
|
||||
y := n / screenWidth
|
||||
|
||||
if y > len(lines)-1 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if len(screenCells[n].Runes) == 0 { // shouldn't really happen unless there is no output
|
||||
continue
|
||||
}
|
||||
lines[y] += string(screenCells[n].Runes[0])
|
||||
}
|
||||
|
||||
require.GreaterOrEqual(t, len(lines), 5, "Screen contents should have at least 5 lines")
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if t.Failed() {
|
||||
printScreen(t, getContents, "After failing")
|
||||
}
|
||||
})
|
||||
|
||||
screen := tcell.NewSimulationScreen("")
|
||||
screenCaptureC := make(chan terminal.ScreenCapture, 1)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-t.Context().Done():
|
||||
return
|
||||
case capture := <-screenCaptureC:
|
||||
screenMu.Lock()
|
||||
screenCells = capture.Cells
|
||||
screenWidth = capture.Width
|
||||
screenMu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return screen, screenCaptureC, getContents
|
||||
}
|
||||
|
||||
func contentsIncludes(contents []string, search string) bool {
|
||||
for _, line := range contents {
|
||||
if strings.Contains(line, search) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func setupConfigService(t *testing.T, cfg config.Config) *config.Service {
|
||||
t.Helper()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "octoplex_"+strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { os.RemoveAll(tmpDir) })
|
||||
configService, err := config.NewService(func() (string, error) { return tmpDir, nil }, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, configService.SetConfig(cfg))
|
||||
|
||||
return configService
|
||||
}
|
||||
|
||||
func printScreen(t *testing.T, getContents func() []string, label string) {
|
||||
t.Helper()
|
||||
|
||||
fmt.Println(label + ":")
|
||||
for _, line := range getContents() {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
|
||||
func sendKey(t *testing.T, screen tcell.SimulationScreen, key tcell.Key, ch rune) {
|
||||
t.Helper()
|
||||
|
||||
screen.InjectKey(key, ch, tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func sendKeys(t *testing.T, screen tcell.SimulationScreen, keys string) {
|
||||
t.Helper()
|
||||
|
||||
screen.InjectKeyBytes([]byte(keys))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
func sendBackspaces(t *testing.T, screen tcell.SimulationScreen, n int) {
|
||||
t.Helper()
|
||||
|
||||
for range n {
|
||||
screen.InjectKey(tcell.KeyBackspace, ' ', tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// kickFirstRTMPConn kicks the first RTMP connection from the mediaMTX server.
|
||||
func kickFirstRTMPConn(t *testing.T, srv testcontainers.Container) {
|
||||
t.Helper()
|
||||
|
||||
type conn struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type apiResponse struct {
|
||||
Items []conn `json:"items"`
|
||||
}
|
||||
|
||||
port, err := srv.MappedPort(t.Context(), "9997/tcp")
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/v3/rtmpconns/list", port.Int()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
var apiResp apiResponse
|
||||
require.NoError(t, json.Unmarshal(respBody, &apiResp))
|
||||
require.NoError(t, err)
|
||||
require.True(t, len(apiResp.Items) > 0, "No RTMP connections found")
|
||||
|
||||
resp, err = http.Post(fmt.Sprintf("http://localhost:%d/v3/rtmpconns/kick/%s", port.Int(), apiResp.Items[0].ID), "application/json", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
12
internal/app/testdata/mediamtx.yml
vendored
Normal file
12
internal/app/testdata/mediamtx.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
rtmp: true
|
||||
rtmpAddress: :1936
|
||||
api: true
|
||||
authInternalUsers:
|
||||
- user: any
|
||||
ips: []
|
||||
permissions:
|
||||
- action: api
|
||||
- action: read
|
||||
- action: publish
|
||||
paths:
|
||||
live:
|
17
internal/app/testdata/openssl.cnf
vendored
Normal file
17
internal/app/testdata/openssl.cnf
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
# openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout server.key -out server.crt -config openssl.cnf
|
||||
|
||||
[req]
|
||||
default_bits = 2048
|
||||
prompt = no
|
||||
default_md = sha256
|
||||
distinguished_name = dn
|
||||
x509_extensions = v3_req
|
||||
|
||||
[dn]
|
||||
CN = localhost
|
||||
|
||||
[v3_req]
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = localhost
|
18
internal/app/testdata/server.crt
vendored
Normal file
18
internal/app/testdata/server.crt
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC7TCCAdWgAwIBAgIUTeqv46R19q+BS2e4DBkbIHuWyIIwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDQyMDA4NTMwN1oXDTM1MDQx
|
||||
ODA4NTMwN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEA0v/KndfKfG8XItStHeMQ/3z1r8vhkH9KGpfSwDMp8MdH
|
||||
Mox6vcAsIIr1RFKmalQQg+T+TK9v3XM6F4sJ+WPyb5/31xLUqG6zivitrMy1AZ8w
|
||||
XLgAz/CTufXL3OBntDwg29QXWt9lOUJyjRa66AQqreTlItuLG65bswfPA4g35f+U
|
||||
hyr49paukqnVHRr44GtyiNxlfYCEdQWdOR0EQmZ7y6WNQQhnR8odQyftR2lykf17
|
||||
MSJ8us4JAgZ2fr1QR+DfX5bCSS/WJ2aO7xxeES40NizBx08qYFami1zXrGMMo35I
|
||||
SfedCohcok8ZZ1oWL+MfSJ2OLVclDnznDPTx39pZPQIDAQABozcwNTAUBgNVHREE
|
||||
DTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFCgZah+m2NXkI9biS2vnhNUrd3FiMA0G
|
||||
CSqGSIb3DQEBCwUAA4IBAQAPbofZIKCm3DnudFnK+LRkdlpMNOyH2zn3g8h8vrfL
|
||||
Tfi0oBgHb7EYxcHYDanZbcIKracWCfQVze2FRLgNFBWiyhDO4IXe/LpwSnbyLWCh
|
||||
psbGuyVmEz9CuiyVdIi+CWQs5dBBRUCFg6NE2/r6Diw9LD0fVCVUwkvqopetfp1B
|
||||
tvA74O0RduLWs+iXNs5XW4sODVkrOmhBbRrP9GRCVqiqVWJka6CzrNdBm0Y9zZMQ
|
||||
GD/6fEgDaW8YlShoO+e4FwmD2IgIx+m4xamr/cQkWpbOHMxAwv7vP0stfkpyUacW
|
||||
dh9eJmsDAmgGgdtMJvbIfyR9ilG8D6zwOmSlkF6fDJ3E
|
||||
-----END CERTIFICATE-----
|
28
internal/app/testdata/server.key
vendored
Normal file
28
internal/app/testdata/server.key
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDS/8qd18p8bxci
|
||||
1K0d4xD/fPWvy+GQf0oal9LAMynwx0cyjHq9wCwgivVEUqZqVBCD5P5Mr2/dczoX
|
||||
iwn5Y/Jvn/fXEtSobrOK+K2szLUBnzBcuADP8JO59cvc4Ge0PCDb1Bda32U5QnKN
|
||||
FrroBCqt5OUi24sbrluzB88DiDfl/5SHKvj2lq6SqdUdGvjga3KI3GV9gIR1BZ05
|
||||
HQRCZnvLpY1BCGdHyh1DJ+1HaXKR/XsxIny6zgkCBnZ+vVBH4N9flsJJL9YnZo7v
|
||||
HF4RLjQ2LMHHTypgVqaLXNesYwyjfkhJ950KiFyiTxlnWhYv4x9InY4tVyUOfOcM
|
||||
9PHf2lk9AgMBAAECggEAC3E3qaukHW9gz9C8upwvtcsu/6OMzes5N4v4L9gWdCo6
|
||||
YDFiDpw3SGSAvH3G7Ik2hBCNAdeZt2aiRdiSZ+XVpdwE8rLguWmXbvfhYzeOsVHS
|
||||
q5SG5r/jIviDX60DsrB4D7PGuHTY5mwGDkSnSiG/tsJs8qD5QD0KWAEaZtSiQ2Sp
|
||||
kcRbdq13/2tjHyx7nBxEYUFC4EJQjK3cNNV4G7nG2xcfT46uPvFV0+1CQtMpFYhi
|
||||
IsGaSBhW9gOAheycYxCi+LRdUh1IAnLUyYUenu0o8PoXsHp6KD8eS5RXtfA6THd/
|
||||
Jr614gdAB2Sffw+bFf6FIBNWa5Jwsg9UtbGtjNdo+QKBgQDrOJ2nj7El6MIqeDHs
|
||||
1cCeGDKmjB1CYWALLHrwwiwmrvEoeBMiJuMN4epZdQw9hwExa7fNpERI7Ay8s5HD
|
||||
cdppxgcW7CWChNncbVZ39P+YI9URWC2Q2Y8FBhc9FA0sKpDak0rf5UE63SGjU8/I
|
||||
FGgwjd1Ln5wws00OsYXBZw1lzwKBgQDlo2kRy6xvrUNAbeggT9OQeg2SdkWqvS3v
|
||||
NUhBzZkVhJNf1oApNRoAvRMQt+Xt+Euw1pQ+TvdOZQhhqxs/pD/wGdM7rhq9r0+G
|
||||
itsQ5LvNCxCePbSkbFMLgC8JgNuM3aRqhtsU+Illk9xvCj2nKsd+UUN3NxYgjCqa
|
||||
evTKSzUfMwKBgFapy1w7EteWxEMFec96ibc1zyORqA4W9l3ni3w87itqdSul4dbJ
|
||||
YQpyW/eNqm7Y2NWujE/V39rGLYMw3dmWjxQ9g8ssQj2uWN5f4mXb/He/a/cx98fQ
|
||||
gGMndVRpmNjW7fu6HPIU802Ov5//dySOcDzDZ+8+5TsENLXfLhqtrz/9AoGBALc+
|
||||
/BQoTFTdlSHv0mEecjwDOZtbZ+KEjggpo5xm/TbPkW7T03eOmU5nkrQvm3qXPYdC
|
||||
5A8Ioo5bTyHpEZhqcF8frJEeMNaW88XwPjmv3TEVGFC9+s2OZ4Jw6pgRzKEPKSmc
|
||||
rWyBm9qD8E5nhKVGHOVu4YBbY/va/hBB998Jvr1DAoGBAK5nnswLyQZi0lgpkl1P
|
||||
ITkmvnQlZBfuqvoD7wcQ3nx/K/mdacsxepRne+U/4+iNzRtd3gU0iccCWUTJl4aB
|
||||
cFRW1eXWuff+4vmM4JToDevGPXrS0CHE20mATJRZPH+YjZFl0pFSc4/tnjxBnx4y
|
||||
vgM382WU9N9jIHCCnM6DYsbK
|
||||
-----END PRIVATE KEY-----
|
@ -1,5 +1,7 @@
|
||||
package config
|
||||
|
||||
import "cmp"
|
||||
|
||||
// Destination holds the configuration for a destination.
|
||||
type Destination struct {
|
||||
Name string `yaml:"name"`
|
||||
@ -9,18 +11,48 @@ type Destination struct {
|
||||
// LogFile holds the configuration for the log file.
|
||||
type LogFile struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Path string `yaml:"path"`
|
||||
Path string `yaml:"path,omitempty"`
|
||||
|
||||
defaultPath string
|
||||
}
|
||||
|
||||
// GetPath returns the path to the log file. If the path is not set, it
|
||||
// returns the default log path.
|
||||
func (l LogFile) GetPath() string {
|
||||
return cmp.Or(l.Path, l.defaultPath)
|
||||
}
|
||||
|
||||
// NetAddr holds an IP and/or port.
|
||||
type NetAddr struct {
|
||||
IP string `yaml:"ip,omitempty"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
}
|
||||
|
||||
// RTMPSource holds the configuration for the RTMP source.
|
||||
type RTMPSource struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
StreamKey string `yaml:"streamkey"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
|
||||
NetAddr `yaml:",inline"`
|
||||
}
|
||||
|
||||
// TLS holds the TLS configuration.
|
||||
type TLS struct {
|
||||
CertPath string `yaml:"cert,omitempty"`
|
||||
KeyPath string `yaml:"key,omitempty"`
|
||||
}
|
||||
|
||||
// MediaServerSource holds the configuration for the media server source.
|
||||
type MediaServerSource struct {
|
||||
StreamKey string `yaml:"streamKey,omitempty"`
|
||||
Host string `yaml:"host,omitempty"`
|
||||
TLS *TLS `yaml:"tls,omitempty"`
|
||||
RTMP RTMPSource `yaml:"rtmp"`
|
||||
RTMPS RTMPSource `yaml:"rtmps"`
|
||||
}
|
||||
|
||||
// Sources holds the configuration for the sources.
|
||||
type Sources struct {
|
||||
RTMP RTMPSource `yaml:"rtmp"`
|
||||
MediaServer MediaServerSource `yaml:"mediaServer"`
|
||||
}
|
||||
|
||||
// Config holds the configuration for the application.
|
||||
|
@ -1,24 +0,0 @@
|
||||
# Octoplex is a live stream multiplexer.
|
||||
---
|
||||
#
|
||||
sources:
|
||||
# Currently the only source type is RTMP server.
|
||||
rtmp:
|
||||
enabled: yes
|
||||
# Your local stream key. Defaults to "live".
|
||||
#
|
||||
# rtmp://localhost:1935/live
|
||||
streamkey: live
|
||||
#
|
||||
logfile:
|
||||
# Change to yes to log to system location.
|
||||
enabled: no
|
||||
# Or, log to this absolute path:
|
||||
# path: octoplex.log
|
||||
#
|
||||
# Define your destinations here.
|
||||
destinations:
|
||||
# - name: YouTube
|
||||
# url: rtmp://rtmp.youtube.com/myYoutubeStreamKey
|
||||
# - name: Twitch
|
||||
# url: rtmp://ingest.global-contribute.live-video.net/app/myTwitchStreamKey
|
@ -1,41 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed data/config.example.yml
|
||||
var exampleConfig []byte
|
||||
|
||||
// Service provides configuration services.
|
||||
type Service struct {
|
||||
userConfigDir string
|
||||
appConfigDir string
|
||||
appStateDir string
|
||||
current Config
|
||||
appConfigDir string
|
||||
appStateDir string
|
||||
configC chan Config
|
||||
}
|
||||
|
||||
// ConfigDirFunc is a function that returns the user configuration directory.
|
||||
type ConfigDirFunc func() (string, error)
|
||||
|
||||
// defaultChanSize is the default size of the configuration channel.
|
||||
const defaultChanSize = 64
|
||||
|
||||
// NewDefaultService creates a new service with the default configuration file
|
||||
// location.
|
||||
func NewDefaultService() (*Service, error) {
|
||||
return NewService(os.UserConfigDir)
|
||||
return NewService(os.UserConfigDir, defaultChanSize)
|
||||
}
|
||||
|
||||
// NewService creates a new service with provided ConfigDirFunc.
|
||||
//
|
||||
// The app data directories (config and state) are created if they do not
|
||||
// exist.
|
||||
func NewService(configDirFunc ConfigDirFunc) (*Service, error) {
|
||||
func NewService(configDirFunc ConfigDirFunc, chanSize int) (*Service, error) {
|
||||
configDir, err := configDirFunc()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user config dir: %w", err)
|
||||
@ -46,23 +47,43 @@ func NewService(configDirFunc ConfigDirFunc) (*Service, error) {
|
||||
return nil, fmt.Errorf("app config dir: %w", err)
|
||||
}
|
||||
|
||||
// TODO: inject StateDirFunc
|
||||
appStateDir, err := createAppStateDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("app state dir: %w", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
userConfigDir: configDir,
|
||||
appConfigDir: appConfigDir,
|
||||
appStateDir: appStateDir,
|
||||
}, nil
|
||||
svc := &Service{
|
||||
appConfigDir: appConfigDir,
|
||||
appStateDir: appStateDir,
|
||||
configC: make(chan Config, chanSize),
|
||||
}
|
||||
|
||||
svc.populateConfigOnBuild(&svc.current)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// ReadOrCreateConfig reads the configuration from the file at the given path or
|
||||
// creates it with default values.
|
||||
// Current returns the current configuration.
|
||||
//
|
||||
// This will be the last-loaded or last-updated configuration, or a default
|
||||
// configuration if nothing else is available.
|
||||
func (s *Service) Current() Config {
|
||||
return s.current
|
||||
}
|
||||
|
||||
// C returns a channel that receives configuration updates.
|
||||
//
|
||||
// The channel is never closed.
|
||||
func (s *Service) C() <-chan Config {
|
||||
return s.configC
|
||||
}
|
||||
|
||||
// ReadOrCreateConfig reads the configuration from the file or creates it with
|
||||
// default values.
|
||||
func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) {
|
||||
if _, err := os.Stat(s.Path()); os.IsNotExist(err) {
|
||||
return s.createConfig()
|
||||
return s.writeDefaultConfig()
|
||||
} else if err != nil {
|
||||
return cfg, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
@ -70,6 +91,33 @@ func (s *Service) ReadOrCreateConfig() (cfg Config, _ error) {
|
||||
return s.readConfig()
|
||||
}
|
||||
|
||||
// SetConfig sets the configuration to the given value and writes it to the
|
||||
// file.
|
||||
func (s *Service) SetConfig(cfg Config) error {
|
||||
if err := validate(cfg); err != nil {
|
||||
return fmt.Errorf("validate: %w", err)
|
||||
}
|
||||
|
||||
cfgBytes, err := marshalConfig(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
if err = s.writeConfig(cfgBytes); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
s.current = cfg
|
||||
s.configC <- cfg
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the path to the configuration file.
|
||||
func (s *Service) Path() string {
|
||||
return filepath.Join(s.appConfigDir, "config.yaml")
|
||||
}
|
||||
|
||||
func (s *Service) readConfig() (cfg Config, _ error) {
|
||||
contents, err := os.ReadFile(s.Path())
|
||||
if err != nil {
|
||||
@ -80,49 +128,97 @@ func (s *Service) readConfig() (cfg Config, _ error) {
|
||||
return cfg, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
s.setDefaults(&cfg)
|
||||
|
||||
s.populateConfigOnRead(&cfg)
|
||||
if err = validate(cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
s.current = cfg
|
||||
|
||||
return s.current, nil
|
||||
}
|
||||
|
||||
func (s *Service) writeDefaultConfig() (Config, error) {
|
||||
var cfg Config
|
||||
s.populateConfigOnBuild(&cfg)
|
||||
|
||||
cfgBytes, err := marshalConfig(cfg)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
if err := s.writeConfig(cfgBytes); err != nil {
|
||||
return Config{}, fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Service) createConfig() (Config, error) {
|
||||
func marshalConfig(cfg Config) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := yaml.NewEncoder(&buf)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return nil, fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *Service) writeConfig(cfgBytes []byte) error {
|
||||
if err := os.MkdirAll(s.appConfigDir, 0744); err != nil {
|
||||
return Config{}, fmt.Errorf("mkdir: %w", err)
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.Path(), exampleConfig, 0644); err != nil {
|
||||
return Config{}, fmt.Errorf("write file: %w", err)
|
||||
if err := os.WriteFile(s.Path(), cfgBytes, 0644); err != nil {
|
||||
return fmt.Errorf("write file: %w", err)
|
||||
}
|
||||
|
||||
return Config{}, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Path() string {
|
||||
return filepath.Join(s.appConfigDir, "config.yaml")
|
||||
}
|
||||
|
||||
func (s *Service) setDefaults(cfg *Config) {
|
||||
if cfg.LogFile.Enabled && cfg.LogFile.Path == "" {
|
||||
cfg.LogFile.Path = filepath.Join(s.appStateDir, domain.AppName+".log")
|
||||
// populateConfigOnBuild is called to set default values for a new, empty
|
||||
// configuration.
|
||||
//
|
||||
// This function may set serialized fields to arbitrary values.
|
||||
func (s *Service) populateConfigOnBuild(cfg *Config) {
|
||||
cfg.Sources.MediaServer.StreamKey = "live"
|
||||
cfg.Sources.MediaServer.RTMP = RTMPSource{
|
||||
Enabled: true,
|
||||
NetAddr: NetAddr{IP: "127.0.0.1", Port: 1935},
|
||||
}
|
||||
|
||||
for i := range cfg.Destinations {
|
||||
if strings.TrimSpace(cfg.Destinations[i].Name) == "" {
|
||||
cfg.Destinations[i].Name = fmt.Sprintf("Stream %d", i+1)
|
||||
}
|
||||
}
|
||||
s.populateConfigOnRead(cfg)
|
||||
}
|
||||
|
||||
// populateConfigOnRead is called to set default values for a configuration
|
||||
// read from an existing file.
|
||||
//
|
||||
// This function should not update any serialized values, which would be a
|
||||
// confusing experience for the user.
|
||||
func (s *Service) populateConfigOnRead(cfg *Config) {
|
||||
cfg.LogFile.defaultPath = filepath.Join(s.appStateDir, "octoplex.log")
|
||||
}
|
||||
|
||||
// TODO: validate URL format
|
||||
func validate(cfg Config) error {
|
||||
var err error
|
||||
|
||||
urlCounts := make(map[string]int)
|
||||
|
||||
for _, dest := range cfg.Destinations {
|
||||
if !strings.HasPrefix(dest.URL, "rtmp://") {
|
||||
err = errors.Join(err, fmt.Errorf("destination URL must start with rtmp://"))
|
||||
if u, urlErr := url.Parse(dest.URL); urlErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("invalid destination URL: %w", urlErr))
|
||||
} else if u.Scheme != "rtmp" {
|
||||
err = errors.Join(err, errors.New("destination URL must be an RTMP URL"))
|
||||
}
|
||||
|
||||
urlCounts[dest.URL]++
|
||||
}
|
||||
|
||||
for url, count := range urlCounts {
|
||||
if count > 1 {
|
||||
err = errors.Join(err, fmt.Errorf("duplicate destination URL: %s", url))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,47 +9,72 @@ import (
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/config"
|
||||
"git.netflux.io/rob/octoplex/internal/shortid"
|
||||
gocmp "github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
//go:embed testdata/complete.yml
|
||||
var configComplete []byte
|
||||
|
||||
//go:embed testdata/no-logfile.yml
|
||||
var configNoLogfile []byte
|
||||
//go:embed testdata/rtmps-only.yml
|
||||
var configRTMPSOnly []byte
|
||||
|
||||
//go:embed testdata/logfile.yml
|
||||
var configLogfile []byte
|
||||
|
||||
//go:embed testdata/no-name.yml
|
||||
var configNoName []byte
|
||||
//go:embed testdata/no-logfile.yml
|
||||
var configNoLogfile []byte
|
||||
|
||||
//go:embed testdata/invalid-destination-url.yml
|
||||
var configInvalidDestinationURL []byte
|
||||
//go:embed testdata/destination-url-not-rtmp.yml
|
||||
var configDestinationURLNotRTMP []byte
|
||||
|
||||
//go:embed testdata/destination-url-not-valid.yml
|
||||
var configDestinationURLNotValid []byte
|
||||
|
||||
//go:embed testdata/multiple-invalid-destination-urls.yml
|
||||
var configMultipleInvalidDestinationURLs []byte
|
||||
|
||||
func TestConfigServiceCurrent(t *testing.T) {
|
||||
suffix := "current_" + shortid.New().String()
|
||||
systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
|
||||
systemConfigDir, _ := systemConfigDirFunc()
|
||||
|
||||
service, err := config.NewService(systemConfigDirFunc, 1)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
|
||||
|
||||
// Ensure defaults are set:
|
||||
assert.NotNil(t, service.Current().Sources.MediaServer.RTMP)
|
||||
assert.Equal(t, "127.0.0.1", service.Current().Sources.MediaServer.RTMP.IP)
|
||||
assert.Equal(t, 1935, service.Current().Sources.MediaServer.RTMP.Port)
|
||||
}
|
||||
|
||||
func TestConfigServiceCreateConfig(t *testing.T) {
|
||||
suffix := "read_or_create_" + shortid.New().String()
|
||||
systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
|
||||
systemConfigDir, _ := systemConfigDirFunc()
|
||||
|
||||
service, err := config.NewService(systemConfigDirFunc)
|
||||
service, err := config.NewService(systemConfigDirFunc, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
|
||||
|
||||
cfg, err := service.ReadOrCreateConfig()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cfg.LogFile, "expected no log file")
|
||||
require.False(t, cfg.LogFile.Enabled, "expected logging to be disabled")
|
||||
require.Empty(t, cfg.LogFile.Path, "expected no log file")
|
||||
|
||||
p := filepath.Join(systemConfigDir, "octoplex", "config.yaml")
|
||||
cfgBytes, err := os.ReadFile(p)
|
||||
require.NoError(t, err, "config file was not created")
|
||||
// Ensure the example config file is written:
|
||||
assert.Contains(t, string(cfgBytes), "# Octoplex is a live stream multiplexer.")
|
||||
|
||||
var readCfg config.Config
|
||||
require.NoError(t, yaml.Unmarshal(cfgBytes, &readCfg))
|
||||
assert.NotNil(t, readCfg.Sources.MediaServer.RTMP)
|
||||
assert.Equal(t, "127.0.0.1", readCfg.Sources.MediaServer.RTMP.IP)
|
||||
assert.Equal(t, 1935, readCfg.Sources.MediaServer.RTMP.Port)
|
||||
}
|
||||
|
||||
func TestConfigServiceReadConfig(t *testing.T) {
|
||||
@ -63,33 +88,76 @@ func TestConfigServiceReadConfig(t *testing.T) {
|
||||
name: "complete",
|
||||
configBytes: configComplete,
|
||||
want: func(t *testing.T, cfg config.Config) {
|
||||
require.Equal(
|
||||
require.Empty(
|
||||
t,
|
||||
config.Config{
|
||||
LogFile: config.LogFile{
|
||||
Enabled: true,
|
||||
Path: "test.log",
|
||||
},
|
||||
Sources: config.Sources{
|
||||
RTMP: config.RTMPSource{
|
||||
Enabled: true,
|
||||
StreamKey: "s3cr3t",
|
||||
gocmp.Diff(
|
||||
config.Config{
|
||||
LogFile: config.LogFile{
|
||||
Enabled: true,
|
||||
Path: "test.log",
|
||||
},
|
||||
Sources: config.Sources{
|
||||
MediaServer: config.MediaServerSource{
|
||||
StreamKey: "s3cr3t",
|
||||
Host: "rtmp.example.com",
|
||||
TLS: &config.TLS{
|
||||
CertPath: "/etc/cert.pem",
|
||||
KeyPath: "/etc/key.pem",
|
||||
},
|
||||
RTMP: config.RTMPSource{
|
||||
Enabled: true,
|
||||
NetAddr: config.NetAddr{
|
||||
IP: "0.0.0.0",
|
||||
Port: 19350,
|
||||
},
|
||||
},
|
||||
RTMPS: config.RTMPSource{
|
||||
Enabled: true,
|
||||
NetAddr: config.NetAddr{
|
||||
IP: "0.0.0.0",
|
||||
Port: 19443,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Destinations: []config.Destination{
|
||||
{
|
||||
Name: "my stream",
|
||||
URL: "rtmp://rtmp.example.com:1935/live",
|
||||
},
|
||||
},
|
||||
},
|
||||
Destinations: []config.Destination{
|
||||
{
|
||||
Name: "my stream",
|
||||
URL: "rtmp://rtmp.example.com:1935/live",
|
||||
},
|
||||
},
|
||||
}, cfg)
|
||||
cfg,
|
||||
cmpopts.IgnoreUnexported(config.LogFile{}),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "logging enabled, no logfile",
|
||||
configBytes: configNoLogfile,
|
||||
name: "RTMPS only",
|
||||
configBytes: configRTMPSOnly,
|
||||
want: func(t *testing.T, cfg config.Config) {
|
||||
assert.True(t, strings.HasSuffix(cfg.LogFile.Path, "/octoplex/octoplex.log"))
|
||||
require.Empty(
|
||||
t,
|
||||
gocmp.Diff(
|
||||
config.Config{
|
||||
LogFile: config.LogFile{Enabled: true},
|
||||
Sources: config.Sources{
|
||||
MediaServer: config.MediaServerSource{
|
||||
RTMPS: config.RTMPSource{
|
||||
Enabled: true,
|
||||
NetAddr: config.NetAddr{
|
||||
IP: "0.0.0.0",
|
||||
Port: 1935,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cfg,
|
||||
cmpopts.IgnoreUnexported(config.LogFile{}),
|
||||
),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -100,21 +168,26 @@ func TestConfigServiceReadConfig(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no name",
|
||||
configBytes: configNoName,
|
||||
name: "logging enabled, no logfile",
|
||||
configBytes: configNoLogfile,
|
||||
want: func(t *testing.T, cfg config.Config) {
|
||||
assert.Equal(t, "Stream 1", cfg.Destinations[0].Name)
|
||||
assert.True(t, strings.HasSuffix(cfg.LogFile.GetPath(), "/octoplex/octoplex.log"), "expected %q to end with /tmp/octoplex.log", cfg.LogFile.GetPath())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid destination URL",
|
||||
configBytes: configInvalidDestinationURL,
|
||||
wantErr: "destination URL must start with rtmp://",
|
||||
name: "destination URL is not rtmp scheme",
|
||||
configBytes: configDestinationURLNotRTMP,
|
||||
wantErr: "destination URL must be an RTMP URL",
|
||||
},
|
||||
{
|
||||
name: "destination URL is not valid",
|
||||
configBytes: configDestinationURLNotValid,
|
||||
wantErr: `invalid destination URL: parse "rtmp://rtmp.example.com/%%+": invalid URL escape "%%+"`,
|
||||
},
|
||||
{
|
||||
name: "multiple invalid destination URLs",
|
||||
configBytes: configMultipleInvalidDestinationURLs,
|
||||
wantErr: "destination URL must start with rtmp://\ndestination URL must start with rtmp://",
|
||||
wantErr: "destination URL must be an RTMP URL\ndestination URL must be an RTMP URL",
|
||||
},
|
||||
}
|
||||
|
||||
@ -131,7 +204,7 @@ func TestConfigServiceReadConfig(t *testing.T) {
|
||||
configPath := filepath.Join(appConfigDir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(configPath, tc.configBytes, 0644))
|
||||
|
||||
service, err := config.NewService(buildSystemConfigDirFunc(suffix))
|
||||
service, err := config.NewService(buildSystemConfigDirFunc(suffix), 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := service.ReadOrCreateConfig()
|
||||
@ -146,6 +219,25 @@ func TestConfigServiceReadConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigServiceSetConfig(t *testing.T) {
|
||||
suffix := "set_config_" + shortid.New().String()
|
||||
systemConfigDirFunc := buildSystemConfigDirFunc(suffix)
|
||||
systemConfigDir, _ := systemConfigDirFunc()
|
||||
|
||||
service, err := config.NewService(systemConfigDirFunc, 1)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { require.NoError(t, os.RemoveAll(systemConfigDir)) })
|
||||
|
||||
cfg := config.Config{LogFile: config.LogFile{Enabled: true, Path: "test.log"}}
|
||||
require.NoError(t, service.SetConfig(cfg))
|
||||
|
||||
cfg, err = service.ReadOrCreateConfig()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test.log", cfg.LogFile.Path)
|
||||
assert.True(t, cfg.LogFile.Enabled)
|
||||
}
|
||||
|
||||
// buildAppConfigDir returns a temporary directory which mimics
|
||||
// $XDG_CONFIG_HOME/octoplex.
|
||||
func buildAppConfigDir(suffix string) string {
|
||||
|
17
internal/config/testdata/complete.yml
vendored
17
internal/config/testdata/complete.yml
vendored
@ -3,9 +3,20 @@ logfile:
|
||||
enabled: true
|
||||
path: test.log
|
||||
sources:
|
||||
rtmp:
|
||||
enabled: true
|
||||
streamkey: s3cr3t
|
||||
mediaServer:
|
||||
streamKey: s3cr3t
|
||||
host: rtmp.example.com
|
||||
tls:
|
||||
cert: /etc/cert.pem
|
||||
key: /etc/key.pem
|
||||
rtmp:
|
||||
enabled: true
|
||||
ip: 0.0.0.0
|
||||
port: 19350
|
||||
rtmps:
|
||||
enabled: true
|
||||
ip: 0.0.0.0
|
||||
port: 19443
|
||||
destinations:
|
||||
- name: my stream
|
||||
url: rtmp://rtmp.example.com:1935/live
|
||||
|
@ -3,4 +3,4 @@ logfile:
|
||||
enabled: true
|
||||
path: test.log
|
||||
destinations:
|
||||
- url: rtmp://rtmp.example.com:1935/live
|
||||
- url: rtmp://rtmp.example.com/%%+
|
9
internal/config/testdata/rtmps-only.yml
vendored
Normal file
9
internal/config/testdata/rtmps-only.yml
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
---
|
||||
logfile:
|
||||
enabled: true
|
||||
sources:
|
||||
mediaServer:
|
||||
rtmps:
|
||||
enabled: true
|
||||
ip: 0.0.0.0
|
||||
port: 1935
|
@ -28,7 +28,7 @@ func createAppStateDir() (string, error) {
|
||||
var dir string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
dir = filepath.Join(userHomeDir, "/Library", "Caches", domain.AppName)
|
||||
dir = filepath.Join(userHomeDir, "Library", "Caches", domain.AppName)
|
||||
case "windows":
|
||||
// TODO: Windows support
|
||||
return "", errors.New("not implemented")
|
||||
|
@ -21,7 +21,6 @@ import (
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/errdefs"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
@ -38,8 +37,8 @@ type DockerClient interface {
|
||||
io.Closer
|
||||
|
||||
ContainerCreate(context.Context, *container.Config, *container.HostConfig, *network.NetworkingConfig, *ocispec.Platform, string) (container.CreateResponse, error)
|
||||
ContainerInspect(context.Context, string) (container.InspectResponse, error)
|
||||
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
|
||||
ContainerLogs(context.Context, string, container.LogsOptions) (io.ReadCloser, error)
|
||||
ContainerRemove(context.Context, string, container.RemoveOptions) error
|
||||
ContainerStart(context.Context, string, container.StartOptions) error
|
||||
ContainerStats(context.Context, string, bool) (container.StatsResponseReader, error)
|
||||
@ -72,6 +71,7 @@ type Client struct {
|
||||
wg sync.WaitGroup
|
||||
apiClient DockerClient
|
||||
networkID string
|
||||
cancelFuncs map[string]context.CancelFunc
|
||||
pulledImages map[string]struct{}
|
||||
logger *slog.Logger
|
||||
}
|
||||
@ -99,6 +99,7 @@ func NewClient(ctx context.Context, apiClient DockerClient, logger *slog.Logger)
|
||||
cancel: cancel,
|
||||
apiClient: apiClient,
|
||||
networkID: network.ID,
|
||||
cancelFuncs: make(map[string]context.CancelFunc),
|
||||
pulledImages: make(map[string]struct{}),
|
||||
logger: logger,
|
||||
}
|
||||
@ -136,17 +137,53 @@ func (a *Client) getEvents(containerID string) <-chan events.Message {
|
||||
return ch
|
||||
}
|
||||
|
||||
// getLogs returns a channel (which is never closed) that will receive
|
||||
// container logs.
|
||||
func (a *Client) getLogs(ctx context.Context, containerID string, cfg LogConfig) <-chan []byte {
|
||||
if !cfg.Stdout && !cfg.Stderr {
|
||||
return nil
|
||||
}
|
||||
|
||||
ch := make(chan []byte)
|
||||
|
||||
go getLogs(ctx, containerID, a.apiClient, cfg, ch, a.logger)
|
||||
|
||||
return ch
|
||||
}
|
||||
|
||||
// NetworkCountConfig holds configuration for observing network traffic.
|
||||
type NetworkCountConfig struct {
|
||||
Rx string // the network name to count the Rx bytes
|
||||
Tx string // the network name to count the Tx bytes
|
||||
}
|
||||
|
||||
// CopyFileConfig holds configuration for a single file which should be copied
|
||||
// into a container.
|
||||
type CopyFileConfig struct {
|
||||
Path string
|
||||
Payload io.Reader
|
||||
Mode int64
|
||||
}
|
||||
|
||||
// LogConfig holds configuration for container logs.
|
||||
type LogConfig struct {
|
||||
Stdout, Stderr bool
|
||||
}
|
||||
|
||||
// ShouldRestartFunc is a callback function that is called when a container
|
||||
// exits. It should return true if the container is to be restarted. If not
|
||||
// restarting, err may be non-nil.
|
||||
type ShouldRestartFunc func(
|
||||
exitCode int64,
|
||||
restartCount int,
|
||||
containerLogs [][]byte,
|
||||
runningTime time.Duration,
|
||||
) (bool, error)
|
||||
|
||||
// defaultRestartInterval is the default interval between restarts.
|
||||
// TODO: exponential backoff
|
||||
const defaultRestartInterval = 10 * time.Second
|
||||
|
||||
// RunContainerParams are the parameters for running a container.
|
||||
type RunContainerParams struct {
|
||||
Name string
|
||||
@ -155,7 +192,10 @@ type RunContainerParams struct {
|
||||
HostConfig *container.HostConfig
|
||||
NetworkingConfig *network.NetworkingConfig
|
||||
NetworkCountConfig NetworkCountConfig
|
||||
CopyFileConfigs []CopyFileConfig
|
||||
CopyFiles []CopyFileConfig
|
||||
Logs LogConfig
|
||||
ShouldRestart ShouldRestartFunc
|
||||
RestartInterval time.Duration // defaults to 10 seconds
|
||||
}
|
||||
|
||||
// RunContainer runs a container with the given parameters.
|
||||
@ -164,13 +204,18 @@ type RunContainerParams struct {
|
||||
// never be closed. The error channel will receive an error if the container
|
||||
// fails to start, and will be closed when the container exits, possibly after
|
||||
// receiving an error.
|
||||
//
|
||||
// Panics if ShouldRestart is non-nil and the host config defines a restart
|
||||
// policy of its own.
|
||||
func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<-chan domain.Container, <-chan error) {
|
||||
if params.ShouldRestart != nil && !params.HostConfig.RestartPolicy.IsNone() {
|
||||
panic("shouldRestart and restart policy are mutually exclusive")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
containerStateC := make(chan domain.Container, cmp.Or(params.ChanSize, defaultChanSize))
|
||||
errC := make(chan error, 1)
|
||||
sendError := func(err error) {
|
||||
errC <- err
|
||||
}
|
||||
sendError := func(err error) { errC <- err }
|
||||
|
||||
a.wg.Add(1)
|
||||
go func() {
|
||||
@ -178,9 +223,7 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
|
||||
defer close(errC)
|
||||
|
||||
if err := a.pullImageIfNeeded(ctx, params.ContainerConfig.Image, containerStateC); err != nil {
|
||||
a.logger.Error("Error pulling image", "err", err)
|
||||
sendError(fmt.Errorf("image pull: %w", err))
|
||||
return
|
||||
a.logger.Warn("Error pulling image", "err", err)
|
||||
}
|
||||
|
||||
containerConfig := *params.ContainerConfig
|
||||
@ -213,7 +256,7 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
|
||||
return
|
||||
}
|
||||
|
||||
if err = a.copyFilesToContainer(ctx, createResp.ID, params.CopyFileConfigs); err != nil {
|
||||
if err = a.copyFilesToContainer(ctx, createResp.ID, params.CopyFiles); err != nil {
|
||||
sendError(fmt.Errorf("copy files to container: %w", err))
|
||||
return
|
||||
}
|
||||
@ -226,11 +269,21 @@ func (a *Client) RunContainer(ctx context.Context, params RunContainerParams) (<
|
||||
|
||||
containerStateC <- domain.Container{ID: createResp.ID, Status: domain.ContainerStatusRunning}
|
||||
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
a.mu.Lock()
|
||||
a.cancelFuncs[createResp.ID] = cancel
|
||||
a.mu.Unlock()
|
||||
|
||||
a.runContainerLoop(
|
||||
ctx,
|
||||
cancel,
|
||||
createResp.ID,
|
||||
params.ContainerConfig.Image,
|
||||
params.NetworkCountConfig,
|
||||
params.Logs,
|
||||
params.ShouldRestart,
|
||||
cmp.Or(params.RestartInterval, defaultRestartInterval),
|
||||
containerStateC,
|
||||
errC,
|
||||
)
|
||||
@ -309,64 +362,39 @@ func (a *Client) pullImageIfNeeded(ctx context.Context, imageName string, contai
|
||||
return nil
|
||||
}
|
||||
|
||||
type containerWaitResponse struct {
|
||||
container.WaitResponse
|
||||
|
||||
restarting bool
|
||||
restartCount int
|
||||
err error
|
||||
}
|
||||
|
||||
// runContainerLoop is the control loop for a single container. It returns only
|
||||
// when the container exits.
|
||||
func (a *Client) runContainerLoop(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
containerID string,
|
||||
imageName string,
|
||||
networkCountConfig NetworkCountConfig,
|
||||
logConfig LogConfig,
|
||||
shouldRestartFunc ShouldRestartFunc,
|
||||
restartInterval time.Duration,
|
||||
stateC chan<- domain.Container,
|
||||
errC chan<- error,
|
||||
) {
|
||||
type containerWaitResponse struct {
|
||||
container.WaitResponse
|
||||
restarting bool
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
containerRespC := make(chan containerWaitResponse)
|
||||
containerErrC := make(chan error)
|
||||
containerErrC := make(chan error, 1)
|
||||
statsC := a.getStats(containerID, networkCountConfig)
|
||||
eventsC := a.getEvents(containerID)
|
||||
|
||||
// ContainerWait only sends a result for the first non-running state, so we
|
||||
// need to poll it repeatedly.
|
||||
//
|
||||
// The goroutine exits when a value is received on the error channel, or when
|
||||
// the container exits and is not restarting, or when the context is cancelled.
|
||||
go func() {
|
||||
for {
|
||||
respC, errC := a.apiClient.ContainerWait(ctx, containerID, container.WaitConditionNextExit)
|
||||
select {
|
||||
case resp := <-respC:
|
||||
var restarting bool
|
||||
// Check if the container is restarting. If it is not then we don't
|
||||
// want to wait for it again and can return early.
|
||||
ctr, err := a.apiClient.ContainerInspect(ctx, containerID)
|
||||
// Race condition: the container may already have been removed.
|
||||
if errdefs.IsNotFound(err) {
|
||||
// ignore error but do not restart
|
||||
} else if err != nil {
|
||||
a.logger.Error("Error inspecting container", "err", err, "id", shortID(containerID))
|
||||
containerErrC <- err
|
||||
return
|
||||
// Race condition: the container may have already restarted.
|
||||
} else if ctr.State.Status == domain.ContainerStatusRestarting || ctr.State.Status == domain.ContainerStatusRunning {
|
||||
restarting = true
|
||||
}
|
||||
|
||||
containerRespC <- containerWaitResponse{WaitResponse: resp, restarting: restarting}
|
||||
if !restarting {
|
||||
return
|
||||
}
|
||||
case err := <-errC:
|
||||
// Otherwise, this is probably unexpected and we need to handle it.
|
||||
containerErrC <- err
|
||||
return
|
||||
case <-ctx.Done():
|
||||
containerErrC <- ctx.Err()
|
||||
return
|
||||
}
|
||||
var restartCount int
|
||||
for a.waitForContainerExit(ctx, containerID, containerRespC, containerErrC, logConfig, shouldRestartFunc, restartInterval, restartCount) {
|
||||
restartCount++
|
||||
}
|
||||
}()
|
||||
|
||||
@ -384,20 +412,23 @@ func (a *Client) runContainerLoop(
|
||||
a.logger.Info("Container entered non-running state", "exit_code", resp.StatusCode, "id", shortID(containerID), "restarting", resp.restarting)
|
||||
|
||||
var containerState string
|
||||
var containerErr error
|
||||
if resp.restarting {
|
||||
containerState = domain.ContainerStatusRestarting
|
||||
} else {
|
||||
containerState = domain.ContainerStatusExited
|
||||
containerErr = resp.err
|
||||
}
|
||||
|
||||
state.Status = containerState
|
||||
state.Err = containerErr
|
||||
state.RestartCount = resp.restartCount
|
||||
state.CPUPercent = 0
|
||||
state.MemoryUsageBytes = 0
|
||||
state.HealthState = "unhealthy"
|
||||
state.RxRate = 0
|
||||
state.TxRate = 0
|
||||
state.RxSince = time.Time{}
|
||||
state.RestartCount++
|
||||
|
||||
if !resp.restarting {
|
||||
exitCode := int(resp.StatusCode)
|
||||
@ -408,7 +439,7 @@ func (a *Client) runContainerLoop(
|
||||
|
||||
sendState()
|
||||
case err := <-containerErrC:
|
||||
// TODO: error handling?
|
||||
// TODO: verify error handling
|
||||
if err != context.Canceled {
|
||||
a.logger.Error("Error setting container wait", "err", err, "id", shortID(containerID))
|
||||
}
|
||||
@ -422,6 +453,7 @@ func (a *Client) runContainerLoop(
|
||||
if evt.Action == "start" {
|
||||
state.Status = domain.ContainerStatusRunning
|
||||
sendState()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@ -451,6 +483,96 @@ func (a *Client) runContainerLoop(
|
||||
}
|
||||
}
|
||||
|
||||
// waitForContainerExit blocks while it waits for a container to exit, and restarts
|
||||
// it if configured to do so.
|
||||
func (a *Client) waitForContainerExit(
|
||||
ctx context.Context,
|
||||
containerID string,
|
||||
containerRespC chan<- containerWaitResponse,
|
||||
containerErrC chan<- error,
|
||||
logConfig LogConfig,
|
||||
shouldRestartFunc ShouldRestartFunc,
|
||||
restartInterval time.Duration,
|
||||
restartCount int,
|
||||
) bool {
|
||||
var logs [][]byte
|
||||
startedWaitingAt := time.Now()
|
||||
respC, errC := a.apiClient.ContainerWait(ctx, containerID, container.WaitConditionNextExit)
|
||||
logsC := a.getLogs(ctx, containerID, logConfig)
|
||||
|
||||
timer := time.NewTimer(restartInterval)
|
||||
defer timer.Stop()
|
||||
timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case resp := <-respC:
|
||||
exit := func(err error) {
|
||||
a.logger.Info("Container exited", "id", shortID(containerID), "should_restart", "false", "exit_code", resp.StatusCode, "restart_count", restartCount)
|
||||
containerRespC <- containerWaitResponse{
|
||||
WaitResponse: resp,
|
||||
restarting: false,
|
||||
restartCount: restartCount,
|
||||
err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// If the container exited with a non-zero status code, and debug
|
||||
// logging is not enabled, log the container logs at ERROR level for
|
||||
// debugging.
|
||||
// TODO: parameterize
|
||||
if resp.StatusCode != 0 && !a.logger.Enabled(ctx, slog.LevelDebug) {
|
||||
for _, line := range logs {
|
||||
a.logger.Error("Container log", "id", shortID(containerID), "log", string(line))
|
||||
}
|
||||
}
|
||||
|
||||
if shouldRestartFunc == nil {
|
||||
exit(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
shouldRestart, err := shouldRestartFunc(resp.StatusCode, restartCount, logs, time.Since(startedWaitingAt))
|
||||
if shouldRestart && err != nil {
|
||||
panic(fmt.Errorf("shouldRestart must return nil error if restarting, but returned: %w", err))
|
||||
}
|
||||
if !shouldRestart {
|
||||
exit(err)
|
||||
return false
|
||||
}
|
||||
|
||||
a.logger.Info("Container exited", "id", shortID(containerID), "should_restart", "true", "exit_code", resp.StatusCode, "restart_count", restartCount)
|
||||
timer.Reset(restartInterval)
|
||||
|
||||
containerRespC <- containerWaitResponse{
|
||||
WaitResponse: resp,
|
||||
restarting: true,
|
||||
restartCount: restartCount,
|
||||
}
|
||||
// Don't return yet. Wait for the timer to fire.
|
||||
case <-timer.C:
|
||||
a.logger.Info("Container restarting", "id", shortID(containerID), "restart_count", restartCount)
|
||||
if err := a.apiClient.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
|
||||
containerErrC <- fmt.Errorf("container start: %w", err)
|
||||
return false
|
||||
}
|
||||
a.logger.Info("Restarted container", "id", shortID(containerID))
|
||||
return true
|
||||
case line := <-logsC:
|
||||
a.logger.Debug("Container log", "id", shortID(containerID), "log", string(line))
|
||||
// TODO: limit max stored lines
|
||||
logs = append(logs, line)
|
||||
case err := <-errC:
|
||||
containerErrC <- err
|
||||
return false
|
||||
case <-ctx.Done():
|
||||
// This is probably because the container was stopped.
|
||||
containerRespC <- containerWaitResponse{WaitResponse: container.WaitResponse{}, restarting: false}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the client, stopping and removing all running containers.
|
||||
func (a *Client) Close() error {
|
||||
a.cancel()
|
||||
@ -481,6 +603,24 @@ func (a *Client) Close() error {
|
||||
}
|
||||
|
||||
func (a *Client) removeContainer(ctx context.Context, id string) error {
|
||||
a.mu.Lock()
|
||||
cancel, ok := a.cancelFuncs[id]
|
||||
if ok {
|
||||
delete(a.cancelFuncs, id)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
cancel()
|
||||
} else {
|
||||
// It is attempted to keep track of cancel functions for each container,
|
||||
// which allow clean cancellation of container restart logic during
|
||||
// removal. But there are legitimate occasions where the cancel function
|
||||
// would not exist (e.g. during startup check) and in general the state of
|
||||
// the Docker engine is preferred to local state in this package.
|
||||
a.logger.Debug("removeContainer: cancelFunc not found", "id", shortID(id))
|
||||
}
|
||||
|
||||
a.logger.Info("Stopping container", "id", shortID(id))
|
||||
stopTimeout := int(stopTimeout.Seconds())
|
||||
if err := a.apiClient.ContainerStop(ctx, id, container.StopOptions{Timeout: &stopTimeout}); err != nil {
|
||||
|
@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
func TestClientRunContainer(t *testing.T) {
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
|
||||
// channels returned by Docker's ContainerWait:
|
||||
containerWaitC := make(chan dockercontainer.WaitResponse)
|
||||
@ -44,7 +44,7 @@ func TestClientRunContainer(t *testing.T) {
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ImagePull(mock.Anything, "alpine", image.PullOptions{}).
|
||||
Return(io.NopCloser(bytes.NewReader(nil)), nil)
|
||||
Return(nil, errors.New("error pulling image should not be fatal"))
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerCreate(mock.Anything, mock.Anything, mock.Anything, mock.Anything, (*ocispec.Platform)(nil), mock.Anything).
|
||||
@ -69,14 +69,14 @@ func TestClientRunContainer(t *testing.T) {
|
||||
EXPECT().
|
||||
ContainerWait(mock.Anything, "123", dockercontainer.WaitConditionNextExit).
|
||||
Return(containerWaitC, containerErrC)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerInspect(mock.Anything, "123").
|
||||
Return(dockercontainer.InspectResponse{ContainerJSONBase: &dockercontainer.ContainerJSONBase{State: &dockercontainer.State{Status: "exited"}}}, nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
Events(mock.Anything, events.ListOptions{Filters: filters.NewArgs(filters.Arg("container", "123"), filters.Arg("type", "container"))}).
|
||||
Return(eventsC, eventsErrC)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerLogs(mock.Anything, "123", mock.Anything).
|
||||
Return(io.NopCloser(bytes.NewReader(nil)), nil)
|
||||
|
||||
containerClient, err := container.NewClient(t.Context(), &dockerClient, logger)
|
||||
require.NoError(t, err)
|
||||
@ -86,7 +86,8 @@ func TestClientRunContainer(t *testing.T) {
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &dockercontainer.Config{Image: "alpine"},
|
||||
HostConfig: &dockercontainer.HostConfig{},
|
||||
CopyFileConfigs: []container.CopyFileConfig{
|
||||
Logs: container.LogConfig{Stdout: true},
|
||||
CopyFiles: []container.CopyFileConfig{
|
||||
{
|
||||
Path: "/hello",
|
||||
Payload: bytes.NewReader([]byte("world")),
|
||||
@ -122,13 +123,131 @@ func TestClientRunContainer(t *testing.T) {
|
||||
assert.Equal(t, "unhealthy", state.HealthState)
|
||||
require.NotNil(t, state.ExitCode)
|
||||
assert.Equal(t, 1, *state.ExitCode)
|
||||
assert.Equal(t, 0, state.RestartCount)
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestClientRunContainerWithRestart(t *testing.T) {
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
|
||||
// channels returned by Docker's ContainerWait:
|
||||
containerWaitC := make(chan dockercontainer.WaitResponse)
|
||||
containerErrC := make(chan error)
|
||||
|
||||
// channels returned by Docker's Events:
|
||||
eventsC := make(chan events.Message)
|
||||
eventsErrC := make(chan error)
|
||||
|
||||
var dockerClient mocks.DockerClient
|
||||
defer dockerClient.AssertExpectations(t)
|
||||
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
NetworkCreate(mock.Anything, mock.Anything, mock.MatchedBy(func(opts network.CreateOptions) bool {
|
||||
return opts.Driver == "bridge" && len(opts.Labels) > 0
|
||||
})).
|
||||
Return(network.CreateResponse{ID: "test-network"}, nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ImagePull(mock.Anything, "alpine", image.PullOptions{}).
|
||||
Return(io.NopCloser(bytes.NewReader(nil)), nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerCreate(mock.Anything, mock.Anything, mock.Anything, mock.Anything, (*ocispec.Platform)(nil), mock.Anything).
|
||||
Return(dockercontainer.CreateResponse{ID: "123"}, nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
NetworkConnect(mock.Anything, "test-network", "123", (*network.EndpointSettings)(nil)).
|
||||
Return(nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerStart(mock.Anything, "123", dockercontainer.StartOptions{}).
|
||||
Once().
|
||||
Return(nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerStats(mock.Anything, "123", true).
|
||||
Return(dockercontainer.StatsResponseReader{Body: io.NopCloser(bytes.NewReader(nil))}, nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerWait(mock.Anything, "123", dockercontainer.WaitConditionNextExit).
|
||||
Return(containerWaitC, containerErrC)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
Events(mock.Anything, events.ListOptions{Filters: filters.NewArgs(filters.Arg("container", "123"), filters.Arg("type", "container"))}).
|
||||
Return(eventsC, eventsErrC)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerStart(mock.Anything, "123", dockercontainer.StartOptions{}). // restart
|
||||
Return(nil)
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerLogs(mock.Anything, "123", mock.Anything).
|
||||
Return(io.NopCloser(bytes.NewReader(nil)), nil)
|
||||
|
||||
containerClient, err := container.NewClient(t.Context(), &dockerClient, logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
containerStateC, errC := containerClient.RunContainer(t.Context(), container.RunContainerParams{
|
||||
Name: "test-run-container",
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &dockercontainer.Config{Image: "alpine"},
|
||||
HostConfig: &dockercontainer.HostConfig{},
|
||||
Logs: container.LogConfig{Stdout: true},
|
||||
ShouldRestart: func(_ int64, restartCount int, _ [][]byte, _ time.Duration) (bool, error) {
|
||||
if restartCount == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, errors.New("max restarts reached")
|
||||
},
|
||||
RestartInterval: 10 * time.Millisecond,
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
require.NoError(t, <-errC)
|
||||
}()
|
||||
|
||||
assert.Equal(t, "pulling", (<-containerStateC).Status)
|
||||
assert.Equal(t, "created", (<-containerStateC).Status)
|
||||
assert.Equal(t, "running", (<-containerStateC).Status)
|
||||
assert.Equal(t, "running", (<-containerStateC).Status)
|
||||
|
||||
// Enough time for the restart to occur:
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
containerWaitC <- dockercontainer.WaitResponse{StatusCode: 1}
|
||||
|
||||
state := <-containerStateC
|
||||
assert.Equal(t, "restarting", state.Status)
|
||||
assert.Equal(t, "unhealthy", state.HealthState)
|
||||
assert.Nil(t, state.ExitCode)
|
||||
assert.Zero(t, state.RestartCount) // not incremented until the actual restart
|
||||
|
||||
// During the restart, the "running" status is triggered by Docker events
|
||||
// only. So we don't expect one in unit tests. (Probably the initial startup
|
||||
// flow should behave the same.)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
containerWaitC <- dockercontainer.WaitResponse{StatusCode: 1}
|
||||
|
||||
state = <-containerStateC
|
||||
assert.Equal(t, "exited", state.Status)
|
||||
assert.Equal(t, "unhealthy", state.HealthState)
|
||||
require.NotNil(t, state.ExitCode)
|
||||
assert.Equal(t, 1, *state.ExitCode)
|
||||
assert.Equal(t, 1, state.RestartCount)
|
||||
assert.Equal(t, "max restarts reached", state.Err.Error())
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestClientRunContainerErrorStartingContainer(t *testing.T) {
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
|
||||
var dockerClient mocks.DockerClient
|
||||
defer dockerClient.AssertExpectations(t)
|
||||
@ -174,7 +293,7 @@ func TestClientRunContainerErrorStartingContainer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestClientClose(t *testing.T) {
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
|
||||
var dockerClient mocks.DockerClient
|
||||
defer dockerClient.AssertExpectations(t)
|
||||
@ -213,7 +332,7 @@ func TestClientClose(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRemoveUnusedNetworks(t *testing.T) {
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
|
||||
var dockerClient mocks.DockerClient
|
||||
defer dockerClient.AssertExpectations(t)
|
||||
|
@ -22,8 +22,8 @@ func TestIntegrationClientStartStop(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
logger := testhelpers.NewTestLogger()
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv)
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
require.NoError(t, err)
|
||||
containerName := "octoplex-test-" + shortid.New().String()
|
||||
component := "test-start-stop"
|
||||
@ -39,7 +39,7 @@ func TestIntegrationClientStartStop(t *testing.T) {
|
||||
Name: containerName,
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: "netfluxio/mediamtx-alpine:latest",
|
||||
Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
|
||||
Labels: map[string]string{container.LabelComponent: component},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{
|
||||
@ -72,8 +72,8 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
logger := testhelpers.NewTestLogger()
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv)
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
require.NoError(t, err)
|
||||
component := "test-remove-containers"
|
||||
|
||||
@ -84,7 +84,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
|
||||
stateC, err1C := client.RunContainer(ctx, container.RunContainerParams{
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: "netfluxio/mediamtx-alpine:latest",
|
||||
Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
|
||||
Labels: map[string]string{container.LabelComponent: component, "group": "test1"},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
|
||||
@ -95,7 +95,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
|
||||
stateC, err2C := client.RunContainer(ctx, container.RunContainerParams{
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: "netfluxio/mediamtx-alpine:latest",
|
||||
Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
|
||||
Labels: map[string]string{container.LabelComponent: component, "group": "test1"},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
|
||||
@ -106,7 +106,7 @@ func TestIntegrationClientRemoveContainers(t *testing.T) {
|
||||
stateC, err3C := client.RunContainer(ctx, container.RunContainerParams{
|
||||
ChanSize: 1,
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: "netfluxio/mediamtx-alpine:latest",
|
||||
Image: "ghcr.io/rfwatson/mediamtx-alpine:latest",
|
||||
Labels: map[string]string{container.LabelComponent: component, "group": "test2"},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
|
||||
@ -171,8 +171,8 @@ func TestContainerRestart(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
logger := testhelpers.NewTestLogger()
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv)
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
require.NoError(t, err)
|
||||
containerName := "octoplex-test-" + shortid.New().String()
|
||||
component := "test-restart"
|
||||
|
53
internal/container/logs.go
Normal file
53
internal/container/logs.go
Normal file
@ -0,0 +1,53 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
typescontainer "github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
func getLogs(
|
||||
ctx context.Context,
|
||||
containerID string,
|
||||
apiClient DockerClient,
|
||||
cfg LogConfig,
|
||||
ch chan<- []byte,
|
||||
logger *slog.Logger,
|
||||
) {
|
||||
logsC, err := apiClient.ContainerLogs(
|
||||
ctx,
|
||||
containerID,
|
||||
typescontainer.LogsOptions{
|
||||
ShowStdout: cfg.Stdout,
|
||||
ShowStderr: cfg.Stderr,
|
||||
Follow: true,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error("Error getting container logs", "err", err, "id", shortID(containerID))
|
||||
return
|
||||
}
|
||||
defer logsC.Close()
|
||||
|
||||
scanner := bufio.NewScanner(logsC)
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
// Docker logs are prefixed with an 8 byte prefix.
|
||||
// See client.ContainerLogs for more details.
|
||||
// We could use
|
||||
// [StdCopy](https://pkg.go.dev/github.com/docker/docker/pkg/stdcopy#StdCopy)
|
||||
// but for our purposes it's enough to just slice it off.
|
||||
const prefixLen = 8
|
||||
line := scanner.Bytes()
|
||||
if len(line) <= prefixLen {
|
||||
continue
|
||||
}
|
||||
ch <- line[prefixLen:]
|
||||
}
|
||||
}
|
||||
}
|
45
internal/container/logs_test.go
Normal file
45
internal/container/logs_test.go
Normal file
@ -0,0 +1,45 @@
|
||||
package container
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/container/mocks"
|
||||
"git.netflux.io/rob/octoplex/internal/testhelpers"
|
||||
typescontainer "github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestGetLogs(t *testing.T) {
|
||||
var dockerClient mocks.DockerClient
|
||||
dockerClient.
|
||||
EXPECT().
|
||||
ContainerLogs(mock.Anything, "123", typescontainer.LogsOptions{ShowStderr: true, Follow: true}).
|
||||
Return(io.NopCloser(strings.NewReader("********line 1\n********line 2\n********line 3\n")), nil)
|
||||
|
||||
ch := make(chan []byte)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
getLogs(
|
||||
t.Context(),
|
||||
"123",
|
||||
&dockerClient,
|
||||
LogConfig{Stderr: true},
|
||||
ch,
|
||||
testhelpers.NewTestLogger(t),
|
||||
)
|
||||
}()
|
||||
|
||||
// Ensure we get the expected lines, including stripping 8 bytes of Docker
|
||||
// multiplexing prefix.
|
||||
assert.Equal(t, "line 1", string(<-ch))
|
||||
assert.Equal(t, "line 2", string(<-ch))
|
||||
assert.Equal(t, "line 3", string(<-ch))
|
||||
|
||||
_, ok := <-ch
|
||||
assert.False(t, ok)
|
||||
}
|
@ -138,63 +138,6 @@ func (_c *DockerClient_ContainerCreate_Call) RunAndReturn(run func(context.Conte
|
||||
return _c
|
||||
}
|
||||
|
||||
// ContainerInspect provides a mock function with given fields: _a0, _a1
|
||||
func (_m *DockerClient) ContainerInspect(_a0 context.Context, _a1 string) (typescontainer.InspectResponse, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ContainerInspect")
|
||||
}
|
||||
|
||||
var r0 typescontainer.InspectResponse
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) (typescontainer.InspectResponse, error)); ok {
|
||||
return rf(_a0, _a1)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) typescontainer.InspectResponse); ok {
|
||||
r0 = rf(_a0, _a1)
|
||||
} else {
|
||||
r0 = ret.Get(0).(typescontainer.InspectResponse)
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
|
||||
r1 = rf(_a0, _a1)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DockerClient_ContainerInspect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContainerInspect'
|
||||
type DockerClient_ContainerInspect_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ContainerInspect is a helper method to define mock.On call
|
||||
// - _a0 context.Context
|
||||
// - _a1 string
|
||||
func (_e *DockerClient_Expecter) ContainerInspect(_a0 interface{}, _a1 interface{}) *DockerClient_ContainerInspect_Call {
|
||||
return &DockerClient_ContainerInspect_Call{Call: _e.mock.On("ContainerInspect", _a0, _a1)}
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerInspect_Call) Run(run func(_a0 context.Context, _a1 string)) *DockerClient_ContainerInspect_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerInspect_Call) Return(_a0 typescontainer.InspectResponse, _a1 error) *DockerClient_ContainerInspect_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerInspect_Call) RunAndReturn(run func(context.Context, string) (typescontainer.InspectResponse, error)) *DockerClient_ContainerInspect_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ContainerList provides a mock function with given fields: _a0, _a1
|
||||
func (_m *DockerClient) ContainerList(_a0 context.Context, _a1 typescontainer.ListOptions) ([]typescontainer.Summary, error) {
|
||||
ret := _m.Called(_a0, _a1)
|
||||
@ -254,6 +197,66 @@ func (_c *DockerClient_ContainerList_Call) RunAndReturn(run func(context.Context
|
||||
return _c
|
||||
}
|
||||
|
||||
// ContainerLogs provides a mock function with given fields: _a0, _a1, _a2
|
||||
func (_m *DockerClient) ContainerLogs(_a0 context.Context, _a1 string, _a2 typescontainer.LogsOptions) (io.ReadCloser, error) {
|
||||
ret := _m.Called(_a0, _a1, _a2)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for ContainerLogs")
|
||||
}
|
||||
|
||||
var r0 io.ReadCloser
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, typescontainer.LogsOptions) (io.ReadCloser, error)); ok {
|
||||
return rf(_a0, _a1, _a2)
|
||||
}
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, typescontainer.LogsOptions) io.ReadCloser); ok {
|
||||
r0 = rf(_a0, _a1, _a2)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(io.ReadCloser)
|
||||
}
|
||||
}
|
||||
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, typescontainer.LogsOptions) error); ok {
|
||||
r1 = rf(_a0, _a1, _a2)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// DockerClient_ContainerLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ContainerLogs'
|
||||
type DockerClient_ContainerLogs_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ContainerLogs is a helper method to define mock.On call
|
||||
// - _a0 context.Context
|
||||
// - _a1 string
|
||||
// - _a2 typescontainer.LogsOptions
|
||||
func (_e *DockerClient_Expecter) ContainerLogs(_a0 interface{}, _a1 interface{}, _a2 interface{}) *DockerClient_ContainerLogs_Call {
|
||||
return &DockerClient_ContainerLogs_Call{Call: _e.mock.On("ContainerLogs", _a0, _a1, _a2)}
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerLogs_Call) Run(run func(_a0 context.Context, _a1 string, _a2 typescontainer.LogsOptions)) *DockerClient_ContainerLogs_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(typescontainer.LogsOptions))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerLogs_Call) Return(_a0 io.ReadCloser, _a1 error) *DockerClient_ContainerLogs_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *DockerClient_ContainerLogs_Call) RunAndReturn(run func(context.Context, string, typescontainer.LogsOptions) (io.ReadCloser, error)) *DockerClient_ContainerLogs_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ContainerRemove provides a mock function with given fields: _a0, _a1, _a2
|
||||
func (_m *DockerClient) ContainerRemove(_a0 context.Context, _a1 string, _a2 typescontainer.RemoveOptions) error {
|
||||
ret := _m.Called(_a0, _a1, _a2)
|
||||
|
@ -33,7 +33,7 @@ func TestHandleStats(t *testing.T) {
|
||||
Return(dockercontainer.StatsResponseReader{Body: pr}, nil)
|
||||
|
||||
networkCountConfig := NetworkCountConfig{Rx: "eth0", Tx: "eth1"}
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
ch := make(chan stats)
|
||||
|
||||
go func() {
|
||||
@ -79,7 +79,7 @@ func TestHandleStatsWithContainerRestart(t *testing.T) {
|
||||
Return(dockercontainer.StatsResponseReader{Body: pr}, nil)
|
||||
|
||||
networkCountConfig := NetworkCountConfig{Rx: "eth1", Tx: "eth0"}
|
||||
logger := testhelpers.NewTestLogger()
|
||||
logger := testhelpers.NewTestLogger(t)
|
||||
ch := make(chan stats)
|
||||
|
||||
go func() {
|
||||
|
@ -1,4 +1,25 @@
|
||||
package terminal
|
||||
package domain
|
||||
|
||||
// CommandAddDestination adds a destination.
|
||||
type CommandAddDestination struct {
|
||||
DestinationName string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Name implements the Command interface.
|
||||
func (c CommandAddDestination) Name() string {
|
||||
return "add_destination"
|
||||
}
|
||||
|
||||
// CommandRemoveDestination removes a destination.
|
||||
type CommandRemoveDestination struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// Name implements the Command interface.
|
||||
func (c CommandRemoveDestination) Name() string {
|
||||
return "remove_destination"
|
||||
}
|
||||
|
||||
// CommandStartDestination starts a destination.
|
||||
type CommandStartDestination struct {
|
@ -31,14 +31,11 @@ type BuildInfo struct {
|
||||
|
||||
// Source represents the source, currently always the mediaserver.
|
||||
type Source struct {
|
||||
Container Container
|
||||
Live bool
|
||||
LiveChangedAt time.Time
|
||||
Listeners int
|
||||
Tracks []string
|
||||
RTMPURL string
|
||||
RTMPInternalURL string
|
||||
ExitReason string
|
||||
Container Container
|
||||
Live bool
|
||||
LiveChangedAt time.Time
|
||||
Tracks []string
|
||||
ExitReason string
|
||||
}
|
||||
|
||||
// DestinationStatus reflects the high-level status of a single destination.
|
||||
@ -58,6 +55,27 @@ type Destination struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// NetAddr holds a network address.
|
||||
type NetAddr struct {
|
||||
IP string
|
||||
Port int
|
||||
}
|
||||
|
||||
// IsZero returns true if the NetAddr is zero value.
|
||||
func (n NetAddr) IsZero() bool {
|
||||
return n.IP == "" && n.Port == 0
|
||||
}
|
||||
|
||||
// KeyPair holds a TLS key pair.
|
||||
type KeyPair struct {
|
||||
Cert, Key []byte
|
||||
}
|
||||
|
||||
// IsZero returns true if the KeyPair is zero value.
|
||||
func (k KeyPair) IsZero() bool {
|
||||
return k.Cert == nil && k.Key == nil
|
||||
}
|
||||
|
||||
// Container status strings.
|
||||
//
|
||||
// TODO: refactor to strictly reflect Docker status strings.
|
||||
|
@ -31,3 +31,21 @@ func TestAppStateClone(t *testing.T) {
|
||||
s.Destinations[0].Name = "Twitch"
|
||||
assert.Equal(t, "YouTube", s2.Destinations[0].Name)
|
||||
}
|
||||
|
||||
func TestNetAddr(t *testing.T) {
|
||||
var addr domain.NetAddr
|
||||
assert.True(t, addr.IsZero())
|
||||
|
||||
addr.IP = "127.0.0.1"
|
||||
addr.Port = 3000
|
||||
assert.False(t, addr.IsZero())
|
||||
}
|
||||
|
||||
func TestKeyPair(t *testing.T) {
|
||||
var keyPair domain.KeyPair
|
||||
assert.True(t, keyPair.IsZero())
|
||||
|
||||
keyPair.Cert = []byte("cert")
|
||||
keyPair.Key = []byte("key")
|
||||
assert.False(t, keyPair.IsZero())
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
typescontainer "github.com/docker/docker/api/types/container"
|
||||
@ -27,14 +27,22 @@ import (
|
||||
type StreamKey string
|
||||
|
||||
const (
|
||||
defaultFetchIngressStateInterval = 5 * time.Second // default interval to fetch the state of the media server
|
||||
defaultAPIPort = 9997 // default API host port for the media server
|
||||
defaultRTMPPort = 1935 // default RTMP host port for the media server
|
||||
defaultChanSize = 64 // default channel size for asynchronous non-error channels
|
||||
imageNameMediaMTX = "netfluxio/mediamtx-alpine:latest" // image name for mediamtx
|
||||
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey].
|
||||
componentName = "mediaserver" // component name, mostly used for Docker labels
|
||||
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests
|
||||
defaultUpdateStateInterval = 5 * time.Second // default interval to update the state of the media server
|
||||
defaultAPIPort = 9997 // default API host port for the media server
|
||||
defaultRTMPIP = "127.0.0.1" // default RTMP host IP, bound to localhost for security
|
||||
defaultRTMPPort = 1935 // default RTMP host port for the media server
|
||||
defaultRTMPSPort = 1936 // default RTMPS host port for the media server
|
||||
defaultHost = "localhost" // default mediaserver host name
|
||||
defaultChanSize = 64 // default channel size for asynchronous non-error channels
|
||||
imageNameMediaMTX = "ghcr.io/rfwatson/mediamtx-alpine:latest" // image name for mediamtx
|
||||
defaultStreamKey StreamKey = "live" // Default stream key. See [StreamKey].
|
||||
componentName = "mediaserver" // component name, mostly used for Docker labels
|
||||
httpClientTimeout = time.Second // timeout for outgoing HTTP client requests
|
||||
configPath = "/mediamtx.yml" // path to the media server config file
|
||||
tlsInternalCertPath = "/etc/tls-internal.crt" // path to the internal TLS cert
|
||||
tlsInternalKeyPath = "/etc/tls-internal.key" // path to the internal TLS key
|
||||
tlsCertPath = "/etc/tls.crt" // path to the custom TLS cert
|
||||
tlsKeyPath = "/etc/tls.key" // path to the custom TLS key
|
||||
)
|
||||
|
||||
// action is an action to be performed by the actor.
|
||||
@ -42,120 +50,193 @@ type action func()
|
||||
|
||||
// Actor is responsible for managing the media server.
|
||||
type Actor struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
actorC chan action
|
||||
stateC chan domain.Source
|
||||
containerClient *container.Client
|
||||
apiPort int
|
||||
rtmpPort int
|
||||
streamKey StreamKey
|
||||
fetchIngressStateInterval time.Duration
|
||||
pass string // password for the media server
|
||||
logger *slog.Logger
|
||||
httpClient *http.Client
|
||||
actorC chan action
|
||||
stateC chan domain.Source
|
||||
chanSize int
|
||||
containerClient *container.Client
|
||||
rtmpAddr domain.NetAddr
|
||||
rtmpsAddr domain.NetAddr
|
||||
apiPort int
|
||||
host string
|
||||
streamKey StreamKey
|
||||
updateStateInterval time.Duration
|
||||
pass string // password for the media server
|
||||
keyPairInternal domain.KeyPair // TLS key pair for the media server
|
||||
keyPairCustom domain.KeyPair // TLS key pair for the media server
|
||||
logger *slog.Logger
|
||||
apiClient *http.Client
|
||||
|
||||
// mutable state
|
||||
state *domain.Source
|
||||
}
|
||||
|
||||
// StartActorParams contains the parameters for starting a new media server
|
||||
// NewActorParams contains the parameters for building a new media server
|
||||
// actor.
|
||||
type StartActorParams struct {
|
||||
APIPort int // defaults to 9997
|
||||
RTMPPort int // defaults to 1935
|
||||
StreamKey StreamKey // defaults to "live"
|
||||
ChanSize int // defaults to 64
|
||||
FetchIngressStateInterval time.Duration // defaults to 5 seconds
|
||||
ContainerClient *container.Client
|
||||
Logger *slog.Logger
|
||||
type NewActorParams struct {
|
||||
RTMPAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1935
|
||||
RTMPSAddr OptionalNetAddr // defaults to disabled, or 127.0.0.1:1936
|
||||
APIPort int // defaults to 9997
|
||||
Host string // defaults to "localhost"
|
||||
TLSCertPath string // defaults to empty
|
||||
TLSKeyPath string // defaults to empty
|
||||
StreamKey StreamKey // defaults to "live"
|
||||
ChanSize int // defaults to 64
|
||||
UpdateStateInterval time.Duration // defaults to 5 seconds
|
||||
ContainerClient *container.Client
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// StartActor starts a new media server actor.
|
||||
// OptionalNetAddr is a wrapper around domain.NetAddr that indicates whether it
|
||||
// is enabled or not.
|
||||
type OptionalNetAddr struct {
|
||||
domain.NetAddr
|
||||
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// NewActor creates a new media server actor.
|
||||
//
|
||||
// Callers must consume the state channel exposed via [C].
|
||||
func StartActor(ctx context.Context, params StartActorParams) *Actor {
|
||||
func NewActor(ctx context.Context, params NewActorParams) (_ *Actor, err error) {
|
||||
dnsNames := []string{"localhost"}
|
||||
if params.Host != "" {
|
||||
dnsNames = append(dnsNames, params.Host)
|
||||
}
|
||||
|
||||
keyPairInternal, err := generateTLSCert(dnsNames...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate TLS cert: %w", err)
|
||||
}
|
||||
|
||||
var keyPairCustom domain.KeyPair
|
||||
if params.TLSCertPath != "" {
|
||||
keyPairCustom.Cert, err = os.ReadFile(params.TLSCertPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read TLS cert: %w", err)
|
||||
}
|
||||
keyPairCustom.Key, err = os.ReadFile(params.TLSKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read TLS key: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: custom cert for API?
|
||||
apiClient, err := buildAPIClient(keyPairInternal.Cert)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build API client: %w", err)
|
||||
}
|
||||
|
||||
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &Actor{
|
||||
rtmpAddr: toRTMPAddr(params.RTMPAddr, defaultRTMPPort),
|
||||
rtmpsAddr: toRTMPAddr(params.RTMPSAddr, defaultRTMPSPort),
|
||||
apiPort: cmp.Or(params.APIPort, defaultAPIPort),
|
||||
host: cmp.Or(params.Host, defaultHost),
|
||||
streamKey: cmp.Or(params.StreamKey, defaultStreamKey),
|
||||
updateStateInterval: cmp.Or(params.UpdateStateInterval, defaultUpdateStateInterval),
|
||||
keyPairInternal: keyPairInternal,
|
||||
keyPairCustom: keyPairCustom,
|
||||
pass: generatePassword(),
|
||||
actorC: make(chan action, chanSize),
|
||||
state: new(domain.Source),
|
||||
stateC: make(chan domain.Source, chanSize),
|
||||
chanSize: chanSize,
|
||||
containerClient: params.ContainerClient,
|
||||
logger: params.Logger,
|
||||
apiClient: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
actor := &Actor{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
apiPort: cmp.Or(params.APIPort, defaultAPIPort),
|
||||
rtmpPort: cmp.Or(params.RTMPPort, defaultRTMPPort),
|
||||
streamKey: cmp.Or(params.StreamKey, defaultStreamKey),
|
||||
fetchIngressStateInterval: cmp.Or(params.FetchIngressStateInterval, defaultFetchIngressStateInterval),
|
||||
pass: generatePassword(),
|
||||
actorC: make(chan action, chanSize),
|
||||
state: new(domain.Source),
|
||||
stateC: make(chan domain.Source, chanSize),
|
||||
containerClient: params.ContainerClient,
|
||||
logger: params.Logger,
|
||||
httpClient: &http.Client{Timeout: httpClientTimeout},
|
||||
func (a *Actor) Start(ctx context.Context) error {
|
||||
var portSpecs []string
|
||||
portSpecs = append(portSpecs, fmt.Sprintf("127.0.0.1:%d:9997", a.apiPort))
|
||||
if !a.rtmpAddr.IsZero() {
|
||||
portSpecs = append(portSpecs, fmt.Sprintf("%s:%d:%d", a.rtmpAddr.IP, a.rtmpAddr.Port, 1935))
|
||||
}
|
||||
if !a.rtmpsAddr.IsZero() {
|
||||
portSpecs = append(portSpecs, fmt.Sprintf("%s:%d:%d", a.rtmpsAddr.IP, a.rtmpsAddr.Port, 1936))
|
||||
}
|
||||
exposedPorts, portBindings, err := nat.ParsePortSpecs(portSpecs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse port specs: %w", err)
|
||||
}
|
||||
|
||||
apiPortSpec := nat.Port(strconv.Itoa(actor.apiPort) + ":9997")
|
||||
rtmpPortSpec := nat.Port(strconv.Itoa(actor.rtmpPort) + ":1935")
|
||||
exposedPorts, portBindings, _ := nat.ParsePortSpecs([]string{string(apiPortSpec), string(rtmpPortSpec)})
|
||||
cfg, err := a.buildServerConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("build server config: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := yaml.Marshal(
|
||||
Config{
|
||||
LogLevel: "debug",
|
||||
LogDestinations: []string{"stdout"},
|
||||
AuthMethod: "internal",
|
||||
AuthInternalUsers: []User{
|
||||
// TODO: TLS
|
||||
{
|
||||
User: "any",
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{
|
||||
{Action: "publish"},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: "api",
|
||||
Pass: actor.pass,
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{
|
||||
{Action: "read"},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: "api",
|
||||
Pass: actor.pass,
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{{Action: "api"}},
|
||||
},
|
||||
},
|
||||
API: true,
|
||||
Paths: map[string]Path{
|
||||
string(actor.streamKey): {
|
||||
Source: "publisher",
|
||||
},
|
||||
},
|
||||
copyFiles := []container.CopyFileConfig{
|
||||
{
|
||||
Path: configPath,
|
||||
Payload: bytes.NewReader(cfg),
|
||||
Mode: 0600,
|
||||
},
|
||||
{
|
||||
Path: tlsInternalCertPath,
|
||||
Payload: bytes.NewReader(a.keyPairInternal.Cert),
|
||||
Mode: 0600,
|
||||
},
|
||||
{
|
||||
Path: tlsInternalKeyPath,
|
||||
Payload: bytes.NewReader(a.keyPairInternal.Key),
|
||||
Mode: 0600,
|
||||
},
|
||||
{
|
||||
Path: "/etc/healthcheckopts.txt",
|
||||
Payload: bytes.NewReader([]byte(fmt.Sprintf("--user api:%s", a.pass))),
|
||||
Mode: 0600,
|
||||
},
|
||||
)
|
||||
if err != nil { // should never happen
|
||||
panic(fmt.Sprintf("failed to marshal config: %v", err))
|
||||
}
|
||||
|
||||
containerStateC, errC := params.ContainerClient.RunContainer(
|
||||
if !a.keyPairCustom.IsZero() {
|
||||
copyFiles = append(
|
||||
copyFiles,
|
||||
container.CopyFileConfig{
|
||||
Path: tlsCertPath,
|
||||
Payload: bytes.NewReader(a.keyPairCustom.Cert),
|
||||
Mode: 0600,
|
||||
},
|
||||
container.CopyFileConfig{
|
||||
Path: tlsKeyPath,
|
||||
Payload: bytes.NewReader(a.keyPairCustom.Key),
|
||||
Mode: 0600,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
args := []any{"host", a.host}
|
||||
if a.rtmpAddr.IsZero() {
|
||||
args = append(args, "rtmp.enabled", false)
|
||||
} else {
|
||||
args = append(args, "rtmp.enabled", true, "rtmp.bind_addr", a.rtmpAddr.IP, "rtmp.bind_port", a.rtmpAddr.Port)
|
||||
}
|
||||
if a.rtmpsAddr.IsZero() {
|
||||
args = append(args, "rtmps.enabled", false)
|
||||
} else {
|
||||
args = append(args, "rtmps.enabled", true, "rtmps.bind_addr", a.rtmpsAddr.IP, "rtmps.bind_port", a.rtmpsAddr.Port)
|
||||
}
|
||||
a.logger.Info("Starting media server", args...)
|
||||
|
||||
containerStateC, errC := a.containerClient.RunContainer(
|
||||
ctx,
|
||||
container.RunContainerParams{
|
||||
Name: componentName,
|
||||
ChanSize: chanSize,
|
||||
ChanSize: a.chanSize,
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: imageNameMediaMTX,
|
||||
Hostname: "mediaserver",
|
||||
Env: []string{
|
||||
"MTX_LOGLEVEL=info",
|
||||
"MTX_API=yes",
|
||||
},
|
||||
Labels: map[string]string{
|
||||
container.LabelComponent: componentName,
|
||||
},
|
||||
Labels: map[string]string{container.LabelComponent: componentName},
|
||||
Healthcheck: &typescontainer.HealthConfig{
|
||||
Test: []string{"CMD", "curl", "-f", actor.pathsURL()},
|
||||
Test: []string{
|
||||
"CMD",
|
||||
"curl",
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--cacert", "/etc/tls-internal.crt",
|
||||
"--config", "/etc/healthcheckopts.txt",
|
||||
a.healthCheckURL(),
|
||||
},
|
||||
Interval: time.Second * 10,
|
||||
StartPeriod: time.Second * 2,
|
||||
StartInterval: time.Second * 2,
|
||||
@ -168,22 +249,79 @@ func StartActor(ctx context.Context, params StartActorParams) *Actor {
|
||||
PortBindings: portBindings,
|
||||
},
|
||||
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth0", Tx: "eth1"},
|
||||
CopyFileConfigs: []container.CopyFileConfig{
|
||||
{
|
||||
Path: "/mediamtx.yml",
|
||||
Payload: bytes.NewReader(cfg),
|
||||
Mode: 0600,
|
||||
},
|
||||
},
|
||||
Logs: container.LogConfig{Stdout: true},
|
||||
CopyFiles: copyFiles,
|
||||
},
|
||||
)
|
||||
|
||||
actor.state.RTMPURL = actor.rtmpURL()
|
||||
actor.state.RTMPInternalURL = actor.rtmpInternalURL()
|
||||
go a.actorLoop(ctx, containerStateC, errC)
|
||||
|
||||
go actor.actorLoop(containerStateC, errC)
|
||||
return nil
|
||||
}
|
||||
|
||||
return actor
|
||||
func (a *Actor) buildServerConfig() ([]byte, error) {
|
||||
// NOTE: Regardless of the user configuration (which mostly affects exposed
|
||||
// ports and UI rendering) plain RTMP must be enabled at the container level,
|
||||
// for internal connections.
|
||||
var encryptionString string
|
||||
if a.rtmpsAddr.IsZero() {
|
||||
encryptionString = "no"
|
||||
} else {
|
||||
encryptionString = "optional"
|
||||
}
|
||||
|
||||
var certPath, keyPath string
|
||||
if a.keyPairCustom.IsZero() {
|
||||
certPath = tlsInternalCertPath
|
||||
keyPath = tlsInternalKeyPath
|
||||
} else {
|
||||
certPath = tlsCertPath
|
||||
keyPath = tlsKeyPath
|
||||
}
|
||||
|
||||
return yaml.Marshal(
|
||||
Config{
|
||||
LogLevel: "debug",
|
||||
LogDestinations: []string{"stdout"},
|
||||
AuthMethod: "internal",
|
||||
AuthInternalUsers: []User{
|
||||
{
|
||||
User: "any",
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{
|
||||
{Action: "publish"},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: "api",
|
||||
Pass: a.pass,
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{
|
||||
{Action: "read"},
|
||||
},
|
||||
},
|
||||
{
|
||||
User: "api",
|
||||
Pass: a.pass,
|
||||
IPs: []string{}, // any IP
|
||||
Permissions: []UserPermission{{Action: "api"}},
|
||||
},
|
||||
},
|
||||
RTMP: true,
|
||||
RTMPEncryption: encryptionString,
|
||||
RTMPAddress: ":1935",
|
||||
RTMPSAddress: ":1936",
|
||||
RTMPServerCert: certPath,
|
||||
RTMPServerKey: keyPath,
|
||||
API: true,
|
||||
APIEncryption: true,
|
||||
APIServerCert: tlsInternalCertPath,
|
||||
APIServerKey: tlsInternalKeyPath,
|
||||
Paths: map[string]Path{
|
||||
string(a.streamKey): {Source: "publisher"},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// C returns a channel that will receive the current state of the media server.
|
||||
@ -192,6 +330,8 @@ func (s *Actor) C() <-chan domain.Source {
|
||||
}
|
||||
|
||||
// State returns the current state of the media server.
|
||||
//
|
||||
// Blocks if the actor is not started yet.
|
||||
func (s *Actor) State() domain.Source {
|
||||
resultChan := make(chan domain.Source)
|
||||
s.actorC <- func() {
|
||||
@ -209,24 +349,14 @@ func (s *Actor) Close() error {
|
||||
return fmt.Errorf("remove containers: %w", err)
|
||||
}
|
||||
|
||||
s.cancel()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// actorLoop is the main loop of the media server actor. It exits when the
|
||||
// actor is closed, or the parent context is cancelled.
|
||||
func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan error) {
|
||||
fetchStateT := time.NewTicker(s.fetchIngressStateInterval)
|
||||
defer fetchStateT.Stop()
|
||||
|
||||
// fetchTracksT is used to signal that tracks should be fetched from the
|
||||
// media server, after the stream goes on-air. A short delay is needed due to
|
||||
// workaround a race condition in the media server.
|
||||
var fetchTracksT *time.Timer
|
||||
resetFetchTracksT := func(d time.Duration) { fetchTracksT = time.NewTimer(d) }
|
||||
resetFetchTracksT(time.Second)
|
||||
fetchTracksT.Stop()
|
||||
func (s *Actor) actorLoop(ctx context.Context, containerStateC <-chan domain.Container, errC <-chan error) {
|
||||
updateStateT := time.NewTicker(s.updateStateInterval)
|
||||
defer updateStateT.Stop()
|
||||
|
||||
sendState := func() { s.stateC <- *s.state }
|
||||
|
||||
@ -236,7 +366,7 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
|
||||
s.state.Container = containerState
|
||||
|
||||
if s.state.Container.Status == domain.ContainerStatusExited {
|
||||
fetchStateT.Stop()
|
||||
updateStateT.Stop()
|
||||
s.handleContainerExit(nil)
|
||||
}
|
||||
|
||||
@ -255,43 +385,21 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
|
||||
s.logger.Error("Error from container client", "err", err, "id", shortID(s.state.Container.ID))
|
||||
}
|
||||
|
||||
fetchStateT.Stop()
|
||||
updateStateT.Stop()
|
||||
s.handleContainerExit(err)
|
||||
|
||||
sendState()
|
||||
case <-fetchStateT.C:
|
||||
ingressState, err := fetchIngressState(s.rtmpConnsURL(), s.streamKey, s.httpClient)
|
||||
case <-updateStateT.C:
|
||||
path, err := fetchPath(s.pathURL(string(s.streamKey)), s.apiClient)
|
||||
if err != nil {
|
||||
s.logger.Error("Error fetching server state", "err", err)
|
||||
s.logger.Error("Error fetching path", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
var shouldSendState bool
|
||||
if ingressState.ready != s.state.Live {
|
||||
s.state.Live = ingressState.ready
|
||||
if path.Ready != s.state.Live {
|
||||
s.state.Live = path.Ready
|
||||
s.state.LiveChangedAt = time.Now()
|
||||
resetFetchTracksT(time.Second)
|
||||
shouldSendState = true
|
||||
}
|
||||
if ingressState.listeners != s.state.Listeners {
|
||||
s.state.Listeners = ingressState.listeners
|
||||
shouldSendState = true
|
||||
}
|
||||
if shouldSendState {
|
||||
sendState()
|
||||
}
|
||||
case <-fetchTracksT.C:
|
||||
if !s.state.Live {
|
||||
continue
|
||||
}
|
||||
|
||||
if tracks, err := fetchTracks(s.pathsURL(), s.streamKey, s.httpClient); err != nil {
|
||||
s.logger.Error("Error fetching tracks", "err", err)
|
||||
resetFetchTracksT(3 * time.Second)
|
||||
} else if len(tracks) == 0 {
|
||||
resetFetchTracksT(time.Second)
|
||||
} else {
|
||||
s.state.Tracks = tracks
|
||||
s.state.Tracks = path.Tracks
|
||||
sendState()
|
||||
}
|
||||
case action, ok := <-s.actorC:
|
||||
@ -299,7 +407,7 @@ func (s *Actor) actorLoop(containerStateC <-chan domain.Container, errC <-chan e
|
||||
continue
|
||||
}
|
||||
action()
|
||||
case <-s.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -319,27 +427,41 @@ func (s *Actor) handleContainerExit(err error) {
|
||||
s.state.Live = false
|
||||
}
|
||||
|
||||
// rtmpURL returns the RTMP URL for the media server, accessible from the host.
|
||||
func (s *Actor) rtmpURL() string {
|
||||
return fmt.Sprintf("rtmp://localhost:%d/%s", s.rtmpPort, s.streamKey)
|
||||
// RTMPURL returns the RTMP URL for the media server, accessible from the host.
|
||||
func (s *Actor) RTMPURL() string {
|
||||
if s.rtmpAddr.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("rtmp://%s:%d/%s", s.host, s.rtmpAddr.Port, s.streamKey)
|
||||
}
|
||||
|
||||
// rtmpInternalURL returns the RTMP URL for the media server, accessible from
|
||||
// RTMPSURL returns the RTMPS URL for the media server, accessible from the host.
|
||||
func (s *Actor) RTMPSURL() string {
|
||||
if s.rtmpsAddr.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("rtmps://%s:%d/%s", s.host, s.rtmpsAddr.Port, s.streamKey)
|
||||
}
|
||||
|
||||
// RTMPInternalURL returns the RTMP URL for the media server, accessible from
|
||||
// the app network.
|
||||
func (s *Actor) rtmpInternalURL() string {
|
||||
func (s *Actor) RTMPInternalURL() string {
|
||||
// Container port, not host port:
|
||||
return fmt.Sprintf("rtmp://mediaserver:1935/%s?user=api&pass=%s", s.streamKey, s.pass)
|
||||
}
|
||||
|
||||
// rtmpConnsURL returns the URL for fetching RTMP connections, accessible from
|
||||
// the host.
|
||||
func (s *Actor) rtmpConnsURL() string {
|
||||
return fmt.Sprintf("http://api:%s@localhost:%d/v3/rtmpconns/list", s.pass, s.apiPort)
|
||||
// pathURL returns the URL for fetching a path, accessible from the host.
|
||||
func (s *Actor) pathURL(path string) string {
|
||||
return fmt.Sprintf("https://api:%s@localhost:%d/v3/paths/get/%s", s.pass, s.apiPort, path)
|
||||
}
|
||||
|
||||
// pathsURL returns the URL for fetching paths, accessible from the host.
|
||||
func (s *Actor) pathsURL() string {
|
||||
return fmt.Sprintf("http://api:%s@localhost:%d/v3/paths/list", s.pass, s.apiPort)
|
||||
// healthCheckURL returns the URL for the health check, accessible from the
|
||||
// container. It is logged to Docker's events log so must not include
|
||||
// credentials.
|
||||
func (s *Actor) healthCheckURL() string {
|
||||
return fmt.Sprintf("https://localhost:%d/v3/paths/list", s.apiPort)
|
||||
}
|
||||
|
||||
// shortID returns the first 12 characters of the given container ID.
|
||||
@ -358,3 +480,17 @@ func generatePassword() string {
|
||||
_, _ = rand.Read(p)
|
||||
return fmt.Sprintf("%x", []byte(p))
|
||||
}
|
||||
|
||||
// toRTMPAddr builds a domain.NetAddr from an OptionalNetAddr, with default
|
||||
// values set to RTMP default bind config if needed. If the OptionalNetAddr is
|
||||
// not enabled, a zero value is returned.
|
||||
func toRTMPAddr(a OptionalNetAddr, defaultPort int) domain.NetAddr {
|
||||
if !a.Enabled {
|
||||
return domain.NetAddr{}
|
||||
}
|
||||
|
||||
return domain.NetAddr{
|
||||
IP: cmp.Or(a.IP, defaultRTMPIP),
|
||||
Port: cmp.Or(a.Port, defaultPort),
|
||||
}
|
||||
}
|
||||
|
@ -1,118 +1,79 @@
|
||||
package mediaserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type apiResponse[T any] struct {
|
||||
Items []T `json:"items"`
|
||||
func buildAPIClient(certPEM []byte) (*http.Client, error) {
|
||||
certPool := x509.NewCertPool()
|
||||
if !certPool.AppendCertsFromPEM(certPEM) {
|
||||
return nil, errors.New("failed to add certificate to pool")
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
|
||||
cert, err := x509.ParseCertificate(rawCerts[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
|
||||
if _, err := cert.Verify(x509.VerifyOptions{Roots: certPool}); err != nil {
|
||||
return fmt.Errorf("TLS verification: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type rtmpConnsResponse struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created"`
|
||||
State string `json:"state"`
|
||||
Path string `json:"path"`
|
||||
BytesReceived int64 `json:"bytesReceived"`
|
||||
BytesSent int64 `json:"bytesSent"`
|
||||
RemoteAddr string `json:"remoteAddr"`
|
||||
}
|
||||
const userAgent = "octoplex-client"
|
||||
|
||||
type ingressStreamState struct {
|
||||
ready bool
|
||||
listeners int
|
||||
}
|
||||
|
||||
// TODO: handle pagination
|
||||
func fetchIngressState(apiURL string, streamKey StreamKey, httpClient httpClient) (state ingressStreamState, _ error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
|
||||
httpResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return state, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return state, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var resp apiResponse[rtmpConnsResponse]
|
||||
if err = json.Unmarshal(respBody, &resp); err != nil {
|
||||
return state, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
for _, conn := range resp.Items {
|
||||
if conn.Path != string(streamKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch conn.State {
|
||||
case "publish":
|
||||
// mediamtx may report a stream as being in publish state via the API,
|
||||
// but still refuse to serve them due to being unpublished. This seems to
|
||||
// be a bug, this is a hacky workaround.
|
||||
state.ready = conn.BytesReceived > 20_000
|
||||
case "read":
|
||||
state.listeners++
|
||||
}
|
||||
}
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
type path struct {
|
||||
type apiPath struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"`
|
||||
Tracks []string `json:"tracks"`
|
||||
}
|
||||
|
||||
// TODO: handle pagination
|
||||
func fetchTracks(apiURL string, streamKey StreamKey, httpClient httpClient) ([]string, error) {
|
||||
func fetchPath(apiURL string, httpClient httpClient) (apiPath, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request: %w", err)
|
||||
return apiPath{}, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
|
||||
httpResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
return apiPath{}, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode)
|
||||
return apiPath{}, fmt.Errorf("unexpected status code: %d", httpResp.StatusCode)
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
return apiPath{}, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var resp apiResponse[path]
|
||||
if err = json.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal: %w", err)
|
||||
var path apiPath
|
||||
if err = json.Unmarshal(respBody, &path); err != nil {
|
||||
return apiPath{}, fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
var tracks []string
|
||||
for _, path := range resp.Items {
|
||||
if path.Name == string(streamKey) {
|
||||
tracks = path.Tracks
|
||||
}
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
return path, nil
|
||||
}
|
||||
|
@ -12,14 +12,14 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFetchIngressState(t *testing.T) {
|
||||
const url = "http://localhost:8989/v3/rtmpconns/list"
|
||||
func TestFetchPath(t *testing.T) {
|
||||
const url = "http://localhost:8989/v3/paths/get/live"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
httpResponse *http.Response
|
||||
httpError error
|
||||
wantState ingressStreamState
|
||||
wantPath apiPath
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
@ -36,36 +36,20 @@ func TestFetchIngressState(t *testing.T) {
|
||||
wantErr: errors.New("unmarshal: invalid character 'i' looking for beginning of value"),
|
||||
},
|
||||
{
|
||||
name: "successful response, no streams",
|
||||
name: "successful response, not ready",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":0,"pageCount":0,"items":[]}`))),
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"name":"live","confName":"live","source":null,"ready":false,"readyTime":null,"tracks":[],"bytesReceived":0,"bytesSent":0,"readers":[]}`))),
|
||||
},
|
||||
wantState: ingressStreamState{ready: false, listeners: 0},
|
||||
},
|
||||
{
|
||||
name: "successful response, not yet ready",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"id":"d2953cf8-9cd6-4c30-816f-807b80b6a71f","created":"2025-02-15T08:19:00.616220354Z","remoteAddr":"172.17.0.1:32972","state":"publish","path":"live","query":"","bytesReceived":15462,"bytesSent":3467}]}`))),
|
||||
},
|
||||
wantState: ingressStreamState{ready: false, listeners: 0},
|
||||
wantPath: apiPath{Name: "live", Ready: false, Tracks: []string{}},
|
||||
},
|
||||
{
|
||||
name: "successful response, ready",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"id":"d2953cf8-9cd6-4c30-816f-807b80b6a71f","created":"2025-02-15T08:19:00.616220354Z","remoteAddr":"172.17.0.1:32972","state":"publish","path":"live","query":"","bytesReceived":27832,"bytesSent":3467}]}`))),
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"name":"live","confName":"live","source":{"type":"rtmpConn","id":"fd2d79a8-bab9-4141-a1b5-55bd1a8649df"},"ready":true,"readyTime":"2025-04-18T07:44:53.683627506Z","tracks":["H264"],"bytesReceived":254677,"bytesSent":0,"readers":[]}`))),
|
||||
},
|
||||
wantState: ingressStreamState{ready: true, listeners: 0},
|
||||
},
|
||||
{
|
||||
name: "successful response, ready, with listeners",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":2,"pageCount":1,"items":[{"id":"12668315-0572-41f1-8384-fe7047cc73be","created":"2025-02-15T08:23:43.836589664Z","remoteAddr":"172.17.0.1:40026","state":"publish","path":"live","query":"","bytesReceived":7180753,"bytesSent":3467},{"id":"079370fd-43bb-4798-b079-860cc3159e4e","created":"2025-02-15T08:24:32.396794364Z","remoteAddr":"192.168.48.3:44736","state":"read","path":"live","query":"","bytesReceived":333435,"bytesSent":24243}]}`))),
|
||||
},
|
||||
wantState: ingressStreamState{ready: true, listeners: 1},
|
||||
wantPath: apiPath{Name: "live", Ready: true, Tracks: []string{"H264"}},
|
||||
},
|
||||
}
|
||||
|
||||
@ -79,74 +63,12 @@ func TestFetchIngressState(t *testing.T) {
|
||||
})).
|
||||
Return(tc.httpResponse, tc.httpError)
|
||||
|
||||
state, err := fetchIngressState(url, StreamKey("live"), &httpClient)
|
||||
path, err := fetchPath(url, &httpClient)
|
||||
if tc.wantErr != nil {
|
||||
require.EqualError(t, err, tc.wantErr.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantState, state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTracks(t *testing.T) {
|
||||
const url = "http://localhost:8989/v3/paths/list"
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
httpResponse *http.Response
|
||||
httpError error
|
||||
wantTracks []string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "non-200 status",
|
||||
httpResponse: &http.Response{StatusCode: http.StatusNotFound},
|
||||
wantErr: errors.New("unexpected status code: 404"),
|
||||
},
|
||||
{
|
||||
name: "unparseable response",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte("invalid json"))),
|
||||
},
|
||||
wantErr: errors.New("unmarshal: invalid character 'i' looking for beginning of value"),
|
||||
},
|
||||
{
|
||||
name: "successful response, no tracks",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"name":"live","confName":"all_others","source":{"type":"rtmpConn","id":"287340b2-04c2-4fcc-ab9c-089f4ff15aeb"},"ready":true,"readyTime":"2025-02-22T17:26:05.527206818Z","tracks":[],"bytesReceived":94430983,"bytesSent":0,"readers":[]}]}`))),
|
||||
},
|
||||
wantTracks: []string{},
|
||||
},
|
||||
{
|
||||
name: "successful response, tracks",
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"itemCount":1,"pageCount":1,"items":[{"name":"live","confName":"all_others","source":{"type":"rtmpConn","id":"287340b2-04c2-4fcc-ab9c-089f4ff15aeb"},"ready":true,"readyTime":"2025-02-22T17:26:05.527206818Z","tracks":["H264","MPEG-4 Audio"],"bytesReceived":94430983,"bytesSent":0,"readers":[]}]}`))),
|
||||
},
|
||||
wantTracks: []string{"H264", "MPEG-4 Audio"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var httpClient mocks.HTTPClient
|
||||
httpClient.
|
||||
EXPECT().
|
||||
Do(mock.MatchedBy(func(req *http.Request) bool {
|
||||
return req.URL.String() == url && req.Method == http.MethodGet
|
||||
})).
|
||||
Return(tc.httpResponse, tc.httpError)
|
||||
|
||||
tracks, err := fetchTracks(url, StreamKey("live"), &httpClient)
|
||||
if tc.wantErr != nil {
|
||||
require.EqualError(t, err, tc.wantErr.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantTracks, tracks)
|
||||
require.Equal(t, tc.wantPath, path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -14,8 +14,15 @@ type Config struct {
|
||||
MetricsAddress string `yaml:"metricsAddress,omitempty"`
|
||||
API bool `yaml:"api,omitempty"`
|
||||
APIAddr bool `yaml:"apiAddress,omitempty"`
|
||||
RTMP bool `yaml:"rtmp,omitempty"`
|
||||
APIEncryption bool `yaml:"apiEncryption,omitempty"`
|
||||
APIServerCert string `yaml:"apiServerCert,omitempty"`
|
||||
APIServerKey string `yaml:"apiServerKey,omitempty"`
|
||||
RTMP bool `yaml:"rtmp"`
|
||||
RTMPEncryption string `yaml:"rtmpEncryption,omitempty"`
|
||||
RTMPAddress string `yaml:"rtmpAddress,omitempty"`
|
||||
RTMPSAddress string `yaml:"rtmpsAddress,omitempty"`
|
||||
RTMPServerCert string `yaml:"rtmpServerCert,omitempty"`
|
||||
RTMPServerKey string `yaml:"rtmpServerKey,omitempty"`
|
||||
HLS bool `yaml:"hls"`
|
||||
RTSP bool `yaml:"rtsp"`
|
||||
WebRTC bool `yaml:"webrtc"`
|
||||
|
67
internal/mediaserver/tls.go
Normal file
67
internal/mediaserver/tls.go
Normal file
@ -0,0 +1,67 @@
|
||||
package mediaserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
)
|
||||
|
||||
// generateTLSCert generates a self-signed TLS certificate and private key.
|
||||
func generateTLSCert(dnsNames ...string) (domain.KeyPair, error) {
|
||||
privKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"octoplex.netflux.io"},
|
||||
},
|
||||
NotBefore: now,
|
||||
NotAfter: now.Add(5 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: dnsNames,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
|
||||
if err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
var certPEM, keyPEM bytes.Buffer
|
||||
|
||||
if err = pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
privKeyDER, err := x509.MarshalECPrivateKey(privKey)
|
||||
if err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
if err := pem.Encode(&keyPEM, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privKeyDER}); err != nil {
|
||||
return domain.KeyPair{}, err
|
||||
}
|
||||
|
||||
return domain.KeyPair{
|
||||
Cert: certPEM.Bytes(),
|
||||
Key: keyPEM.Bytes(),
|
||||
}, nil
|
||||
}
|
47
internal/mediaserver/tls_test.go
Normal file
47
internal/mediaserver/tls_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package mediaserver
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateTLSCert(t *testing.T) {
|
||||
keyPair, err := generateTLSCert("localhost", "rtmp.example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, keyPair.Cert)
|
||||
require.NotEmpty(t, keyPair.Key)
|
||||
|
||||
block, _ := pem.Decode(keyPair.Cert)
|
||||
require.NotNil(t, block, "failed to decode certificate PEM")
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "octoplex.netflux.io", cert.Subject.Organization[0])
|
||||
assert.Greater(t, cert.NotBefore, time.Now().Add(-time.Second), "not before should be in the future")
|
||||
assert.Greater(t, cert.NotAfter, time.Now().Add(4*365*24*time.Hour), "not after should be a long time in the future")
|
||||
|
||||
// BitLen does not count leading zeroes, so the length will not always be 128 bits:
|
||||
assert.GreaterOrEqual(t, cert.SerialNumber.BitLen(), 100, "serial number should be around 128 bits")
|
||||
|
||||
assert.True(t, cert.BasicConstraintsValid, "basic constraints should be valid")
|
||||
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
|
||||
assert.Contains(t, cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
|
||||
assert.Contains(t, cert.DNSNames, "localhost", "DNS names should include localhost")
|
||||
assert.Contains(t, cert.DNSNames, "rtmp.example.com", "DNS names should include rtmp.example.com")
|
||||
|
||||
block, _ = pem.Decode(keyPair.Key)
|
||||
require.NotNil(t, block, "failed to decode private key PEM")
|
||||
|
||||
privKey, err := x509.ParseECPrivateKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
assert.IsType(t, &ecdsa.PrivateKey{}, privKey, "expected ECDSA private key")
|
||||
|
||||
assert.True(t, privKey.PublicKey.Equal(cert.PublicKey), "private key should match the certificate's public key")
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
package multiplexer
|
||||
package replicator
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@ -19,19 +21,19 @@ type action func()
|
||||
|
||||
const (
|
||||
defaultChanSize = 64 // default channel size for asynchronous non-error channels
|
||||
componentName = "multiplexer" // component name, mostly used for Docker labels
|
||||
componentName = "replicator" // component name, mostly used for Docker labels
|
||||
imageNameFFMPEG = "ghcr.io/jrottenberg/ffmpeg:7.1-scratch" // image name for ffmpeg
|
||||
)
|
||||
|
||||
// State is the state of a single destination from the point of view of the
|
||||
// multiplexer.
|
||||
// replicator.
|
||||
type State struct {
|
||||
URL string
|
||||
Container domain.Container
|
||||
Status domain.DestinationStatus
|
||||
}
|
||||
|
||||
// Actor is responsible for managing the multiplexer.
|
||||
// Actor is responsible for managing the replicator.
|
||||
type Actor struct {
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
@ -43,22 +45,23 @@ type Actor struct {
|
||||
stateC chan State
|
||||
|
||||
// mutable state
|
||||
|
||||
currURLs map[string]struct{}
|
||||
nextIndex int
|
||||
}
|
||||
|
||||
// NewActorParams contains the parameters for starting a new multiplexer actor.
|
||||
type NewActorParams struct {
|
||||
// StartActorParams contains the parameters for starting a new replicator actor.
|
||||
type StartActorParams struct {
|
||||
SourceURL string
|
||||
ChanSize int
|
||||
ContainerClient *container.Client
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewActor starts a new multiplexer actor.
|
||||
// StartActor starts a new replicator actor.
|
||||
//
|
||||
// The channel exposed by [C] must be consumed by the caller.
|
||||
func NewActor(ctx context.Context, params NewActorParams) *Actor {
|
||||
func StartActor(ctx context.Context, params StartActorParams) *Actor {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
actor := &Actor{
|
||||
@ -94,6 +97,7 @@ func (a *Actor) StartDestination(url string) {
|
||||
ContainerConfig: &typescontainer.Config{
|
||||
Image: imageNameFFMPEG,
|
||||
Cmd: []string{
|
||||
"-loglevel", "level+error",
|
||||
"-i", a.sourceURL,
|
||||
"-c", "copy",
|
||||
"-f", "flv",
|
||||
@ -104,11 +108,20 @@ func (a *Actor) StartDestination(url string) {
|
||||
container.LabelURL: url,
|
||||
},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{
|
||||
NetworkMode: "default",
|
||||
RestartPolicy: typescontainer.RestartPolicy{Name: "always"},
|
||||
},
|
||||
HostConfig: &typescontainer.HostConfig{NetworkMode: "default"},
|
||||
NetworkCountConfig: container.NetworkCountConfig{Rx: "eth1", Tx: "eth0"},
|
||||
Logs: container.LogConfig{Stderr: true},
|
||||
ShouldRestart: func(_ int64, restartCount int, logs [][]byte, runningTime time.Duration) (bool, error) {
|
||||
// Try to infer if the container failed to start.
|
||||
//
|
||||
// For now, we just check if it was running for less than ten seconds.
|
||||
if restartCount == 0 && runningTime < 10*time.Second {
|
||||
return false, containerStartErrFromLogs(logs)
|
||||
}
|
||||
|
||||
// Otherwise, always restart, regardless of the exit code.
|
||||
return true, nil
|
||||
},
|
||||
})
|
||||
|
||||
a.wg.Add(1)
|
||||
@ -120,6 +133,32 @@ func (a *Actor) StartDestination(url string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Grab the first fatal log line, if it exists, or the first error log line,
|
||||
// from the FFmpeg output.
|
||||
func containerStartErrFromLogs(logs [][]byte) error {
|
||||
var fatalLog, errLog string
|
||||
|
||||
for _, logBytes := range logs {
|
||||
log := string(logBytes)
|
||||
if strings.HasPrefix(log, "[fatal]") {
|
||||
fatalLog = log
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fatalLog == "" {
|
||||
for _, logBytes := range logs {
|
||||
log := string(logBytes)
|
||||
if strings.HasPrefix(log, "[error]") {
|
||||
errLog = log
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New(cmp.Or(fatalLog, errLog, "container failed to start"))
|
||||
}
|
||||
|
||||
// StopDestination stops a destination stream.
|
||||
func (a *Actor) StopDestination(url string) {
|
||||
a.actorC <- func() {
|
||||
@ -175,7 +214,7 @@ func (a *Actor) destLoop(url string, containerStateC <-chan domain.Container, er
|
||||
}
|
||||
}
|
||||
|
||||
// C returns a channel that will receive the current state of the multiplexer.
|
||||
// C returns a channel that will receive the current state of the replicator.
|
||||
// The channel is never closed.
|
||||
func (a *Actor) C() <-chan State {
|
||||
return a.stateC
|
51
internal/replicator/replicator_test.go
Normal file
51
internal/replicator/replicator_test.go
Normal file
@ -0,0 +1,51 @@
|
||||
package replicator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContainerStartErrFromLogs(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
logs [][]byte
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "no logs",
|
||||
want: errors.New("container failed to start"),
|
||||
},
|
||||
{
|
||||
name: "with only error logs",
|
||||
logs: [][]byte{
|
||||
[]byte("[error] this is an error log"),
|
||||
[]byte("[error] this is another error log"),
|
||||
},
|
||||
want: errors.New("[error] this is an error log"),
|
||||
},
|
||||
{
|
||||
name: "with only fatal logs",
|
||||
logs: [][]byte{
|
||||
[]byte("[fatal] this is a fatal log"),
|
||||
[]byte("[fatal] this is another fatal log"),
|
||||
},
|
||||
want: errors.New("[fatal] this is a fatal log"),
|
||||
},
|
||||
{
|
||||
name: "with error and fatal logs",
|
||||
logs: [][]byte{
|
||||
[]byte("[error] this is an error log"),
|
||||
[]byte("[fatal] this is a fatal log"),
|
||||
},
|
||||
want: errors.New("[fatal] this is a fatal log"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, containerStartErrFromLogs(tc.logs))
|
||||
})
|
||||
}
|
||||
}
|
@ -13,13 +13,13 @@ import (
|
||||
"time"
|
||||
|
||||
"git.netflux.io/rob/octoplex/internal/domain"
|
||||
"git.netflux.io/rob/octoplex/internal/shortid"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
type sourceViews struct {
|
||||
url *tview.TextView
|
||||
status *tview.TextView
|
||||
tracks *tview.TextView
|
||||
health *tview.TextView
|
||||
@ -40,9 +40,12 @@ const (
|
||||
|
||||
// UI is responsible for managing the terminal user interface.
|
||||
type UI struct {
|
||||
commandCh chan Command
|
||||
buildInfo domain.BuildInfo
|
||||
logger *slog.Logger
|
||||
commandC chan domain.Command
|
||||
clipboardAvailable bool
|
||||
configFilePath string
|
||||
rtmpURL, rtmpsURL string
|
||||
buildInfo domain.BuildInfo
|
||||
logger *slog.Logger
|
||||
|
||||
// tview state
|
||||
|
||||
@ -50,15 +53,26 @@ type UI struct {
|
||||
screen tcell.Screen
|
||||
screenCaptureC chan<- ScreenCapture
|
||||
pages *tview.Pages
|
||||
container *tview.Flex
|
||||
sourceViews sourceViews
|
||||
destView *tview.Table
|
||||
noDestView *tview.TextView
|
||||
aboutView *tview.Flex
|
||||
pullProgressModal *tview.Modal
|
||||
|
||||
// other mutable state
|
||||
|
||||
mu sync.Mutex
|
||||
urlsToStartState map[string]startState
|
||||
allowQuit bool
|
||||
|
||||
/// addingDestination is true if add destination modal is currently visible.
|
||||
addingDestination bool
|
||||
// hasDestinations is true if the UI thinks there are destinations
|
||||
// configured.
|
||||
hasDestinations bool
|
||||
// lastSelectedDestIndex is the index of the last selected destination, starting
|
||||
// at 1 (because 0 is the header).
|
||||
lastSelectedDestIndex int
|
||||
}
|
||||
|
||||
// Screen represents a terminal screen. This includes its desired dimensions,
|
||||
@ -92,7 +106,7 @@ const defaultChanSize = 64
|
||||
// StartUI starts the terminal user interface.
|
||||
func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
|
||||
commandCh := make(chan Command, chanSize)
|
||||
commandCh := make(chan domain.Command, chanSize)
|
||||
|
||||
app := tview.NewApplication()
|
||||
|
||||
@ -114,8 +128,8 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
sourceView := tview.NewFlex()
|
||||
sourceView.SetDirection(tview.FlexColumn)
|
||||
sourceView.SetBorder(true)
|
||||
sourceView.SetTitle("Ingress RTMP server")
|
||||
sidebar.AddItem(sourceView, 9, 0, false)
|
||||
sourceView.SetTitle("Source")
|
||||
sidebar.AddItem(sourceView, 8, 0, false)
|
||||
|
||||
leftCol := tview.NewFlex()
|
||||
leftCol.SetDirection(tview.FlexRow)
|
||||
@ -124,11 +138,6 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
sourceView.AddItem(leftCol, 9, 0, false)
|
||||
sourceView.AddItem(rightCol, 0, 1, false)
|
||||
|
||||
urlHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerURL)
|
||||
leftCol.AddItem(urlHeaderTextView, 1, 0, false)
|
||||
urlTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
||||
rightCol.AddItem(urlTextView, 1, 0, false)
|
||||
|
||||
statusHeaderTextView := tview.NewTextView().SetDynamicColors(true).SetText("[grey]" + headerStatus)
|
||||
leftCol.AddItem(statusHeaderTextView, 1, 0, false)
|
||||
statusTextView := tview.NewTextView().SetDynamicColors(true).SetText("[white]" + dash)
|
||||
@ -163,15 +172,11 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
aboutView.SetDirection(tview.FlexRow)
|
||||
aboutView.SetBorder(true)
|
||||
aboutView.SetTitle("Actions")
|
||||
aboutView.AddItem(tview.NewTextView().SetText("[Space] Toggle destination"), 1, 0, false)
|
||||
aboutView.AddItem(tview.NewTextView().SetText("[u] Copy ingress RTMP URL"), 1, 0, false)
|
||||
aboutView.AddItem(tview.NewTextView().SetText("[c] Copy config file path"), 1, 0, false)
|
||||
aboutView.AddItem(tview.NewTextView().SetText("[?] About"), 1, 0, false)
|
||||
|
||||
sidebar.AddItem(aboutView, 0, 1, false)
|
||||
|
||||
destView := tview.NewTable()
|
||||
destView.SetTitle("Egress streams")
|
||||
destView.SetTitle("Destinations")
|
||||
destView.SetBorder(true)
|
||||
destView.SetSelectable(true, false)
|
||||
destView.SetWrapSelection(true, false)
|
||||
@ -183,28 +188,39 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
||||
|
||||
flex := tview.NewFlex().
|
||||
container := tview.NewFlex().
|
||||
SetDirection(tview.FlexColumn).
|
||||
AddItem(sidebar, 40, 0, false).
|
||||
AddItem(destView, 0, 6, false)
|
||||
|
||||
// noDestView is overlaid on top of the main view when there are no
|
||||
// destinations configured.
|
||||
noDestView := tview.NewTextView().
|
||||
SetText(`No destinations added yet. Press [a] to add a new destination.`).
|
||||
SetTextAlign(tview.AlignCenter).
|
||||
SetTextColor(tcell.ColorGrey)
|
||||
noDestView.SetBorder(false)
|
||||
|
||||
pages := tview.NewPages()
|
||||
pages.AddPage("main", flex, true, true)
|
||||
pages.AddPage(pageNameMain, container, true, true)
|
||||
pages.AddPage(pageNameNoDestinations, noDestView, false, false)
|
||||
|
||||
app.SetRoot(pages, true)
|
||||
app.SetFocus(destView)
|
||||
app.EnableMouse(false)
|
||||
|
||||
ui := &UI{
|
||||
commandCh: commandCh,
|
||||
buildInfo: params.BuildInfo,
|
||||
logger: params.Logger,
|
||||
app: app,
|
||||
screen: screen,
|
||||
screenCaptureC: screenCaptureC,
|
||||
pages: pages,
|
||||
commandC: commandCh,
|
||||
clipboardAvailable: params.ClipboardAvailable,
|
||||
configFilePath: params.ConfigFilePath,
|
||||
buildInfo: params.BuildInfo,
|
||||
logger: params.Logger,
|
||||
app: app,
|
||||
screen: screen,
|
||||
screenCaptureC: screenCaptureC,
|
||||
pages: pages,
|
||||
container: container,
|
||||
sourceViews: sourceViews{
|
||||
url: urlTextView,
|
||||
status: statusTextView,
|
||||
tracks: tracksTextView,
|
||||
health: healthTextView,
|
||||
@ -213,47 +229,51 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
|
||||
rx: rxTextView,
|
||||
},
|
||||
destView: destView,
|
||||
noDestView: noDestView,
|
||||
aboutView: aboutView,
|
||||
pullProgressModal: pullProgressModal,
|
||||
urlsToStartState: make(map[string]startState),
|
||||
}
|
||||
|
||||
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyRune:
|
||||
switch event.Rune() {
|
||||
case ' ':
|
||||
ui.toggleDestination()
|
||||
case 'u', 'U':
|
||||
ui.copySourceURLToClipboard(params.ClipboardAvailable)
|
||||
case 'c', 'C':
|
||||
ui.copyConfigFilePathToClipboard(params.ClipboardAvailable, params.ConfigFilePath)
|
||||
case '?':
|
||||
ui.showAbout()
|
||||
}
|
||||
case tcell.KeyCtrlC:
|
||||
ui.confirmQuit()
|
||||
return nil
|
||||
}
|
||||
|
||||
return event
|
||||
})
|
||||
|
||||
if ui.screenCaptureC != nil {
|
||||
app.SetAfterDrawFunc(ui.captureScreen)
|
||||
}
|
||||
app.SetInputCapture(ui.inputCaptureHandler)
|
||||
app.SetAfterDrawFunc(ui.afterDrawHandler)
|
||||
|
||||
go ui.run(ctx)
|
||||
|
||||
return ui, nil
|
||||
}
|
||||
|
||||
func (ui *UI) renderAboutView() {
|
||||
ui.aboutView.Clear()
|
||||
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]a[-] Add destination"), 1, 0, false)
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Del[-] Remove destination"), 1, 0, false)
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]Space[-] Start/stop destination"), 1, 0, false)
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText(""), 1, 0, false)
|
||||
|
||||
i := 1
|
||||
if ui.rtmpURL != "" {
|
||||
rtmpURLView := tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf("[grey]F%d[-] Copy source RTMP URL", i))
|
||||
ui.aboutView.AddItem(rtmpURLView, 1, 0, false)
|
||||
i++
|
||||
}
|
||||
|
||||
if ui.rtmpsURL != "" {
|
||||
rtmpsURLView := tview.NewTextView().SetDynamicColors(true).SetText(fmt.Sprintf("[grey]F%d[-] Copy source RTMPS URL", i))
|
||||
ui.aboutView.AddItem(rtmpsURLView, 1, 0, false)
|
||||
}
|
||||
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]c[-] Copy config file path"), 1, 0, false)
|
||||
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]?[-] About"), 1, 0, false)
|
||||
}
|
||||
|
||||
// C returns a channel that receives commands from the user interface.
|
||||
func (ui *UI) C() <-chan Command {
|
||||
return ui.commandCh
|
||||
func (ui *UI) C() <-chan domain.Command {
|
||||
return ui.commandC
|
||||
}
|
||||
|
||||
func (ui *UI) run(ctx context.Context) {
|
||||
defer close(ui.commandCh)
|
||||
defer close(ui.commandC)
|
||||
|
||||
uiDone := make(chan struct{})
|
||||
go func() {
|
||||
@ -276,6 +296,97 @@ func (ui *UI) run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetRTMPURLs sets the RTMP and RTMPS URLs for the user interface, which are
|
||||
// unavailable when the UI is first created.
|
||||
func (ui *UI) SetRTMPURLs(rtmpURL, rtmpsURL string) {
|
||||
ui.mu.Lock()
|
||||
ui.rtmpURL = rtmpURL
|
||||
ui.rtmpsURL = rtmpsURL
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.renderAboutView()
|
||||
}
|
||||
|
||||
func (ui *UI) inputCaptureHandler(event *tcell.EventKey) *tcell.EventKey {
|
||||
// Special case: handle CTRL-C even when a modal is visible.
|
||||
if event.Key() == tcell.KeyCtrlC {
|
||||
ui.confirmQuit()
|
||||
return nil
|
||||
}
|
||||
|
||||
if ui.modalVisible() {
|
||||
return event
|
||||
}
|
||||
|
||||
handleKeyUp := func() {
|
||||
row, _ := ui.destView.GetSelection()
|
||||
if row == 1 {
|
||||
ui.destView.Select(ui.destView.GetRowCount(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
switch event.Key() {
|
||||
case tcell.KeyRune:
|
||||
switch event.Rune() {
|
||||
case 'a', 'A':
|
||||
ui.addDestination()
|
||||
return nil
|
||||
case ' ':
|
||||
ui.toggleDestination()
|
||||
case 'c', 'C':
|
||||
ui.copyConfigFilePathToClipboard(ui.clipboardAvailable, ui.configFilePath)
|
||||
case '?':
|
||||
ui.showAbout()
|
||||
case 'k': // tview vim bindings
|
||||
handleKeyUp()
|
||||
}
|
||||
case tcell.KeyF1, tcell.KeyF2:
|
||||
ui.fkeyHandler(event.Key())
|
||||
case tcell.KeyDelete, tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
ui.removeDestination()
|
||||
return nil
|
||||
case tcell.KeyUp:
|
||||
handleKeyUp()
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
func (ui *UI) fkeyHandler(key tcell.Key) {
|
||||
var urls []string
|
||||
if ui.rtmpURL != "" {
|
||||
urls = append(urls, ui.rtmpURL)
|
||||
}
|
||||
if ui.rtmpsURL != "" {
|
||||
urls = append(urls, ui.rtmpsURL)
|
||||
}
|
||||
|
||||
switch key {
|
||||
case tcell.KeyF1:
|
||||
if len(urls) == 0 {
|
||||
return
|
||||
}
|
||||
ui.copySourceURLToClipboard(urls[0])
|
||||
case tcell.KeyF2:
|
||||
if len(urls) < 2 {
|
||||
return
|
||||
}
|
||||
ui.copySourceURLToClipboard(urls[1])
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UI) ShowSourceNotLiveModal() {
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.showModal(
|
||||
pageNameModalNotLive,
|
||||
"Waiting for stream.\n\nStart streaming to a source URL then try again.",
|
||||
[]string{"Ok"},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ShowStartupCheckModal shows a modal dialog to the user, asking if they want
|
||||
// to kill a running instance of Octoplex.
|
||||
//
|
||||
@ -287,12 +398,12 @@ func (ui *UI) ShowStartupCheckModal() bool {
|
||||
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.showModal(
|
||||
modalGroupStartupCheck,
|
||||
"Another instance of Octoplex may already be running. Pressing continue will close that instance. Continue?",
|
||||
pageNameModalStartupCheck,
|
||||
"Another instance of Octoplex may already be running.\n\nPressing continue will close that instance. Continue?",
|
||||
[]string{"Continue", "Exit"},
|
||||
false,
|
||||
func(buttonIndex int, _ string) {
|
||||
if buttonIndex == 0 {
|
||||
ui.app.SetFocus(ui.destView)
|
||||
done <- true
|
||||
} else {
|
||||
done <- false
|
||||
@ -305,37 +416,46 @@ func (ui *UI) ShowStartupCheckModal() bool {
|
||||
}
|
||||
|
||||
func (ui *UI) ShowDestinationErrorModal(name string, err error) {
|
||||
done := make(chan struct{})
|
||||
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.showModal(
|
||||
modalGroupStartupCheck,
|
||||
pageNameModalDestinationError,
|
||||
fmt.Sprintf(
|
||||
"Streaming to %s failed:\n\n%s",
|
||||
cmp.Or(name, "this destination"),
|
||||
err,
|
||||
),
|
||||
[]string{"Ok"},
|
||||
true,
|
||||
nil,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ShowFatalErrorModal displays the provided error. It sends a CommandQuit to the
|
||||
// command channel when the user selects the Quit button.
|
||||
func (ui *UI) ShowFatalErrorModal(errString string) {
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.showModal(
|
||||
pageNameModalFatalError,
|
||||
fmt.Sprintf(
|
||||
"An error occurred:\n\n%s",
|
||||
errString,
|
||||
),
|
||||
[]string{"Quit"},
|
||||
false,
|
||||
func(int, string) {
|
||||
done <- struct{}{}
|
||||
ui.commandC <- domain.CommandQuit{}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
// AllowQuit enables the quit action.
|
||||
func (ui *UI) AllowQuit() {
|
||||
ui.mu.Lock()
|
||||
defer ui.mu.Unlock()
|
||||
func (ui *UI) afterDrawHandler(screen tcell.Screen) {
|
||||
if ui.screenCaptureC == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// This is required to prevent the user from quitting during the startup
|
||||
// check modal, when the main event loop is not yet running, and avoid an
|
||||
// unexpected user experience. It might be nice to find a way to remove this
|
||||
// but it probably means refactoring the mediaserver actor to separate
|
||||
// starting the server from starting the event loop.
|
||||
ui.allowQuit = true
|
||||
ui.captureScreen(screen)
|
||||
}
|
||||
|
||||
// captureScreen captures the screen and sends it to the screenCaptureC
|
||||
@ -346,7 +466,8 @@ func (ui *UI) AllowQuit() {
|
||||
func (ui *UI) captureScreen(screen tcell.Screen) {
|
||||
simScreen, ok := screen.(tcell.SimulationScreen)
|
||||
if !ok {
|
||||
ui.logger.Error("simulation screen not available")
|
||||
ui.logger.Warn("captureScreen: simulation screen not available")
|
||||
return
|
||||
}
|
||||
|
||||
cells, w, h := simScreen.GetContents()
|
||||
@ -369,6 +490,8 @@ func (ui *UI) SetState(state domain.AppState) {
|
||||
for _, dest := range state.Destinations {
|
||||
ui.urlsToStartState[dest.URL] = containerStateToStartState(dest.Container.Status)
|
||||
}
|
||||
|
||||
ui.hasDestinations = len(state.Destinations) > 0
|
||||
ui.mu.Unlock()
|
||||
|
||||
// The state is mutable so can't be passed into QueueUpdateDraw, which
|
||||
@ -395,7 +518,9 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
|
||||
}
|
||||
|
||||
if len(pullingContainers) == 0 {
|
||||
ui.hideModal(modalGroupPullProgress)
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.hideModal(pageNameModalPullProgress)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@ -410,7 +535,7 @@ func (ui *UI) updatePullProgress(state domain.AppState) {
|
||||
|
||||
func (ui *UI) updateProgressModal(container domain.Container) {
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
modalName := "modal-" + string(modalGroupPullProgress)
|
||||
modalName := string(pageNameModalPullProgress)
|
||||
|
||||
var status string
|
||||
// Avoid showing the long Docker pull status in the modal content.
|
||||
@ -434,21 +559,95 @@ func (ui *UI) updateProgressModal(container domain.Container) {
|
||||
})
|
||||
}
|
||||
|
||||
// modalGroup represents a specific modal of which only one may be shown
|
||||
// simultaneously.
|
||||
type modalGroup string
|
||||
|
||||
// page names represent a specific page in the terminal user interface.
|
||||
//
|
||||
// Modals should generally have a unique name, which allows them to be stacked
|
||||
// on top of other modals.
|
||||
const (
|
||||
modalGroupAbout modalGroup = "about"
|
||||
modalGroupQuit modalGroup = "quit"
|
||||
modalGroupStartupCheck modalGroup = "startup-check"
|
||||
modalGroupClipboard modalGroup = "clipboard"
|
||||
modalGroupPullProgress modalGroup = "pull-progress"
|
||||
pageNameMain = "main"
|
||||
pageNameAddDestination = "add-destination"
|
||||
pageNameViewURLs = "view-urls"
|
||||
pageNameConfigUpdateFailed = "modal-config-update-failed"
|
||||
pageNameNoDestinations = "no-destinations"
|
||||
pageNameModalAbout = "modal-about"
|
||||
pageNameModalClipboard = "modal-clipboard"
|
||||
pageNameModalDestinationError = "modal-destination-error"
|
||||
pageNameModalFatalError = "modal-fatal-error"
|
||||
pageNameModalPullProgress = "modal-pull-progress"
|
||||
pageNameModalQuit = "modal-quit"
|
||||
pageNameModalRemoveDestination = "modal-remove-destination"
|
||||
pageNameModalSourceError = "modal-source-error"
|
||||
pageNameModalStartupCheck = "modal-startup-check"
|
||||
pageNameModalNotLive = "modal-not-live"
|
||||
)
|
||||
|
||||
func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFunc func(int, string)) {
|
||||
modalName := "modal-" + string(group)
|
||||
if ui.pages.HasPage(modalName) {
|
||||
// modalVisible returns true if any modal, including the add destination form,
|
||||
// is visible.
|
||||
func (ui *UI) modalVisible() bool {
|
||||
pageName, _ := ui.pages.GetFrontPage()
|
||||
return pageName != pageNameMain && pageName != pageNameNoDestinations
|
||||
}
|
||||
|
||||
// saveSelectedDestination saves the last selected destination index to local
|
||||
// mutable state.
|
||||
//
|
||||
// This is needed so that the user's selection can be restored
|
||||
// after redrawing the screen. It may be possible to remove this if we can
|
||||
// re-render the screen more selectively instead of calling [redrawFromState]
|
||||
// every time the state changes.
|
||||
func (ui *UI) saveSelectedDestination() {
|
||||
row, _ := ui.destView.GetSelection()
|
||||
ui.mu.Lock()
|
||||
ui.lastSelectedDestIndex = row
|
||||
ui.mu.Unlock()
|
||||
}
|
||||
|
||||
// selectPreviousDestination sets the focus to the last-selected destination.
|
||||
func (ui *UI) selectPreviousDestination() {
|
||||
if ui.modalVisible() {
|
||||
return
|
||||
}
|
||||
|
||||
var row int
|
||||
ui.mu.Lock()
|
||||
row = ui.lastSelectedDestIndex
|
||||
ui.mu.Unlock()
|
||||
|
||||
// If the last element has been removed, select the new last element.
|
||||
row = min(ui.destView.GetRowCount()-1, row)
|
||||
|
||||
ui.app.SetFocus(ui.destView)
|
||||
|
||||
if row == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ui.destView.Select(row, 0)
|
||||
}
|
||||
|
||||
// selectLastDestination sets the user selection to the last destination.
|
||||
func (ui *UI) selectLastDestination() {
|
||||
if ui.modalVisible() {
|
||||
return
|
||||
}
|
||||
|
||||
ui.app.SetFocus(ui.destView)
|
||||
|
||||
if rowCount := ui.destView.GetRowCount(); rowCount > 1 {
|
||||
ui.destView.Select(rowCount-1, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UI) showModal(
|
||||
pageName string,
|
||||
text string,
|
||||
buttons []string,
|
||||
allowMultiple bool,
|
||||
doneFunc func(int, string),
|
||||
) {
|
||||
if allowMultiple {
|
||||
pageName = pageName + "-" + shortid.New().String()
|
||||
} else if ui.pages.HasPage(pageName) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -458,52 +657,52 @@ func (ui *UI) showModal(group modalGroup, text string, buttons []string, doneFun
|
||||
SetBackgroundColor(tcell.ColorBlack).
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
ui.pages.RemovePage(modalName)
|
||||
ui.pages.RemovePage(pageName)
|
||||
|
||||
if ui.pages.GetPageCount() == 1 {
|
||||
ui.app.SetFocus(ui.destView)
|
||||
if !ui.modalVisible() {
|
||||
ui.app.SetInputCapture(ui.inputCaptureHandler)
|
||||
}
|
||||
|
||||
if doneFunc != nil {
|
||||
doneFunc(buttonIndex, buttonLabel)
|
||||
}
|
||||
|
||||
ui.selectPreviousDestination()
|
||||
}).
|
||||
SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
||||
|
||||
ui.pages.AddPage(modalName, modal, true, true)
|
||||
ui.saveSelectedDestination()
|
||||
|
||||
ui.pages.AddPage(pageName, modal, true, true)
|
||||
}
|
||||
|
||||
func (ui *UI) hideModal(group modalGroup) {
|
||||
modalName := "modal-" + string(group)
|
||||
if !ui.pages.HasPage(modalName) {
|
||||
func (ui *UI) hideModal(pageName string) {
|
||||
if !ui.pages.HasPage(pageName) {
|
||||
return
|
||||
}
|
||||
|
||||
ui.pages.RemovePage(modalName)
|
||||
ui.pages.RemovePage(pageName)
|
||||
ui.app.SetFocus(ui.destView)
|
||||
}
|
||||
|
||||
func (ui *UI) handleMediaServerClosed(exitReason string) {
|
||||
done := make(chan struct{})
|
||||
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
if ui.pages.HasPage(pageNameModalSourceError) {
|
||||
return
|
||||
}
|
||||
|
||||
modal := tview.NewModal()
|
||||
modal.SetText("Mediaserver error: " + exitReason).
|
||||
AddButtons([]string{"Quit"}).
|
||||
SetBackgroundColor(tcell.ColorBlack).
|
||||
SetTextColor(tcell.ColorWhite).
|
||||
SetDoneFunc(func(int, string) {
|
||||
// TODO: improve app cleanup
|
||||
done <- struct{}{}
|
||||
|
||||
ui.app.Stop()
|
||||
ui.commandC <- domain.CommandQuit{}
|
||||
})
|
||||
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
|
||||
|
||||
ui.pages.AddPage("modal", modal, true, true)
|
||||
ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
|
||||
})
|
||||
|
||||
<-done
|
||||
}
|
||||
|
||||
const dash = "—"
|
||||
@ -522,6 +721,22 @@ const (
|
||||
)
|
||||
|
||||
func (ui *UI) redrawFromState(state domain.AppState) {
|
||||
var addingDestination bool
|
||||
ui.mu.Lock()
|
||||
addingDestination = ui.addingDestination
|
||||
ui.mu.Unlock()
|
||||
|
||||
var showNoDestinationsPage bool
|
||||
if len(state.Destinations) == 0 && !addingDestination {
|
||||
showNoDestinationsPage = true
|
||||
}
|
||||
|
||||
if showNoDestinationsPage {
|
||||
x, y, w, _ := ui.destView.GetRect()
|
||||
ui.noDestView.SetRect(x+5, y+4, w-10, 3)
|
||||
ui.pages.ShowPage(pageNameNoDestinations)
|
||||
}
|
||||
|
||||
headerCell := func(content string, expansion int) *tview.TableCell {
|
||||
return tview.
|
||||
NewTableCell(content).
|
||||
@ -530,8 +745,6 @@ func (ui *UI) redrawFromState(state domain.AppState) {
|
||||
SetSelectable(false)
|
||||
}
|
||||
|
||||
ui.sourceViews.url.SetText(state.Source.RTMPURL)
|
||||
|
||||
tracks := dash
|
||||
if state.Source.Live && len(state.Source.Tracks) > 0 {
|
||||
tracks = strings.Join(state.Source.Tracks, ", ")
|
||||
@ -546,7 +759,7 @@ func (ui *UI) redrawFromState(state domain.AppState) {
|
||||
|
||||
ui.sourceViews.status.SetText("[black:green]receiving" + durStr)
|
||||
} else if state.Source.Container.Status == domain.ContainerStatusRunning && state.Source.Container.HealthState == "healthy" {
|
||||
ui.sourceViews.status.SetText("[black:yellow]ready")
|
||||
ui.sourceViews.status.SetText("[black:yellow]waiting for stream")
|
||||
} else {
|
||||
ui.sourceViews.status.SetText("[white:red]not ready")
|
||||
}
|
||||
@ -637,6 +850,138 @@ func (ui *UI) Close() {
|
||||
ui.app.Stop()
|
||||
}
|
||||
|
||||
func (ui *UI) ConfigUpdateFailed(err error) {
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.showModal(
|
||||
pageNameConfigUpdateFailed,
|
||||
"Configuration update failed:\n\n"+err.Error(),
|
||||
[]string{"Ok"},
|
||||
false,
|
||||
func(int, string) {
|
||||
pageName, frontPage := ui.pages.GetFrontPage()
|
||||
if pageName != pageNameAddDestination {
|
||||
ui.logger.Warn("Unexpected page when configuration form closed", "page", pageName)
|
||||
}
|
||||
ui.app.SetFocus(frontPage)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *UI) addDestination() {
|
||||
const (
|
||||
inputLen = 60
|
||||
inputLabelName = "Name"
|
||||
inputLabelURL = "RTMP URL"
|
||||
formInnerWidth = inputLen + 8 + 1 // inputLen + length of longest label + one space
|
||||
formInnerHeight = 7 // line count from first input field to last button
|
||||
formWidth = formInnerWidth + 4
|
||||
formHeight = formInnerHeight + 2
|
||||
)
|
||||
|
||||
var currWidth, currHeight int
|
||||
_, _, currWidth, currHeight = ui.container.GetRect()
|
||||
|
||||
form := tview.NewForm()
|
||||
form.
|
||||
AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
|
||||
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
|
||||
AddButton("Add", func() {
|
||||
ui.commandC <- domain.CommandAddDestination{
|
||||
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
|
||||
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
|
||||
}
|
||||
}).
|
||||
AddButton("Cancel", func() {
|
||||
ui.closeAddDestinationForm()
|
||||
ui.selectPreviousDestination()
|
||||
}).
|
||||
SetFieldBackgroundColor(tcell.ColorDarkSlateGrey).
|
||||
SetBorder(true).
|
||||
SetTitle("Add a new destination").
|
||||
SetTitleAlign(tview.AlignLeft).
|
||||
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEscape {
|
||||
ui.closeAddDestinationForm()
|
||||
ui.selectPreviousDestination()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}).
|
||||
SetRect((currWidth-formWidth)/2, (currHeight-formHeight)/2, formWidth, formHeight)
|
||||
|
||||
ui.mu.Lock()
|
||||
ui.addingDestination = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.saveSelectedDestination()
|
||||
|
||||
ui.pages.HidePage(pageNameNoDestinations)
|
||||
ui.pages.AddPage(pageNameAddDestination, form, false, true)
|
||||
}
|
||||
|
||||
func (ui *UI) removeDestination() {
|
||||
const urlCol = 1
|
||||
row, _ := ui.destView.GetSelection()
|
||||
url, ok := ui.destView.GetCell(row, urlCol).GetReference().(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var started bool
|
||||
ui.mu.Lock()
|
||||
started = ui.urlsToStartState[url] != startStateNotStarted
|
||||
ui.mu.Unlock()
|
||||
|
||||
text := "Are you sure you want to remove the destination?"
|
||||
if started {
|
||||
text += "\n\nThis will stop the current live stream for this destination."
|
||||
}
|
||||
|
||||
ui.showModal(
|
||||
pageNameModalRemoveDestination,
|
||||
text,
|
||||
[]string{"Remove", "Cancel"},
|
||||
false,
|
||||
func(buttonIndex int, _ string) {
|
||||
if buttonIndex == 0 {
|
||||
ui.commandC <- domain.CommandRemoveDestination{URL: url}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// DestinationAdded should be called when a new destination is added.
|
||||
func (ui *UI) DestinationAdded() {
|
||||
ui.mu.Lock()
|
||||
ui.hasDestinations = true
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.app.QueueUpdateDraw(func() {
|
||||
ui.pages.HidePage(pageNameNoDestinations)
|
||||
ui.closeAddDestinationForm()
|
||||
ui.selectLastDestination()
|
||||
})
|
||||
}
|
||||
|
||||
// DestinationRemoved should be called when a destination is removed.
|
||||
func (ui *UI) DestinationRemoved() {
|
||||
ui.selectPreviousDestination()
|
||||
}
|
||||
|
||||
func (ui *UI) closeAddDestinationForm() {
|
||||
var hasDestinations bool
|
||||
ui.mu.Lock()
|
||||
ui.addingDestination = false
|
||||
hasDestinations = ui.hasDestinations
|
||||
ui.mu.Unlock()
|
||||
|
||||
ui.pages.RemovePage(pageNameAddDestination)
|
||||
if !hasDestinations {
|
||||
ui.pages.ShowPage(pageNameNoDestinations)
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UI) toggleDestination() {
|
||||
const urlCol = 1
|
||||
row, _ := ui.destView.GetSelection()
|
||||
@ -645,7 +990,7 @@ func (ui *UI) toggleDestination() {
|
||||
return
|
||||
}
|
||||
|
||||
// Communicating with the multiplexer/container client is asynchronous. To
|
||||
// Communicating with the replicator/container client is asynchronous. To
|
||||
// ensure we can limit each destination to a single container we need some
|
||||
// kind of local mutable state which synchronously tracks the "start state"
|
||||
// of each destination.
|
||||
@ -664,30 +1009,30 @@ func (ui *UI) toggleDestination() {
|
||||
switch ss {
|
||||
case startStateNotStarted:
|
||||
ui.urlsToStartState[url] = startStateStarting
|
||||
ui.commandCh <- CommandStartDestination{URL: url}
|
||||
ui.commandC <- domain.CommandStartDestination{URL: url}
|
||||
case startStateStarting:
|
||||
// do nothing
|
||||
return
|
||||
case startStateStarted:
|
||||
ui.commandCh <- CommandStopDestination{URL: url}
|
||||
ui.commandC <- domain.CommandStopDestination{URL: url}
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UI) copySourceURLToClipboard(clipboardAvailable bool) {
|
||||
func (ui *UI) copySourceURLToClipboard(url string) {
|
||||
var text string
|
||||
|
||||
url := ui.sourceViews.url.GetText(true)
|
||||
if clipboardAvailable {
|
||||
if ui.clipboardAvailable {
|
||||
clipboard.Write(clipboard.FmtText, []byte(url))
|
||||
text = "Ingress URL copied to clipboard:\n\n" + url
|
||||
text = "URL copied to clipboard:\n\n" + url
|
||||
} else {
|
||||
text = "Copy to clipboard not available:\n\n" + url
|
||||
}
|
||||
|
||||
ui.showModal(
|
||||
modalGroupClipboard,
|
||||
pageNameModalClipboard,
|
||||
text,
|
||||
[]string{"Ok"},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
@ -706,31 +1051,23 @@ func (ui *UI) copyConfigFilePathToClipboard(clipboardAvailable bool, configFileP
|
||||
}
|
||||
|
||||
ui.showModal(
|
||||
modalGroupClipboard,
|
||||
pageNameModalClipboard,
|
||||
text,
|
||||
[]string{"Ok"},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (ui *UI) confirmQuit() {
|
||||
var allowQuit bool
|
||||
ui.mu.Lock()
|
||||
allowQuit = ui.allowQuit
|
||||
ui.mu.Unlock()
|
||||
|
||||
if !allowQuit {
|
||||
return
|
||||
}
|
||||
|
||||
ui.showModal(
|
||||
modalGroupQuit,
|
||||
pageNameModalQuit,
|
||||
"Are you sure you want to quit?",
|
||||
[]string{"Quit", "Cancel"},
|
||||
false,
|
||||
func(buttonIndex int, _ string) {
|
||||
if buttonIndex == 0 {
|
||||
ui.commandCh <- CommandQuit{}
|
||||
return
|
||||
ui.commandC <- domain.CommandQuit{}
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -743,9 +1080,9 @@ func (ui *UI) showAbout() {
|
||||
}
|
||||
|
||||
ui.showModal(
|
||||
modalGroupAbout,
|
||||
pageNameModalAbout,
|
||||
fmt.Sprintf(
|
||||
"%s: live stream multiplexer\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).",
|
||||
"%s: live stream replicator\n(c) Rob Watson\nhttps://git.netflux.io/rob/octoplex\n\nReleased under AGPL3.\n\nv%s (%s)\nBuilt on %s (%s).",
|
||||
domain.AppName,
|
||||
cmp.Or(ui.buildInfo.Version, "0.0.0-devel"),
|
||||
cmp.Or(commit, "unknown SHA"),
|
||||
@ -753,6 +1090,7 @@ func (ui *UI) showAbout() {
|
||||
ui.buildInfo.GoVersion,
|
||||
),
|
||||
[]string{"Ok"},
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
@ -70,7 +70,7 @@ func TestRightPad(t *testing.T) {
|
||||
want: "foo ",
|
||||
},
|
||||
{
|
||||
name: "string with equal lenth to required width",
|
||||
name: "string with length equal to required width",
|
||||
input: "foobar",
|
||||
want: "foobar",
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ package testhelpers
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// NewNopLogger returns a logger that discards all log output.
|
||||
@ -11,11 +12,11 @@ func NewNopLogger() *slog.Logger {
|
||||
}
|
||||
|
||||
// NewTestLogger returns a logger that writes to stderr.
|
||||
func NewTestLogger() *slog.Logger {
|
||||
func NewTestLogger(t *testing.T) *slog.Logger {
|
||||
var handlerOpts slog.HandlerOptions
|
||||
// RUNNER_DEBUG is used in the GitHub actions runner to enable debug logging.
|
||||
if os.Getenv("DEBUG") != "" || os.Getenv("RUNNER_DEBUG") != "" {
|
||||
handlerOpts.Level = slog.LevelDebug
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &handlerOpts))
|
||||
return slog.New(slog.NewTextHandler(os.Stderr, &handlerOpts)).With("test", t.Name())
|
||||
}
|
||||
|
51
main.go
51
main.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@ -41,21 +42,28 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("build config service: %w", err)
|
||||
}
|
||||
|
||||
help := flag.Bool("h", false, "Show help")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *help {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
if narg := flag.NArg(); narg > 1 {
|
||||
flag.Usage()
|
||||
printUsage()
|
||||
return fmt.Errorf("too many arguments")
|
||||
} else if narg == 1 {
|
||||
switch flag.Arg(0) {
|
||||
case "edit-config":
|
||||
return editConfigFile(configService.Path())
|
||||
return editConfigFile(configService)
|
||||
case "print-config":
|
||||
return printConfigPath(configService.Path())
|
||||
case "version":
|
||||
return printVersion()
|
||||
case "help", "-h", "--help":
|
||||
// TODO: improve help message
|
||||
flag.Usage()
|
||||
case "help":
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -76,7 +84,10 @@ func run(ctx context.Context) error {
|
||||
clipboardAvailable = true
|
||||
}
|
||||
|
||||
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv)
|
||||
dockerClient, err := dockerclient.NewClientWithOpts(
|
||||
dockerclient.FromEnv,
|
||||
dockerclient.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new docker client: %w", err)
|
||||
}
|
||||
@ -89,7 +100,7 @@ func run(ctx context.Context) error {
|
||||
return app.Run(
|
||||
ctx,
|
||||
app.RunParams{
|
||||
Config: cfg,
|
||||
ConfigService: configService,
|
||||
DockerClient: dockerClient,
|
||||
ClipboardAvailable: clipboardAvailable,
|
||||
ConfigFilePath: configService.Path(),
|
||||
@ -105,7 +116,11 @@ func run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// editConfigFile opens the config file in the user's editor.
|
||||
func editConfigFile(configPath string) error {
|
||||
func editConfigFile(configService *config.Service) error {
|
||||
if _, err := configService.ReadOrCreateConfig(); err != nil {
|
||||
return fmt.Errorf("read or create config: %w", err)
|
||||
}
|
||||
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
@ -115,10 +130,10 @@ func editConfigFile(configPath string) error {
|
||||
return fmt.Errorf("look path: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Editing config file: %s\n", configPath)
|
||||
fmt.Fprintf(os.Stderr, "Editing config file: %s\n", configService.Path())
|
||||
fmt.Println(binary)
|
||||
|
||||
if err := syscall.Exec(binary, []string{"--", configPath}, os.Environ()); err != nil {
|
||||
if err := syscall.Exec(binary, []string{"--", configService.Path()}, os.Environ()); err != nil {
|
||||
return fmt.Errorf("exec: %w", err)
|
||||
}
|
||||
|
||||
@ -133,17 +148,29 @@ func printConfigPath(configPath string) error {
|
||||
|
||||
// printVersion prints the version of the application to stderr.
|
||||
func printVersion() error {
|
||||
fmt.Fprintf(os.Stderr, "%s version %s\n", domain.AppName, "0.0.0")
|
||||
fmt.Fprintf(os.Stderr, "%s version %s\n", domain.AppName, cmp.Or(version, "0.0.0-dev"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
os.Stderr.WriteString("Usage: octoplex [command]\n\n")
|
||||
os.Stderr.WriteString("Commands:\n\n")
|
||||
os.Stderr.WriteString(" edit-config Edit the config file\n")
|
||||
os.Stderr.WriteString(" print-config Print the path to the config file\n")
|
||||
os.Stderr.WriteString(" version Print the version of the application\n")
|
||||
os.Stderr.WriteString(" help Print this help message\n")
|
||||
os.Stderr.WriteString("\n")
|
||||
os.Stderr.WriteString("Additionally, Octoplex can be configured with the following environment variables:\n\n")
|
||||
os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n")
|
||||
}
|
||||
|
||||
// buildLogger builds the logger, which may be a no-op logger.
|
||||
func buildLogger(cfg config.LogFile) (*slog.Logger, error) {
|
||||
if !cfg.Enabled {
|
||||
return slog.New(slog.DiscardHandler), nil
|
||||
}
|
||||
|
||||
fptr, err := os.OpenFile(cfg.Path, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
fptr, err := os.OpenFile(cfg.GetPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening log file: %w", err)
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
[env]
|
||||
GOTOOLCHAIN = "go1.24.1"
|
||||
GOTOOLCHAIN = "go1.24.2"
|
||||
|
||||
[tasks.test]
|
||||
description = "Run tests"
|
||||
@ -10,7 +10,7 @@ alias = "t"
|
||||
[tasks.test_integration]
|
||||
description = "Run integration tests"
|
||||
dir = "{{cwd}}"
|
||||
run = "go test -v -count 1 -parallel 1 -tags=integration -run TestIntegration ./..."
|
||||
run = "go test -v -count 1 -p 1 -tags=integration -run TestIntegration ./..."
|
||||
alias = "ti"
|
||||
|
||||
[tasks.test_ci]
|
||||
@ -21,7 +21,7 @@ run = "go test -v -count 1 -race ./..."
|
||||
[tasks.test_integration_ci]
|
||||
description = "Run integration tests in CI"
|
||||
dir = "{{cwd}}"
|
||||
run = "go test -v -count 1 -race -parallel 1 -tags=integration -run TestIntegration ./..."
|
||||
run = "go test -v -count 1 -race -p 1 -tags=integration -run TestIntegration ./..."
|
||||
|
||||
[tasks.lint]
|
||||
description = "Run linters"
|
||||
@ -29,6 +29,12 @@ dir = "{{cwd}}"
|
||||
run = "golangci-lint run"
|
||||
alias = "l"
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Run formatter"
|
||||
dir = "{{cwd}}"
|
||||
run = "goimports -w ."
|
||||
alias = "f"
|
||||
|
||||
[tasks.generate_mocks]
|
||||
description = "Generate mocks"
|
||||
dir = "{{cwd}}"
|
||||
|
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#MISE description="Build and push mediamtx image"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
docker build -f build/mediamtx.Dockerfile -t netfluxio/mediamtx-alpine:latest .
|
||||
docker push netfluxio/mediamtx-alpine:latest
|
Loading…
x
Reference in New Issue
Block a user