snapshot of https://github.com/jitsi/jibri.git @ 3b45602c539ec69d128ea152ae587d0b6582191a (2026-09-15)
This commit is contained in:
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
[*.{kt,kts}]
|
||||
# Keep constructor parameters wrapped one per line, and kotest specs unindented.
|
||||
ktlint_standard_class-signature = disabled
|
||||
max_line_length=120
|
||||
ktlint_code_style = intellij_idea
|
||||
|
||||
# I find trailing commas annoying
|
||||
ktlint_standard_trailing-comma-on-call-site = disabled
|
||||
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
||||
|
||||
# This forbids underscores in package names, which we use
|
||||
ktlint_standard_package-name = disabled
|
||||
27
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Before posting, please make sure you check https://community.jitsi.org
|
||||
|
||||
---
|
||||
|
||||
*This Issue tracker is only for reporting bugs and tracking code related issues.*
|
||||
|
||||
Before posting, please make sure you check community.jitsi.org to see if the same or similar bugs have already been discussed. General questions, installation help, and feature requests can also be posted to community.jitsi.org.
|
||||
|
||||
## Description
|
||||
---
|
||||
|
||||
## Current behavior
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
---
|
||||
|
||||
## Possible Solution
|
||||
---
|
||||
|
||||
## Steps to reproduce
|
||||
---
|
||||
|
||||
# Environment details
|
||||
---
|
||||
40
.github/workflows/maven.yml
vendored
Normal file
40
.github/workflows/maven.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# This workflow will build a Java project with Maven
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven
|
||||
|
||||
name: Java CI with Maven
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
java: [ 17, 21 ]
|
||||
|
||||
name: Java ${{ matrix.java }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Set up JDK ${{ matrix.java }}
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.java }}
|
||||
cache: maven
|
||||
|
||||
- name: Build with Maven
|
||||
run: ./resources/build.sh -Pcoverage
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.idea/
|
||||
target/
|
||||
.kotlintest
|
||||
*~
|
||||
*.sw?
|
||||
*.iml
|
||||
147
CLAUDE.md
Normal file
147
CLAUDE.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## About Jibri
|
||||
|
||||
Jibri (JItsi BRoadcasting Infrastructure) provides services for recording or streaming Jitsi Meet conferences. It works by launching a Chrome instance in a virtual framebuffer, capturing and encoding output with ffmpeg. Only one recording at a time is supported per Jibri instance.
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Full build with tests and linting
|
||||
mvn verify
|
||||
|
||||
# Package without running tests
|
||||
mvn package -DskipTests
|
||||
|
||||
# Build JAR with dependencies
|
||||
mvn clean package
|
||||
# Output: target/jibri-8.0-SNAPSHOT-jar-with-dependencies.jar
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests
|
||||
mvn test
|
||||
|
||||
# Run a specific test class
|
||||
mvn test -Dtest=SeleniumStateMachineTest
|
||||
|
||||
# Run a specific test method
|
||||
mvn test -Dtest=SeleniumStateMachineTest#testMethodName
|
||||
```
|
||||
|
||||
### Linting
|
||||
```bash
|
||||
# Run both Java checkstyle and Kotlin ktlint
|
||||
mvn verify
|
||||
|
||||
# Run only ktlint
|
||||
mvn exec:exec@ktlint-check
|
||||
|
||||
# Auto-format Kotlin code
|
||||
mvn exec:exec@ktlint-format
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **JibriManager** (`JibriManager.kt`) - Central orchestrator that manages Jibri services and tracks busy/idle state. Publishes status updates and enforces single-service-at-a-time constraint.
|
||||
|
||||
2. **Services** (in `service/impl/`) - Three main service types:
|
||||
- `FileRecordingJibriService` - Records conferences to local files
|
||||
- `StreamingJibriService` - Streams conferences to RTMP endpoints
|
||||
- `SipGatewayJibriService` - SIP gateway functionality
|
||||
|
||||
All services extend `StatefulJibriService` and use a state machine pattern.
|
||||
|
||||
3. **APIs** - Three API layers for control:
|
||||
- `XmppApi` - XMPP-based control via MUC (JibriBrewery)
|
||||
- `HttpApi` - External HTTP API (port 2222) for start/stop recording
|
||||
- `InternalHttpApi` - Internal HTTP API (port 3333) for health checks and shutdown
|
||||
|
||||
4. **Selenium Layer** (`selenium/`) - Controls Chrome browser:
|
||||
- `JibriSelenium` - WebDriver wrapper for browser control
|
||||
- `SeleniumStateMachine` - Manages browser state transitions
|
||||
- Status checks in `status_checks/` monitor call health (media received, ICE connection, empty call detection)
|
||||
|
||||
5. **Capture Layer** (`capture/ffmpeg/`) - FFmpeg integration:
|
||||
- `FfmpegCapturer` - Manages ffmpeg process for audio/video capture
|
||||
- `FfmpegStatusStateMachine` - Tracks capture state
|
||||
- Platform-specific capture commands (Linux X11grab, macOS AVFoundation)
|
||||
|
||||
6. **Configuration** (`config/`) - Uses jitsi-metaconfig library:
|
||||
- `reference.conf` contains all default values
|
||||
- Override defaults in `/etc/jitsi/jibri/jibri.conf`
|
||||
- Config hot-reloading triggers graceful restart when idle
|
||||
|
||||
### Control Flow
|
||||
|
||||
1. Request arrives via XMPP or HTTP API
|
||||
2. JibriManager checks if idle, creates appropriate service
|
||||
3. Service starts Selenium (Chrome browser joins call)
|
||||
4. Service starts FFmpeg (captures screen/audio)
|
||||
5. Status checks monitor call health
|
||||
6. On completion, service finalizes recording and returns to idle
|
||||
|
||||
### State Management
|
||||
|
||||
Extensive use of state machines via Tinder StateMachine library:
|
||||
- `JibriServiceStateMachine` - Service lifecycle states
|
||||
- `SeleniumStateMachine` - Browser states
|
||||
- `FfmpegStatusStateMachine` - Capture states
|
||||
|
||||
All state transitions are logged and published to metrics.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is layered (priority order):
|
||||
1. Command-line arguments
|
||||
2. `jibri.conf` (HOCON format)
|
||||
3. `reference.conf` (defaults in resources)
|
||||
|
||||
Key config sections:
|
||||
- `jibri.api.xmpp.environments` - XMPP connection details
|
||||
- `jibri.ffmpeg.*` - FFmpeg encoding parameters
|
||||
- `jibri.chrome.flags` - Chrome launch flags
|
||||
- `jibri.call-status-checks.*` - Timeouts for empty call detection
|
||||
|
||||
## Code Style
|
||||
|
||||
- Primary language: Kotlin (Java only in extreme cases)
|
||||
- Follow Kotlin style guide (enforced by ktlint)
|
||||
- Pre-commit hook available: `resources/add_git_pre_commit_script.sh`
|
||||
- All Kotlin warnings are errors (`-Werror` compiler flag)
|
||||
|
||||
## Testing
|
||||
|
||||
- Test framework: Kotest (JUnit 5 compatible)
|
||||
- Mocking: MockK for Kotlin
|
||||
- Test helpers in `src/test/kotlin/org/jitsi/jibri/helpers/`
|
||||
- Tests use in-memory filesystems for file operations
|
||||
|
||||
## Java Version
|
||||
|
||||
- Source/Target: Java 11 minimum
|
||||
- Runtime: Java 21 supported (as of recent commits)
|
||||
- Kotlin JVM target: 11
|
||||
|
||||
## Dependencies
|
||||
|
||||
Key external dependencies:
|
||||
- Selenium WebDriver 3.12.0 - Browser automation
|
||||
- Ktor 3.0 - HTTP server framework
|
||||
- jicoco libraries - Jitsi common components (XMPP, config, metrics)
|
||||
- Tinder StateMachine - State machine implementation
|
||||
- FFmpeg - External binary for capture (not a Maven dependency)
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Jibri requires exclusive access to display and audio devices
|
||||
- Single-use mode: If `jibri.single-use-mode = true`, Jibri exits after one session
|
||||
- Health checks exposed at internal HTTP API `/jibri/api/v1.0/health`
|
||||
- Metrics available via StatsD and optionally Prometheus
|
||||
- Webhook support for external status notifications
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
289
README.md
Normal file
289
README.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Jibri
|
||||
|
||||
JItsi BRoadcasting Infrastructure
|
||||
|
||||
# What is Jibri
|
||||
|
||||
Jibri provides services for recording or streaming a Jitsi Meet conference.
|
||||
|
||||
It works by launching a Chrome instance rendered in a virtual framebuffer and
|
||||
capturing and encoding the output with ffmpeg. It is intended to be run on a
|
||||
separate machine (or a VM), with no other applications using the display or
|
||||
audio devices. Only one recording at a time is supported on a single jibri.
|
||||
|
||||
**NOTE:** Jibri currently only works with a full-fledged Jitsi Meet
|
||||
installation. Using a different frontend won't work.
|
||||
|
||||
# Installing Jibri
|
||||
|
||||
### Installation notes
|
||||
|
||||
- Jibri was built on Ubuntu 18.04 (Bionic), and has been tested with the
|
||||
pre-built kernel and extra kernel modules (`linux-image-extra-virtual`
|
||||
package). Any other distribution or kernel configuration MAY work but has not
|
||||
been tested.
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
### ALSA and Loopback Device
|
||||
|
||||
- First make sure the ALSA loopback module is available. The extra modules
|
||||
(including ALSA loopback) can be installed on Ubuntu 16.04 using package name
|
||||
`linux-image-extra-virtual`
|
||||
- Perform the following tasks as the root user
|
||||
- Set up the module to be loaded on boot: `echo "snd_aloop" >> /etc/modules`
|
||||
- Load the module into the running kernel: `modprobe snd_aloop`
|
||||
- Check to see that the module is already loaded: `lsmod | grep snd_aloop`
|
||||
- If the output shows the snd-aloop module loaded, then the ALSA loopback
|
||||
configuration step is complete.
|
||||
|
||||
### Ffmpeg with X11 capture support
|
||||
|
||||
- Jibri requires a relatively modern ffmpeg install with x11 capture compiled
|
||||
in. This comes by default in Ubuntu 16.04, by installing the ffmpeg package.
|
||||
- If building Jibri for Ubuntu 14.04 (trusty), the mc3man repo provides
|
||||
packages. They can be used by the following in Ubuntu 14.04:
|
||||
|
||||
```bash
|
||||
sudo add-apt-repository ppa:mc3man/trusty-media
|
||||
sudo apt-get update
|
||||
sudo apt-get install ffmpeg
|
||||
```
|
||||
|
||||
### Google Chrome stable & Chromedriver
|
||||
|
||||
The latest Google Chrome stable build should be used. It may be able to be
|
||||
installed direclty via apt, but the manual instructions for installing it are as
|
||||
follows:
|
||||
|
||||
```bash
|
||||
sudo su -l
|
||||
apt-get -y install wget curl gnupg jq unzip
|
||||
|
||||
curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome-keyring.gpg
|
||||
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list
|
||||
|
||||
apt-get -y update
|
||||
apt-get -y install google-chrome-stable
|
||||
apt-mark hold google-chrome-stable
|
||||
```
|
||||
|
||||
Add chrome managed policies file and set
|
||||
`CommandLineFlagSecurityWarningsEnabled` to `false`. It will hide warnings in
|
||||
Chrome. You can set it like so:
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/opt/chrome/policies/managed
|
||||
echo '{ "CommandLineFlagSecurityWarningsEnabled": false }' >>/etc/opt/chrome/policies/managed/managed_policies.json
|
||||
```
|
||||
|
||||
Chromedriver is also required and can be installed like so:
|
||||
|
||||
```bash
|
||||
CHROME_VER=$(dpkg -s google-chrome-stable | egrep "^Version" | cut -d " " -f2 | cut -d. -f1-3)
|
||||
CHROMELAB_LINK="https://googlechromelabs.github.io/chrome-for-testing"
|
||||
CHROMEDRIVER_LINK=$(curl -s $CHROMELAB_LINK/known-good-versions-with-downloads.json | jq -r ".versions[].downloads.chromedriver | select(. != null) | .[].url" | grep linux64 | grep "$CHROME_VER" | tail -1)
|
||||
wget -O /tmp/chromedriver-linux64.zip $CHROMEDRIVER_LINK
|
||||
|
||||
rm -rf /tmp/chromedriver-linux64
|
||||
unzip -o /tmp/chromedriver-linux64.zip -d /tmp
|
||||
mv /tmp/chromedriver-linux64/chromedriver /usr/local/bin/
|
||||
chown root:root /usr/local/bin/chromedriver
|
||||
chmod 755 /usr/local/bin/chromedriver
|
||||
```
|
||||
|
||||
### Jitsi Debian Repository
|
||||
|
||||
The Jibri packages can be found in the stable repository on downloads.jitsi.org.
|
||||
First install the Jitsi repository key onto your system:
|
||||
|
||||
```bash
|
||||
curl https://download.jitsi.org/jitsi-key.gpg.key | sudo sh -c 'gpg --dearmor > /usr/share/keyrings/jitsi-keyring.gpg'
|
||||
```
|
||||
|
||||
Create a sources.list.d file with the repository:
|
||||
|
||||
```bash
|
||||
echo 'deb [signed-by=/usr/share/keyrings/jitsi-keyring.gpg] https://download.jitsi.org stable/' | sudo tee /etc/apt/sources.list.d/jitsi-stable.list > /dev/null
|
||||
```
|
||||
|
||||
Update your package list:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
```
|
||||
|
||||
Install the latest jibri
|
||||
|
||||
```bash
|
||||
sudo apt-get install jibri
|
||||
```
|
||||
|
||||
### User and Group
|
||||
|
||||
- Jibri is currently meant to be run as a regular system user. This example
|
||||
creatively uses username 'jibri' and group name 'jibri', but any user will do.
|
||||
This has not been tested with the root user.
|
||||
- Ensure that the jibri user is in the correct groups to make full access of the
|
||||
audio and video devices. The example jibri account in Ubuntu 16.04 are:
|
||||
"adm","audio","video","plugdev".
|
||||
|
||||
```bash
|
||||
sudo usermod -aG adm,audio,video,plugdev jibri
|
||||
```
|
||||
|
||||
### Config files
|
||||
|
||||
- Edit the `jibri.conf` file (installed to `/etc/jitsi/jibri/jibri.conf` by
|
||||
default) appropriately. You can look at
|
||||
[reference.conf](src/main/resources/reference.conf) for the default values and
|
||||
an example of how to set up jibri.conf. Only override the values you want to
|
||||
change from their defaults in `jibri.conf`.
|
||||
|
||||
```
|
||||
jibri {
|
||||
.....
|
||||
api {
|
||||
xmpp {
|
||||
environments = [
|
||||
{
|
||||
name = "yourdomain.com"
|
||||
xmpp-server-hosts = ["1.2.3.4"],
|
||||
xmpp-domain = "yourdomain.com"
|
||||
control-login {
|
||||
domain = "auth.yourdomain.com"
|
||||
username = "jibri"
|
||||
password = "jibriauthpass"
|
||||
port = 5222
|
||||
}
|
||||
control-muc {
|
||||
domain = "internal.auth.yourdomain.com"
|
||||
room-name = "JibriBrewery"
|
||||
nickname = "myjibri-1-2-3-4"
|
||||
}
|
||||
call-login {
|
||||
domain = "recorder.yourdomain.com"
|
||||
username = "recorder"
|
||||
password = "jibrirecorderpass"
|
||||
}
|
||||
strip-from-room-domain = "conference."
|
||||
trust-all-xmpp-certs = true
|
||||
usage-timeout = 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
.....
|
||||
}
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
By default, Jibri logs to `/var/log/jitsi/jibri`. If you don't install via the
|
||||
debian package, you'll need to make sure this directory exists (or change the
|
||||
location to which Jibri logs by editing the [log config](lib/logging.properties)
|
||||
|
||||
# Configuring a Jitsi Meet environment for Jibri
|
||||
|
||||
Jibri requires some settings to be enabled within a Jitsi Meet configuration.
|
||||
These changes include virtualhosts and accounts in Prosody, settings for the
|
||||
jitsi meet web (within config.js) as well as `jicofo.conf`.
|
||||
|
||||
## Prosody
|
||||
|
||||
Create the internal MUC component entry. This is required so that the jibri
|
||||
clients can be discovered by Jicofo in a MUC that's not externally accessible by
|
||||
jitsi meet users. Add the following in `/etc/prosody/prosody.cfg.lua`:
|
||||
|
||||
```lua
|
||||
-- internal muc component, meant to enable pools of jibri and jigasi clients
|
||||
Component "internal.auth.yourdomain.com" "muc"
|
||||
modules_enabled = {
|
||||
"ping";
|
||||
}
|
||||
-- storage should be "none" for prosody 0.10 and "memory" for prosody 0.11
|
||||
storage = "memory"
|
||||
muc_room_cache_size = 1000
|
||||
```
|
||||
|
||||
Create the recorder virtual host entry, to hold the user account for the jibri
|
||||
chrome session. This is used to restrict only authenticated jibri chrome
|
||||
sessions to be hidden participants in the conference being recordered. Add the
|
||||
following in `/etc/prosody/prosody.cfg.lua`:
|
||||
|
||||
```lua
|
||||
VirtualHost "recorder.yourdomain.com"
|
||||
modules_enabled = {
|
||||
"ping";
|
||||
}
|
||||
authentication = "internal_hashed"
|
||||
```
|
||||
|
||||
Setup the two accounts jibri will use:
|
||||
|
||||
```bash
|
||||
prosodyctl register jibri auth.yourdomain.com jibriauthpass
|
||||
prosodyctl register recorder recorder.yourdomain.com jibrirecorderpass
|
||||
```
|
||||
|
||||
The first account is the one Jibri will use to log into the control MUC (where
|
||||
Jibri will send its status and await commands). The second account is the one
|
||||
Jibri will use as a client in selenium when it joins the call so that it can be
|
||||
treated in a special way by the Jitsi Meet web UI.
|
||||
|
||||
## Jicofo
|
||||
|
||||
Edit `/etc/jitsi/jicofo/jicofo.conf`, set the appropriate MUC to look for the
|
||||
Jibri Controllers. This should be the same MUC as is referenced in jibri's
|
||||
`jibri.conf` file. Restart Jicofo after setting this property. It's also
|
||||
suggested to set the pending-timeout to 90 seconds, to allow the Jibri some time
|
||||
to start up before being marked as failed.
|
||||
|
||||
```
|
||||
jicofo {
|
||||
...
|
||||
jibri {
|
||||
brewery-jid = "JibriBrewery@internal.auth.yourdomain.com"
|
||||
pending-timeout = 90 seconds
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Jitsi Meet
|
||||
|
||||
Edit the `/etc/jitsi/meet/yourdomain-config.js` file, add/set the following
|
||||
properties:
|
||||
|
||||
```javascript
|
||||
// recording
|
||||
config.recordingService = {
|
||||
enabled: true,
|
||||
sharingEnabled: true,
|
||||
hideStorageWarning: false,
|
||||
};
|
||||
|
||||
// liveStreaming
|
||||
config.liveStreaming = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
config.hiddenDomain = "recorder.yourdomain.com";
|
||||
```
|
||||
|
||||
Once recording is enabled in `yourdomain-config.js`, the recording button will
|
||||
become available in the user interface. However, until a valid jibri is seen by
|
||||
Jicofo, the mesage "Recording currently unavailable" will be displayed when it
|
||||
is pressed. Once a jibri connects successfully, the user will instead be
|
||||
prompted to enter a stream key.
|
||||
|
||||
**Note**: Make sure to update Jibri's `jibri.conf` appropriately to match any
|
||||
config done above.
|
||||
|
||||
## Start Jibri
|
||||
|
||||
Once you have configured `jibri.conf`, start the jibri service:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart jibri
|
||||
```
|
||||
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Security
|
||||
|
||||
## Reporting security issuess
|
||||
|
||||
We take security very seriously and develop all Jitsi projects to be secure and safe.
|
||||
|
||||
If you find (or simply suspect) a security issue in any of the Jitsi projects, please send us an email to security@jitsi.org.
|
||||
|
||||
**We encourage responsible disclosure for the sake of our users, so please reach out before posting in a public space.**
|
||||
37
checkstyle.xml
Normal file
37
checkstyle.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
|
||||
<module name="Checker">
|
||||
<module name="LineLength">
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="max" value="120"/>
|
||||
<property name="ignorePattern" value="http://|https://"/>
|
||||
</module>
|
||||
|
||||
<module name="TreeWalker">
|
||||
<module name="LeftCurly">
|
||||
<property name="option" value="nl"/>
|
||||
<property name="tokens" value="LITERAL_CATCH"/>
|
||||
<property name="tokens" value="LITERAL_ELSE"/>
|
||||
<property name="tokens" value="LITERAL_FINALLY"/>
|
||||
<property name="tokens" value="LITERAL_IF"/>
|
||||
<property name="tokens" value="LITERAL_TRY"/>
|
||||
</module>
|
||||
|
||||
<!-- Skips empty blocks {}, to not skip them: value="alone" -->
|
||||
<module name="RightCurly">
|
||||
<property name="option" value="alone_or_singleline"/>
|
||||
</module>
|
||||
|
||||
<module name="WhitespaceAfter">
|
||||
<!-- Drops TYPECAST from default tokens -->
|
||||
<property name="tokens" value="COMMA , SEMI , LITERAL_IF , LITERAL_ELSE , LITERAL_WHILE , LITERAL_DO , LITERAL_FOR , DO_WHILE"/>
|
||||
</module>
|
||||
</module>
|
||||
|
||||
<module name="FileTabCharacter">
|
||||
<property name="eachLine" value="true"/>
|
||||
</module>
|
||||
</module>
|
||||
5
debian/changelog
vendored
Normal file
5
debian/changelog
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
jibri (0.2.0-1) UNRELEASED; urgency=medium
|
||||
|
||||
* Initial release. (Closes: #XXXXXX)
|
||||
|
||||
-- brian <brian@brian-VirtualBox> Tue, 27 Mar 2018 14:04:40 -0700
|
||||
1
debian/compat
vendored
Normal file
1
debian/compat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
10
|
||||
12
debian/control
vendored
Normal file
12
debian/control
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
Source: jibri
|
||||
Maintainer: dev@jitsi.org
|
||||
Section: net
|
||||
Priority: optional
|
||||
Standards-Version: 3.9.2
|
||||
Build-Depends: openjdk-17-jdk | openjdk-21-jdk, debhelper (>= 9)
|
||||
|
||||
Package: jibri
|
||||
Architecture: all
|
||||
Depends: openjdk-17-jre-headless | openjdk-17-jre | openjdk-21-jre-headless | openjdk-21-jre, ffmpeg, curl, alsa-utils, icewm, xserver-xorg-video-dummy, procps, ruby-hocon
|
||||
Description: Jibri
|
||||
Jibri can be used to capture data from a Jitsi Meet conference and record it to a file or stream it to a url
|
||||
13
debian/copyright
vendored
Normal file
13
debian/copyright
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: Jibri
|
||||
Upstream-Contact: Emil Ivov <emcho@jitsi.org>
|
||||
Source: https://github.com/jitsi/jibri
|
||||
|
||||
Files: *
|
||||
Copyright: 2018 Atlassian Pty Ltd
|
||||
License: Apache-2.0
|
||||
|
||||
License: Apache-2.0
|
||||
On Debian systems, the full text of the Apache
|
||||
License version 2 can be found in the file
|
||||
'/usr/share/common-licenses/Apache-2.0'.
|
||||
23
debian/jibri.install
vendored
Normal file
23
debian/jibri.install
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/dh-exec
|
||||
|
||||
resources/debian-package/etc/jitsi/jibri/asoundrc etc/jitsi/jibri
|
||||
resources/debian-package/etc/jitsi/jibri/jibri.conf etc/jitsi/jibri
|
||||
resources/debian-package/etc/jitsi/jibri/icewm.preferences etc/jitsi/jibri
|
||||
resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf etc/jitsi/jibri
|
||||
resources/debian-package/etc/jitsi/jibri/pjsua.config etc/jitsi/jibri
|
||||
|
||||
resources/debian-package/etc/systemd/system/jibri-icewm.service etc/systemd/system
|
||||
resources/debian-package/etc/systemd/system/jibri.service etc/systemd/system
|
||||
resources/debian-package/etc/systemd/system/jibri-xorg.service etc/systemd/system
|
||||
|
||||
resources/debian-package/opt/jitsi/jibri/launch.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/reload.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/shutdown.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/pjsua.sh opt/jitsi/jibri
|
||||
resources/debian-package/opt/jitsi/jibri/finalize_sip.sh opt/jitsi/jibri
|
||||
|
||||
target/jibri.jar opt/jitsi/jibri
|
||||
lib/logging.properties etc/jitsi/jibri
|
||||
50
debian/postinst
vendored
Executable file
50
debian/postinst
vendored
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
die () {
|
||||
echo >&2 "$@"
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
|
||||
if ! getent group jitsi > /dev/null ; then
|
||||
groupadd jitsi
|
||||
fi
|
||||
|
||||
if ! getent passwd jibri > /dev/null ; then
|
||||
echo "Creating jibri user and group"
|
||||
# Create the Jibri user and group
|
||||
useradd --system --create-home jibri
|
||||
echo "Done creating jibri user and group"
|
||||
usermod -a -G jibri,jitsi,audio,video jibri
|
||||
echo "jibri user added to audio, video and jibri groups"
|
||||
fi
|
||||
|
||||
# Make the directory for the logs and set the permissions
|
||||
mkdir -p /var/log/jitsi/jibri
|
||||
chgrp jitsi /var/log/jitsi/jibri
|
||||
chmod g+w /var/log/jitsi/jibri
|
||||
|
||||
# Move the asoundrc file to the jibri home directory
|
||||
if [ -f /etc/jitsi/jibri/asoundrc ] ; then
|
||||
mv /etc/jitsi/jibri/asoundrc /home/jibri/.asoundrc
|
||||
fi
|
||||
|
||||
# Make the directory for the icewm preferences and move the preferences file into it
|
||||
if [ -f /etc/jitsi/jibri/icewm.preferences ] ; then
|
||||
mkdir -p /home/jibri/.icewm
|
||||
mv /etc/jitsi/jibri/icewm.preferences /home/jibri/.icewm/preferences
|
||||
fi
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
7
debian/rules
vendored
Executable file
7
debian/rules
vendored
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
override_dh_strip_nondeterminism:
|
||||
# Disables dh_strip_nondeterminism to speed up the debian package creation
|
||||
|
||||
%:
|
||||
dh $@
|
||||
1
debian/source/format
vendored
Normal file
1
debian/source/format
vendored
Normal file
@@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
||||
15
doc/apis.md
Normal file
15
doc/apis.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Jibri APIs
|
||||
Jibri has two types of APIs:
|
||||
1. One is for 'external' use and is used to control stopping and starting of Jibri services, as well as querying Jibri's health.
|
||||
1. The other is 'internal' and is used to notify Jibri that there has been a change to its config file.
|
||||
|
||||
There is an HTTP implementation of the 'internal' API which lives in `InternalHttpApi.kt`, documentation for it can be found [here](internal_http_api.md).
|
||||
|
||||
The external API has both XMPP and HTTP implementations, however the HTTP implementation is not fully developed (though it's close to complete functionality). Detailed documentation for the XMPP API can be found [here](xmpp_api.md), and for the HTTP API [here](http_api.md).
|
||||
|
||||
In general, the 'external' APIs boil down to the following available actions:
|
||||
1. Start a service
|
||||
1. Stop a service
|
||||
1. Query the current 'status' of Jibri (is it busy or is it available to start a new service)
|
||||
|
||||
At this time, Jibri only runs a single service at a time, so if it has one running it will consider itself "busy".
|
||||
178
doc/config_migration_guide.md
Normal file
178
doc/config_migration_guide.md
Normal file
@@ -0,0 +1,178 @@
|
||||
Jibri is moving to a new config file. The old config file/command-line-arguments will be supported for a while and eventually removed. This doc is a guide on how to migrate from an "old" configuration to a new one.
|
||||
|
||||
The new configuration leverages the [Typesafe config](https://github.com/lightbend/config) library. Default configuration values are now stored in [reference.conf](src/main/resources/reference.conf). Overrides are written to a file and passed via a JVM parameter:
|
||||
```
|
||||
-Dconfig.file=/path/to/config.conf
|
||||
```
|
||||
|
||||
### Property migrations
|
||||
---
|
||||
#### Notes
|
||||
* No default values have been changed, so if you weren't specifying a property before you can continue to leave it unspecified and the value will not change.
|
||||
* The new configuration library supports different formats and many different features in configuration (substitutions, includes, overrides via environment variables, etc.). The text here describes the 'plainest' way to define a new config file, but other methods are possible. Read about the Typesafe config to learn more.
|
||||
* The example below assume that you will define a new configuration file called `jibri.conf`
|
||||
|
||||
---
|
||||
##### Configuration file
|
||||
Old method: path to a JSON file via command line argument `--config=/path/to/config.json`
|
||||
New method: path to a HOCON .conf file passed via JVM arg: `-Dconfig.file=/path/to/config.conf`
|
||||
|
||||
---
|
||||
##### Internal and external HTTP API ports
|
||||
Old method: command line arguments `--http-api-port` and `--internal-http-port`
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
api {
|
||||
http {
|
||||
external-api-port = <number>
|
||||
internal-api-port = <number>
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
##### Single use mode
|
||||
Old method: in `config.json`:
|
||||
```json
|
||||
{
|
||||
"single_use_mode": <Boolean>
|
||||
}
|
||||
```
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
single-use-mode = <boolean>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
##### Recordings directory
|
||||
Old method: in `config.json`:
|
||||
```json
|
||||
{
|
||||
"recording_directory": <path>
|
||||
}
|
||||
```
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
recording {
|
||||
recordings-directory = <path>
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
##### Finalize script path
|
||||
Old method: in `config.json`:
|
||||
```json
|
||||
{
|
||||
"finalize_recording_script_path": <path>
|
||||
}
|
||||
```
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
recording {
|
||||
finalize-script = <path>
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
##### Enabling statsD
|
||||
Old method: in `config.json`:
|
||||
```json
|
||||
{
|
||||
"enable_stats_d": <boolean>
|
||||
}
|
||||
```
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
stats {
|
||||
enable-stats-d = <boolean>
|
||||
host = <string>
|
||||
port = <int>
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
##### XMPP environment config
|
||||
Old method: in `config.json`:
|
||||
```json
|
||||
{
|
||||
"xmpp_environments": [
|
||||
{
|
||||
"name": <String>,
|
||||
"xmpp_server_hosts": List<String>,
|
||||
"xmpp_domain": <String>,
|
||||
"control_login": {
|
||||
"domain": <String>,
|
||||
"username": <String>,
|
||||
"password": <String>
|
||||
},
|
||||
"control_muc": {
|
||||
"domain": <String>,
|
||||
"room_name": <String>,
|
||||
"nickname": <String>
|
||||
},
|
||||
"call_login": {
|
||||
"domain": <String>,
|
||||
"username": <String>,
|
||||
"password": <String>
|
||||
},
|
||||
"room_jid_domain_string_to_strip_from_start": <String>,
|
||||
"usage_timeout": <number>
|
||||
},
|
||||
{ <more> },
|
||||
]
|
||||
}
|
||||
```
|
||||
New method: in `jibri.conf`:
|
||||
```hocon
|
||||
jibri {
|
||||
api {
|
||||
xmpp {
|
||||
environments = [
|
||||
{
|
||||
name = <String>
|
||||
xmpp-server-hosts = <List<String>>
|
||||
xmpp-domain = <String>
|
||||
|
||||
control-muc {
|
||||
domain = <String>
|
||||
room-name = <String>
|
||||
nickname = <String>
|
||||
}
|
||||
|
||||
control-login {
|
||||
domain = <String>
|
||||
username = <String>
|
||||
password = <String>
|
||||
}
|
||||
|
||||
sip-control-muc {
|
||||
domain = <String>
|
||||
room-name = <String>
|
||||
nickname = <String>
|
||||
}
|
||||
|
||||
call-login {
|
||||
domain = <String>
|
||||
username = <String>
|
||||
password = <String>
|
||||
}
|
||||
|
||||
strip-from-room-domain = <String>
|
||||
usage-timeout = <Duration String i.e. '5 minutes' or '1 hour'>
|
||||
|
||||
trust-all-xmpp-certs = <boolean>
|
||||
},
|
||||
{ <more> }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
An example of a new environments config can be found [here](example_xmpp_envs.conf).
|
||||
15
doc/debian_package.md
Normal file
15
doc/debian_package.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Building the Jibri debian package
|
||||
Building the debian package has been tested on Ubuntu Xenial.
|
||||
|
||||
### Steps
|
||||
* Install the prerequisites:
|
||||
* `sudo apt install git maven openjdk-8-jdk`
|
||||
* (`openjdk-8-jdk` specifically is not necessarily required, any java 8 jdk will probably work)
|
||||
* Clone the repo:
|
||||
* `git clone https://github.com/jitsi/jibri.git`
|
||||
* Create the jar:
|
||||
* `cd jibri`
|
||||
* `mvn package`
|
||||
* Call the `create_debian_package_script` and pass it the location of the jar:
|
||||
* `` export WORKSPACE=`pwd` ``
|
||||
* `resources/jenkins/release.sh Minor`
|
||||
16
doc/development.md
Normal file
16
doc/development.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Jibri Development
|
||||
## Style
|
||||
Code should follow the Kotlin style guide. This style is enforced with ktlint in the project itself, linting can be executed by running `mvn verify` (which will build, run tests and lint) or `mvn exec:exec@ktlint-check` (which will run just the linting; `mvn exec:exec@ktlint-format` auto-formats). Jibri is a Kotlin codebase, so Kotlin should be used for development (save for extreme circumstances where falling back to Java is acceptable). The linter can be run automatically by installing a pre-commit script, you can run [this script](resources/add_git_pre_commit_script.sh) to create/install this hook automatically.
|
||||
|
||||
## Versioning
|
||||
Jibri uses (annotated) tagged versions and follows [semantic versioning](https://semver.org/). Adding an annotated tag is done as follows:
|
||||
```
|
||||
git tag -a v1.4 -m "my version 1.4"
|
||||
```
|
||||
Tags are not pushed by default when doing `git push`, but a tag can be pushed like a remote branch:
|
||||
```
|
||||
git push origin v1.4
|
||||
```
|
||||
NOTE: Tagging should not be done as part of a PR, it will be handled separately.
|
||||
|
||||
More can be read about git tagging [here](https://git-scm.com/book/en/v2/Git-Basics-Tagging).
|
||||
65
doc/example_xmpp_envs.conf
Normal file
65
doc/example_xmpp_envs.conf
Normal file
@@ -0,0 +1,65 @@
|
||||
// An example file showing the fields for the XMPP environment config.
|
||||
{
|
||||
environments = [
|
||||
{
|
||||
// A user-friendly name for this environment
|
||||
name = "env name"
|
||||
|
||||
// A list of XMPP server hosts to which we'll connect
|
||||
xmpp-server-hosts = [ "host1", "host2" ]
|
||||
|
||||
// The base XMPP domain
|
||||
xmpp-domain = "xmpp-domain"
|
||||
|
||||
// An (optional) base url the Jibri will join if it is set
|
||||
// base-url = "https://meet.example.com"
|
||||
|
||||
// The MUC we'll join to announce our presence for
|
||||
// recording and streaming services
|
||||
control-muc {
|
||||
domain = "domain"
|
||||
room-name = "room-name"
|
||||
nickname = "nickname"
|
||||
}
|
||||
|
||||
// The login information for the control MUC
|
||||
control-login {
|
||||
domain = "domain"
|
||||
// Optional port, defaults to 5222.
|
||||
port = 6222
|
||||
username = "username"
|
||||
password = "password"
|
||||
// Whether to use `username` as is or add a random suffix to it.
|
||||
randomize-username = false
|
||||
}
|
||||
|
||||
// An (optional) MUC configuration where we'll
|
||||
// join to announce SIP gateway services
|
||||
sip-control-muc {
|
||||
domain = "domain"
|
||||
room-name = "room-name"
|
||||
nickname = "nickname"
|
||||
}
|
||||
|
||||
// The login information the selenium web client will use
|
||||
call-login {
|
||||
domain = "domain"
|
||||
username = "username"
|
||||
password = "password"
|
||||
}
|
||||
|
||||
// The value we'll strip from the room JID domain to derive
|
||||
// the call URL
|
||||
strip-from-room-domain = ""
|
||||
|
||||
// How long Jibri sessions will be allowed to last before
|
||||
// they are stopped. A value of 0 allows them to go on
|
||||
// indefinitely
|
||||
usage-timeout = 1 hour
|
||||
|
||||
// Whether or not we'll automatically trust any cert on
|
||||
// this XMPP domain
|
||||
trust-all-xmpp-certs = true
|
||||
}
|
||||
]
|
||||
}
|
||||
82
doc/http_api.md
Normal file
82
doc/http_api.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Jibri HTTP API
|
||||
|
||||
Jibri has a somewhat-implemented HTTP API to mirror the XMPP API with the following endpoints:
|
||||
|
||||
##### URL
|
||||
`/jibri/api/v1.0/health`
|
||||
##### Method
|
||||
`GET`
|
||||
##### URL Params
|
||||
None
|
||||
##### Data Params
|
||||
None
|
||||
##### Response
|
||||
Code: `200`
|
||||
Body:
|
||||
```
|
||||
{
|
||||
"status":{
|
||||
"busyStatus": String // "IDLE", "BUSY" or "EXPIRED"
|
||||
"health":{
|
||||
"healthStatus": String // "HEALTHY" or "UNHEALTHY"
|
||||
"details": Map<String,HealthStatus> // Hash of component -> healthStatus above - only valid is "JibriManager" at present.
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
This call should always respond with a `200` and the status encoded in the response body. The lack of any response would represent an error of some sort
|
||||
|
||||
|
||||
##### URL
|
||||
`/jibri/api/v1.0/startService`
|
||||
##### Method
|
||||
`POST`
|
||||
##### URL Params
|
||||
None
|
||||
##### Data Params
|
||||
```
|
||||
{
|
||||
"sessionId": String, // the recording operation session (e.g. RecordTest)
|
||||
"callParams": {
|
||||
"callUrlInfo": {
|
||||
"baseUrl": String, // the base url of the call (e.g. https://meet.jit.si)
|
||||
"callName": String // the call name to be appended to the base url
|
||||
}
|
||||
},
|
||||
"callLoginParams": {
|
||||
"domain": String, // The xmpp domain the Jibri client should log into when joining the call
|
||||
"username": String, // The username to use for logging in to the above domain
|
||||
"password": String // The password to use for logging in to the above domain
|
||||
},
|
||||
"sinkType": String, // "stream" for streaming, "file" for recording
|
||||
"youTubeStreamKey": String // If using "stream" above, this is the YouTube stream key to use
|
||||
}
|
||||
```
|
||||
##### Success Response
|
||||
Code: `200`
|
||||
Body: None
|
||||
##### Error Response
|
||||
Code: `412 Precondition Failed` // When the Jibri is already busy
|
||||
Body: None
|
||||
Code: `500 Internal Server Error` // When an internal error occurs
|
||||
Body: None
|
||||
|
||||
##### URL
|
||||
`/jibri/api/v1.0/stopService`
|
||||
##### Method
|
||||
`POST`
|
||||
##### URL Params
|
||||
None
|
||||
##### Data Params
|
||||
None
|
||||
##### Response
|
||||
Code: `200`
|
||||
Body: None
|
||||
|
||||
This call should always respond with a `200`. The lack of any response would represent an error of some sort.
|
||||
|
||||
Known HTTP API limitations:
|
||||
* No push status updates.
|
||||
* No way currently exists for interested parties to be notified of Jibri status changes. Queries must be made to the `health` endpoint.
|
||||
* No way of passing Jitsi Meet call credentials.
|
||||
* When Jibri joins a call in order to capture the media, it can use a special set of credentials so that it doesn't show up like a normal participant in the meeting. The HTTP API does not currently have a way of passing-in or configuring these credentials.
|
||||
13
doc/internal_http_api.md
Normal file
13
doc/internal_http_api.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Jibri Internal HTTP API
|
||||
At startup, Jibri reads from a configuration file to determine which (if any) xmpp enviroments to connect to and to read some other configuration data. Jibri takes a simple approach of only reading this file at startup, so if changes are made to the file and you want Jibri to read them, Jibri needs to be restarted. Obviously one would prefer not to restart Jibri while it's busy, so Jibri has an internal API to notify it of config file changes. When the API is called, Jibri will schedule a shutdown for the next time it's idle. It is up for whatever is managing Jibri to restart it.
|
||||
|
||||
##### URL
|
||||
`/jibri/api/internal/v1.0/notifyConfigChanged`
|
||||
##### Method
|
||||
`POST`
|
||||
##### URL Params
|
||||
None
|
||||
##### Data Params
|
||||
None
|
||||
##### Response
|
||||
If Jibri is currently idle, no respose will be sent as Jibri will shutdown immediately. If Jibri is currently busy, it will respond with a `200`
|
||||
310
doc/local-run/README.md
Normal file
310
doc/local-run/README.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Running jibri locally against a docker-jitsi-meet deployment
|
||||
|
||||
This directory contains a script and a docker-compose override that let you
|
||||
run jibri **from this source tree** while the rest of the Jitsi stack
|
||||
(prosody, jicofo, jvb, web) runs in docker via
|
||||
[docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet). Useful for
|
||||
iterating on jibri code changes without rebuilding the docker image.
|
||||
|
||||
## Files
|
||||
|
||||
- `run-jibri-local.sh` — generates `jibri.conf` + `logging.properties` from
|
||||
your `docker-jitsi-meet/.env`, builds jibri if needed, and launches it.
|
||||
- `prosody.override.yml` — publishes prosody's port 5222 on `127.0.0.1` so
|
||||
the host-side jibri can reach it. The default compose only `expose:`s
|
||||
this port inside the docker network.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `docker` + `docker compose`
|
||||
- `mvn` and Java 17 (to build jibri)
|
||||
- `ffmpeg` on `PATH` (jibri shells out to it for capture)
|
||||
- A clone of [docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet)
|
||||
(defaults to `~/dev/docker-jitsi-meet`; override with `DOCKER_JITSI_DIR`)
|
||||
- A `chromedriver` binary at the jibri repo root, matching your installed
|
||||
Chrome milestone (download from <https://googlechromelabs.github.io/chrome-for-testing/>)
|
||||
|
||||
## Step by step
|
||||
|
||||
### 1. Set up docker-jitsi-meet
|
||||
|
||||
```sh
|
||||
cd ~/dev/docker-jitsi-meet
|
||||
cp env.example .env
|
||||
./gen-passwords.sh
|
||||
mkdir -p ~/.jitsi-meet-cfg/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri}
|
||||
```
|
||||
|
||||
### 2. Edit `.env`: enable recording and set `PUBLIC_URL`
|
||||
|
||||
Two changes in `~/dev/docker-jitsi-meet/.env`:
|
||||
|
||||
**Enable recording.** This is what tells prosody to provision the `jibri`
|
||||
and `recorder` accounts (using `JIBRI_XMPP_PASSWORD` /
|
||||
`JIBRI_RECORDER_PASSWORD` from `gen-passwords.sh`) and what makes the web
|
||||
UI surface the record button. Without it, jibri can't even log into
|
||||
prosody.
|
||||
|
||||
```sh
|
||||
ENABLE_RECORDING=true
|
||||
```
|
||||
|
||||
**Point `PUBLIC_URL` at your LAN IP.** Jibri opens the conference URL in
|
||||
Chrome from the host. `localhost` won't do, because Chrome's WebRTC needs
|
||||
an address that resolves the same way for jibri *and* the other browsers
|
||||
joining the call. Use the host's LAN address.
|
||||
|
||||
Find your local IP:
|
||||
|
||||
```sh
|
||||
# macOS
|
||||
ipconfig getifaddr en0
|
||||
# Linux
|
||||
hostname -I | awk '{print $1}'
|
||||
```
|
||||
|
||||
Then set (HTTP/HTTPS port already exist in env.example — adjust if you
|
||||
moved them):
|
||||
|
||||
```sh
|
||||
HTTP_PORT=8000
|
||||
HTTPS_PORT=8443
|
||||
PUBLIC_URL=https://192.168.1.42:8443 # ← your IP and the HTTPS_PORT
|
||||
```
|
||||
|
||||
The script copies `PUBLIC_URL` into jibri's `base-url`, so jibri opens
|
||||
`https://192.168.1.42:8443/<room>` instead of the default `https://meet.jitsi/`.
|
||||
|
||||
### 3. Bring the stack up with prosody 5222 published
|
||||
|
||||
The host-side jibri talks to prosody over XMPP, so we need 5222 reachable
|
||||
from the host. Apply the override from this directory:
|
||||
|
||||
```sh
|
||||
cd ~/dev/docker-jitsi-meet
|
||||
docker compose \
|
||||
-f docker-compose.yml \
|
||||
-f ~/dev/jibri/doc/local-run/prosody.override.yml \
|
||||
up -d
|
||||
```
|
||||
|
||||
Confirm prosody is listening on the host:
|
||||
|
||||
```sh
|
||||
nc -zv 127.0.0.1 5222 # → "succeeded!"
|
||||
```
|
||||
|
||||
### 4. Build jibri
|
||||
|
||||
```sh
|
||||
cd ~/dev/jibri
|
||||
mvn -B -DskipTests clean package
|
||||
```
|
||||
|
||||
The script will also build automatically if no jar is found in `target/`.
|
||||
|
||||
### 5. Drop a matching `chromedriver` next to the jibri repo
|
||||
|
||||
```sh
|
||||
# look up your Chrome major version
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version
|
||||
|
||||
# fetch the matching chromedriver and place it at ~/dev/jibri/chromedriver
|
||||
# (see https://googlechromelabs.github.io/chrome-for-testing/)
|
||||
chmod +x ~/dev/jibri/chromedriver
|
||||
```
|
||||
|
||||
### 6. (Linux only) Set up audio routing for ffmpeg
|
||||
|
||||
Jibri's default ffmpeg config captures audio from the ALSA PCM `plug:bsnoop`,
|
||||
which is a custom loopback device defined in `/etc/jitsi/jibri/asoundrc` and
|
||||
backed by the kernel `snd-aloop` module. The .deb postinst sets this up for
|
||||
the system `jibri` user; if you're running locally as your own user, you
|
||||
need to do it yourself, or switch to PulseAudio capture.
|
||||
|
||||
**Option A — ALSA loopback (matches the deb):**
|
||||
|
||||
```sh
|
||||
# 1. Load the kernel loopback module (creates ALSA card "Loopback")
|
||||
sudo modprobe snd-aloop
|
||||
# Persist across reboots
|
||||
echo snd-aloop | sudo tee /etc/modules-load.d/snd-aloop.conf
|
||||
|
||||
# 2. Drop jibri's asoundrc as your user's ALSA config
|
||||
cp ~/dev/jibri/resources/debian-package/etc/jitsi/jibri/asoundrc ~/.asoundrc
|
||||
|
||||
# Verify
|
||||
aplay -L | grep bsnoop
|
||||
arecord -D plug:bsnoop -f cd -d 1 /tmp/t.wav && echo OK
|
||||
```
|
||||
|
||||
Caveat: this changes your default ALSA device to a loopback, and on a
|
||||
modern Ubuntu desktop Chrome routes audio through PulseAudio/PipeWire — so
|
||||
even with `bsnoop` defined, Chrome won't play into the loopback unless you
|
||||
also force Chrome to ALSA (e.g. `PULSE_SERVER=/dev/null`).
|
||||
|
||||
**Option B — PulseAudio capture (recommended on a desktop dev box):**
|
||||
|
||||
Skip the asoundrc/snd-aloop setup entirely and override the audio knobs
|
||||
when invoking the script:
|
||||
|
||||
```sh
|
||||
JIBRI_AUDIO_SOURCE=pulse \
|
||||
JIBRI_AUDIO_DEVICE=default \
|
||||
~/dev/jibri/doc/local-run/run-jibri-local.sh
|
||||
```
|
||||
|
||||
`default` reads from Pulse's default source. To capture system audio
|
||||
specifically, find the right monitor source with `pactl list short sources`
|
||||
and pass it as `JIBRI_AUDIO_DEVICE` (e.g. `alsa_output.pci-…analog-stereo.monitor`).
|
||||
|
||||
### 6b. (macOS only) Capture the screen instead of the camera
|
||||
|
||||
Jibri's default macOS ffmpeg input is `-i 0:0` — whatever avfoundation
|
||||
device 0 is (usually a camera + the mic). To record the conference you
|
||||
want a **screen** device for video and a **loopback** device for audio.
|
||||
|
||||
1. **Grant Screen Recording permission** to the terminal app you launch
|
||||
the script from (System Settings → Privacy & Security → Screen
|
||||
Recording), then fully quit and relaunch the terminal. Without it
|
||||
avfoundation silently produces black frames.
|
||||
|
||||
2. **Install a loopback audio device**, e.g. [BlackHole](https://github.com/ExistentialAudio/BlackHole)
|
||||
(`brew install blackhole-2ch`), and route the call audio into it:
|
||||
Audio MIDI Setup → `+` → *Create Multi-Output Device* → check both
|
||||
BlackHole and your speakers, then select that multi-output device as
|
||||
the system sound output. (Selecting BlackHole alone also works, but
|
||||
you won't hear anything.)
|
||||
|
||||
3. **Find the device indices**:
|
||||
|
||||
```sh
|
||||
ffmpeg -f avfoundation -list_devices true -i ""
|
||||
# e.g. video: [2] Capture screen 0 audio: [2] BlackHole 2ch
|
||||
```
|
||||
|
||||
4. **Run with the indices**:
|
||||
|
||||
```sh
|
||||
JIBRI_MAC_VIDEO_DEVICE=2 JIBRI_MAC_AUDIO_DEVICE=2 \
|
||||
~/dev/jibri/doc/local-run/run-jibri-local.sh
|
||||
```
|
||||
|
||||
Note avfoundation screen devices capture the *whole* display at native
|
||||
(retina) resolution — there is no per-window capture in ffmpeg on macOS.
|
||||
The script scales the output down to `JIBRI_RECORDING_RESOLUTION` width,
|
||||
but whatever else is on that screen ends up in the recording, so keep the
|
||||
jibri Chrome window in front (or point it at a secondary display, e.g.
|
||||
`Capture screen 1`).
|
||||
|
||||
### 7. Run jibri
|
||||
|
||||
```sh
|
||||
~/dev/jibri/doc/local-run/run-jibri-local.sh
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
```
|
||||
config: /Users/you/dev/jibri/.local/jibri.conf
|
||||
jar: /Users/you/dev/jibri/target/jibri-*-jar-with-dependencies.jar
|
||||
chromedriver: /Users/you/dev/jibri/chromedriver
|
||||
xmpp: 127.0.0.1:5222 (domain=meet.jitsi)
|
||||
|
||||
… INFO: Jibri starting up …
|
||||
… INFO: Connecting to xmpp environment on 127.0.0.1 …
|
||||
… INFO: Joined MUC: jibribrewery@internal-muc.meet.jitsi
|
||||
```
|
||||
|
||||
Now open `https://192.168.1.42:8443/<room>` in two browsers, click the
|
||||
record button — jibri will pick up the start IQ and join the call.
|
||||
|
||||
## Where things land
|
||||
|
||||
Generated files live under `~/dev/jibri/.local/`:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `jibri.conf` | HOCON config built from `.env` |
|
||||
| `logging.properties` | JUL config (console + ffmpeg/browser file handlers) |
|
||||
| `recordings/<session-id>/*.mp4` | recorded files |
|
||||
| `ffmpeg.0.txt` | ffmpeg stderr (JUL captures it; not on console) |
|
||||
| `browser.0.txt` | chrome browser logs |
|
||||
| `chromedriver.log` | chromedriver's own log |
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All overridable as env vars when invoking the script:
|
||||
|
||||
| Var | Default | Notes |
|
||||
|---|---|---|
|
||||
| `JIBRI_DIR` | resolved from script path | jibri repo root |
|
||||
| `DOCKER_JITSI_DIR` | `$HOME/dev/docker-jitsi-meet` | source of `.env` |
|
||||
| `XMPP_HOST` | `127.0.0.1` | where prosody is reachable |
|
||||
| `XMPP_PORT` | `5222` | matches `.env` if set there |
|
||||
| `JIBRI_INSTANCE_ID` | `jibri-local-$$` | shows up as MUC nickname |
|
||||
| `CHROMEDRIVER` | `$JIBRI_DIR/chromedriver` | path to chromedriver binary |
|
||||
| `JIBRI_AUDIO_SOURCE` | `alsa` | Linux: ffmpeg `-f` for audio (`alsa`, `pulse`) |
|
||||
| `JIBRI_AUDIO_DEVICE` | `plug:bsnoop` | Linux: ffmpeg audio input device (e.g. `default` for pulse) |
|
||||
| `JIBRI_MAC_VIDEO_DEVICE` | unset | macOS: avfoundation video index (use a `Capture screen N` device) |
|
||||
| `JIBRI_MAC_AUDIO_DEVICE` | unset | macOS: avfoundation audio index (use a loopback device, e.g. BlackHole) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`permission denied while trying to connect to the docker API at unix:///var/run/docker.sock`**
|
||||
— Linux only; your user isn't in the `docker` group. Fix:
|
||||
|
||||
```sh
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker # apply the new group in this shell, or log out/in
|
||||
docker ps # verify
|
||||
```
|
||||
|
||||
Avoid `sudo docker compose …` — files written to `~/.jitsi-meet-cfg` end up
|
||||
root-owned and bite you later.
|
||||
|
||||
**`cannot reach prosody at 127.0.0.1:5222`** — you brought up the stack
|
||||
without `prosody.override.yml`. Re-run step 3.
|
||||
|
||||
**`This version of ChromeDriver only supports Chrome version N`** — your
|
||||
chromedriver and Chrome milestones don't match. Re-fetch chromedriver for
|
||||
your Chrome version (step 5).
|
||||
|
||||
**Jibri opens `https://meet.jitsi/<room>` instead of your IP** — `PUBLIC_URL`
|
||||
isn't set in `.env`, or you didn't restart the script after editing `.env`.
|
||||
|
||||
**`No such file or directory: /var/log/jitsi/jibri/chromedriver.log`** —
|
||||
older jibri pinned that path unconditionally. Pull a tree where
|
||||
`webdriver.chrome.logfile` is overridable, or `mkdir -p` the path with
|
||||
write permissions.
|
||||
|
||||
**ffmpeg quits with `Unknown PCM bsnoop` / `cannot open audio device plug:bsnoop`**
|
||||
on Linux — you don't have the ALSA loopback set up. Either follow step 6
|
||||
Option A (snd-aloop + `~/.asoundrc`) or skip ALSA and use Pulse via
|
||||
`JIBRI_AUDIO_SOURCE=pulse JIBRI_AUDIO_DEVICE=default`.
|
||||
|
||||
**ffmpeg quits abruptly with "Error opening output files: Invalid argument"**
|
||||
on macOS — usually an avfoundation arg conflict. Inspect
|
||||
`~/dev/jibri/.local/ffmpeg.0.txt` for the real ffmpeg stderr. Note that the
|
||||
default macOS ffmpeg input is `-i 0:0` (camera + system mic), so the
|
||||
resulting mp4 records your camera, **not** the chrome window — see step 6b
|
||||
for screen capture via `JIBRI_MAC_VIDEO_DEVICE`/`JIBRI_MAC_AUDIO_DEVICE`.
|
||||
|
||||
**Black video on macOS** — the terminal app that launched the script lacks
|
||||
the Camera (default `0:0` input) or Screen Recording (screen-capture input)
|
||||
permission. avfoundation doesn't error on a denied device; it delivers
|
||||
black frames. Grant the permission in System Settings → Privacy & Security
|
||||
and fully restart the terminal app.
|
||||
|
||||
**Black video on Linux** — you're on a Wayland session
|
||||
(`echo $XDG_SESSION_TYPE`), where `x11grab` cannot see the screen and
|
||||
records black. Either log into an Xorg session, or run a dedicated virtual
|
||||
display and point both Chrome and ffmpeg at it:
|
||||
|
||||
```sh
|
||||
Xvfb :1 -screen 0 1280x720x24 &
|
||||
DISPLAY=:1 ~/dev/jibri/doc/local-run/run-jibri-local.sh
|
||||
```
|
||||
|
||||
(the second part — overriding ffmpeg's hardcoded `-i :0.0+0,0` grab target —
|
||||
currently requires editing `input-linux` in the generated conf).
|
||||
4
doc/local-run/prosody.override.yml
Normal file
4
doc/local-run/prosody.override.yml
Normal file
@@ -0,0 +1,4 @@
|
||||
services:
|
||||
prosody:
|
||||
ports:
|
||||
- '127.0.0.1:${XMPP_PORT:-5222}:${XMPP_PORT:-5222}'
|
||||
238
doc/local-run/run-jibri-local.sh
Executable file
238
doc/local-run/run-jibri-local.sh
Executable file
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Generate a jibri.conf from a docker-jitsi-meet .env and run jibri from
|
||||
# this source tree, connecting it to the running docker deployment.
|
||||
#
|
||||
# Defaults:
|
||||
# JIBRI_DIR -> repo root (resolved from this script's location)
|
||||
# DOCKER_JITSI_DIR -> $HOME/dev/docker-jitsi-meet
|
||||
#
|
||||
# Pre-req: prosody port 5222 must be reachable from the host. The default
|
||||
# docker-jitsi-meet compose only `expose:`s it, so apply prosody.override.yml
|
||||
# from this directory:
|
||||
#
|
||||
# docker compose -f docker-compose.yml \
|
||||
# -f /path/to/jibri/doc/local-run/prosody.override.yml up -d
|
||||
#
|
||||
# See README.md alongside this script for a full walkthrough.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
JIBRI_DIR="${JIBRI_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
||||
DOCKER_JITSI_DIR="${DOCKER_JITSI_DIR:-$HOME/dev/docker-jitsi-meet}"
|
||||
ENV_FILE="$DOCKER_JITSI_DIR/.env"
|
||||
|
||||
[ -f "$ENV_FILE" ] || { echo "missing $ENV_FILE (set DOCKER_JITSI_DIR)" >&2; exit 1; }
|
||||
[ -d "$JIBRI_DIR" ] || { echo "missing $JIBRI_DIR" >&2; exit 1; }
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
# Defaults match jibri/rootfs/defaults/xmpp.conf and jibri.conf templates.
|
||||
XMPP_DOMAIN="${XMPP_DOMAIN:-meet.jitsi}"
|
||||
XMPP_AUTH_DOMAIN="${XMPP_AUTH_DOMAIN:-auth.meet.jitsi}"
|
||||
XMPP_INTERNAL_MUC_DOMAIN="${XMPP_INTERNAL_MUC_DOMAIN:-internal-muc.meet.jitsi}"
|
||||
XMPP_HIDDEN_DOMAIN="${XMPP_HIDDEN_DOMAIN:-hidden.meet.jitsi}"
|
||||
XMPP_MUC_DOMAIN="${XMPP_MUC_DOMAIN:-muc.meet.jitsi}"
|
||||
JIBRI_BREWERY_MUC="${JIBRI_BREWERY_MUC:-jibribrewery}"
|
||||
JIBRI_RECORDER_USER="${JIBRI_RECORDER_USER:-recorder}"
|
||||
JIBRI_XMPP_USER="${JIBRI_XMPP_USER:-jibri}"
|
||||
JIBRI_USAGE_TIMEOUT="${JIBRI_USAGE_TIMEOUT:-0}"
|
||||
XMPP_PORT="${XMPP_PORT:-5222}"
|
||||
XMPP_HOST="${XMPP_HOST:-127.0.0.1}"
|
||||
JIBRI_RECORDING_RESOLUTION="${JIBRI_RECORDING_RESOLUTION:-1280x720}"
|
||||
JIBRI_AUDIO_SOURCE="${JIBRI_AUDIO_SOURCE:-alsa}"
|
||||
JIBRI_AUDIO_DEVICE="${JIBRI_AUDIO_DEVICE:-plug:bsnoop}"
|
||||
# macOS only: avfoundation device indices for screen + audio capture.
|
||||
# Find them with: ffmpeg -f avfoundation -list_devices true -i ""
|
||||
# When unset, jibri's default input ("0:0", usually camera + mic) is used.
|
||||
JIBRI_MAC_VIDEO_DEVICE="${JIBRI_MAC_VIDEO_DEVICE:-}"
|
||||
JIBRI_MAC_AUDIO_DEVICE="${JIBRI_MAC_AUDIO_DEVICE:-}"
|
||||
JIBRI_INSTANCE_ID="${JIBRI_INSTANCE_ID:-jibri-local-$$}"
|
||||
JIBRI_STRIP_DOMAIN_JID="${JIBRI_STRIP_DOMAIN_JID:-${XMPP_MUC_DOMAIN%%.*}}"
|
||||
|
||||
: "${JIBRI_XMPP_PASSWORD:?JIBRI_XMPP_PASSWORD not set in .env (run gen-passwords.sh)}"
|
||||
: "${JIBRI_RECORDER_PASSWORD:?JIBRI_RECORDER_PASSWORD not set in .env}"
|
||||
|
||||
BASE_URL_LINE=""
|
||||
[ -n "${PUBLIC_URL:-}" ] && BASE_URL_LINE=" base-url = \"$PUBLIC_URL\""
|
||||
|
||||
WORKDIR="$JIBRI_DIR/.local"
|
||||
JIBRI_CONF="$WORKDIR/jibri.conf"
|
||||
LOGGING="$WORKDIR/logging.properties"
|
||||
RECORDINGS="$WORKDIR/recordings"
|
||||
mkdir -p "$WORKDIR" "$RECORDINGS"
|
||||
|
||||
# Probe prosody on the host before generating config / starting jibri.
|
||||
# Run the /dev/tcp probe in a subshell so neither fd 3 nor stderr leak back here.
|
||||
if ! (exec 3<>"/dev/tcp/$XMPP_HOST/$XMPP_PORT") 2>/dev/null; then
|
||||
cat >&2 <<EOF
|
||||
cannot reach prosody at $XMPP_HOST:$XMPP_PORT.
|
||||
|
||||
The default compose only exposes 5222 inside the docker network. Apply the
|
||||
override shipped next to this script and restart:
|
||||
|
||||
docker compose -f docker-compose.yml -f $SCRIPT_DIR/prosody.override.yml up -d
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# macOS screen capture: avfoundation's screen devices only deliver the native
|
||||
# (retina) resolution, so drop -video_size from the input and scale on the
|
||||
# encoder side instead.
|
||||
FFMPEG_MAC_OVERRIDES=""
|
||||
if [ -n "$JIBRI_MAC_VIDEO_DEVICE" ]; then
|
||||
[ -n "$JIBRI_MAC_AUDIO_DEVICE" ] || {
|
||||
echo "JIBRI_MAC_VIDEO_DEVICE is set but JIBRI_MAC_AUDIO_DEVICE is not (pick one from: ffmpeg -f avfoundation -list_devices true -i \"\")" >&2
|
||||
exit 1
|
||||
}
|
||||
FFMPEG_MAC_OVERRIDES=$(cat <<EOG
|
||||
input-mac = [
|
||||
"-thread_queue_size", "4096",
|
||||
"-f", "avfoundation",
|
||||
"-capture_cursor", "0",
|
||||
"-framerate", "30",
|
||||
"-i", "$JIBRI_MAC_VIDEO_DEVICE:$JIBRI_MAC_AUDIO_DEVICE"
|
||||
]
|
||||
video-params = [
|
||||
"-vf", "scale=${JIBRI_RECORDING_RESOLUTION%%x*}:-2",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-r", "30",
|
||||
"-crf", "25"
|
||||
]
|
||||
EOG
|
||||
)
|
||||
fi
|
||||
|
||||
FFMPEG_LINUX_OVERRIDES=$(cat <<EOG
|
||||
input-linux = [
|
||||
"-f", "x11grab",
|
||||
"-i", "${DISPLAY:-:0}",
|
||||
]
|
||||
EOG
|
||||
)
|
||||
|
||||
cat > "$JIBRI_CONF" <<EOF
|
||||
jibri {
|
||||
id = "$JIBRI_INSTANCE_ID"
|
||||
single-use-mode = false
|
||||
|
||||
recording {
|
||||
recordings-directory = "$RECORDINGS"
|
||||
finalize-script = "$JIBRI_DIR/resources/finalize_recording.sh"
|
||||
}
|
||||
|
||||
ffmpeg {
|
||||
resolution = "$JIBRI_RECORDING_RESOLUTION"
|
||||
audio-source = "$JIBRI_AUDIO_SOURCE"
|
||||
audio-device = "$JIBRI_AUDIO_DEVICE"
|
||||
$FFMPEG_MAC_OVERRIDES
|
||||
$FFMPEG_LINUX_OVERRIDES
|
||||
}
|
||||
|
||||
chrome {
|
||||
flags = [
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--enabled",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--ignore-certificate-errors",
|
||||
"--ozone-platform=x11" # Force X11, required for x11grab to capture video
|
||||
]
|
||||
}
|
||||
|
||||
stats {
|
||||
prometheus.enabled = false
|
||||
}
|
||||
|
||||
api {
|
||||
xmpp {
|
||||
environments = [
|
||||
{
|
||||
name = "docker-local"
|
||||
xmpp-server-hosts = ["$XMPP_HOST"]
|
||||
xmpp-domain = "$XMPP_DOMAIN"
|
||||
$BASE_URL_LINE
|
||||
|
||||
control-muc {
|
||||
domain = "$XMPP_INTERNAL_MUC_DOMAIN"
|
||||
room-name = "$JIBRI_BREWERY_MUC"
|
||||
nickname = "$JIBRI_INSTANCE_ID"
|
||||
}
|
||||
|
||||
control-login {
|
||||
domain = "$XMPP_AUTH_DOMAIN"
|
||||
port = "$XMPP_PORT"
|
||||
username = "$JIBRI_XMPP_USER"
|
||||
password = "$JIBRI_XMPP_PASSWORD"
|
||||
}
|
||||
|
||||
call-login {
|
||||
domain = "$XMPP_HIDDEN_DOMAIN"
|
||||
username = "$JIBRI_RECORDER_USER"
|
||||
password = "$JIBRI_RECORDER_PASSWORD"
|
||||
}
|
||||
|
||||
strip-from-room-domain = "$JIBRI_STRIP_DOMAIN_JID."
|
||||
usage-timeout = "$JIBRI_USAGE_TIMEOUT"
|
||||
trust-all-xmpp-certs = true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Console-only logging. Root .level must allow FINE through to the handler.
|
||||
cat > "$LOGGING" <<EOF
|
||||
handlers = java.util.logging.ConsoleHandler
|
||||
|
||||
.level = FINE
|
||||
|
||||
java.util.logging.ConsoleHandler.level = FINE
|
||||
java.util.logging.ConsoleHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
|
||||
# ffmpeg/browser are attached to dedicated JUL loggers with parent propagation
|
||||
# disabled, so their output never reaches the console. Pin their FileHandlers
|
||||
# to the workdir so we can read them.
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.pattern = $WORKDIR/ffmpeg.%g.txt
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.limit = 10000000
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.count = 5
|
||||
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.pattern = $WORKDIR/browser.%g.txt
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.limit = 10000000
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.count = 5
|
||||
|
||||
org.jitsi.level = FINE
|
||||
org.jitsi.jibri.config.level = INFO
|
||||
org.glassfish.level = INFO
|
||||
jdk.internal.net.http.level = INFO
|
||||
EOF
|
||||
|
||||
JAR="$(ls -t "$JIBRI_DIR"/target/jibri-*-jar-with-dependencies.jar 2>/dev/null | head -n1 || true)"
|
||||
if [ -z "$JAR" ]; then
|
||||
echo "building jibri..."
|
||||
(cd "$JIBRI_DIR" && mvn -B -DskipTests clean package)
|
||||
JAR="$(ls -t "$JIBRI_DIR"/target/jibri-*-jar-with-dependencies.jar | head -n1)"
|
||||
fi
|
||||
|
||||
CHROMEDRIVER="${CHROMEDRIVER:-$JIBRI_DIR/chromedriver}"
|
||||
[ -x "$CHROMEDRIVER" ] || { echo "missing chromedriver at $CHROMEDRIVER" >&2; exit 1; }
|
||||
|
||||
echo "config: $JIBRI_CONF"
|
||||
echo "jar: $JAR"
|
||||
echo "chromedriver: $CHROMEDRIVER"
|
||||
echo "xmpp: $XMPP_HOST:$XMPP_PORT (domain=$XMPP_DOMAIN)"
|
||||
echo
|
||||
|
||||
exec java \
|
||||
-Djava.util.logging.config.file="$LOGGING" \
|
||||
-Dconfig.file="$JIBRI_CONF" \
|
||||
-Dwebdriver.chrome.driver="$CHROMEDRIVER" \
|
||||
-Dwebdriver.chrome.logfile="$WORKDIR/chromedriver.log" \
|
||||
-jar "$JAR"
|
||||
4
doc/sip_gateway.md
Normal file
4
doc/sip_gateway.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# SIP Gateway
|
||||
Currently the XMPP API is the only officially supported API, so this doc will discuss how to configure Jibri to allow SIP gateway calls via the XMPP API.
|
||||
|
||||
To use the SIP gateway functionality of Jibri, you'll need to set the `sip_control_muc` field in `config.json`, it can be found within the `XmppEnvironment` field. Jibri will join this MUC to announce itself of being capable of providing SIP gateway services to Jicofo.
|
||||
9
doc/xmpp_api.md
Normal file
9
doc/xmpp_api.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Jibri XMPP API
|
||||
|
||||
Jibri can be configured to join multiple XMPP environments which can be used as a control surface for leveraging Jibri services. The configuration for these environments lives in config.json (a documented sample of config.json can be seen in the code [here](TODO)). For a given XMPP environment, Jibri will:
|
||||
* Connect to the provided XMPP domain on the provided XMPP host
|
||||
* Login to a given auth domain with the given credentials
|
||||
* Join a MUC on the given MUC domain with the given MUC jid using the given MUC nickname
|
||||
* Publish its status (defined by the status packet extension [here](https://github.com/jitsi/jitsi/blob/master/src/net/java/sip/communicator/impl/protocol/jabber/extensions/jibri/JibriStatusPacketExt.java)
|
||||
|
||||
At this point it will await an IQ message (defined by the custom IQ [here](https://github.com/jitsi/jitsi/blob/master/src/net/java/sip/communicator/impl/protocol/jabber/extensions/jibri/JibriIq.java)) asking it to start or stop a given service. Whenever Jibri's status changes, it will send a new presence to reflect its current state.
|
||||
32
lib/logging.properties
Normal file
32
lib/logging.properties
Normal file
@@ -0,0 +1,32 @@
|
||||
handlers = java.util.logging.FileHandler
|
||||
|
||||
java.util.logging.FileHandler.level = FINE
|
||||
java.util.logging.FileHandler.pattern = /var/log/jitsi/jibri/log.%g.txt
|
||||
java.util.logging.FileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
java.util.logging.FileHandler.count = 10
|
||||
java.util.logging.FileHandler.limit = 10000000
|
||||
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.level = FINE
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.pattern = /var/log/jitsi/jibri/ffmpeg.%g.txt
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.count = 10
|
||||
org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler.limit = 10000000
|
||||
|
||||
org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.level = FINE
|
||||
org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.pattern = /var/log/jitsi/jibri/pjsua.%g.txt
|
||||
org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.count = 10
|
||||
org.jitsi.jibri.sipgateway.pjsua.util.PjsuaFileHandler.limit = 10000000
|
||||
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.level = FINE
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.pattern = /var/log/jitsi/jibri/browser.%g.txt
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.count = 10
|
||||
org.jitsi.jibri.selenium.util.BrowserFileHandler.limit = 10000000
|
||||
|
||||
org.jitsi.level = FINE
|
||||
org.jitsi.jibri.config.level = INFO
|
||||
|
||||
org.glassfish.level = INFO
|
||||
org.osgi.level = INFO
|
||||
org.jitsi.xmpp.level = INFO
|
||||
14
lib/test-logging.properties
Normal file
14
lib/test-logging.properties
Normal file
@@ -0,0 +1,14 @@
|
||||
handlers = java.util.logging.ConsoleHandler
|
||||
|
||||
java.util.logging.ConsoleHandler.level = FINE
|
||||
java.util.logging.ConsoleHandler.pattern = /var/log/jitsi/jibri/log.%g.txt
|
||||
java.util.logging.ConsoleHandler.formatter = org.jitsi.utils.logging2.JitsiLogFormatter
|
||||
java.util.logging.ConsoleHandler.count = 10
|
||||
java.util.logging.ConsoleHandler.limit = 10000000
|
||||
|
||||
org.jitsi.level = FINE
|
||||
org.jitsi.jibri.config.level = INFO
|
||||
|
||||
org.glassfish.level = INFO
|
||||
org.osgi.level = INFO
|
||||
org.jitsi.xmpp.level = INFO
|
||||
540
pom.xml
Normal file
540
pom.xml
Normal file
@@ -0,0 +1,540 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>org.jitsi</groupId>
|
||||
<artifactId>jibri</artifactId>
|
||||
<version>8.0-SNAPSHOT</version>
|
||||
|
||||
<properties>
|
||||
<selenium.version>4.43.0</selenium.version>
|
||||
<kotlin.version>2.4.20</kotlin.version>
|
||||
<exec.mainClass>org.jitsi.jibri.MainKt</exec.mainClass>
|
||||
<maven.compiler.source>17</maven.compiler.source>
|
||||
<maven.compiler.target>17</maven.compiler.target>
|
||||
<kotlin.compiler.jvmTarget>17</kotlin.compiler.jvmTarget>
|
||||
<jackson.version>2.21.5</jackson.version>
|
||||
<junit.version>5.14.4</junit.version>
|
||||
<kotest.version>5.9.1</kotest.version>
|
||||
<mockk.version>1.14.11</mockk.version>
|
||||
<ktor.version>3.4.3</ktor.version>
|
||||
<jicoco.version>1.1-179-g52cf6ef</jicoco.version>
|
||||
<jitsi.utils.version>1.0-153-gd5c0102</jitsi.utils.version>
|
||||
<jetty.version>12.0.35</jetty.version>
|
||||
<ktlint.version>1.8.0</ktlint.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<!-- Align all Jetty artifacts with the version used by jicoco. -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty</groupId>
|
||||
<artifactId>jetty-bom</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jetty.ee10</groupId>
|
||||
<artifactId>jetty-ee10-bom</artifactId>
|
||||
<version>${jetty.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-mucclient</artifactId>
|
||||
<version>${jicoco.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-osgi-bundle</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-config</artifactId>
|
||||
<version>${jicoco.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jicoco-jwt</artifactId>
|
||||
<version>${jicoco.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-reflect</artifactId>
|
||||
<version>${kotlin.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.seleniumhq.selenium</groupId>
|
||||
<artifactId>selenium-java</artifactId>
|
||||
<version>${selenium.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.seleniumhq.selenium</groupId>
|
||||
<artifactId>selenium-support</artifactId>
|
||||
<version>${selenium.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-core</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
<version>2.21</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.module</groupId>
|
||||
<artifactId>jackson-module-kotlin</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.jaxrs</groupId>
|
||||
<artifactId>jackson-jaxrs-json-provider</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.sourceforge.argparse4j</groupId>
|
||||
<artifactId>argparse4j</artifactId>
|
||||
<version>0.8.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-xmpp-extensions</artifactId>
|
||||
<version>1.0-124-g024f15d</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.datadoghq</groupId>
|
||||
<artifactId>java-dogstatsd-client</artifactId>
|
||||
<version>2.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.tinder.statemachine</groupId>
|
||||
<artifactId>statemachine</artifactId>
|
||||
<version>0.2.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-apache5-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-jackson-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-core-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-jetty-jakarta-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-serialization-jackson-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-content-negotiation-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-content-negotiation-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-metaconfig</artifactId>
|
||||
<version>1.0-18-ge06bc45</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib-jdk8</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>jitsi-utils</artifactId>
|
||||
<version>${jitsi.utils.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.typesafe</groupId>
|
||||
<artifactId>config</artifactId>
|
||||
<version>1.4.0</version>
|
||||
</dependency>
|
||||
<!-- testing -->
|
||||
<dependency>
|
||||
<groupId>org.jitsi</groupId>
|
||||
<artifactId>jicoco-test-kotlin</artifactId>
|
||||
<version>${jicoco.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-runner-junit5-jvm</artifactId>
|
||||
<version>${kotest.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-assertions-core-jvm</artifactId>
|
||||
<version>${kotest.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.kotest</groupId>
|
||||
<artifactId>kotest-extensions-junitxml-jvm</artifactId>
|
||||
<version>${kotest.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jdom</groupId>
|
||||
<artifactId>jdom2</artifactId>
|
||||
<version>2.0.6</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.mockk</groupId>
|
||||
<artifactId>mockk-jvm</artifactId>
|
||||
<version>${mockk.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-mock-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-server-test-host-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
<scope>test</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-jetty-jvm</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- Force ktor-server-test-host to use a compatible jetty version. -->
|
||||
<dependency>
|
||||
<groupId>io.ktor</groupId>
|
||||
<artifactId>ktor-client-jetty-jakarta-jvm</artifactId>
|
||||
<version>${ktor.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- https://mvnrepository.com/artifact/net.bytebuddy/byte-buddy -->
|
||||
<dependency>
|
||||
<groupId>net.bytebuddy</groupId>
|
||||
<artifactId>byte-buddy</artifactId>
|
||||
<version>1.14.6</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.marschall</groupId>
|
||||
<artifactId>memoryfilesystem</artifactId>
|
||||
<version>1.2.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jitsi</groupId>
|
||||
<artifactId>jicoco-metrics</artifactId>
|
||||
<version>${jicoco.version}</version>
|
||||
<scope>compile</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-enforcer-plugin</artifactId>
|
||||
<version>3.0.0-M3</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>enforce-maven</id>
|
||||
<goals>
|
||||
<goal>enforce</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<requireMavenVersion>
|
||||
<version>3.5.0</version>
|
||||
</requireMavenVersion>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>kotlin-maven-plugin</artifactId>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<version>${kotlin.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>compile</id>
|
||||
<goals> <goal>compile</goal> </goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
|
||||
<sourceDir>${project.basedir}/src/main/java</sourceDir>
|
||||
</sourceDirs>
|
||||
<args>
|
||||
<arg>-Werror</arg>
|
||||
<arg>-opt-in=kotlin.ExperimentalStdlibApi</arg>
|
||||
<arg>-opt-in=kotlin.time.ExperimentalTime</arg>
|
||||
</args>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>test-compile</id>
|
||||
<goals> <goal>test-compile</goal> </goals>
|
||||
<configuration>
|
||||
<sourceDirs>
|
||||
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
|
||||
</sourceDirs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.8.1</version>
|
||||
<executions>
|
||||
<!-- Replacing default-compile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-compile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<!-- Replacing default-testCompile as it is treated specially by maven -->
|
||||
<execution>
|
||||
<id>default-testCompile</id>
|
||||
<phase>none</phase>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-compile</id>
|
||||
<phase>compile</phase>
|
||||
<goals> <goal>compile</goal> </goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>java-test-compile</id>
|
||||
<phase>test-compile</phase>
|
||||
<goals> <goal>testCompile</goal> </goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.2</version>
|
||||
<configuration>
|
||||
<argLine>
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED
|
||||
--add-opens java.base/java.util=ALL-UNNAMED
|
||||
--add-opens java.base/java.lang.reflect=ALL-UNNAMED
|
||||
</argLine>
|
||||
<systemPropertyVariables>
|
||||
<!-- gradle.build.dir is the property name that JunitXmlReporter expects to find, even though
|
||||
this is maven -->
|
||||
<gradle.build.dir>${project.build.directory}/surefire-reports</gradle.build.dir>
|
||||
<java.util.logging.config.file>${project.basedir}/lib/test-logging.properties</java.util.logging.config.file>
|
||||
<kotest.framework.classpath.scanning.autoscan.disable>true</kotest.framework.classpath.scanning.autoscan.disable>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-assembly-plugin</artifactId>
|
||||
<version>3.1.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>make-assembly</id>
|
||||
<phase>package</phase>
|
||||
<goals> <goal>single</goal> </goals>
|
||||
<configuration>
|
||||
<archive>
|
||||
<manifest>
|
||||
<mainClass>${exec.mainClass}</mainClass>
|
||||
</manifest>
|
||||
</archive>
|
||||
<descriptorRefs>
|
||||
<descriptorRef>jar-with-dependencies</descriptorRef>
|
||||
</descriptorRefs>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.1.1</version>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<!-- ktlint: check runs in the verify phase; auto-format with "mvn exec:exec@ktlint-format" -->
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
<version>3.6.3</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.pinterest.ktlint</groupId>
|
||||
<artifactId>ktlint-cli</artifactId>
|
||||
<version>${ktlint.version}</version>
|
||||
<classifier>all</classifier>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>ktlint-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>ktlint-format</id>
|
||||
<goals>
|
||||
<goal>exec</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<executable>java</executable>
|
||||
<includePluginDependencies>true</includePluginDependencies>
|
||||
<arguments>
|
||||
<argument>-classpath</argument>
|
||||
<classpath>
|
||||
<dependency>com.pinterest.ktlint:ktlint-cli</dependency>
|
||||
</classpath>
|
||||
<argument>com.pinterest.ktlint.Main</argument>
|
||||
<argument>--format</argument>
|
||||
<argument>--relative</argument>
|
||||
<argument>src/main/kotlin/**/*.kt</argument>
|
||||
<argument>src/test/kotlin/**/*.kt</argument>
|
||||
</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-releases</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Releases)</name>
|
||||
<releases>
|
||||
<enabled>true</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/releases/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>jitsi-maven-repository-snapshots</id>
|
||||
<layout>default</layout>
|
||||
<name>Jitsi Maven Repository (Snapshots)</name>
|
||||
<releases>
|
||||
<enabled>false</enabled>
|
||||
</releases>
|
||||
<snapshots>
|
||||
<enabled>true</enabled>
|
||||
</snapshots>
|
||||
<url>https://github.com/jitsi/jitsi-maven-repository/raw/master/snapshots/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>coverage</id>
|
||||
<properties>
|
||||
<jacoco.version>0.8.8</jacoco.version>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>test</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
9
resources/add_git_pre_commit_script.sh
Executable file
9
resources/add_git_pre_commit_script.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
PRE_COMMIT_HOOK_FILE=".git/hooks/pre-commit"
|
||||
|
||||
echo "#!/bin/bash" >> $PRE_COMMIT_HOOK_FILE
|
||||
echo "echo \"Running linter\"" >> $PRE_COMMIT_HOOK_FILE
|
||||
echo "mvn exec:exec@ktlint-check" >> $PRE_COMMIT_HOOK_FILE
|
||||
|
||||
chmod +x $PRE_COMMIT_HOOK_FILE
|
||||
5
resources/build.sh
Executable file
5
resources/build.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
mvn -B clean verify package "$@"
|
||||
45
resources/debian-package/etc/jitsi/jibri/asoundrc
Normal file
45
resources/debian-package/etc/jitsi/jibri/asoundrc
Normal file
@@ -0,0 +1,45 @@
|
||||
pcm.amix {
|
||||
type dmix
|
||||
ipc_key 219345
|
||||
slave.pcm "hw:Loopback,0,0"
|
||||
}
|
||||
|
||||
pcm.asnoop {
|
||||
type dsnoop
|
||||
ipc_key 219346
|
||||
slave.pcm "hw:Loopback_1,1,0"
|
||||
}
|
||||
|
||||
pcm.aduplex {
|
||||
type asym
|
||||
playback.pcm "amix"
|
||||
capture.pcm "asnoop"
|
||||
}
|
||||
|
||||
pcm.bmix {
|
||||
type dmix
|
||||
ipc_key 219347
|
||||
slave.pcm "hw:Loopback_1,0,0"
|
||||
}
|
||||
|
||||
pcm.bsnoop {
|
||||
type dsnoop
|
||||
ipc_key 219348
|
||||
slave.pcm "hw:Loopback,1,0"
|
||||
}
|
||||
|
||||
pcm.bduplex {
|
||||
type asym
|
||||
playback.pcm "bmix"
|
||||
capture.pcm "bsnoop"
|
||||
}
|
||||
|
||||
pcm.pjsua {
|
||||
type plug
|
||||
slave.pcm "bduplex"
|
||||
}
|
||||
|
||||
pcm.!default {
|
||||
type plug
|
||||
slave.pcm "aduplex"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ShowTaskBar = 0
|
||||
2
resources/debian-package/etc/jitsi/jibri/jibri.conf
Normal file
2
resources/debian-package/etc/jitsi/jibri/jibri.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
jibri {
|
||||
}
|
||||
31
resources/debian-package/etc/jitsi/jibri/pjsua.config
Normal file
31
resources/debian-package/etc/jitsi/jibri/pjsua.config
Normal file
@@ -0,0 +1,31 @@
|
||||
--capture-dev=11
|
||||
--playback-dev=14
|
||||
--video
|
||||
--vcapture-dev=1
|
||||
--no-color
|
||||
--log-level=5
|
||||
--app-log-level=5
|
||||
--auto-update-nat 0
|
||||
--disable-stun
|
||||
--no-tcp
|
||||
--dis-codec GSM
|
||||
--dis-codec H263
|
||||
--dis-codec iLBC
|
||||
--dis-codec G722
|
||||
--dis-codec speex
|
||||
--dis-codec pcmu
|
||||
--dis-codec pcma
|
||||
--dis-codec opus
|
||||
--add-codec pcmu
|
||||
--add-codec pcma
|
||||
--add-codec speex
|
||||
--add-codec G722
|
||||
--add-codec opus
|
||||
--no-vad
|
||||
--ec-tail 0
|
||||
--quality 10
|
||||
--max-calls=1
|
||||
--auto-keyframe=30
|
||||
--no-stderr
|
||||
--log-file=/var/log/jitsi/jibri/pjsua.log
|
||||
--id "jibri <sip:jibri@127.0.0.1>"
|
||||
119
resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf
Normal file
119
resources/debian-package/etc/jitsi/jibri/xorg-video-dummy.conf
Normal file
@@ -0,0 +1,119 @@
|
||||
# This xorg configuration file is meant to be used by xpra
|
||||
# to start a dummy X11 server.
|
||||
# For details, please see:
|
||||
# https://xpra.org/Xdummy.html
|
||||
|
||||
Section "ServerFlags"
|
||||
Option "DontVTSwitch" "true"
|
||||
Option "AllowMouseOpenFail" "true"
|
||||
Option "PciForceNone" "true"
|
||||
Option "AutoEnableDevices" "false"
|
||||
Option "AutoAddDevices" "false"
|
||||
EndSection
|
||||
|
||||
Section "Device"
|
||||
Identifier "dummy_videocard"
|
||||
Driver "dummy"
|
||||
Option "ConstantDPI" "true"
|
||||
#VideoRam 4096000
|
||||
#VideoRam 256000
|
||||
VideoRam 192000
|
||||
EndSection
|
||||
|
||||
Section "Monitor"
|
||||
Identifier "dummy_monitor"
|
||||
HorizSync 5.0 - 1000.0
|
||||
VertRefresh 5.0 - 200.0
|
||||
#This can be used to get a specific DPI, but only for the default resolution:
|
||||
#DisplaySize 508 317
|
||||
#NOTE: the highest modes will not work without increasing the VideoRam
|
||||
# for the dummy video card.
|
||||
Modeline "32768x32768" 15226.50 32768 35800 39488 46208 32768 32771 32781 32953
|
||||
Modeline "32768x16384" 7516.25 32768 35544 39192 45616 16384 16387 16397 16478
|
||||
Modeline "16384x8192" 2101.93 16384 16416 24400 24432 8192 8390 8403 8602
|
||||
Modeline "8192x4096" 424.46 8192 8224 9832 9864 4096 4195 4202 4301
|
||||
Modeline "5496x1200" 199.13 5496 5528 6280 6312 1200 1228 1233 1261
|
||||
Modeline "5280x1080" 169.96 5280 5312 5952 5984 1080 1105 1110 1135
|
||||
Modeline "5280x1200" 191.40 5280 5312 6032 6064 1200 1228 1233 1261
|
||||
Modeline "5120x3200" 199.75 5120 5152 5904 5936 3200 3277 3283 3361
|
||||
Modeline "4800x1200" 64.42 4800 4832 5072 5104 1200 1229 1231 1261
|
||||
Modeline "3840x2880" 133.43 3840 3872 4376 4408 2880 2950 2955 3025
|
||||
Modeline "3840x2560" 116.93 3840 3872 4312 4344 2560 2622 2627 2689
|
||||
Modeline "3840x2048" 91.45 3840 3872 4216 4248 2048 2097 2101 2151
|
||||
Modeline "3840x1080" 100.38 3840 3848 4216 4592 1080 1081 1084 1093
|
||||
Modeline "3600x1200" 106.06 3600 3632 3984 4368 1200 1201 1204 1214
|
||||
Modeline "3288x1080" 39.76 3288 3320 3464 3496 1080 1106 1108 1135
|
||||
Modeline "2048x2048" 49.47 2048 2080 2264 2296 2048 2097 2101 2151
|
||||
Modeline "2048x1536" 80.06 2048 2104 2312 2576 1536 1537 1540 1554
|
||||
Modeline "2560x1600" 47.12 2560 2592 2768 2800 1600 1639 1642 1681
|
||||
Modeline "2560x1440" 42.12 2560 2592 2752 2784 1440 1475 1478 1513
|
||||
Modeline "1920x1440" 69.47 1920 1960 2152 2384 1440 1441 1444 1457
|
||||
Modeline "1920x1200" 26.28 1920 1952 2048 2080 1200 1229 1231 1261
|
||||
Modeline "1920x1080" 23.53 1920 1952 2040 2072 1080 1106 1108 1135
|
||||
Modeline "1680x1050" 20.08 1680 1712 1784 1816 1050 1075 1077 1103
|
||||
Modeline "1600x1200" 22.04 1600 1632 1712 1744 1200 1229 1231 1261
|
||||
Modeline "1600x900" 33.92 1600 1632 1760 1792 900 921 924 946
|
||||
Modeline "1440x900" 30.66 1440 1472 1584 1616 900 921 924 946
|
||||
ModeLine "1366x768" 72.00 1366 1414 1446 1494 768 771 777 803
|
||||
Modeline "1280x1024" 31.50 1280 1312 1424 1456 1024 1048 1052 1076
|
||||
Modeline "1280x800" 24.15 1280 1312 1400 1432 800 819 822 841
|
||||
Modeline "1280x768" 23.11 1280 1312 1392 1424 768 786 789 807
|
||||
Modeline "1360x768" 24.49 1360 1392 1480 1512 768 786 789 807
|
||||
Modeline "1024x768" 18.71 1024 1056 1120 1152 768 786 789 807
|
||||
Modeline "768x1024" 19.50 768 800 872 904 1024 1048 1052 1076
|
||||
|
||||
|
||||
#common resolutions for android devices (both orientations):
|
||||
Modeline "800x1280" 25.89 800 832 928 960 1280 1310 1315 1345
|
||||
Modeline "1280x800" 24.15 1280 1312 1400 1432 800 819 822 841
|
||||
Modeline "720x1280" 30.22 720 752 864 896 1280 1309 1315 1345
|
||||
Modeline "1280x720" 27.41 1280 1312 1416 1448 720 737 740 757
|
||||
Modeline "768x1024" 24.93 768 800 888 920 1024 1047 1052 1076
|
||||
Modeline "1024x768" 23.77 1024 1056 1144 1176 768 785 789 807
|
||||
Modeline "600x1024" 19.90 600 632 704 736 1024 1047 1052 1076
|
||||
Modeline "1024x600" 18.26 1024 1056 1120 1152 600 614 617 631
|
||||
Modeline "536x960" 16.74 536 568 624 656 960 982 986 1009
|
||||
Modeline "960x536" 15.23 960 992 1048 1080 536 548 551 563
|
||||
Modeline "600x800" 15.17 600 632 688 720 800 818 822 841
|
||||
Modeline "800x600" 14.50 800 832 880 912 600 614 617 631
|
||||
Modeline "480x854" 13.34 480 512 560 592 854 873 877 897
|
||||
Modeline "848x480" 12.09 848 880 920 952 480 491 493 505
|
||||
Modeline "480x800" 12.43 480 512 552 584 800 818 822 841
|
||||
Modeline "800x480" 11.46 800 832 872 904 480 491 493 505
|
||||
#resolutions for android devices (both orientations)
|
||||
#minus the status bar
|
||||
#38px status bar (and width rounded up)
|
||||
Modeline "800x1242" 25.03 800 832 920 952 1242 1271 1275 1305
|
||||
Modeline "1280x762" 22.93 1280 1312 1392 1424 762 780 783 801
|
||||
Modeline "720x1242" 29.20 720 752 856 888 1242 1271 1276 1305
|
||||
Modeline "1280x682" 25.85 1280 1312 1408 1440 682 698 701 717
|
||||
Modeline "768x986" 23.90 768 800 888 920 986 1009 1013 1036
|
||||
Modeline "1024x730" 22.50 1024 1056 1136 1168 730 747 750 767
|
||||
Modeline "600x986" 19.07 600 632 704 736 986 1009 1013 1036
|
||||
Modeline "1024x562" 17.03 1024 1056 1120 1152 562 575 578 591
|
||||
Modeline "536x922" 16.01 536 568 624 656 922 943 947 969
|
||||
Modeline "960x498" 14.09 960 992 1040 1072 498 509 511 523
|
||||
Modeline "600x762" 14.39 600 632 680 712 762 779 783 801
|
||||
Modeline "800x562" 13.52 800 832 880 912 562 575 578 591
|
||||
Modeline "480x810" 12.59 480 512 552 584 810 828 832 851
|
||||
Modeline "848x442" 11.09 848 880 920 952 442 452 454 465
|
||||
Modeline "480x762" 11.79 480 512 552 584 762 779 783 801
|
||||
EndSection
|
||||
|
||||
Section "Screen"
|
||||
Identifier "dummy_screen"
|
||||
Device "dummy_videocard"
|
||||
Monitor "dummy_monitor"
|
||||
DefaultDepth 24
|
||||
SubSection "Display"
|
||||
Viewport 0 0
|
||||
Depth 24
|
||||
#Modes "1280x720"
|
||||
Modes "1920x1080"
|
||||
EndSubSection
|
||||
EndSection
|
||||
|
||||
Section "ServerLayout"
|
||||
Identifier "dummy_layout"
|
||||
Screen "dummy_screen"
|
||||
EndSection
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Jibri Window Manager
|
||||
Requires=jibri-xorg.service
|
||||
After=jibri-xorg.service
|
||||
|
||||
[Service]
|
||||
User=jibri
|
||||
Group=jibri
|
||||
Environment=DISPLAY=:0
|
||||
ExecStart=/usr/bin/icewm-session
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=255
|
||||
Type=simple
|
||||
|
||||
[Install]
|
||||
WantedBy=jibri.service
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Jibri Xorg Process
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=jibri
|
||||
Group=jibri
|
||||
Environment=DISPLAY=:0
|
||||
ExecStart=/usr/bin/Xorg -nocursor -noreset +extension RANDR +extension RENDER -logfile /var/log/jitsi/jibri/xorg.log -config /etc/jitsi/jibri/xorg-video-dummy.conf :0
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=255
|
||||
Type=simple
|
||||
|
||||
[Install]
|
||||
WantedBy=jibri.service jibri-icewm.service
|
||||
19
resources/debian-package/etc/systemd/system/jibri.service
Normal file
19
resources/debian-package/etc/systemd/system/jibri.service
Normal file
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Jibri Process
|
||||
Requires=jibri-icewm.service jibri-xorg.service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=jibri
|
||||
Group=jibri
|
||||
PermissionsStartOnly=true
|
||||
ExecStartPre=sudo -u jibri /usr/bin/google-chrome --timeout=1000 --headless about:blank
|
||||
ExecStart=/opt/jitsi/jibri/launch.sh
|
||||
ExecStop=/opt/jitsi/jibri/graceful_shutdown.sh
|
||||
ExecReload=/opt/jitsi/jibri/reload.sh
|
||||
Restart=always
|
||||
RestartPreventExitStatus=255
|
||||
Type=simple
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
39
resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh
Executable file
39
resources/debian-package/opt/jitsi/jibri/collect-dump-logs.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
# script that creates an archive in current folder
|
||||
# containing the heap and thread dump and the current log file
|
||||
|
||||
JAVA_HEAPDUMP_PATH="/tmp/java_*.hprof"
|
||||
STAMP=`date +%Y-%m-%d-%H%M`
|
||||
jibri_USER="jibri"
|
||||
jibri_UID=`id -u $jibri_USER`
|
||||
RUNNING=""
|
||||
unset PID
|
||||
|
||||
#Find any crashes in /var/crash from our user in the past 20 minutes, if they exist
|
||||
CRASH_FILES=$(find /var/crash -name '*.crash' -uid $jibri_UID -mmin -20 -type f)
|
||||
PID=$(systemctl show -p MainPID jibri 2>/dev/null | cut -d= -f2)
|
||||
|
||||
if [ ! -z $PID ]; then
|
||||
ps -p $PID | grep -q java
|
||||
[ $? -eq 0 ] && RUNNING="true"
|
||||
fi
|
||||
if [ ! -z $RUNNING ]; then
|
||||
echo "jibri pid $PID"
|
||||
THREADS_FILE="/tmp/stack-${STAMP}-${PID}.threads"
|
||||
HEAP_FILE="/tmp/heap-${STAMP}-${PID}.bin"
|
||||
sudo -u $jibri_USER jstack ${PID} > ${THREADS_FILE}
|
||||
sudo -u $jibri_USER jmap -dump:live,format=b,file=${HEAP_FILE} ${PID}
|
||||
tar zcvf jibri-dumps-${STAMP}-${PID}.tgz ${THREADS_FILE} ${HEAP_FILE} ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_*
|
||||
rm ${HEAP_FILE} ${THREADS_FILE}
|
||||
else
|
||||
ls $JAVA_HEAPDUMP_PATH >/dev/null 2>&1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "jibri not running, but previous heap dump found."
|
||||
tar zcvf jibri-dumps-${STAMP}-crash.tgz $JAVA_HEAPDUMP_PATH ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_*
|
||||
rm ${JAVA_HEAPDUMP_PATH}
|
||||
else
|
||||
echo "jibri not running, no previous dump found. Including logs only."
|
||||
tar zcvf jibri-dumps-${STAMP}-crash.tgz ${CRASH_FILES} /var/log/jitsi/jibri/* /tmp/hs_err_*
|
||||
fi
|
||||
fi
|
||||
10
resources/debian-package/opt/jitsi/jibri/finalize_sip.sh
Executable file
10
resources/debian-package/opt/jitsi/jibri/finalize_sip.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
LOG_DIR_PATH="/var/log/jitsi/jibri"
|
||||
LATEST_PJSUA_LOG_FILE="$LOG_DIR_PATH/pjsua.log"
|
||||
AGGREGATED_PJSUA_LOG_FILE="$LOG_DIR_PATH/pjsua_all.log"
|
||||
|
||||
if [ -f "$LATEST_PJSUA_LOG_FILE" ]; then
|
||||
echo "Appending pjsua logs from $LATEST_PJSUA_LOG_FILE, to $AGGREGATED_PJSUA_LOG_FILE"
|
||||
cat "$LATEST_PJSUA_LOG_FILE" >> "$AGGREGATED_PJSUA_LOG_FILE"
|
||||
fi
|
||||
8
resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh
Executable file
8
resources/debian-package/opt/jitsi/jibri/graceful_shutdown.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
CONF="/etc/jitsi/jibri/jibri.conf"
|
||||
|
||||
PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true)
|
||||
[[ -z "$PORT" ]] && PORT=3333
|
||||
|
||||
curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/gracefulShutdown
|
||||
3
resources/debian-package/opt/jitsi/jibri/launch.sh
Executable file
3
resources/debian-package/opt/jitsi/jibri/launch.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exec java -Djava.util.logging.config.file=/etc/jitsi/jibri/logging.properties -Dconfig.file="/etc/jitsi/jibri/jibri.conf" -jar /opt/jitsi/jibri/jibri.jar --config "/etc/jitsi/jibri/config.json"
|
||||
3
resources/debian-package/opt/jitsi/jibri/pjsua.sh
Executable file
3
resources/debian-package/opt/jitsi/jibri/pjsua.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
exec /usr/local/bin/pjsua --config-file /etc/jitsi/jibri/pjsua.config "$@" > /dev/null
|
||||
8
resources/debian-package/opt/jitsi/jibri/reload.sh
Executable file
8
resources/debian-package/opt/jitsi/jibri/reload.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
CONF="/etc/jitsi/jibri/jibri.conf"
|
||||
|
||||
PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true)
|
||||
[[ -z "$PORT" ]] && PORT=3333
|
||||
|
||||
curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/notifyConfigChanged
|
||||
8
resources/debian-package/opt/jitsi/jibri/shutdown.sh
Executable file
8
resources/debian-package/opt/jitsi/jibri/shutdown.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
CONF="/etc/jitsi/jibri/jibri.conf"
|
||||
|
||||
PORT=$(hocon -f $CONF get jibri.api.http.internal-api-port 2>/dev/null || true)
|
||||
[[ -z "$PORT" ]] && PORT=3333
|
||||
|
||||
curl -sX POST http://127.0.0.1:$PORT/jibri/api/internal/v1.0/shutdown
|
||||
154
resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh
Executable file
154
resources/debian-package/opt/jitsi/jibri/wait_graceful_shutdown.sh
Executable file
@@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 1. The script issues shutdown command via the graceful shutdown command
|
||||
# If unsuccessful then it exits with 1.
|
||||
# 2. If the code is ok then it checks if jibri has exited.
|
||||
# 3. If not then it polls jibri statistics until recording status is not true
|
||||
# 4. Gives some time for jibri to shutdown. If it does not quit after that
|
||||
# time then it kills the process. If the process was successfully killed 0 is
|
||||
# returned and 1 otherwise.
|
||||
#
|
||||
# NOTE: script depends on the tool jq, used to parse json, and curl to access URLs
|
||||
#
|
||||
|
||||
# Initialize arguments
|
||||
CURL_BIN="/usr/bin/curl"
|
||||
|
||||
# URL for jibri status
|
||||
STATUS_URL="http://localhost:2222/jibri/api/v1.0/health"
|
||||
# URL to POST to signal jibri stop/cleanup
|
||||
STOP_URL="http://localhost:2222/jibri/api/v1.0/stopService"
|
||||
|
||||
# wait this long before failing stop curl command
|
||||
STOP_TIMEOUT=3600
|
||||
# wait this long before failing status curl command
|
||||
STATUS_TIMEOUT=30
|
||||
|
||||
# sleep this long between checking jibri status
|
||||
SLEEP_TIME=10
|
||||
# delay shutdown AT MOST 6 hours = 21600 seconds
|
||||
TERMINATION_DELAY_TIMEOUT=21600
|
||||
|
||||
function getJibriStatus() {
|
||||
$CURL_BIN --max-time $STATUS_TIMEOUT $STATUS_URL 2>/dev/null
|
||||
}
|
||||
|
||||
function stopJibri() {
|
||||
$CURL_BIN -H 'Content-Type: application/json' -d '{}' --max-time $STOP_TIMEOUT $STOP_URL 2>/dev/null
|
||||
}
|
||||
|
||||
function isRecording() {
|
||||
STATUS=`getJibriStatus`
|
||||
echo $STATUS | jq -r ".status.busyStatus"
|
||||
}
|
||||
|
||||
verbose=1
|
||||
|
||||
# Parse arguments
|
||||
OPTIND=1
|
||||
while getopts "h:t:s" opt; do
|
||||
case "$opt" in
|
||||
h)
|
||||
STATUS_URL=$OPTARG
|
||||
;;
|
||||
t)
|
||||
STATUS_TIMEOUT=$OPTARG
|
||||
;;
|
||||
s)
|
||||
verbose=0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift "$((OPTIND-1))"
|
||||
|
||||
|
||||
# Prints info messages
|
||||
function printInfo {
|
||||
if [ "$verbose" == "1" ]
|
||||
then
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Prints errors
|
||||
function printError {
|
||||
echo "$@" 1>&2
|
||||
}
|
||||
|
||||
function gracefulShutdownJibri() {
|
||||
/opt/jitsi/jibri/graceful_shutdown.sh
|
||||
}
|
||||
|
||||
pid=$(/bin/systemctl show -p MainPID jibri 2>/dev/null | cut -d= -f2)
|
||||
|
||||
echo -n "Graceful shutdown signal..."
|
||||
gracefulShutdownJibri
|
||||
shutdownStatus=$?
|
||||
echo "sent"
|
||||
if [ $shutdownStatus == 0 ];
|
||||
then
|
||||
printInfo "Graceful shutdown started"
|
||||
recordingStatus=`isRecording`
|
||||
SLEEP_COUNT=0
|
||||
while [[ $recordingStatus == "BUSY" ]]; do
|
||||
if [[ $SLEEP_COUNT -ge $TERMINATION_DELAY_TIMEOUT ]]; then
|
||||
printInfo "WAITED $TERMINATION_DELAY_TIMEOUT seconds, stopping jibri."
|
||||
# send signal to stop streaming/recording and upload any recordings pending
|
||||
stopJibri
|
||||
# mark jibri service as intentionally stopped to avoid restarts
|
||||
/usr/sbin/service jibri stop
|
||||
# stop looping
|
||||
break;
|
||||
fi
|
||||
printInfo "A recording is in progress"
|
||||
sleep $SLEEP_TIME
|
||||
SLEEP_COUNT=$(( $SLEEP_COUNT + $SLEEP_TIME ))
|
||||
recordingStatus=`isRecording`
|
||||
done
|
||||
|
||||
sleep 5
|
||||
|
||||
# mark jibri service as intentionally stopped to avoid restarts
|
||||
/usr/sbin/service jibri stop
|
||||
|
||||
if ps -p $pid > /dev/null 2>&1
|
||||
then
|
||||
printInfo "It is still running, lets give it $STATUS_TIMEOUT seconds"
|
||||
sleep $STATUS_TIMEOUT
|
||||
if ps -p $pid > /dev/null 2>&1
|
||||
then
|
||||
printError "Jibri did not exit after $STATUS_TIMEOUT sec - killing $pid"
|
||||
kill $pid
|
||||
else
|
||||
printInfo "Jibri shutdown OK"
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
printInfo "Jibri shutdown OK"
|
||||
exit 0
|
||||
fi
|
||||
# check for 3 seconds if we managed to kill
|
||||
for I in 1 2 3
|
||||
do
|
||||
if ps -p $pid > /dev/null 2>&1
|
||||
then
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
if ps -p $pid > /dev/null 2>&1
|
||||
then
|
||||
printError "Failed to kill $pid"
|
||||
printError "Sending force kill to $pid"
|
||||
kill -9 $pid
|
||||
if ps -p $pid > /dev/null 2>&1
|
||||
then
|
||||
printError "Failed to force kill $pid"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
printInfo "Jibri shutdown OK"
|
||||
exit 0
|
||||
else
|
||||
printError "Failed to signal shutdown of Jibri: $shutdownStatus"
|
||||
exit 1
|
||||
fi
|
||||
10
resources/finalize_recording.sh
Executable file
10
resources/finalize_recording.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
RECORDINGS_DIR=$1
|
||||
|
||||
echo "This is a dummy finalize script" > /tmp/finalize.out
|
||||
echo "The script was invoked with recordings directory $RECORDINGS_DIR." >> /tmp/finalize.out
|
||||
echo "You should put any finalize logic (renaming, uploading to a service" >> /tmp/finalize.out
|
||||
echo "or storage provider, etc.) in this script" >> /tmp/finalize.out
|
||||
|
||||
exit 0
|
||||
5
resources/jenkins/build.sh
Executable file
5
resources/jenkins/build.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
./resources/build.sh
|
||||
38
resources/jenkins/release.sh
Executable file
38
resources/jenkins/release.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This script should be run after the build/tests have successfully completed
|
||||
|
||||
# Prune any local tags that aren't on the remote
|
||||
git fetch --prune origin "+refs/tags/*:refs/tags/*"
|
||||
|
||||
cd $WORKSPACE
|
||||
|
||||
# Let's get version from maven
|
||||
MVNVER=`xmllint --xpath "/*[local-name()='project']/*[local-name()='version']/text()" pom.xml`
|
||||
TAG_NAME="v${MVNVER/-SNAPSHOT/}"
|
||||
echo "Current tag name: $TAG_NAME"
|
||||
|
||||
if ! git rev-parse $TAG_NAME >/dev/null 2>&1
|
||||
then
|
||||
git tag -a $TAG_NAME -m "Tagged automatically by Jenkins"
|
||||
git push origin $TAG_NAME
|
||||
else
|
||||
echo "Tag: $TAG_NAME already exists."
|
||||
fi
|
||||
|
||||
VERSION_FULL=`git describe --match "v[0-9\.]*" --long`
|
||||
echo "Full version: ${VERSION_FULL}"
|
||||
|
||||
VERSION=${VERSION_FULL:1}
|
||||
echo "Package version: ${VERSION}"
|
||||
|
||||
REV=$(git log --pretty=format:'%h' -n 1)
|
||||
|
||||
# bulding the debian package expects the file target/jibri.jar
|
||||
mv target/jibri-${MVNVER}-jar-with-dependencies.jar target/jibri.jar
|
||||
|
||||
dch -v "${VERSION}-1" "Built from git. $REV"
|
||||
dch -D unstable -r ""
|
||||
|
||||
dpkg-buildpackage -A -rfakeroot -us -uc
|
||||
47
src/main/java/org/jitsi/jibri/RecordingSinkType.java
Normal file
47
src/main/java/org/jitsi/jibri/RecordingSinkType.java
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* @author bbaldino
|
||||
* NOTE: this file had to be in java, I couldn't get it to recognize the
|
||||
* custom JsonCreator constructor to handle enum-case-agnosticisim in kotlin
|
||||
*/
|
||||
public enum RecordingSinkType
|
||||
{
|
||||
STREAM("stream"),
|
||||
FILE("file"),
|
||||
//TODO: putting gateway in here doesn't feel great (and isn't what the xmpp uses anyway).
|
||||
// we need some top-level param that denotes what we're doing (recording a file,
|
||||
// streaming to youtube, or doing a sipgateway)
|
||||
GATEWAY("gateway");
|
||||
|
||||
@SuppressWarnings("FieldCanBeLocal")
|
||||
private final String recordingSinkType;
|
||||
|
||||
RecordingSinkType(final String recordingMode) {
|
||||
this.recordingSinkType = recordingMode;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static RecordingSinkType fromString(@JsonProperty("sinkType") final String text) {
|
||||
return RecordingSinkType.valueOf(text.toUpperCase());
|
||||
}
|
||||
}
|
||||
63
src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt
Normal file
63
src/main/kotlin/org/jitsi/jibri/CallUrlInfo.kt
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* We assume the 'baseUrl' represents a sort of landing page (on the same
|
||||
* domain) where we can set the necessary local storage values. The call
|
||||
* url will be created by joining [baseUrl] and [callName] with a "/". If
|
||||
* set, a list of [urlParams] will be concatenated after the call name with
|
||||
* a "#" in between.
|
||||
*/
|
||||
data class CallUrlInfo(
|
||||
val baseUrl: String = "",
|
||||
val callName: String = "",
|
||||
val tenant: String = "",
|
||||
val urlParams: List<String> = listOf()
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val callUrl: String
|
||||
get() {
|
||||
return if (urlParams.isNotEmpty()) {
|
||||
"$baseUrl/$callName#${urlParams.joinToString("&")}"
|
||||
} else {
|
||||
"$baseUrl/$callName"
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = when {
|
||||
other == null -> false
|
||||
this === other -> true
|
||||
javaClass != other.javaClass -> false
|
||||
else -> hashCode() == other.hashCode()
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
// Purposefully ignore urlParams here
|
||||
return Objects.hash(baseUrl.lowercase(), callName.lowercase())
|
||||
}
|
||||
|
||||
fun withAdditionalUrlParams(params: List<String>): CallUrlInfo =
|
||||
this.copy(urlParams = (this.urlParams + params).distinct())
|
||||
|
||||
override fun toString() = "CallUrlInfo(baseUrl='$baseUrl', callName='$callName', " +
|
||||
"urlParams=${if (urlParams.isEmpty()) "empty" else "not-empty"})"
|
||||
}
|
||||
299
src/main/kotlin/org/jitsi/jibri/JibriManager.kt
Normal file
299
src/main/kotlin/org/jitsi/jibri/JibriManager.kt
Normal file
@@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri
|
||||
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.health.EnvironmentContext
|
||||
import org.jitsi.jibri.metrics.JibriMetrics
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.service.JibriService
|
||||
import org.jitsi.jibri.service.JibriServiceStatusHandler
|
||||
import org.jitsi.jibri.service.ServiceParams
|
||||
import org.jitsi.jibri.service.impl.FileRecordingJibriService
|
||||
import org.jitsi.jibri.service.impl.FileRecordingParams
|
||||
import org.jitsi.jibri.service.impl.SipGatewayJibriService
|
||||
import org.jitsi.jibri.service.impl.SipGatewayServiceParams
|
||||
import org.jitsi.jibri.service.impl.StreamingJibriService
|
||||
import org.jitsi.jibri.service.impl.StreamingParams
|
||||
import org.jitsi.jibri.status.ComponentBusyStatus
|
||||
import org.jitsi.jibri.status.ComponentHealthStatus
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
import org.jitsi.jibri.util.StatusPublisher
|
||||
import org.jitsi.jibri.util.TaskPools
|
||||
import org.jitsi.jibri.util.extensions.schedule
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class JibriBusyException : Exception()
|
||||
|
||||
/**
|
||||
* Some of the values in [FileRecordingParams] come from the configuration
|
||||
* file, so the incoming request won't contain all of them. This class
|
||||
* models the subset of values which will come in the request.
|
||||
*/
|
||||
data class FileRecordingRequestParams(
|
||||
/**
|
||||
* Which call we'll join
|
||||
*/
|
||||
val callParams: CallParams,
|
||||
/**
|
||||
* The ID of this session
|
||||
*/
|
||||
val sessionId: String,
|
||||
/**
|
||||
* The login information needed to appear invisible in
|
||||
* the call
|
||||
*/
|
||||
val callLoginParams: XmppCredentials
|
||||
)
|
||||
|
||||
/**
|
||||
* [JibriManager] is responsible for managing the various services Jibri
|
||||
* provides, as well as providing an API to query the health of this Jibri
|
||||
* instance. NOTE: currently Jibri only runs a single service at a time, so
|
||||
* if one is running, the Jibri will describe itself as busy
|
||||
*
|
||||
* TODO: we mark 'Any' as the status type we publish because we have 2 different status types we want to publish:
|
||||
* ComponentBusyStatus and ComponentState and i was unable to think of a better solution for that (yet...)
|
||||
*/
|
||||
class JibriManager : StatusPublisher<Any>() {
|
||||
private val logger = createLogger()
|
||||
private var currentActiveService: JibriService? = null
|
||||
|
||||
/**
|
||||
* Store some arbitrary context optionally sent in the start service request so that we can report it in our
|
||||
* status
|
||||
*/
|
||||
var currentEnvironmentContext: EnvironmentContext? = null
|
||||
|
||||
/**
|
||||
* A function which will be executed the next time this Jibri is idle. This can be used to schedule work that
|
||||
* can't be run while a Jibri session is active
|
||||
*/
|
||||
private var pendingIdleFunc: () -> Unit = {}
|
||||
private var serviceTimeoutTask: ScheduledFuture<*>? = null
|
||||
|
||||
private val singleUseMode: Boolean by config {
|
||||
"JibriConfig::singleUseMode" { Config.legacyConfigSource.singleUseMode!! }
|
||||
"jibri.single-use-mode".from(Config.configSource)
|
||||
}
|
||||
|
||||
val jibriMetrics = JibriMetrics()
|
||||
|
||||
/**
|
||||
* Note: should only be called if the instance-wide lock is held (i.e. called from
|
||||
* one of the synchronized methods)
|
||||
* TODO: instead of the synchronized decorators, use a synchronized(this) block
|
||||
* which we can also use here
|
||||
*/
|
||||
private fun throwIfBusy(sinkType: RecordingSinkType) {
|
||||
if (busy()) {
|
||||
logger.info("Jibri is busy, can't start service")
|
||||
jibriMetrics.requestWhileBusy(sinkType)
|
||||
throw JibriBusyException()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a [FileRecordingJibriService] to record the call described
|
||||
* in the params to a file.
|
||||
*/
|
||||
@Synchronized
|
||||
fun startFileRecording(
|
||||
serviceParams: ServiceParams,
|
||||
fileRecordingRequestParams: FileRecordingRequestParams,
|
||||
environmentContext: EnvironmentContext? = null,
|
||||
serviceStatusHandler: JibriServiceStatusHandler? = null
|
||||
) {
|
||||
throwIfBusy(RecordingSinkType.FILE)
|
||||
logger.info(
|
||||
"Starting a file recording, sessionId=${fileRecordingRequestParams.sessionId}, " +
|
||||
"call=${fileRecordingRequestParams.callParams}"
|
||||
)
|
||||
val service = FileRecordingJibriService(
|
||||
FileRecordingParams(
|
||||
fileRecordingRequestParams.callParams,
|
||||
fileRecordingRequestParams.sessionId,
|
||||
fileRecordingRequestParams.callLoginParams,
|
||||
serviceParams.appData?.fileRecordingMetadata
|
||||
)
|
||||
)
|
||||
jibriMetrics.start(RecordingSinkType.FILE)
|
||||
startService(service, serviceParams, environmentContext, serviceStatusHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a [StreamingJibriService] to capture the call according
|
||||
* to [streamingParams].
|
||||
*/
|
||||
@Synchronized
|
||||
fun startStreaming(
|
||||
serviceParams: ServiceParams,
|
||||
streamingParams: StreamingParams,
|
||||
environmentContext: EnvironmentContext? = null,
|
||||
serviceStatusHandler: JibriServiceStatusHandler? = null
|
||||
) {
|
||||
logger.info("Starting a stream, sessionId=${streamingParams.sessionId}, call=${streamingParams.callParams}")
|
||||
throwIfBusy(RecordingSinkType.STREAM)
|
||||
val service = StreamingJibriService(streamingParams)
|
||||
jibriMetrics.start(RecordingSinkType.STREAM)
|
||||
startService(service, serviceParams, environmentContext, serviceStatusHandler)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startSipGateway(
|
||||
serviceParams: ServiceParams,
|
||||
sipGatewayServiceParams: SipGatewayServiceParams,
|
||||
environmentContext: EnvironmentContext? = null,
|
||||
serviceStatusHandler: JibriServiceStatusHandler? = null
|
||||
) {
|
||||
logger.info("Starting a SIP gateway, call=${sipGatewayServiceParams.callParams}")
|
||||
throwIfBusy(RecordingSinkType.GATEWAY)
|
||||
val service = SipGatewayJibriService(
|
||||
SipGatewayServiceParams(
|
||||
sipGatewayServiceParams.callParams,
|
||||
sipGatewayServiceParams.callLoginParams,
|
||||
sipGatewayServiceParams.sipClientParams
|
||||
)
|
||||
)
|
||||
jibriMetrics.start(RecordingSinkType.GATEWAY)
|
||||
return startService(service, serviceParams, environmentContext, serviceStatusHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to handle the boilerplate of starting a [JibriService].
|
||||
*/
|
||||
private fun startService(
|
||||
jibriService: JibriService,
|
||||
serviceParams: ServiceParams,
|
||||
environmentContext: EnvironmentContext?,
|
||||
serviceStatusHandler: JibriServiceStatusHandler? = null
|
||||
) {
|
||||
publishStatus(ComponentBusyStatus.BUSY)
|
||||
if (serviceStatusHandler != null) {
|
||||
jibriService.addStatusHandler(serviceStatusHandler)
|
||||
}
|
||||
// The manager adds its own status handler so that it can stop
|
||||
// the error'd service and update presence appropriately
|
||||
jibriService.addStatusHandler {
|
||||
when (it) {
|
||||
is ComponentState.Error -> {
|
||||
if (it.error.scope == ErrorScope.SYSTEM) {
|
||||
jibriMetrics.error(jibriService.getSinkType())
|
||||
publishStatus(ComponentHealthStatus.UNHEALTHY)
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
|
||||
is ComponentState.Finished -> {
|
||||
// If a 'stop' was received externally, then this stopService call
|
||||
// will be redundant, but we need to make it anyway as the service
|
||||
// can also signal that it has finished (based on its own checks)
|
||||
// and needs to be stopped (cleaned up)
|
||||
stopService()
|
||||
}
|
||||
|
||||
else -> { /* No op */ }
|
||||
}
|
||||
}
|
||||
|
||||
currentActiveService = jibriService
|
||||
currentEnvironmentContext = environmentContext
|
||||
if (serviceParams.usageTimeoutMinutes != 0) {
|
||||
logger.info("This service will have a usage timeout of ${serviceParams.usageTimeoutMinutes} minute(s)")
|
||||
serviceTimeoutTask =
|
||||
TaskPools.recurringTasksPool.schedule(serviceParams.usageTimeoutMinutes.toLong(), TimeUnit.MINUTES) {
|
||||
logger.info("The usage timeout has elapsed, stopping the currently active service")
|
||||
try {
|
||||
stopService()
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error while stopping service due to usage timeout", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskPools.ioPool.submit {
|
||||
jibriService.start()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the currently active [JibriService], if there is one
|
||||
*/
|
||||
@Synchronized
|
||||
fun stopService() {
|
||||
val currentService = currentActiveService ?: run {
|
||||
// After an initial call to 'stopService', we'll stop ffmpeg and it will transition
|
||||
// to 'finished', causing the entire service to transition to 'finished' and trigger
|
||||
// another call to stopService (see the note above when installing the status handler
|
||||
// on the jibri service). A more complete fix for this is much larger, so for now
|
||||
// we'll just check if the currentActiveService has already been cleared to prevent
|
||||
// doing a double stop (which is mostly harmless, but does fire an extra 'stop'
|
||||
// statsd event with an empty service tag)
|
||||
logger.info("No service active, ignoring stop")
|
||||
return
|
||||
}
|
||||
jibriMetrics.stop(currentService.getSinkType())
|
||||
logger.info("Stopping the current service")
|
||||
serviceTimeoutTask?.cancel(false)
|
||||
// Note that this will block until the service is completely stopped
|
||||
currentService.stop()
|
||||
currentActiveService = null
|
||||
currentEnvironmentContext = null
|
||||
// Invoke the function we've been told to next time we're idle
|
||||
// and reset it
|
||||
pendingIdleFunc()
|
||||
pendingIdleFunc = {}
|
||||
if (singleUseMode) {
|
||||
logger.info("Jibri is in single-use mode, not returning to IDLE")
|
||||
publishStatus(ComponentBusyStatus.EXPIRED)
|
||||
} else {
|
||||
publishStatus(ComponentBusyStatus.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not this Jibri is currently "busy". "Busy" is
|
||||
* is defined as "does not currently have the capacity to spin up another
|
||||
* service"
|
||||
*/
|
||||
@Synchronized
|
||||
fun busy(): Boolean = currentActiveService != null
|
||||
|
||||
/**
|
||||
* Execute the given function the next time Jibri is idle
|
||||
*/
|
||||
@Synchronized
|
||||
fun executeWhenIdle(func: () -> Unit) {
|
||||
if (!busy()) {
|
||||
func()
|
||||
} else {
|
||||
pendingIdleFunc = func
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JibriService.getSinkType() = when (this) {
|
||||
is FileRecordingJibriService -> RecordingSinkType.FILE
|
||||
is StreamingJibriService -> RecordingSinkType.GATEWAY
|
||||
is SipGatewayJibriService -> RecordingSinkType.GATEWAY
|
||||
else -> throw IllegalArgumentException("JibriService of unsupported type: ${JibriService::class.java.name}")
|
||||
}
|
||||
245
src/main/kotlin/org/jitsi/jibri/Main.kt
Normal file
245
src/main/kotlin/org/jitsi/jibri/Main.kt
Normal file
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri
|
||||
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.jetty.jakarta.Jetty
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import net.sourceforge.argparse4j.ArgumentParsers
|
||||
import org.jitsi.jibri.api.http.HttpApi
|
||||
import org.jitsi.jibri.api.http.internal.InternalHttpApi
|
||||
import org.jitsi.jibri.api.xmpp.XmppApi
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppEnvironmentConfig
|
||||
import org.jitsi.jibri.config.loadConfigFromFile
|
||||
import org.jitsi.jibri.config.toXmppEnvironment
|
||||
import org.jitsi.jibri.status.ComponentBusyStatus
|
||||
import org.jitsi.jibri.status.ComponentHealthStatus
|
||||
import org.jitsi.jibri.status.JibriStatusManager
|
||||
import org.jitsi.jibri.util.TaskPools
|
||||
import org.jitsi.jibri.util.extensions.scheduleAtFixedRate
|
||||
import org.jitsi.jibri.webhooks.v1.WebhookClient
|
||||
import org.jitsi.metaconfig.ConfigException
|
||||
import org.jitsi.metaconfig.MapConfigSource
|
||||
import org.jitsi.metaconfig.MetaconfigLogger
|
||||
import org.jitsi.metaconfig.MetaconfigSettings
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.metaconfig.configSupplier
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.LoggerImpl
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
val logger: Logger = LoggerImpl("org.jitsi.jibri.Main")
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
setupMetaconfigLogger()
|
||||
handleCommandLineArgs(args)
|
||||
|
||||
logger.info("Jibri starting up with id ${MainConfig.jibriId}")
|
||||
|
||||
val jibriStatusManager = JibriStatusManager()
|
||||
val jibriManager = JibriManager()
|
||||
jibriManager.addStatusHandler { jibriStatus ->
|
||||
when (jibriStatus) {
|
||||
is ComponentBusyStatus -> {
|
||||
jibriStatusManager.busyStatus = jibriStatus
|
||||
}
|
||||
|
||||
is ComponentHealthStatus -> {
|
||||
jibriStatusManager.updateHealth("JibriManager", jibriStatus)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.error("Unrecognized status from JibriManager: ${jibriStatus.javaClass} $jibriStatus")
|
||||
}
|
||||
}
|
||||
}
|
||||
val webhookSubscribers = configSupplier<List<String>> {
|
||||
"jibri.webhook.subscribers".from(Config.configSource)
|
||||
}.get()
|
||||
|
||||
val webhookClient = WebhookClient(MainConfig.jibriId)
|
||||
|
||||
jibriStatusManager.addStatusHandler {
|
||||
webhookClient.updateStatus(it)
|
||||
}
|
||||
jibriStatusManager.addStatusHandler {
|
||||
jibriManager.jibriMetrics.updateStatus(it)
|
||||
}
|
||||
webhookSubscribers.forEach(webhookClient::addSubscriber)
|
||||
val statusUpdaterTask = TaskPools.recurringTasksPool.scheduleAtFixedRate(
|
||||
1,
|
||||
TimeUnit.MINUTES
|
||||
) {
|
||||
webhookClient.updateStatus(jibriStatusManager.overallStatus)
|
||||
}
|
||||
|
||||
val cleanupAndExit = { exitCode: Int ->
|
||||
statusUpdaterTask.cancel(true)
|
||||
try {
|
||||
statusUpdaterTask.get(5, TimeUnit.SECONDS)
|
||||
} catch (t: Throwable) {
|
||||
when (t) {
|
||||
is CancellationException -> {}
|
||||
else -> logger.error("Error cleaning up status updater task", t)
|
||||
}
|
||||
}
|
||||
exitProcess(exitCode)
|
||||
}
|
||||
|
||||
val configChangedHandler = {
|
||||
logger.info("The config file has changed, waiting for Jibri to be idle before exiting")
|
||||
jibriManager.executeWhenIdle {
|
||||
logger.info("Jibri is idle and there are config file changes, exiting")
|
||||
// Exit so we can be restarted and load the new config
|
||||
cleanupAndExit(0)
|
||||
}
|
||||
}
|
||||
val gracefulShutdownHandler = {
|
||||
logger.info("Jibri has been told to graceful shutdown, waiting to be idle before exiting")
|
||||
jibriManager.executeWhenIdle {
|
||||
logger.info("Jibri is idle and has been told to gracefully shutdown, exiting")
|
||||
// Exit with code 255 to indicate we do not want process restart
|
||||
cleanupAndExit(255)
|
||||
}
|
||||
}
|
||||
val shutdownHandler = {
|
||||
logger.info("Jibri has been told to shutdown, stopping any active service")
|
||||
jibriManager.stopService()
|
||||
logger.info("Service stopped")
|
||||
cleanupAndExit(255)
|
||||
}
|
||||
|
||||
logger.info("Using ${InternalHttpApi.host}:${InternalHttpApi.port} for internal HTTP API")
|
||||
|
||||
with(InternalHttpApi(configChangedHandler, gracefulShutdownHandler, shutdownHandler)) {
|
||||
embeddedServer(Jetty, host = InternalHttpApi.host, port = InternalHttpApi.port) {
|
||||
internalApiModule()
|
||||
}.start()
|
||||
}
|
||||
|
||||
val xmppEnvironments = configSupplier<List<XmppEnvironmentConfig>> {
|
||||
"JibriConfig::xmppEnvironments" {
|
||||
Config.legacyConfigSource.xmppEnvironments.takeIf { it?.isNotEmpty() == true }
|
||||
?: throw ConfigException.UnableToRetrieve.NotFound("Considering empty XMPP envs list as not found")
|
||||
}
|
||||
"jibri.api.xmpp.environments"
|
||||
.from(Config.configSource)
|
||||
.convertFrom<List<com.typesafe.config.Config>> { envConfigs -> envConfigs.map { it.toXmppEnvironment() } }
|
||||
}.get()
|
||||
|
||||
// XmppApi
|
||||
val xmppApi = XmppApi(
|
||||
jibriManager = jibriManager,
|
||||
xmppConfigs = xmppEnvironments,
|
||||
jibriStatusManager = jibriStatusManager
|
||||
)
|
||||
xmppApi.start()
|
||||
|
||||
logger.info("Using ${HttpApi.host}:${HttpApi.port} for HTTP API")
|
||||
|
||||
// HttpApi
|
||||
with(HttpApi(jibriManager, jibriStatusManager, webhookClient)) {
|
||||
embeddedServer(Jetty, host = HttpApi.host, port = HttpApi.port) {
|
||||
apiModule()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
class MainConfig {
|
||||
companion object {
|
||||
val jibriId: String by config {
|
||||
"JibriConfig::jibriId" { Config.legacyConfigSource.jibriId!! }
|
||||
"jibri.id".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCommandLineArgs(args: Array<String>) {
|
||||
val argParser = ArgumentParsers.newFor("Jibri").build()
|
||||
.defaultHelp(true)
|
||||
.description("Start Jibri")
|
||||
argParser.addArgument("-c", "--config")
|
||||
.required(false)
|
||||
.type(String::class.java)
|
||||
.help("Path to the jibri config file")
|
||||
argParser.addArgument("--internal-http-port")
|
||||
.type(Int::class.java)
|
||||
.help("Port to start the internal HTTP server on")
|
||||
argParser.addArgument("--http-api-port")
|
||||
.type(Int::class.java)
|
||||
.help("Port to start the HTTP API server on")
|
||||
|
||||
logger.info("Jibri run with args ${args.asList()}")
|
||||
val ns = argParser.parseArgs(args)
|
||||
val configFilePath = ns.getString("config")
|
||||
val internalHttpPort: Int? = ns.getInt("internal_http_port")
|
||||
val httpApiPort: Int? = ns.getInt("http_api_port")
|
||||
|
||||
// Map the command line arguments into a ConfigSource
|
||||
Config.commandLineArgs = MapConfigSource("command line args") {
|
||||
internalHttpPort?.let {
|
||||
put("internal_http_port", it)
|
||||
}
|
||||
httpApiPort?.let {
|
||||
put("http_api_port", it)
|
||||
}
|
||||
}
|
||||
|
||||
configFilePath?.let {
|
||||
setupLegacyConfig(it)
|
||||
} ?: logger.info("No legacy config file set")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the legacy config file and set it in [Config] as the legacy config source
|
||||
*/
|
||||
private fun setupLegacyConfig(configFilePath: String) {
|
||||
logger.info("Checking legacy config file $configFilePath")
|
||||
val jibriConfigFile = File(configFilePath)
|
||||
if (!jibriConfigFile.exists()) {
|
||||
logger.info("Legacy config file $configFilePath doesn't exist")
|
||||
return
|
||||
}
|
||||
|
||||
val jibriConfig = loadConfigFromFile(jibriConfigFile) ?: run {
|
||||
logger.info("Parsing legacy config file failed")
|
||||
return
|
||||
}
|
||||
Config.legacyConfigSource = jibriConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the jitsi-metaconfig logger into ours
|
||||
*/
|
||||
private fun setupMetaconfigLogger() {
|
||||
val configLogger = LoggerImpl("org.jitsi.jibri.config")
|
||||
MetaconfigSettings.logger = object : MetaconfigLogger {
|
||||
override fun debug(block: () -> String) {
|
||||
configLogger.debug(block)
|
||||
}
|
||||
override fun error(block: () -> String) {
|
||||
configLogger.error(block())
|
||||
}
|
||||
override fun warn(block: () -> String) {
|
||||
configLogger.warn(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
264
src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt
Normal file
264
src/main/kotlin/org/jitsi/jibri/api/http/HttpApi.kt
Normal file
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.api.http
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.parseHeaderValue
|
||||
import io.ktor.serialization.jackson.jackson
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import org.jitsi.jibri.FileRecordingRequestParams
|
||||
import org.jitsi.jibri.JibriBusyException
|
||||
import org.jitsi.jibri.JibriManager
|
||||
import org.jitsi.jibri.RecordingSinkType
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.health.JibriHealth
|
||||
import org.jitsi.jibri.metrics.JibriMetricsContainer
|
||||
import org.jitsi.jibri.metrics.StatsConfig
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.service.JibriServiceStatusHandler
|
||||
import org.jitsi.jibri.service.ServiceParams
|
||||
import org.jitsi.jibri.service.impl.SipGatewayServiceParams
|
||||
import org.jitsi.jibri.service.impl.StreamingParams
|
||||
import org.jitsi.jibri.sipgateway.SipClientParams
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.status.JibriFailure
|
||||
import org.jitsi.jibri.status.JibriSessionStatus
|
||||
import org.jitsi.jibri.status.JibriStatusManager
|
||||
import org.jitsi.jibri.webhooks.v1.WebhookClient
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIq
|
||||
|
||||
// TODO: this needs to include usageTimeout
|
||||
data class StartServiceParams(
|
||||
val sessionId: String,
|
||||
val callParams: CallParams,
|
||||
/**
|
||||
* XMPP login information to be used if [RecordingSinkType] is
|
||||
* [RecordingSinkType.FILE] or [RecordingSinkType.STREAM] in order
|
||||
* to make the recorder 'invisible'
|
||||
*/
|
||||
val callLoginParams: XmppCredentials? = null,
|
||||
val sinkType: RecordingSinkType,
|
||||
val youTubeStreamKey: String? = null,
|
||||
/**
|
||||
* Params to be used if [RecordingSinkType] is [RecordingSinkType.GATEWAY]
|
||||
*/
|
||||
val sipClientParams: SipClientParams? = null
|
||||
)
|
||||
|
||||
class HttpApi(
|
||||
private val jibriManager: JibriManager,
|
||||
private val jibriStatusManager: JibriStatusManager,
|
||||
private val webhookClient: WebhookClient
|
||||
) {
|
||||
private val logger = createLogger()
|
||||
|
||||
fun Application.apiModule() {
|
||||
install(ContentNegotiation) {
|
||||
jackson {}
|
||||
}
|
||||
|
||||
routing {
|
||||
route("jibri/api/v1.0") {
|
||||
/*
|
||||
* Get the health of this Jibri in the format of a json-encoded
|
||||
* [JibriHealth] object
|
||||
*/
|
||||
get("health") {
|
||||
logger.debug { "Got health request" }
|
||||
val health = JibriHealth(jibriStatusManager.overallStatus, jibriManager.currentEnvironmentContext)
|
||||
logger.debug { "Returning health $health" }
|
||||
call.respond(health)
|
||||
}
|
||||
|
||||
/*
|
||||
* Start a new service using the given [StartServiceParams].
|
||||
* Returns a response with [Response.Status.OK] on success, [Response.Status.PRECONDITION_FAILED]
|
||||
* if this Jibri is already busy or params were missing and [Response.Status.INTERNAL_SERVER_ERROR] on
|
||||
* error
|
||||
* NOTE: start service is largely async, so a return of [Response.Status.OK] here just means Jibri
|
||||
* was able to *try* to start the request. We don't have a way to get ongoing updates about services
|
||||
* via the HTTP API at this point.
|
||||
*/
|
||||
post("startService") {
|
||||
try {
|
||||
val startServiceParams = call.receive<StartServiceParams>()
|
||||
logger.debug { "Got a start service request with params $startServiceParams" }
|
||||
|
||||
val serviceStatusHandler = createServiceStatusHandler(startServiceParams, webhookClient)
|
||||
handleStartService(startServiceParams, serviceStatusHandler)
|
||||
call.respond(HttpStatusCode.OK)
|
||||
} catch (e: JibriBusyException) {
|
||||
call.respond(HttpStatusCode.PreconditionFailed, "Jibri is currently busy")
|
||||
} catch (e: IllegalStateException) {
|
||||
call.respond(HttpStatusCode.PreconditionFailed, e.message ?: "")
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error starting service $t", t)
|
||||
call.respond(HttpStatusCode.InternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* [stopService] will stop the current service immediately
|
||||
*/
|
||||
post("stopService") {
|
||||
logger.debug { "Got stop service request" }
|
||||
jibriManager.stopService()
|
||||
call.respond(HttpStatusCode.OK)
|
||||
}
|
||||
}
|
||||
if (StatsConfig.enablePrometheus) {
|
||||
logger.info("Enabling prometheus interface at :$port/metrics")
|
||||
get("/metrics") {
|
||||
val accepts =
|
||||
parseHeaderValue(call.request.headers["Accept"]).sortedByDescending { it.quality }
|
||||
.map { it.value }
|
||||
val (metrics, contentType) = JibriMetricsContainer.getMetrics(accepts)
|
||||
call.respondText(metrics, contentType = ContentType.parse(contentType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createServiceStatusHandler(
|
||||
serviceParams: StartServiceParams,
|
||||
webhookClient: WebhookClient
|
||||
): JibriServiceStatusHandler = { serviceState ->
|
||||
when (serviceState) {
|
||||
is ComponentState.Error -> {
|
||||
val failure = JibriFailure(
|
||||
JibriIq.FailureReason.ERROR,
|
||||
serviceState.error
|
||||
)
|
||||
val componentSessionStatus = JibriSessionStatus(
|
||||
serviceParams.sessionId,
|
||||
JibriIq.Status.OFF,
|
||||
serviceParams.sipClientParams?.sipAddress,
|
||||
failure,
|
||||
serviceState.error.shouldRetry()
|
||||
)
|
||||
logger.info(
|
||||
"Current service had an error ${serviceState.error}, " +
|
||||
"sending status error $componentSessionStatus"
|
||||
)
|
||||
webhookClient.updateSessionStatus(componentSessionStatus)
|
||||
}
|
||||
|
||||
is ComponentState.Finished -> {
|
||||
val componentSessionStatus = JibriSessionStatus(
|
||||
serviceParams.sessionId,
|
||||
JibriIq.Status.OFF,
|
||||
serviceParams.sipClientParams?.sipAddress
|
||||
)
|
||||
logger.info("Current service finished, sending status off $componentSessionStatus")
|
||||
webhookClient.updateSessionStatus(componentSessionStatus)
|
||||
}
|
||||
|
||||
is ComponentState.Running -> {
|
||||
val componentSessionStatus = JibriSessionStatus(
|
||||
serviceParams.sessionId,
|
||||
JibriIq.Status.ON,
|
||||
serviceParams.sipClientParams?.sipAddress
|
||||
)
|
||||
logger.info("Current service started up successfully, sending status on $componentSessionStatus")
|
||||
webhookClient.updateSessionStatus(componentSessionStatus)
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.info("Webhook client ignoring service state update: $serviceState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartService(startServiceParams: StartServiceParams, statusHandler: JibriServiceStatusHandler) {
|
||||
when (startServiceParams.sinkType) {
|
||||
RecordingSinkType.FILE -> {
|
||||
// If it's a file recording, it must have the callLoginParams set
|
||||
val callLoginParams = startServiceParams.callLoginParams
|
||||
?: throw IllegalStateException("Call login params missing")
|
||||
jibriManager.startFileRecording(
|
||||
ServiceParams(usageTimeoutMinutes = 0),
|
||||
FileRecordingRequestParams(
|
||||
startServiceParams.callParams,
|
||||
startServiceParams.sessionId,
|
||||
callLoginParams
|
||||
),
|
||||
environmentContext = null,
|
||||
statusHandler
|
||||
)
|
||||
}
|
||||
|
||||
RecordingSinkType.STREAM -> {
|
||||
val youTubeStreamKey = startServiceParams.youTubeStreamKey
|
||||
?: throw IllegalStateException("Stream key missing")
|
||||
// If it's a stream, it must have the callLoginParams set
|
||||
val callLoginParams = startServiceParams.callLoginParams
|
||||
?: throw IllegalStateException("Call login params missing")
|
||||
jibriManager.startStreaming(
|
||||
ServiceParams(usageTimeoutMinutes = 0),
|
||||
StreamingParams(
|
||||
startServiceParams.callParams,
|
||||
startServiceParams.sessionId,
|
||||
callLoginParams,
|
||||
youTubeStreamKey
|
||||
),
|
||||
environmentContext = null,
|
||||
statusHandler
|
||||
)
|
||||
}
|
||||
|
||||
RecordingSinkType.GATEWAY -> {
|
||||
// If it's a sip gateway, it must have sipClientParams set
|
||||
val sipClientParams = startServiceParams.sipClientParams
|
||||
?: throw IllegalStateException("SIP client params missing")
|
||||
jibriManager.startSipGateway(
|
||||
ServiceParams(usageTimeoutMinutes = 0),
|
||||
// TODO: add session ID
|
||||
SipGatewayServiceParams(
|
||||
startServiceParams.callParams,
|
||||
startServiceParams.callLoginParams,
|
||||
sipClientParams
|
||||
),
|
||||
environmentContext = null,
|
||||
statusHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val port: Int by config {
|
||||
"http_api_port".from(Config.commandLineArgs).softDeprecated("use jibri.api.http.external-api-port")
|
||||
"jibri.api.http.external-api-port".from(Config.configSource)
|
||||
}
|
||||
val host: String by config {
|
||||
"jibri.api.http.external-api-host".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.api.http.internal
|
||||
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.serialization.jackson.jackson
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.RoutingContext
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
|
||||
class InternalHttpApi(
|
||||
private val configChangedHandler: () -> Unit,
|
||||
private val gracefulShutdownHandler: () -> Unit,
|
||||
private val shutdownHandler: () -> Unit
|
||||
) {
|
||||
private val logger = createLogger()
|
||||
|
||||
fun Application.internalApiModule() {
|
||||
install(ContentNegotiation) {
|
||||
jackson {}
|
||||
}
|
||||
|
||||
routing {
|
||||
route("/jibri/api/internal/v1.0") {
|
||||
/*
|
||||
* Signal this Jibri to shutdown gracefully, meaning shut down when
|
||||
* it is idle (i.e. finish any currently running service). Returns a 200
|
||||
* and schedules a shutdown for when it becomes idle.
|
||||
*/
|
||||
post("gracefulShutdown") {
|
||||
logger.info("Jibri gracefully shutting down")
|
||||
respondOkAndRun(gracefulShutdownHandler)
|
||||
}
|
||||
/*
|
||||
* Signal this Jibri to reload its config file at the soonest opportunity
|
||||
* (when it does not have a currently running service). Returns a 200.
|
||||
*/
|
||||
post("notifyConfigChanged") {
|
||||
logger.info("Config file changed")
|
||||
respondOkAndRun(configChangedHandler)
|
||||
}
|
||||
/*
|
||||
* Signal this Jibri to (cleanly) stop any services that are
|
||||
* running and shutdown. Returns a 200.
|
||||
*/
|
||||
post("shutdown") {
|
||||
logger.info("Jibri is forcefully shutting down")
|
||||
respondOkAndRun(shutdownHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
val port: Int by config {
|
||||
"internal_http_port"
|
||||
.from(Config.commandLineArgs).softDeprecated("use jibri.api.http.internal-api-port")
|
||||
"jibri.api.http.internal-api-port".from(Config.configSource)
|
||||
}
|
||||
val host: String by config {
|
||||
"jibri.api.http.internal-api-host".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Responds with [HttpStatusCode.OK] and then runs the given block
|
||||
*/
|
||||
private suspend fun RoutingContext.respondOkAndRun(block: () -> Unit) {
|
||||
val latch = CompletableDeferred<Nothing>()
|
||||
coroutineScope {
|
||||
launch {
|
||||
latch.join()
|
||||
block()
|
||||
}
|
||||
try {
|
||||
call.respond(HttpStatusCode.OK)
|
||||
} finally {
|
||||
latch.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriIqHelper.kt
Normal file
71
src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriIqHelper.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.api.xmpp
|
||||
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIq
|
||||
import org.jivesoftware.smack.packet.IQ
|
||||
import org.jxmpp.jid.Jid
|
||||
|
||||
// TODO: this functionality should be added to JibriIq
|
||||
|
||||
/**
|
||||
* Create a result iq from the given [JibriIq]
|
||||
*/
|
||||
class JibriIqHelper {
|
||||
companion object {
|
||||
fun create(from: Jid, type: IQ.Type = IQ.Type.set, status: JibriIq.Status = JibriIq.Status.UNDEFINED): JibriIq {
|
||||
val jibriIq = JibriIq()
|
||||
jibriIq.to = from
|
||||
jibriIq.type = type
|
||||
jibriIq.status = status
|
||||
|
||||
return jibriIq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a result IQ for this [JibriIq], setting a few fields and then
|
||||
* applying [block]
|
||||
*/
|
||||
fun JibriIq.createResult(block: JibriIq.() -> Unit): JibriIq = JibriIq().apply {
|
||||
type = IQ.Type.result
|
||||
to = this@createResult.from
|
||||
from = this@createResult.to
|
||||
stanzaId = this@createResult.stanzaId
|
||||
sipAddress = this@createResult.sipAddress
|
||||
block()
|
||||
}
|
||||
|
||||
enum class JibriMode(val mode: String) {
|
||||
FILE(JibriIq.RecordingMode.FILE.toString()),
|
||||
STREAM(JibriIq.RecordingMode.STREAM.toString()),
|
||||
SIPGW("sipgw"),
|
||||
UNDEFINED(JibriIq.RecordingMode.UNDEFINED.toString())
|
||||
}
|
||||
|
||||
fun JibriIq.mode(): JibriMode {
|
||||
if (!sipAddress.isNullOrBlank()) {
|
||||
return JibriMode.SIPGW
|
||||
}
|
||||
return when (recordingMode) {
|
||||
JibriIq.RecordingMode.FILE -> JibriMode.FILE
|
||||
JibriIq.RecordingMode.STREAM -> JibriMode.STREAM
|
||||
else -> JibriMode.UNDEFINED
|
||||
}
|
||||
}
|
||||
71
src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriStatusExts.kt
Normal file
71
src/main/kotlin/org/jitsi/jibri/api/xmpp/JibriStatusExts.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.api.xmpp
|
||||
|
||||
import org.jitsi.jibri.status.ComponentBusyStatus
|
||||
import org.jitsi.jibri.status.ComponentHealthStatus
|
||||
import org.jitsi.jibri.status.JibriStatus
|
||||
import org.jitsi.xmpp.extensions.health.HealthStatusPacketExt
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriBusyStatusPacketExt
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt
|
||||
import java.lang.RuntimeException
|
||||
|
||||
/**
|
||||
* Translate the Jibri busy status enum to the jitsi-protocol-jabber version
|
||||
*/
|
||||
private fun ComponentBusyStatus.toBusyStatusExt(): JibriBusyStatusPacketExt.BusyStatus = when (this) {
|
||||
ComponentBusyStatus.BUSY -> JibriBusyStatusPacketExt.BusyStatus.BUSY
|
||||
ComponentBusyStatus.IDLE -> JibriBusyStatusPacketExt.BusyStatus.IDLE
|
||||
ComponentBusyStatus.EXPIRED -> throw RuntimeException("'EXPIRED' not supported in JibriBusyStatusPacketExt")
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the Jibri health status enum to the jitsi-protocol-jabber version
|
||||
*/
|
||||
private fun ComponentHealthStatus.toHealthStatusExt(): HealthStatusPacketExt.Health = when (this) {
|
||||
ComponentHealthStatus.HEALTHY -> HealthStatusPacketExt.Health.HEALTHY
|
||||
ComponentHealthStatus.UNHEALTHY -> HealthStatusPacketExt.Health.UNHEALTHY
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a [JibriStatus] to [JibriStatusPacketExt]
|
||||
* [shouldBeSentToMuc] should be called before calling this function to ensure the
|
||||
* status can be translated.
|
||||
*/
|
||||
fun JibriStatus.toJibriStatusExt(): JibriStatusPacketExt {
|
||||
val jibriStatusExt = JibriStatusPacketExt()
|
||||
|
||||
val jibriBusyStatusExt = JibriBusyStatusPacketExt()
|
||||
jibriBusyStatusExt.status = this.busyStatus.toBusyStatusExt()
|
||||
jibriStatusExt.busyStatus = jibriBusyStatusExt
|
||||
|
||||
val jibriHealthStatusExt = HealthStatusPacketExt()
|
||||
jibriHealthStatusExt.status = this.health.healthStatus.toHealthStatusExt()
|
||||
jibriStatusExt.healthStatus = jibriHealthStatusExt
|
||||
|
||||
return jibriStatusExt
|
||||
}
|
||||
|
||||
/**
|
||||
* 'Expired' is not a state we reflect in the control MUC (and isn't
|
||||
* defined by [JibriStatusPacketExt]), it's used only for the
|
||||
* internal health status so for now we don't see it to the MUC.
|
||||
*/
|
||||
fun JibriStatus.shouldBeSentToMuc(): Boolean = when (this.busyStatus) {
|
||||
ComponentBusyStatus.EXPIRED -> false
|
||||
else -> true
|
||||
}
|
||||
448
src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt
Normal file
448
src/main/kotlin/org/jitsi/jibri/api/xmpp/XmppApi.kt
Normal file
@@ -0,0 +1,448 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.api.xmpp
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.jitsi.jibri.FileRecordingRequestParams
|
||||
import org.jitsi.jibri.JibriBusyException
|
||||
import org.jitsi.jibri.JibriManager
|
||||
import org.jitsi.jibri.config.XmppEnvironmentConfig
|
||||
import org.jitsi.jibri.health.EnvironmentContext
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.service.AppData
|
||||
import org.jitsi.jibri.service.JibriServiceStatusHandler
|
||||
import org.jitsi.jibri.service.ServiceParams
|
||||
import org.jitsi.jibri.service.impl.SipGatewayServiceParams
|
||||
import org.jitsi.jibri.service.impl.StreamingParams
|
||||
import org.jitsi.jibri.service.impl.YOUTUBE_URL
|
||||
import org.jitsi.jibri.sipgateway.SipClientParams
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.status.JibriStatus
|
||||
import org.jitsi.jibri.status.JibriStatusManager
|
||||
import org.jitsi.jibri.util.getCallUrlInfoFromJid
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIq
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIqProvider
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriStatusPacketExt
|
||||
import org.jitsi.xmpp.mucclient.ConnectionStateListener
|
||||
import org.jitsi.xmpp.mucclient.IQListener
|
||||
import org.jitsi.xmpp.mucclient.MucClient
|
||||
import org.jitsi.xmpp.mucclient.MucClientConfiguration
|
||||
import org.jitsi.xmpp.mucclient.MucClientManager
|
||||
import org.jivesoftware.smack.ConnectionConfiguration
|
||||
import org.jivesoftware.smack.packet.IQ
|
||||
import org.jivesoftware.smack.packet.StanzaError
|
||||
import org.jivesoftware.smack.provider.ProviderManager
|
||||
import org.jivesoftware.smackx.ping.PingManager
|
||||
import org.jxmpp.jid.impl.JidCreate
|
||||
|
||||
private class UnsupportedIqMode(val iqMode: String) : Exception()
|
||||
|
||||
/**
|
||||
* [XmppApi] connects to XMPP MUCs according to the given [XmppEnvironmentConfig]s (which are
|
||||
* parsed from config.json) and listens for IQ messages which contain Jibri commands, which it relays
|
||||
* to the given [JibriManager]. The IQ messages are instances of [JibriIq] and allow the
|
||||
* starting and stopping of the services Jibri provides.
|
||||
* [XmppApi] subscribes to [JibriStatusManager] status updates and translates those into
|
||||
* XMPP presence (defined by [JibriStatusPacketExt]) updates to advertise the status of this Jibri.
|
||||
* XMPP presence (defined by [JibriStatusPacketExt]) updates to advertise the status of this Jibri.
|
||||
* [XmppApi] takes care of translating the XMPP commands into the appropriate
|
||||
* [JibriManager] API calls and translates the results into XMPP IQ responses.
|
||||
*/
|
||||
class XmppApi(
|
||||
private val jibriManager: JibriManager,
|
||||
private val xmppConfigs: List<XmppEnvironmentConfig>,
|
||||
private val jibriStatusManager: JibriStatusManager,
|
||||
) : IQListener {
|
||||
private val logger = createLogger()
|
||||
|
||||
private val connectionStateListener = object : ConnectionStateListener {
|
||||
override fun connected(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppConnected(mucClient.tags())
|
||||
}
|
||||
override fun reconnecting(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppReconnecting(mucClient.tags())
|
||||
}
|
||||
override fun reconnectionFailed(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppReconnectionFailed(mucClient.tags())
|
||||
}
|
||||
override fun pingFailed(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppPingFailed(mucClient.tags())
|
||||
}
|
||||
|
||||
/**
|
||||
* If XMPP disconnects we lost our communication channel with jicofo. Jicofo currently doesn't attempt to
|
||||
* communicate later, and starts a new session (with a different jibri) immediately. Make sure in this case the
|
||||
* recording is stopped.
|
||||
*/
|
||||
override fun closed(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppClosed(mucClient.tags())
|
||||
maybeStop(mucClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* If XMPP disconnects we lost our communication channel with jicofo. Jicofo currently doesn't attempt to
|
||||
* communicate later, and starts a new session (with a different jibri) immediately. Make sure in this case the
|
||||
* recording is stopped.
|
||||
*/
|
||||
override fun closedOnError(mucClient: MucClient) {
|
||||
jibriManager.jibriMetrics.xmppClosedOnError(mucClient.tags())
|
||||
maybeStop(mucClient)
|
||||
}
|
||||
|
||||
private fun maybeStop(mucClient: MucClient) {
|
||||
val xmppEnvironment = getXmppEnvironment(mucClient) ?: return
|
||||
val environmentContext = createEnvironmentContext(xmppEnvironment, mucClient)
|
||||
if (jibriManager.currentEnvironmentContext == environmentContext) {
|
||||
logger.warn("XMPP disconnected, stopping.")
|
||||
jibriManager.jibriMetrics.stoppedOnXmppClosed(mucClient.tags())
|
||||
jibriManager.stopService()
|
||||
}
|
||||
}
|
||||
|
||||
/** Create statsd tags for a [MucClient]. */
|
||||
private fun MucClient.tags() = "xmpp_server_host:$id"
|
||||
}
|
||||
private lateinit var mucClientManager: MucClientManager
|
||||
|
||||
/**
|
||||
* Start up the XMPP API by connecting and logging in to all the configured XMPP environments. For each XMPP
|
||||
* connection, we'll listen for incoming [JibriIq] messages and handle them appropriately. Join the MUC on
|
||||
* each connection and send an initial [JibriStatusPacketExt] presence.
|
||||
*/
|
||||
fun start(mucManager: MucClientManager = MucClientManager()) {
|
||||
this.mucClientManager = mucManager
|
||||
|
||||
PingManager.setDefaultPingInterval(30)
|
||||
JibriStatusPacketExt.registerExtensionProvider()
|
||||
ProviderManager.addIQProvider(
|
||||
JibriIq.ELEMENT,
|
||||
JibriIq.NAMESPACE,
|
||||
JibriIqProvider()
|
||||
)
|
||||
updatePresence(jibriStatusManager.overallStatus)
|
||||
jibriStatusManager.addStatusHandler(::updatePresence)
|
||||
|
||||
mucClientManager.registerIQ(JibriIq())
|
||||
mucClientManager.setIQListener(this)
|
||||
mucClientManager.addConnectionStateListener(connectionStateListener)
|
||||
|
||||
// Join all the MUCs we've been told to
|
||||
for (config in xmppConfigs) {
|
||||
for (host in config.xmppServerHosts) {
|
||||
logger.info("Connecting to xmpp environment '${config.name}' on $host")
|
||||
val hostDetails: List<String> = host.split(":")
|
||||
|
||||
// We need to use the host as the ID because we'll only get one MUC client per 'ID' and
|
||||
// we may have multiple hosts for the same environment
|
||||
val clientConfig = MucClientConfiguration(host).apply {
|
||||
hostname = hostDetails[0]
|
||||
domain = config.controlLogin.domain
|
||||
username = config.controlLogin.username
|
||||
password = config.controlLogin.password
|
||||
|
||||
if (hostDetails.size > 1) {
|
||||
port = hostDetails[1]
|
||||
} else {
|
||||
config.controlLogin.port?.let { port = it.toString() }
|
||||
}
|
||||
|
||||
if (config.trustAllXmppCerts) {
|
||||
logger.info(
|
||||
"The trustAllXmppCerts config is enabled for this domain, " +
|
||||
"all XMPP server provided certificates will be accepted"
|
||||
)
|
||||
disableCertificateVerification = config.trustAllXmppCerts
|
||||
}
|
||||
|
||||
if (config.securityMode == ConnectionConfiguration.SecurityMode.disabled) {
|
||||
logger.info(
|
||||
"XMPP security is disabled for this domain, no TLS will be used."
|
||||
)
|
||||
}
|
||||
securityMode = config.securityMode
|
||||
|
||||
val recordingMucJid =
|
||||
JidCreate.bareFrom("${config.controlMuc.roomName}@${config.controlMuc.domain}").toString()
|
||||
val sipMucJid: String? = config.sipControlMuc?.let {
|
||||
JidCreate.entityBareFrom(
|
||||
"${config.sipControlMuc.roomName}@${config.sipControlMuc.domain}"
|
||||
).toString()
|
||||
}
|
||||
mucJids = listOfNotNull(recordingMucJid, sipMucJid)
|
||||
mucNickname = config.controlMuc.nickname
|
||||
}
|
||||
|
||||
mucClientManager.addMucClient(clientConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to update outgoing [presence] stanza with jibri status.
|
||||
*/
|
||||
private fun updatePresence(status: JibriStatus) {
|
||||
if (status.shouldBeSentToMuc()) {
|
||||
logger.info("Jibri reports its status is now $status, publishing presence to connections")
|
||||
mucClientManager.setPresenceExtension(status.toJibriStatusExt())
|
||||
} else {
|
||||
logger.info("Not forwarding status $status to the MUC")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the JibriIQ.
|
||||
*
|
||||
* @param iq the IQ to be handled.
|
||||
* @param mucClient the [MucClient] from which the IQ comes.
|
||||
* @return the IQ to be sent as a response or `null`.
|
||||
*/
|
||||
override fun handleIq(iq: IQ, mucClient: MucClient): IQ = if (iq is JibriIq) {
|
||||
handleJibriIq(iq, mucClient)
|
||||
} else {
|
||||
IQ.createErrorResponse(iq, StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build())
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle a [JibriIq] message with the context of the [XmppEnvironmentConfig] and [MucClient]
|
||||
* that this [JibriIq] was received on.
|
||||
*/
|
||||
private fun handleJibriIq(jibriIq: JibriIq, mucClient: MucClient): IQ {
|
||||
logger.info(
|
||||
"Received JibriIq action=${jibriIq.action} mode=${jibriIq.recordingMode} " +
|
||||
"room=${jibriIq.room} session=${jibriIq.sessionId} from environment $mucClient"
|
||||
)
|
||||
val xmppEnvironment = getXmppEnvironment(mucClient)
|
||||
?: return IQ.createErrorResponse(
|
||||
jibriIq,
|
||||
StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build()
|
||||
)
|
||||
return when (jibriIq.action) {
|
||||
JibriIq.Action.START -> handleStartJibriIq(jibriIq, xmppEnvironment, mucClient)
|
||||
|
||||
JibriIq.Action.STOP -> handleStopJibriIq(jibriIq)
|
||||
|
||||
else -> IQ.createErrorResponse(
|
||||
jibriIq,
|
||||
StanzaError.getBuilder().setCondition(StanzaError.Condition.bad_request).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getXmppEnvironment(mucClient: MucClient) = xmppConfigs.find {
|
||||
it.xmppServerHosts.contains(mucClient.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a start [JibriIq] message. We'll respond immediately with a [JibriIq.Status.PENDING] IQ response and
|
||||
* send a new IQ with the subsequent stats after starting the service:
|
||||
* [JibriIq.Status.OFF] if there was an error starting the service (or an error while the service was running).
|
||||
* In this case, a [JibriIq.FailureReason] will be set as well.
|
||||
* [JibriIq.Status.ON] if the service started successfully
|
||||
*/
|
||||
private fun handleStartJibriIq(
|
||||
startJibriIq: JibriIq,
|
||||
xmppEnvironment: XmppEnvironmentConfig,
|
||||
mucClient: MucClient
|
||||
): IQ {
|
||||
logger.info("Received start request, starting service")
|
||||
// If there is an issue with the service while it's running, we need to send an IQ
|
||||
// to notify the caller who invoked the service of its status, so we'll listen
|
||||
// for the service's status while it's running and this method will be invoked
|
||||
// if it changes
|
||||
val serviceStatusHandler = createServiceStatusHandler(startJibriIq, mucClient)
|
||||
return try {
|
||||
handleStartService(
|
||||
startJibriIq,
|
||||
xmppEnvironment,
|
||||
createEnvironmentContext(xmppEnvironment, mucClient),
|
||||
serviceStatusHandler
|
||||
)
|
||||
logger.info("Sending 'pending' response to start IQ")
|
||||
startJibriIq.createResult {
|
||||
status = JibriIq.Status.PENDING
|
||||
}
|
||||
} catch (busy: JibriBusyException) {
|
||||
logger.error("Jibri is currently busy, cannot service this request")
|
||||
startJibriIq.createResult {
|
||||
status = JibriIq.Status.OFF
|
||||
failureReason = JibriIq.FailureReason.BUSY
|
||||
shouldRetry = true
|
||||
}
|
||||
} catch (iq: UnsupportedIqMode) {
|
||||
logger.error("Unsupported IQ mode: ${iq.iqMode}")
|
||||
startJibriIq.createResult {
|
||||
status = JibriIq.Status.OFF
|
||||
failureReason = JibriIq.FailureReason.ERROR
|
||||
shouldRetry = false
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error starting Jibri service ", t)
|
||||
startJibriIq.createResult {
|
||||
status = JibriIq.Status.OFF
|
||||
failureReason = JibriIq.FailureReason.ERROR
|
||||
shouldRetry = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createServiceStatusHandler(request: JibriIq, mucClient: MucClient): JibriServiceStatusHandler =
|
||||
{ serviceState ->
|
||||
when (serviceState) {
|
||||
is ComponentState.Error -> {
|
||||
with(JibriIqHelper.create(request.from, status = JibriIq.Status.OFF)) {
|
||||
failureReason = JibriIq.FailureReason.ERROR
|
||||
sipAddress = request.sipAddress
|
||||
shouldRetry = serviceState.error.shouldRetry()
|
||||
logger.info(
|
||||
"Current service had an error ${serviceState.error}, " +
|
||||
"sending error iq status=$status failureReason=$failureReason shouldRetry=$shouldRetry"
|
||||
)
|
||||
mucClient.sendStanza(this)
|
||||
}
|
||||
}
|
||||
|
||||
is ComponentState.Finished -> {
|
||||
with(JibriIqHelper.create(request.from, status = JibriIq.Status.OFF)) {
|
||||
sipAddress = request.sipAddress
|
||||
logger.info("Current service finished, sending off iq status=$status")
|
||||
mucClient.sendStanza(this)
|
||||
}
|
||||
}
|
||||
|
||||
is ComponentState.Running -> {
|
||||
with(JibriIqHelper.create(request.from, status = JibriIq.Status.ON)) {
|
||||
sipAddress = request.sipAddress
|
||||
logger.info("Current service started up successfully, sending on iq status=$status")
|
||||
mucClient.sendStanza(this)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
logger.info("XmppAPI ignoring service state update: $serviceState")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a stop [JibriIq] message to stop the currently running service (if there is one). Send a [JibriIq]
|
||||
* response with [JibriIq.Status.OFF].
|
||||
*/
|
||||
private fun handleStopJibriIq(stopJibriIq: JibriIq): IQ {
|
||||
jibriManager.stopService()
|
||||
// By this point the service has been fully stopped
|
||||
return stopJibriIq.createResult {
|
||||
status = JibriIq.Status.OFF
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to actually start the service. We need to parse the fields in the [JibriIq] message
|
||||
* to determine which [JibriManager] service API to call, as well as convert the types into what [JibriManager]
|
||||
* expects
|
||||
*/
|
||||
private fun handleStartService(
|
||||
startIq: JibriIq,
|
||||
xmppEnvironment: XmppEnvironmentConfig,
|
||||
environmentContext: EnvironmentContext,
|
||||
serviceStatusHandler: JibriServiceStatusHandler
|
||||
) {
|
||||
val callUrlInfo = getCallUrlInfoFromJid(
|
||||
startIq.room,
|
||||
xmppEnvironment.stripFromRoomDomain,
|
||||
xmppEnvironment.xmppDomain,
|
||||
xmppEnvironment.baseUrl
|
||||
)
|
||||
val appData = startIq.appData?.let {
|
||||
jacksonObjectMapper().readValue<AppData>(startIq.appData)
|
||||
}
|
||||
val serviceParams = ServiceParams(xmppEnvironment.usageTimeoutMins, appData)
|
||||
val extraUrlParams = buildList {
|
||||
startIq.rtcStatsEnabled?.let { add("config.analytics.rtcstatsEnabled=$it") }
|
||||
}
|
||||
val callParams = CallParams(callUrlInfo, extraUrlParams = extraUrlParams)
|
||||
logger.info("Parsed call url info: $callUrlInfo")
|
||||
|
||||
when (startIq.mode()) {
|
||||
JibriMode.FILE -> {
|
||||
jibriManager.startFileRecording(
|
||||
serviceParams,
|
||||
FileRecordingRequestParams(callParams, startIq.sessionId, xmppEnvironment.callLogin),
|
||||
environmentContext,
|
||||
serviceStatusHandler
|
||||
)
|
||||
}
|
||||
|
||||
JibriMode.STREAM -> {
|
||||
val rtmpUrl = if (startIq.streamId.isRtmpUrl()) {
|
||||
startIq.streamId
|
||||
} else {
|
||||
"$YOUTUBE_URL/${startIq.streamId}"
|
||||
}
|
||||
val viewingUrl = if (startIq.youtubeBroadcastId != null) {
|
||||
if (startIq.youtubeBroadcastId.isViewingUrl()) {
|
||||
startIq.youtubeBroadcastId
|
||||
} else {
|
||||
"http://youtu.be/${startIq.youtubeBroadcastId}"
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
logger.info("Using RTMP URL $rtmpUrl and viewing URL $viewingUrl")
|
||||
jibriManager.startStreaming(
|
||||
serviceParams,
|
||||
StreamingParams(
|
||||
callParams,
|
||||
startIq.sessionId,
|
||||
xmppEnvironment.callLogin,
|
||||
rtmpUrl = rtmpUrl,
|
||||
viewingUrl = viewingUrl
|
||||
),
|
||||
environmentContext,
|
||||
serviceStatusHandler
|
||||
)
|
||||
}
|
||||
|
||||
JibriMode.SIPGW -> {
|
||||
jibriManager.startSipGateway(
|
||||
serviceParams,
|
||||
SipGatewayServiceParams(
|
||||
callParams,
|
||||
xmppEnvironment.callLogin,
|
||||
SipClientParams(startIq.sipAddress, startIq.displayName)
|
||||
),
|
||||
environmentContext,
|
||||
serviceStatusHandler
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw UnsupportedIqMode(startIq.mode().toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isRtmpUrl(): Boolean =
|
||||
startsWith("rtmp://", ignoreCase = true) || startsWith("rtmps://", ignoreCase = true)
|
||||
private fun String.isViewingUrl(): Boolean =
|
||||
startsWith("http://", ignoreCase = true) || startsWith("https://", ignoreCase = true)
|
||||
|
||||
private fun createEnvironmentContext(xmppEnvironment: XmppEnvironmentConfig, mucClient: MucClient) =
|
||||
EnvironmentContext("${xmppEnvironment.name}-${mucClient.id}")
|
||||
38
src/main/kotlin/org/jitsi/jibri/capture/Capturer.kt
Normal file
38
src/main/kotlin/org/jitsi/jibri/capture/Capturer.kt
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.capture
|
||||
|
||||
import org.jitsi.jibri.sink.Sink
|
||||
|
||||
class UnsupportedOsException(override var message: String = "Jibri does not support this OS") : Exception()
|
||||
class UnsupportedSinkTypeException(sink: Sink) : Exception("Unsupported sink type: ${sink::class.simpleName}")
|
||||
|
||||
/**
|
||||
* [Capturer] represents a process which will capture media.
|
||||
*/
|
||||
interface Capturer {
|
||||
/**
|
||||
* Start the capturer with the given [Sink].
|
||||
*/
|
||||
fun start(sink: Sink)
|
||||
|
||||
/**
|
||||
* Stop the capturer
|
||||
*/
|
||||
fun stop()
|
||||
}
|
||||
29
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/Errors.kt
Normal file
29
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/Errors.kt
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.capture.ffmpeg
|
||||
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
|
||||
open class FfmpegError(scope: ErrorScope, detail: String) : JibriError(scope, detail)
|
||||
object FfmpegFailedToStart : FfmpegError(ErrorScope.SYSTEM, "Ffmpeg failed to start")
|
||||
class FfmpegUnexpectedSignal(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine)
|
||||
class BadRtmpUrl(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine) {
|
||||
override fun shouldRetry(): Boolean = false
|
||||
}
|
||||
class BrokenPipe(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine)
|
||||
class QuitUnexpectedly(outputLine: String) : FfmpegError(ErrorScope.SESSION, outputLine)
|
||||
141
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturer.kt
Normal file
141
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/FfmpegCapturer.kt
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.capture.ffmpeg
|
||||
|
||||
import org.jitsi.jibri.capture.Capturer
|
||||
import org.jitsi.jibri.capture.UnsupportedOsException
|
||||
import org.jitsi.jibri.capture.UnsupportedSinkTypeException
|
||||
import org.jitsi.jibri.capture.ffmpeg.util.FfmpegFileHandler
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.sink.Sink
|
||||
import org.jitsi.jibri.sink.impl.FileSink
|
||||
import org.jitsi.jibri.sink.impl.StreamSink
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.JibriSubprocess
|
||||
import org.jitsi.jibri.util.OsDetector
|
||||
import org.jitsi.jibri.util.OsType
|
||||
import org.jitsi.jibri.util.ProcessExited
|
||||
import org.jitsi.jibri.util.ProcessFailedToStart
|
||||
import org.jitsi.jibri.util.ProcessRunning
|
||||
import org.jitsi.jibri.util.ProcessState
|
||||
import org.jitsi.jibri.util.StatusPublisher
|
||||
import org.jitsi.jibri.util.getLoggerWithHandler
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
|
||||
/**
|
||||
* [FfmpegCapturer] is responsible for launching ffmpeg, capturing from the
|
||||
* configured audio and video devices, and writing to the given [Sink]
|
||||
*/
|
||||
class FfmpegCapturer(
|
||||
parentLogger: Logger,
|
||||
osDetector: OsDetector = OsDetector(),
|
||||
ffmpeg: JibriSubprocess? = null
|
||||
) : Capturer, StatusPublisher<ComponentState>() {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
private val ffmpeg = ffmpeg ?: JibriSubprocess(logger, "ffmpeg", ffmpegOutputLogger)
|
||||
private val getCommand: (Sink) -> List<String>
|
||||
private val ffmpegStatusStateMachine = FfmpegStatusStateMachine()
|
||||
|
||||
companion object {
|
||||
const val COMPONENT_ID = "Ffmpeg Capturer"
|
||||
private val ffmpegOutputLogger = getLoggerWithHandler("ffmpeg", FfmpegFileHandler())
|
||||
|
||||
val commandLinuxRecording: List<String> by config {
|
||||
"jibri.ffmpeg.command-linux-recording".from(Config.configSource)
|
||||
}
|
||||
val commandLinuxStreaming: List<String> by config {
|
||||
"jibri.ffmpeg.command-linux-streaming".from(Config.configSource)
|
||||
}
|
||||
val commandMacRecording: List<String> by config {
|
||||
"jibri.ffmpeg.command-mac-recording".from(Config.configSource)
|
||||
}
|
||||
val commandMacStreaming: List<String> by config {
|
||||
"jibri.ffmpeg.command-mac-streaming".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val osType = osDetector.getOsType()
|
||||
logger.debug { "Detected OS: $osType" }
|
||||
getCommand = when (osType) {
|
||||
OsType.MAC -> { sink: Sink ->
|
||||
when (sink) {
|
||||
is StreamSink -> commandMacStreaming
|
||||
is FileSink -> commandMacRecording
|
||||
else -> throw UnsupportedSinkTypeException(sink)
|
||||
} + listOf(sink.path)
|
||||
}
|
||||
|
||||
OsType.LINUX -> { sink: Sink ->
|
||||
when (sink) {
|
||||
is StreamSink -> commandLinuxStreaming
|
||||
is FileSink -> commandLinuxRecording
|
||||
else -> throw UnsupportedSinkTypeException(sink)
|
||||
} + listOf(sink.path)
|
||||
}
|
||||
|
||||
else -> throw UnsupportedOsException()
|
||||
}
|
||||
|
||||
this.ffmpeg.addStatusHandler(this::onFfmpegProcessUpdate)
|
||||
ffmpegStatusStateMachine.onStateTransition(this::onFfmpegStateMachineStateChange)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the capturer and write to the given [Sink].
|
||||
*/
|
||||
override fun start(sink: Sink) {
|
||||
val command = getCommand(sink)
|
||||
ffmpeg.launch(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a [ProcessState] update from ffmpeg by parsing it into an [FfmpegEvent] and passing it to the state
|
||||
* machine
|
||||
*/
|
||||
private fun onFfmpegProcessUpdate(ffmpegState: ProcessState) {
|
||||
// We handle the case where it failed to start separately, since there is no output
|
||||
when (ffmpegState.runningState) {
|
||||
is ProcessFailedToStart -> {
|
||||
ffmpegStatusStateMachine.transition(
|
||||
FfmpegEvent.ErrorLine(FfmpegFailedToStart)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (ffmpegState.runningState is ProcessExited) {
|
||||
logger.info("Ffmpeg quit abruptly. Last output line: ${ffmpegState.mostRecentOutput}")
|
||||
}
|
||||
val status = OutputParser.parse(ffmpegState.mostRecentOutput)
|
||||
ffmpegStatusStateMachine.transition(status.toFfmpegEvent(ffmpegState.runningState is ProcessRunning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onFfmpegStateMachineStateChange(oldState: ComponentState, newState: ComponentState) {
|
||||
logger.info("Ffmpeg capturer transitioning from state $oldState to $newState")
|
||||
publishStatus(newState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the capturer
|
||||
*/
|
||||
override fun stop() = ffmpeg.stop()
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.capture.ffmpeg
|
||||
|
||||
import com.tinder.StateMachine
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.NotifyingStateMachine
|
||||
|
||||
sealed class FfmpegEvent(val outputLine: String) {
|
||||
class EncodingLine(outputLine: String) : FfmpegEvent(outputLine)
|
||||
class ErrorLine(val error: JibriError) : FfmpegEvent(error.detail)
|
||||
class FinishLine(outputLine: String) : FfmpegEvent(outputLine)
|
||||
class OtherLine(outputLine: String) : FfmpegEvent(outputLine)
|
||||
|
||||
/**
|
||||
* Used any time Ffmpeg has exited, regardless of what [outputLine] contains. However,
|
||||
* [error] will be set if [outputLine] contains an error, so that we may describe the
|
||||
* scope of that error.
|
||||
*/
|
||||
class FfmpegExited(outputLine: String, val error: JibriError? = null) : FfmpegEvent(outputLine)
|
||||
}
|
||||
|
||||
/**
|
||||
* To properly translate an [FfmpegOutputStatus] to an [FfmpegEvent], we need to take into
|
||||
* account what the status of the parsed line indicates, but also the current running state
|
||||
* of ffmpeg itself: if ffmpeg crashes, then its last output line may be "normal", but we need
|
||||
* to react to the fact that ffmpeg is no longer running.
|
||||
*/
|
||||
fun FfmpegOutputStatus.toFfmpegEvent(ffmpegStillRunning: Boolean): FfmpegEvent = when (lineType) {
|
||||
OutputLineClassification.ENCODING -> {
|
||||
if (ffmpegStillRunning) {
|
||||
FfmpegEvent.EncodingLine(detail)
|
||||
} else {
|
||||
FfmpegEvent.FfmpegExited(detail, QuitUnexpectedly(detail))
|
||||
}
|
||||
}
|
||||
|
||||
OutputLineClassification.UNKNOWN -> {
|
||||
if (ffmpegStillRunning) {
|
||||
FfmpegEvent.OtherLine(detail)
|
||||
} else {
|
||||
FfmpegEvent.FfmpegExited(detail, QuitUnexpectedly(detail))
|
||||
}
|
||||
}
|
||||
|
||||
OutputLineClassification.FINISHED -> FfmpegEvent.FinishLine(detail)
|
||||
|
||||
OutputLineClassification.ERROR -> {
|
||||
this as FfmpegErrorStatus
|
||||
FfmpegEvent.ErrorLine(this.error)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SideEffect
|
||||
|
||||
class FfmpegStatusStateMachine : NotifyingStateMachine() {
|
||||
private val stateMachine = StateMachine.create<ComponentState, FfmpegEvent, SideEffect> {
|
||||
initialState(ComponentState.StartingUp)
|
||||
|
||||
state<ComponentState.StartingUp> {
|
||||
on<FfmpegEvent.EncodingLine> {
|
||||
transitionTo(ComponentState.Running)
|
||||
}
|
||||
on<FfmpegEvent.ErrorLine> {
|
||||
transitionTo(ComponentState.Error(it.error))
|
||||
}
|
||||
on<FfmpegEvent.FinishLine> {
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
on<FfmpegEvent.OtherLine> {
|
||||
dontTransition()
|
||||
}
|
||||
on<FfmpegEvent.FfmpegExited> {
|
||||
it.error?.let {
|
||||
transitionTo(ComponentState.Error(it))
|
||||
} ?: transitionTo(ComponentState.Finished)
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Running> {
|
||||
on<FfmpegEvent.EncodingLine> {
|
||||
dontTransition()
|
||||
}
|
||||
on<FfmpegEvent.ErrorLine> {
|
||||
transitionTo(ComponentState.Error(it.error))
|
||||
}
|
||||
on<FfmpegEvent.FinishLine> {
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
on<FfmpegEvent.OtherLine> {
|
||||
dontTransition()
|
||||
}
|
||||
on<FfmpegEvent.FfmpegExited> {
|
||||
it.error?.let {
|
||||
transitionTo(ComponentState.Error(it))
|
||||
} ?: transitionTo(ComponentState.Finished)
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Error> {
|
||||
on(any<FfmpegEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Finished> {
|
||||
on(any<FfmpegEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
onTransition {
|
||||
val validTransition = it as? StateMachine.Transition.Valid ?: run {
|
||||
throw Exception("Invalid state transition: $it")
|
||||
}
|
||||
if (validTransition.fromState::class != validTransition.toState::class) {
|
||||
notify(validTransition.fromState, validTransition.toState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun transition(event: FfmpegEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event)
|
||||
}
|
||||
126
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/OutputParser.kt
Normal file
126
src/main/kotlin/org/jitsi/jibri/capture/ffmpeg/OutputParser.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.jitsi.jibri.capture.ffmpeg
|
||||
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.util.ONE_OR_MORE_DIGITS
|
||||
import org.jitsi.jibri.util.ONE_OR_MORE_NON_SPACES
|
||||
import org.jitsi.jibri.util.ZERO_OR_MORE_SPACES
|
||||
import java.util.regex.Pattern
|
||||
|
||||
enum class OutputLineClassification {
|
||||
UNKNOWN,
|
||||
ENCODING,
|
||||
FINISHED,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a parsed line of ffmpeg's stdout output.
|
||||
*/
|
||||
open class FfmpegOutputStatus(val lineType: OutputLineClassification, val detail: String = "") {
|
||||
override fun toString(): String = "Line type: $lineType, detail: $detail"
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a line of ffmpeg output that indicated there was a warning.
|
||||
*/
|
||||
open class FfmpegErrorStatus(val error: JibriError) : FfmpegOutputStatus(OutputLineClassification.ERROR, error.detail)
|
||||
|
||||
/**
|
||||
* Represents a line of ffmpeg output that indicated a bad RTMP URL
|
||||
*/
|
||||
class BadRtmpUrlStatus(outputLine: String) : FfmpegErrorStatus(BadRtmpUrl(outputLine))
|
||||
|
||||
class BrokenPipeStatus(outputLine: String) : FfmpegErrorStatus(BrokenPipe(outputLine))
|
||||
|
||||
/**
|
||||
* Ffmpeg quit due to a signal other than what we sent to it
|
||||
*/
|
||||
class FfmpegUnexpectedSignalStatus(outputLine: String) : FfmpegErrorStatus(FfmpegUnexpectedSignal(outputLine))
|
||||
|
||||
/**
|
||||
* Parses the stdout output of ffmpeg to check if it's working
|
||||
*/
|
||||
class OutputParser {
|
||||
companion object {
|
||||
/**
|
||||
* Ffmpeg prints to stdout while its running with a status of its current job.
|
||||
* For the most part, it uses the following format:
|
||||
* fieldName=fieldValue fieldName2=fieldValue fieldName3=fieldValue...
|
||||
* where any amount spaces can be inserted anywhere in that pattern (except for within
|
||||
* a fieldName or fieldValue). This pattern will parse all fields from an ffmpeg output
|
||||
* string
|
||||
*/
|
||||
private const val FFMPEG_OUTPUT_FIELD_NAME = ONE_OR_MORE_NON_SPACES
|
||||
private const val FFMPEG_OUTPUT_FIELD_VALUE = ONE_OR_MORE_NON_SPACES
|
||||
private const val FFMPEG_OUTPUT_FIELD =
|
||||
"$ZERO_OR_MORE_SPACES($FFMPEG_OUTPUT_FIELD_NAME)$ZERO_OR_MORE_SPACES=" +
|
||||
"$ZERO_OR_MORE_SPACES($FFMPEG_OUTPUT_FIELD_VALUE)"
|
||||
|
||||
private const val FFMPEG_ENCODING_LINE = "($FFMPEG_OUTPUT_FIELD)+$ZERO_OR_MORE_SPACES"
|
||||
private const val FFMPEG_EXITED_LINE = "Exiting.*signal$ZERO_OR_MORE_SPACES($ONE_OR_MORE_DIGITS).*"
|
||||
private const val BAD_RTMP_URL = "rtmp://.*Input/output error"
|
||||
private const val BROKEN_PIPE = ".*Broken pipe.*"
|
||||
|
||||
/**
|
||||
* Errors are done a bit differently, as different errors have different scopes. For example,
|
||||
* a bad RTMP url is an error that only affects this session but an error about running out of
|
||||
* disk space affects the entire system. This map associates the regex of the ffmpeg error output
|
||||
* to a function which takes in the output line and returns an [FfmpegErrorStatus]
|
||||
*/
|
||||
private val errorTypes = mapOf<String, (String) -> FfmpegErrorStatus>(
|
||||
BAD_RTMP_URL to ::BadRtmpUrlStatus,
|
||||
BROKEN_PIPE to ::BrokenPipeStatus
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse [outputLine], a line of output from Ffmpeg, into an [FfmpegOutputStatus]
|
||||
*/
|
||||
fun parse(outputLine: String): FfmpegOutputStatus {
|
||||
// First we'll check if the output represents that ffmpeg has exited
|
||||
val exitedMatcher = Pattern.compile(FFMPEG_EXITED_LINE).matcher(outputLine)
|
||||
if (exitedMatcher.matches()) {
|
||||
return when (exitedMatcher.group(1).toInt()) {
|
||||
// 2 is the signal we pass to stop ffmpeg
|
||||
2 -> FfmpegOutputStatus(OutputLineClassification.FINISHED, outputLine)
|
||||
|
||||
else -> FfmpegUnexpectedSignalStatus(outputLine)
|
||||
}
|
||||
}
|
||||
// Check if the output is a normal, encoding output
|
||||
val encodingLineMatcher = Pattern.compile(FFMPEG_ENCODING_LINE).matcher(outputLine)
|
||||
if (encodingLineMatcher.matches()) {
|
||||
// val encodingFieldsMatcher = Pattern.compile(ffmpegOutputField).matcher(outputLine)
|
||||
// val fields = mutableMapOf<String, Any>()
|
||||
// while (encodingFieldsMatcher.find()) {
|
||||
// val fieldName = encodingFieldsMatcher.group(1).trim()
|
||||
// val fieldValue = encodingFieldsMatcher.group(2).trim()
|
||||
// fields[fieldName] = fieldValue
|
||||
// }
|
||||
return FfmpegOutputStatus(OutputLineClassification.ENCODING, outputLine)
|
||||
}
|
||||
// Now we'll look for error output
|
||||
for ((errorLine, createError) in errorTypes) {
|
||||
val errorMatcher = Pattern.compile(errorLine).matcher(outputLine)
|
||||
if (errorMatcher.matches()) {
|
||||
return createError(outputLine)
|
||||
}
|
||||
}
|
||||
return FfmpegOutputStatus(OutputLineClassification.UNKNOWN, outputLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.capture.ffmpeg.util
|
||||
|
||||
import java.util.logging.FileHandler
|
||||
|
||||
/**
|
||||
* A distinct [FileHandler] so that we can configure the file
|
||||
* Ffmpeg logs to separately in the logging config
|
||||
*/
|
||||
class FfmpegFileHandler : FileHandler()
|
||||
46
src/main/kotlin/org/jitsi/jibri/config/Config.kt
Normal file
46
src/main/kotlin/org/jitsi/jibri/config/Config.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.config
|
||||
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import org.jitsi.config.TypesafeConfigSource
|
||||
import org.jitsi.metaconfig.ConfigSource
|
||||
import org.jitsi.metaconfig.MapConfigSource
|
||||
|
||||
class Config {
|
||||
companion object {
|
||||
val ConfigFromFile = TypesafeConfigSource("config", ConfigFactory.load())
|
||||
|
||||
/**
|
||||
* The 'new' config source
|
||||
*/
|
||||
var configSource = ConfigFromFile
|
||||
|
||||
/**
|
||||
* The 'legacy' config sources: we parsed a JSON file into a [JibriConfig] instance.
|
||||
* Unfortunately we can't parse the JSON file via the new config library because it
|
||||
* contains comments (which the new library doesn't support).
|
||||
*/
|
||||
var legacyConfigSource = JibriConfig()
|
||||
|
||||
/**
|
||||
* We also accepted config parameters via the command line, so this config source
|
||||
* is set to a [ConfigSource] containing those values.
|
||||
*/
|
||||
var commandLineArgs: ConfigSource = MapConfigSource("command line args")
|
||||
}
|
||||
}
|
||||
203
src/main/kotlin/org/jitsi/jibri/config/JibriConfig.kt
Normal file
203
src/main/kotlin/org/jitsi/jibri/config/JibriConfig.kt
Normal file
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.config
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.exc.InvalidFormatException
|
||||
import com.fasterxml.jackson.databind.exc.MismatchedInputException
|
||||
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.jitsi.jibri.logger
|
||||
import org.jivesoftware.smack.ConnectionConfiguration
|
||||
import java.io.File
|
||||
|
||||
data class XmppCredentials(
|
||||
val domain: String = "",
|
||||
val port: Int? = null,
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val randomizeUsername: Boolean = false
|
||||
) {
|
||||
override fun toString(): String = "XmppCredentials(domain=$domain, port=$port, username=$username, password=*****)"
|
||||
}
|
||||
|
||||
fun com.typesafe.config.Config.toXmppCredentials(): XmppCredentials = XmppCredentials(
|
||||
domain = getString("domain"),
|
||||
port = if (hasPath("port")) getInt("port") else null,
|
||||
username = getString("username"),
|
||||
password = getString("password"),
|
||||
randomizeUsername = if (hasPath("randomize-username")) getBoolean("randomize-username") else false
|
||||
)
|
||||
|
||||
data class XmppMuc(
|
||||
val domain: String,
|
||||
@JsonProperty("room_name")
|
||||
val roomName: String,
|
||||
val nickname: String
|
||||
)
|
||||
|
||||
fun com.typesafe.config.Config.toXmppMuc(): XmppMuc = XmppMuc(
|
||||
domain = getString("domain"),
|
||||
roomName = getString("room-name"),
|
||||
nickname = getString("nickname")
|
||||
)
|
||||
|
||||
data class XmppEnvironmentConfig(
|
||||
/**
|
||||
* A user-friendly name for this environment
|
||||
*/
|
||||
val name: String,
|
||||
/**
|
||||
* A list of xmpp server hosts to which we'll connect
|
||||
*/
|
||||
@JsonProperty("xmpp_server_hosts")
|
||||
val xmppServerHosts: List<String>,
|
||||
/**
|
||||
* The base xmpp domain
|
||||
*/
|
||||
@JsonProperty("xmpp_domain")
|
||||
val xmppDomain: String,
|
||||
/**
|
||||
* The baseUrl we'll use, if set, to join the call
|
||||
*/
|
||||
@JsonProperty("baseUrl")
|
||||
val baseUrl: String?,
|
||||
/**
|
||||
* The login information for the control API
|
||||
*/
|
||||
@JsonProperty("control_login")
|
||||
val controlLogin: XmppCredentials,
|
||||
/**
|
||||
* The muc we'll join to announce our presence for
|
||||
* recording and streaming services
|
||||
*/
|
||||
@JsonProperty("control_muc")
|
||||
val controlMuc: XmppMuc,
|
||||
/**
|
||||
* The muc we'll join to announce our presence
|
||||
* for sip gateway services
|
||||
* TODO: should this and controlMuc above be
|
||||
* optional? but somehow require at least one
|
||||
* to be set?
|
||||
*/
|
||||
@JsonProperty("sip_control_muc")
|
||||
val sipControlMuc: XmppMuc?,
|
||||
/**
|
||||
* The login information the selenium web client will use
|
||||
*/
|
||||
@JsonProperty("call_login")
|
||||
val callLogin: XmppCredentials,
|
||||
/**
|
||||
* The value we'll strip from the room jid domain to derive
|
||||
* the call url
|
||||
*/
|
||||
@JsonProperty("room_jid_domain_string_to_strip_from_start")
|
||||
val stripFromRoomDomain: String,
|
||||
/**
|
||||
* How long Jibri sessions will be allowed to last before
|
||||
* they are stopped. A value of 0 allows them to go on
|
||||
* indefinitely
|
||||
*/
|
||||
@JsonProperty("usage_timeout")
|
||||
val usageTimeoutMins: Int,
|
||||
/**
|
||||
* Whether or not we'll automatically trust any
|
||||
* cert on this XMPP domain
|
||||
*/
|
||||
@JsonProperty("always_trust_certs")
|
||||
val trustAllXmppCerts: Boolean = true,
|
||||
/**
|
||||
* The XMPP security mode to use for the XMPP connection
|
||||
*/
|
||||
@JsonProperty("security_mode")
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
val securityMode: ConnectionConfiguration.SecurityMode? = null
|
||||
)
|
||||
|
||||
fun com.typesafe.config.Config.toXmppEnvironment(): XmppEnvironmentConfig = XmppEnvironmentConfig(
|
||||
name = getString("name"),
|
||||
xmppServerHosts = getStringList("xmpp-server-hosts"),
|
||||
xmppDomain = getString("xmpp-domain"),
|
||||
baseUrl = if (hasPath("base-url")) {
|
||||
getString("base-url")
|
||||
} else {
|
||||
null
|
||||
},
|
||||
controlLogin = getConfig("control-login").toXmppCredentials(),
|
||||
controlMuc = getConfig("control-muc").toXmppMuc(),
|
||||
sipControlMuc = if (hasPath("sip-control-muc")) {
|
||||
getConfig("sip-control-muc").toXmppMuc()
|
||||
} else {
|
||||
null
|
||||
},
|
||||
callLogin = getConfig("call-login").toXmppCredentials(),
|
||||
stripFromRoomDomain = getString("strip-from-room-domain"),
|
||||
usageTimeoutMins = getDuration("usage-timeout").toMinutes().toInt(),
|
||||
trustAllXmppCerts = getBoolean("trust-all-xmpp-certs"),
|
||||
securityMode = if (hasPath("security-mode")) {
|
||||
getEnum(ConnectionConfiguration.SecurityMode::class.java, "security-mode")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
|
||||
data class JibriConfig(
|
||||
@JsonProperty("jibri_id")
|
||||
val jibriId: String? = null,
|
||||
@JsonProperty("recording_directory")
|
||||
val recordingDirectory: String? = null,
|
||||
/**
|
||||
* Whether or not Jibri should return to idle state
|
||||
* after handling (successfully or unsuccessfully)
|
||||
* a request. A value of 'true' here means that a Jibri
|
||||
* will NOT return back to the IDLE state and will need
|
||||
* to be restarted in order to be used again.
|
||||
*/
|
||||
@JsonProperty("single_use_mode")
|
||||
val singleUseMode: Boolean? = null,
|
||||
/**
|
||||
* Whether or not pushing stats to statsd
|
||||
* should be enabled. See [org.jitsi.jibri.statsd.JibriStatsDClient].
|
||||
*/
|
||||
@JsonProperty("enable_stats_d")
|
||||
val enabledStatsD: Boolean? = null,
|
||||
@JsonProperty("finalize_recording_script_path")
|
||||
val finalizeRecordingScriptPath: String? = null,
|
||||
@JsonProperty("xmpp_environments")
|
||||
val xmppEnvironments: List<XmppEnvironmentConfig>? = null
|
||||
)
|
||||
|
||||
fun loadConfigFromFile(configFile: File): JibriConfig? = try {
|
||||
val config: JibriConfig = jacksonObjectMapper()
|
||||
.configure(JsonParser.Feature.ALLOW_COMMENTS, true)
|
||||
.readValue(configFile)
|
||||
logger.info("Successfully parsed legacy config")
|
||||
config
|
||||
} catch (e: MismatchedInputException) {
|
||||
logger.error("A required config parameter was missing: ${e.originalMessage}")
|
||||
null
|
||||
} catch (e: UnrecognizedPropertyException) {
|
||||
logger.error("An unrecognized config parameter was found: ${e.originalMessage}")
|
||||
null
|
||||
} catch (e: InvalidFormatException) {
|
||||
logger.error("A config parameter was incorrectly formatted: ${e.localizedMessage}")
|
||||
null
|
||||
}
|
||||
27
src/main/kotlin/org/jitsi/jibri/error/JibriError.kt
Normal file
27
src/main/kotlin/org/jitsi/jibri/error/JibriError.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.error
|
||||
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
|
||||
open class JibriError(
|
||||
val scope: ErrorScope,
|
||||
val detail: String
|
||||
) {
|
||||
open fun shouldRetry(): Boolean = true
|
||||
override fun toString(): String = "Error: ${this::class.java.simpleName} $scope $detail"
|
||||
}
|
||||
41
src/main/kotlin/org/jitsi/jibri/health/JibriHealth.kt
Normal file
41
src/main/kotlin/org/jitsi/jibri/health/JibriHealth.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.health
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import org.jitsi.jibri.status.JibriStatus
|
||||
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
data class EnvironmentContext(
|
||||
private val name: String
|
||||
)
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
|
||||
data class JibriHealth(
|
||||
/**
|
||||
* Whether or not this Jibri is "busy". See [JibriManager#busy]
|
||||
*/
|
||||
private val status: JibriStatus,
|
||||
/**
|
||||
* Context for the environment Jibri is currently active on
|
||||
* (only present if [busy] is true)
|
||||
*/
|
||||
private val environmentContext: EnvironmentContext? = null
|
||||
)
|
||||
166
src/main/kotlin/org/jitsi/jibri/metrics/JibriMetrics.kt
Normal file
166
src/main/kotlin/org/jitsi/jibri/metrics/JibriMetrics.kt
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright @ 2024-Present 8x8, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.metrics
|
||||
|
||||
import com.timgroup.statsd.NonBlockingStatsDClient
|
||||
import org.jitsi.jibri.RecordingSinkType
|
||||
import org.jitsi.jibri.status.ComponentBusyStatus
|
||||
import org.jitsi.jibri.status.ComponentHealthStatus
|
||||
import org.jitsi.jibri.status.JibriStatus
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
|
||||
class JibriMetrics {
|
||||
private val logger = createLogger()
|
||||
|
||||
private val statsd = if (StatsConfig.enableStatsD) {
|
||||
NonBlockingStatsDClient(
|
||||
"jibri",
|
||||
StatsConfig.statsdHost,
|
||||
StatsConfig.statsdPort
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun incrementStatsDCounter(aspect: String, tag: String) {
|
||||
statsd?.let {
|
||||
logger.debug { "Incrementing statsd counter: $aspect:$tag" }
|
||||
it.incrementCounter(aspect, tag)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestWhileBusy(type: RecordingSinkType) {
|
||||
incrementStatsDCounter(ASPECT_BUSY, type.getTag())
|
||||
requestsWhileBusy.inc()
|
||||
}
|
||||
|
||||
fun start(type: RecordingSinkType) {
|
||||
incrementStatsDCounter(ASPECT_START, type.getTag())
|
||||
sessionsStarted.inc()
|
||||
}
|
||||
|
||||
fun stop(type: RecordingSinkType) {
|
||||
incrementStatsDCounter(ASPECT_STOP, type.getTag())
|
||||
sessionsStopped.inc()
|
||||
}
|
||||
|
||||
fun error(type: RecordingSinkType) {
|
||||
incrementStatsDCounter(ASPECT_ERROR, type.getTag())
|
||||
errors.inc()
|
||||
}
|
||||
|
||||
fun xmppConnected(tags: String) {
|
||||
incrementStatsDCounter(XMPP_CONNECTED, tags)
|
||||
xmppConnected.inc()
|
||||
}
|
||||
|
||||
fun xmppReconnecting(tags: String) {
|
||||
incrementStatsDCounter(XMPP_RECONNECTING, tags)
|
||||
xmppReconnecting.inc()
|
||||
}
|
||||
|
||||
fun xmppReconnectionFailed(tags: String) {
|
||||
incrementStatsDCounter(XMPP_RECONNECTION_FAILED, tags)
|
||||
xmppReconnectionFailed.inc()
|
||||
}
|
||||
|
||||
fun xmppPingFailed(tags: String) {
|
||||
incrementStatsDCounter(XMPP_PING_FAILED, tags)
|
||||
xmppPingFailed.inc()
|
||||
}
|
||||
|
||||
fun xmppClosed(tags: String) {
|
||||
incrementStatsDCounter(XMPP_CLOSED, tags)
|
||||
xmppClosed.inc()
|
||||
}
|
||||
|
||||
fun xmppClosedOnError(tags: String) {
|
||||
incrementStatsDCounter(XMPP_CLOSED_ON_ERROR, tags)
|
||||
xmppClosedOnError.inc()
|
||||
}
|
||||
|
||||
fun stoppedOnXmppClosed(tags: String) {
|
||||
incrementStatsDCounter(STOPPED_ON_XMPP_CLOSED, tags)
|
||||
stoppedOnXmppClosed.inc()
|
||||
}
|
||||
|
||||
fun updateStatus(status: JibriStatus) {
|
||||
healthy.set(status.health.healthStatus == ComponentHealthStatus.HEALTHY)
|
||||
recording.set(status.busyStatus == ComponentBusyStatus.BUSY)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val sessionsStarted = JibriMetricsContainer.registerCounter(
|
||||
"sessions_started",
|
||||
"Number of times a session was started."
|
||||
)
|
||||
val sessionsStopped = JibriMetricsContainer.registerCounter(
|
||||
"sessions_stopped",
|
||||
"Number of times a session was stopped."
|
||||
)
|
||||
val errors = JibriMetricsContainer.registerCounter(
|
||||
"errors",
|
||||
"Number of errors."
|
||||
)
|
||||
val requestsWhileBusy = JibriMetricsContainer.registerCounter(
|
||||
"busy",
|
||||
"Number of times a request was received while the instance was busy."
|
||||
)
|
||||
val xmppConnected = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_connected",
|
||||
"Number of times an XMPP connection connected."
|
||||
)
|
||||
val xmppReconnecting = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_reconnecting",
|
||||
"Number of times an XMPP connection started re-connecting."
|
||||
)
|
||||
val xmppReconnectionFailed = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_reconnection_failed",
|
||||
"Number of times an XMPP re-connection failed."
|
||||
)
|
||||
val xmppPingFailed = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_ping_failed",
|
||||
"Number of times an XMPP ping timed out."
|
||||
)
|
||||
val xmppClosed = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_closed",
|
||||
"Number of times an XMPP connection was closed."
|
||||
)
|
||||
val xmppClosedOnError = JibriMetricsContainer.registerCounter(
|
||||
"xmpp_closed_on_error",
|
||||
"Number of times an XMPP connection was closed on error."
|
||||
)
|
||||
val stoppedOnXmppClosed = JibriMetricsContainer.registerCounter(
|
||||
"stopped_on_xmpp_closed",
|
||||
"Number of times a session was stopped because XMPP disconnected."
|
||||
)
|
||||
val healthy = JibriMetricsContainer.registerBooleanMetric(
|
||||
"healthy",
|
||||
"Whether the jibri instance is currently healthy."
|
||||
)
|
||||
val recording = JibriMetricsContainer.registerBooleanMetric(
|
||||
"recording",
|
||||
"Whether the jibri instance is currently in use."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun RecordingSinkType.getTag() = when (this) {
|
||||
RecordingSinkType.STREAM -> TAG_SERVICE_LIVE_STREAM
|
||||
RecordingSinkType.FILE -> TAG_SERVICE_RECORDING
|
||||
RecordingSinkType.GATEWAY -> TAG_SERVICE_SIP_GATEWAY
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright @ 2024-Present 8x8, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.metrics
|
||||
|
||||
import org.jitsi.metrics.MetricsContainer
|
||||
|
||||
object JibriMetricsContainer : MetricsContainer(namespace = "jitsi_jibri")
|
||||
39
src/main/kotlin/org/jitsi/jibri/metrics/StatsConfig.kt
Normal file
39
src/main/kotlin/org/jitsi/jibri/metrics/StatsConfig.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright @ 2024-Present 8x8, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.metrics
|
||||
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.metaconfig.config
|
||||
|
||||
object StatsConfig {
|
||||
val enableStatsD: Boolean by config {
|
||||
"JibriConfig::enableStatsD" { Config.legacyConfigSource.enabledStatsD!! }
|
||||
"jibri.stats.enable-stats-d".from(Config.configSource)
|
||||
}
|
||||
|
||||
val statsdHost: String by config {
|
||||
"jibri.stats.host".from(Config.configSource)
|
||||
}
|
||||
|
||||
val statsdPort: Int by config {
|
||||
"jibri.stats.port".from(Config.configSource)
|
||||
}
|
||||
|
||||
val enablePrometheus: Boolean by config {
|
||||
"jibri.stats.prometheus.enabled".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
36
src/main/kotlin/org/jitsi/jibri/metrics/StatsDEvents.kt
Normal file
36
src/main/kotlin/org/jitsi/jibri/metrics/StatsDEvents.kt
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.metrics
|
||||
|
||||
internal const val ASPECT_START = "start"
|
||||
internal const val ASPECT_STOP = "stop"
|
||||
internal const val ASPECT_BUSY = "busy"
|
||||
internal const val ASPECT_ERROR = "error"
|
||||
|
||||
internal const val XMPP_CONNECTED = "xmpp-connected"
|
||||
internal const val XMPP_RECONNECTING = "xmpp-reconnecting"
|
||||
internal const val XMPP_RECONNECTION_FAILED = "xmpp-reconnection-failed"
|
||||
internal const val XMPP_PING_FAILED = "xmpp-ping-failed"
|
||||
internal const val XMPP_CLOSED = "xmpp-closed"
|
||||
internal const val XMPP_CLOSED_ON_ERROR = "xmpp-closed-on-error"
|
||||
|
||||
// A recording session was stopped because XMPP disconnected.
|
||||
internal const val STOPPED_ON_XMPP_CLOSED = "stopped-on-xmpp-closed"
|
||||
|
||||
internal const val TAG_SERVICE_RECORDING = "recording"
|
||||
internal const val TAG_SERVICE_LIVE_STREAM = "live_stream"
|
||||
internal const val TAG_SERVICE_SIP_GATEWAY = "sip_gateway"
|
||||
25
src/main/kotlin/org/jitsi/jibri/selenium/Errors.kt
Normal file
25
src/main/kotlin/org/jitsi/jibri/selenium/Errors.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium
|
||||
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
|
||||
object FailedToJoinCall : JibriError(ErrorScope.SESSION, "Failed to join the call")
|
||||
object ChromeHung : JibriError(ErrorScope.SESSION, "Chrome hung")
|
||||
object NoMediaReceived : JibriError(ErrorScope.SESSION, "No media received")
|
||||
object IceFailed : JibriError(ErrorScope.SESSION, "ICE failed")
|
||||
449
src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt
Normal file
449
src/main/kotlin/org/jitsi/jibri/selenium/JibriSelenium.kt
Normal file
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.jitsi.jibri.CallUrlInfo
|
||||
import org.jitsi.jibri.MainConfig
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
import org.jitsi.jibri.selenium.status_checks.EmptyCallStatusCheck
|
||||
import org.jitsi.jibri.selenium.status_checks.IceConnectionStatusCheck
|
||||
import org.jitsi.jibri.selenium.status_checks.LocalParticipantKickedStatusCheck
|
||||
import org.jitsi.jibri.selenium.status_checks.MediaReceivedStatusCheck
|
||||
import org.jitsi.jibri.selenium.util.BrowserFileHandler
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.StatusPublisher
|
||||
import org.jitsi.jibri.util.TaskPools
|
||||
import org.jitsi.jibri.util.extensions.scheduleAtFixedRate
|
||||
import org.jitsi.jibri.util.getLoggerWithHandler
|
||||
import org.jitsi.jibri.util.randomAlphaNum
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.metaconfig.from
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
import org.openqa.selenium.TimeoutException
|
||||
import org.openqa.selenium.WebDriverException
|
||||
import org.openqa.selenium.chrome.ChromeDriver
|
||||
import org.openqa.selenium.chrome.ChromeDriverService
|
||||
import org.openqa.selenium.chrome.ChromeOptions
|
||||
import org.openqa.selenium.logging.LogType
|
||||
import org.openqa.selenium.logging.LoggingPreferences
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Parameters needed for joining the call in Selenium
|
||||
*/
|
||||
data class CallParams(
|
||||
val callUrlInfo: CallUrlInfo,
|
||||
/**
|
||||
* The email that should be used for jibri. Note that this
|
||||
* is currently only used in the sipgateway gateway scenario; when doing
|
||||
* recording the jibri is 'invisible' in the call
|
||||
*/
|
||||
val email: String = "",
|
||||
/**
|
||||
* SipJibri will set the passcode in the local storage
|
||||
* This will be used by jitsi-meet to send the conference passcode to prosody
|
||||
* and bypass the password prompt
|
||||
*/
|
||||
val passcode: String? = null,
|
||||
/**
|
||||
* Override the default value of callStats username.
|
||||
* Note that this is currently only used in the sipgateway gateway scenario;
|
||||
*/
|
||||
val callStatsUsernameOverride: String = "",
|
||||
/**
|
||||
* Display name which when it is set, it is used by jibri when joining the web conference.
|
||||
* Note that this is currently only used in the sipgateway gateway scenario;
|
||||
*/
|
||||
val displayName: String = "",
|
||||
/**
|
||||
* Extra URL params to add when joining the call, on top of the service-specific defaults.
|
||||
*/
|
||||
val extraUrlParams: List<String> = listOf()
|
||||
) {
|
||||
override fun toString(): String = if (passcode.isNullOrEmpty()) {
|
||||
"CallParams(callUrlInfo=$callUrlInfo, email='$email', passcode=$passcode" +
|
||||
", callStatsUsernameOverride=$callStatsUsernameOverride, displayName=$displayName)"
|
||||
} else {
|
||||
"CallParams(callUrlInfo=$callUrlInfo, email='$email', passcode=*****" +
|
||||
", callStatsUsernameOverride=$callStatsUsernameOverride, displayName=$displayName)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be passed to [JibriSelenium]
|
||||
*/
|
||||
data class JibriSeleniumOptions(
|
||||
/**
|
||||
* Which display selenium should be started on
|
||||
*/
|
||||
val display: String = System.getenv("DISPLAY") ?: ":0",
|
||||
/**
|
||||
* The display name that should be used for jibri. Note that this
|
||||
* is currently only used in the sipgateway gateway scenario; when doing
|
||||
* recording the jibri is 'invisible' in the call
|
||||
*/
|
||||
val displayName: String = "",
|
||||
/**
|
||||
* The email that should be used for jibri. Note that this
|
||||
* is currently only used in the sipgateway gateway scenario; when doing
|
||||
* recording the jibri is 'invisible' in the call
|
||||
*/
|
||||
val email: String = "",
|
||||
/**
|
||||
* The callstats username to be used for jibri.
|
||||
* Set this only to override the default callStatsUsername
|
||||
*/
|
||||
val callStatsUsernameOverride: String = "",
|
||||
/**
|
||||
* Chrome command line flags to add (in addition to the common
|
||||
* ones)
|
||||
*/
|
||||
val extraChromeCommandLineFlags: List<String> = listOf(),
|
||||
/**
|
||||
* How long we should stay in a call with no other participants before quitting
|
||||
*/
|
||||
val emptyCallTimeout: Duration = EmptyCallStatusCheck.defaultCallEmptyTimeout,
|
||||
/**
|
||||
* Use local participant status checks, such as if the local participant is kicked out
|
||||
* This is currently only used in the sipgateway gateway scenario;
|
||||
*/
|
||||
val enableLocalParticipantStatusChecks: Boolean = false
|
||||
)
|
||||
|
||||
val SIP_GW_URL_OPTIONS = listOf(
|
||||
"config.iAmRecorder=true",
|
||||
"config.iAmSipGateway=true",
|
||||
"config.ignoreStartMuted=true",
|
||||
"config.analytics.disabled=true",
|
||||
"config.enableEmailInStats=false",
|
||||
"config.p2p.enabled=false",
|
||||
"config.prejoinConfig.enabled=false",
|
||||
"config.requireDisplayName=false",
|
||||
"config.deeplinking.disabled=true",
|
||||
"devices.videoInput=\"PJSUA\""
|
||||
)
|
||||
|
||||
val RECORDING_URL_OPTIONS = listOf(
|
||||
"config.analytics.disabled=true",
|
||||
"config.disableInitialGUM=true",
|
||||
"config.externalConnectUrl=null",
|
||||
"config.iAmRecorder=true",
|
||||
"config.p2p.enabled=false",
|
||||
"config.prejoinConfig.enabled=false",
|
||||
"config.requireDisplayName=false",
|
||||
"config.deeplinking.disabled=true",
|
||||
"config.startWithAudioMuted=true",
|
||||
"config.startWithVideoMuted=true",
|
||||
"interfaceConfig.APP_NAME=\"Jibri\""
|
||||
)
|
||||
|
||||
/**
|
||||
* The [JibriSelenium] class is responsible for all of the interactions with
|
||||
* Selenium. It:
|
||||
* 1) Owns the webdriver
|
||||
* 2) Handles passing the proper options to Chrome
|
||||
* 3) Sets the necessary localstorage variables before joining a call
|
||||
* It implements [StatusPublisher] to publish its status
|
||||
*/
|
||||
class JibriSelenium(
|
||||
parentLogger: Logger,
|
||||
private val jibriSeleniumOptions: JibriSeleniumOptions = JibriSeleniumOptions()
|
||||
) : StatusPublisher<ComponentState>() {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
private var chromeDriver: ChromeDriver
|
||||
private var currCallUrl: String? = null
|
||||
private val stateMachine = SeleniumStateMachine()
|
||||
private var shuttingDown = AtomicBoolean(false)
|
||||
private val chromeOpts: List<String> by config("jibri.chrome.flags".from(Config.configSource))
|
||||
private var callPage: CallPage
|
||||
|
||||
/**
|
||||
* A task which executes at an interval and checks various aspects of the call to make sure things are
|
||||
* working correctly
|
||||
*/
|
||||
private var recurringCallStatusCheckTask: ScheduledFuture<*>? = null
|
||||
|
||||
/*
|
||||
* Set up default chrome driver options (using fake device, etc.)
|
||||
*/
|
||||
init {
|
||||
if (System.getProperty("webdriver.chrome.logfile") == null) {
|
||||
System.setProperty("webdriver.chrome.logfile", "/var/log/jitsi/jibri/chromedriver.log")
|
||||
}
|
||||
val chromeDriverLogFile = File(System.getProperty("webdriver.chrome.logfile"))
|
||||
// chromedriver exits immediately (exit value 1) if it cannot open its log file, so make
|
||||
// sure the parent directory exists.
|
||||
chromeDriverLogFile.parentFile?.mkdirs()
|
||||
val chromeOptions = ChromeOptions()
|
||||
chromeOptions.addArguments(chromeOpts)
|
||||
chromeOptions.addArguments(jibriSeleniumOptions.extraChromeCommandLineFlags)
|
||||
val chromeDriverService = ChromeDriverService.Builder()
|
||||
.withEnvironment(mapOf("DISPLAY" to jibriSeleniumOptions.display))
|
||||
.withLogFile(chromeDriverLogFile)
|
||||
.build()
|
||||
val logPrefs = LoggingPreferences()
|
||||
logPrefs.enable(LogType.DRIVER, Level.ALL)
|
||||
chromeOptions.setCapability("goog:loggingPrefs", logPrefs)
|
||||
chromeDriver = ChromeDriver(chromeDriverService, chromeOptions)
|
||||
chromeDriver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(60))
|
||||
callPage = CallPage.create(chromeDriver)
|
||||
|
||||
stateMachine.onStateTransition(this::onSeleniumStateChange)
|
||||
}
|
||||
|
||||
private fun onSeleniumStateChange(oldState: ComponentState, newState: ComponentState) {
|
||||
logger.info("Transitioning from state $oldState to $newState")
|
||||
publishStatus(newState)
|
||||
}
|
||||
|
||||
private fun startRecurringCallStatusChecks() {
|
||||
// Note that the order here is important: we always want to check for no participants before we check
|
||||
// for media being received, since the call being empty will also mean Jibri is not receiving media but should
|
||||
// not cause Jibri to go unhealthy (like not receiving media when there are others in the call will).
|
||||
val callStatusChecks = buildList {
|
||||
add(EmptyCallStatusCheck(logger, callEmptyTimeout = jibriSeleniumOptions.emptyCallTimeout))
|
||||
add(MediaReceivedStatusCheck(logger))
|
||||
add(IceConnectionStatusCheck(logger))
|
||||
if (jibriSeleniumOptions.enableLocalParticipantStatusChecks) {
|
||||
add(LocalParticipantKickedStatusCheck(logger))
|
||||
}
|
||||
}
|
||||
|
||||
// We fire all state transitions in the ioPool, otherwise we may try and cancel the
|
||||
// recurringCallStatusCheckTask from within the thread it was executing in. Another solution would've been
|
||||
// to pass 'false' to recurringCallStatusCheckTask.cancel, but it felt cleaner to separate the threads
|
||||
val transitionState = { event: SeleniumEvent ->
|
||||
TaskPools.ioPool.submit {
|
||||
stateMachine.transition(event)
|
||||
}
|
||||
}
|
||||
recurringCallStatusCheckTask = TaskPools.recurringTasksPool.scheduleAtFixedRate(15, TimeUnit.SECONDS, 15) {
|
||||
try {
|
||||
// Run through each of the checks. If we hit one that returns an event, then we stop and process the
|
||||
// state transition from that event. Note: it's intentional that we stop at the first check that fails
|
||||
// and 'asSequence' is necessary to do that.
|
||||
val event = callStatusChecks
|
||||
.asSequence()
|
||||
.map { check -> check.run(callPage) }
|
||||
.firstOrNull { result -> result != null }
|
||||
if (event != null) {
|
||||
logger.info("Recurring call status checks generated event $event")
|
||||
transitionState(event)
|
||||
}
|
||||
} catch (t: TimeoutException) {
|
||||
// We've found that a timeout here often implies chrome has hung so consider this as an error
|
||||
// which will result in this Jibri being marked as unhealthy
|
||||
logger.error("Javascript timed out, assuming chrome has hung", t)
|
||||
transitionState(SeleniumEvent.ChromeHung)
|
||||
} catch (t: WebDriverException) {
|
||||
if (!shuttingDown.get()) {
|
||||
logger.error("Can't reach browser", t)
|
||||
transitionState(SeleniumEvent.ChromeHung)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error while running call status checks", t)
|
||||
// We'll just assume anything here is an issue
|
||||
transitionState(SeleniumEvent.ChromeHung)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep track of all the participants who take part in the call while
|
||||
* Jibri is active
|
||||
*/
|
||||
private fun addParticipantTrackers() {
|
||||
callPage.injectParticipantTrackerScript()
|
||||
if (jibriSeleniumOptions.enableLocalParticipantStatusChecks) {
|
||||
callPage.injectLocalParticipantTrackerScript()
|
||||
}
|
||||
}
|
||||
|
||||
fun addToPresence(key: String, value: String): Boolean = callPage.addToPresence(key, value)
|
||||
|
||||
fun sendPresence(): Boolean = callPage.sendPresence()
|
||||
|
||||
fun setParticipantProperties(properties: Map<String, String>): Boolean =
|
||||
callPage.setParticipantProperties(properties)
|
||||
|
||||
fun handleDtmfStar6() {
|
||||
logger.info("Handling *6 DTMF command (audio toggle)")
|
||||
|
||||
if (callPage.isVisitor()) {
|
||||
logger.info("In visitor mode, toggling raise hand")
|
||||
callPage.raiseHand()
|
||||
} else if (!callPage.isLocalAudioMuted()) {
|
||||
logger.info("Currently audio unmuted, muting audio")
|
||||
val result = callPage.toggleAudioMute()
|
||||
logger.info("toggleAudioMute result: $result")
|
||||
} else {
|
||||
val audioForceMuted = callPage.isAudioForceMuted()
|
||||
if (audioForceMuted) {
|
||||
logger.info("Audio force muted by AV moderation, raising hand to request unmute")
|
||||
callPage.raiseHand()
|
||||
} else {
|
||||
val result = callPage.toggleAudioMute()
|
||||
logger.info("toggleAudioMute result: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleDtmfStar7() {
|
||||
logger.info("Handling *7 DTMF command (video toggle)")
|
||||
|
||||
if (callPage.isVisitor()) {
|
||||
logger.info("In visitor mode, toggling raise hand")
|
||||
callPage.raiseHand()
|
||||
} else if (!callPage.isLocalVideoMuted()) {
|
||||
logger.info("Currently video unmuted, muting video")
|
||||
val result = callPage.toggleVideoMute()
|
||||
logger.info("toggleVideoMute result: $result")
|
||||
} else {
|
||||
val videoForceMuted = callPage.isVideoForceMuted()
|
||||
if (videoForceMuted) {
|
||||
logger.info("Video force muted by AV moderation, raising hand to request unmute")
|
||||
callPage.raiseHand()
|
||||
} else {
|
||||
val result = callPage.toggleVideoMute()
|
||||
logger.info("toggleVideoMute result: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a web call with Selenium
|
||||
*/
|
||||
fun joinCall(
|
||||
callUrlInfo: CallUrlInfo,
|
||||
xmppCredentials: XmppCredentials? = null,
|
||||
passcode: String? = null,
|
||||
unmute: Boolean = false
|
||||
) {
|
||||
// These are all blocking calls, so offload the work to another thread
|
||||
TaskPools.ioPool.submit {
|
||||
try {
|
||||
var callStatsUsername = "jibri"
|
||||
if (jibriSeleniumOptions.callStatsUsernameOverride.isNotEmpty()) {
|
||||
callStatsUsername = jibriSeleniumOptions.callStatsUsernameOverride
|
||||
} else if (MainConfig.jibriId.isNotEmpty()) {
|
||||
callStatsUsername = MainConfig.jibriId
|
||||
}
|
||||
|
||||
val localStorageValues = mutableMapOf(
|
||||
"displayname" to jibriSeleniumOptions.displayName,
|
||||
"email" to jibriSeleniumOptions.email,
|
||||
"callStatsUserName" to callStatsUsername
|
||||
)
|
||||
xmppCredentials?.let {
|
||||
val username = if (xmppCredentials.randomizeUsername) {
|
||||
"${xmppCredentials.username}-${randomAlphaNum(8)}"
|
||||
} else {
|
||||
xmppCredentials.username
|
||||
}
|
||||
localStorageValues["xmpp_username_override"] = "$username@${xmppCredentials.domain}"
|
||||
localStorageValues["xmpp_password_override"] = xmppCredentials.password
|
||||
}
|
||||
passcode?.let {
|
||||
localStorageValues["xmpp_conference_password_override"] = passcode
|
||||
}
|
||||
|
||||
// jitsi-meet parses this twice...
|
||||
val innerJson = jacksonObjectMapper().writeValueAsString(localStorageValues)
|
||||
val innerJsonEscaped = innerJson.replace("\"", "\\\"")
|
||||
val outerJson = "\"$innerJsonEscaped\""
|
||||
val callUrl = callUrlInfo.withAdditionalUrlParams(
|
||||
listOf(
|
||||
"config.useHostPageLocalStorage=true",
|
||||
"appData.localStorageContent=${encodeURIComponent(outerJson)}",
|
||||
)
|
||||
)
|
||||
|
||||
if (!callPage.visit(callUrl)) {
|
||||
stateMachine.transition(SeleniumEvent.FailedToJoinCall)
|
||||
} else if (unmute && !callPage.unmute()) {
|
||||
stateMachine.transition(SeleniumEvent.FailedToJoinCall)
|
||||
} else {
|
||||
startRecurringCallStatusChecks()
|
||||
addParticipantTrackers()
|
||||
currCallUrl = callUrlInfo.callUrl
|
||||
stateMachine.transition(SeleniumEvent.CallJoined)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("An error occurred while joining the call", t)
|
||||
stateMachine.transition(SeleniumEvent.FailedToJoinCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getParticipants(): List<Map<String, Any>> = callPage.getParticipants()
|
||||
|
||||
fun leaveCallAndQuitBrowser() {
|
||||
logger.info("Leaving call and quitting browser")
|
||||
shuttingDown.set(true)
|
||||
recurringCallStatusCheckTask?.cancel(true)
|
||||
logger.info("Recurring call status checks cancelled")
|
||||
|
||||
browserOutputLogger.info("Logs for call $currCallUrl")
|
||||
try {
|
||||
chromeDriver.manage().logs().availableLogTypes.forEach { logType ->
|
||||
val logEntries = chromeDriver.manage().logs().get(logType)
|
||||
logger.info("Got ${logEntries.all.size} log entries for type $logType")
|
||||
browserOutputLogger.info("========= TYPE=$logType ===========")
|
||||
logEntries.all.forEach {
|
||||
browserOutputLogger.info(it.toString())
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error trying to get chromedriver logs", t)
|
||||
}
|
||||
logger.info("Leaving web call")
|
||||
try {
|
||||
callPage.leave()
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error trying to leave the call", t)
|
||||
}
|
||||
currCallUrl = null
|
||||
logger.info("Quitting chrome driver")
|
||||
chromeDriver.quit()
|
||||
logger.info("Chrome driver quit")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val browserOutputLogger = getLoggerWithHandler("browser", BrowserFileHandler())
|
||||
const val COMPONENT_ID = "Selenium"
|
||||
}
|
||||
}
|
||||
|
||||
/** Match javascript's encodeURIComponent */
|
||||
private fun encodeURIComponent(str: String) = URLEncoder.encode(str, StandardCharsets.US_ASCII.toString())
|
||||
.replace("+", "%20")
|
||||
.replace("%21", "!")
|
||||
.replace("%27", "'")
|
||||
.replace("%28", "(")
|
||||
.replace("%29", ")")
|
||||
.replace("%7E", "~")
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium
|
||||
|
||||
import com.tinder.StateMachine
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.NotifyingStateMachine
|
||||
|
||||
sealed class SeleniumEvent {
|
||||
object CallJoined : SeleniumEvent()
|
||||
object FailedToJoinCall : SeleniumEvent()
|
||||
object CallEmpty : SeleniumEvent()
|
||||
object NoMediaReceived : SeleniumEvent()
|
||||
object IceFailedEvent : SeleniumEvent()
|
||||
object ChromeHung : SeleniumEvent()
|
||||
object LocalParticipantKicked : SeleniumEvent()
|
||||
}
|
||||
|
||||
sealed class SideEffect
|
||||
|
||||
class SeleniumStateMachine : NotifyingStateMachine() {
|
||||
private val stateMachine = StateMachine.create<ComponentState, SeleniumEvent, SideEffect> {
|
||||
initialState(ComponentState.StartingUp)
|
||||
|
||||
state<ComponentState.StartingUp> {
|
||||
on<SeleniumEvent.CallJoined> {
|
||||
transitionTo(ComponentState.Running)
|
||||
}
|
||||
on<SeleniumEvent.FailedToJoinCall> {
|
||||
transitionTo(ComponentState.Error(FailedToJoinCall))
|
||||
}
|
||||
on<SeleniumEvent.ChromeHung> {
|
||||
transitionTo(ComponentState.Error(ChromeHung))
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Running> {
|
||||
on<SeleniumEvent.CallEmpty> {
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
on<SeleniumEvent.NoMediaReceived> {
|
||||
transitionTo(ComponentState.Error(NoMediaReceived))
|
||||
}
|
||||
on<SeleniumEvent.IceFailedEvent> {
|
||||
transitionTo(ComponentState.Error(IceFailed))
|
||||
}
|
||||
on<SeleniumEvent.LocalParticipantKicked> {
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
on<SeleniumEvent.ChromeHung> {
|
||||
transitionTo(ComponentState.Error(ChromeHung))
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Error> {
|
||||
on(any<SeleniumEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Finished> {
|
||||
on(any<SeleniumEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
onTransition {
|
||||
val validTransition = it as? StateMachine.Transition.Valid ?: run {
|
||||
throw Exception("Invalid state transition: $it")
|
||||
}
|
||||
if (validTransition.fromState::class != validTransition.toState::class) {
|
||||
notify(validTransition.fromState, validTransition.toState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun transition(event: SeleniumEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium.pageobjects
|
||||
|
||||
import org.jitsi.jibri.CallUrlInfo
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import org.openqa.selenium.remote.RemoteWebDriver
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* [AbstractPageObject] is a page object class containing logic common to
|
||||
* all page object instances
|
||||
*/
|
||||
open class AbstractPageObject(protected val driver: RemoteWebDriver) {
|
||||
private val logger = createLogger()
|
||||
|
||||
open fun visit(url: CallUrlInfo): Boolean {
|
||||
logger.info("Visiting url $url")
|
||||
|
||||
val totalTime = measureTime {
|
||||
driver.get(url.callUrl)
|
||||
}
|
||||
|
||||
logger.info("Waited $totalTime for driver to load page")
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package org.jitsi.jibri.selenium.pageobjects
|
||||
|
||||
import org.jitsi.jibri.CallUrlInfo
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import org.openqa.selenium.TimeoutException
|
||||
import org.openqa.selenium.remote.RemoteWebDriver
|
||||
import org.openqa.selenium.support.PageFactory
|
||||
import org.openqa.selenium.support.ui.WebDriverWait
|
||||
import java.time.Duration
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
/**
|
||||
* Page object representing the in-call page on a jitsi-meet server.
|
||||
* NOTE that all the calls here which execute javascript may throw (if, for example, chrome has crashed). It is
|
||||
* intentional that this exceptions are propagated up: the caller should handle those cases.
|
||||
*/
|
||||
class AppCallPage(driver: RemoteWebDriver) : AbstractPageObject(driver), CallPage {
|
||||
private val logger = createLogger()
|
||||
|
||||
init {
|
||||
PageFactory.initElements(driver, this)
|
||||
}
|
||||
|
||||
override fun visit(url: CallUrlInfo): Boolean {
|
||||
if (!super.visit(url)) {
|
||||
return false
|
||||
}
|
||||
val (result, totalTime) = measureTimedValue {
|
||||
try {
|
||||
WebDriverWait(driver, Duration.ofSeconds(30)).until {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference._room.isJoined();
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
when (result) {
|
||||
is Boolean -> result
|
||||
|
||||
else -> {
|
||||
logger.debug { "Not joined yet: $result" }
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (t: TimeoutException) {
|
||||
logger.error("Timed out waiting for call page to load")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
logger.info("Waited $totalTime to join the conference")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the local camera and microphone. Used in sip gateway mode.
|
||||
*/
|
||||
override fun unmute() = doUnmute("Audio") && doUnmute("Video")
|
||||
|
||||
private fun doUnmute(mediaType: String): Boolean {
|
||||
return try {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
APP.conference.mute$mediaType(false);
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
if (result != null) {
|
||||
logger.info { "Failed to unmute $mediaType: $result" }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (t: TimeoutException) {
|
||||
logger.error("Timed out waiting for unmute $mediaType")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the number of participants excluding hidden participants. */
|
||||
override fun getNumParticipants(): Int {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return (APP.conference._room.getParticipants().$PARTICIPANT_FILTER_SCRIPT).length + 1;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Number -> result.toInt()
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if there are no other participants in the conference.
|
||||
*/
|
||||
override fun isCallEmpty() = getNumParticipants() <= 1
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getStats(): Map<String, Any?> {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference.getStats();
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
if (result is String) {
|
||||
return mapOf()
|
||||
}
|
||||
return result as Map<String, Any?>
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getBitrates(): Map<String, Any?> {
|
||||
val stats = getStats()
|
||||
return stats.getOrDefault("bitrate", mapOf<String, Any?>()) as Map<String, Any?>
|
||||
}
|
||||
|
||||
override fun injectParticipantTrackerScript(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
window._jibriParticipants = new Map();
|
||||
const existingMembers = APP.conference._room.room.members || {};
|
||||
const existingMemberJids = Object.keys(existingMembers);
|
||||
console.log("There were " + existingMemberJids.length + " existing members");
|
||||
existingMemberJids.forEach(jid => {
|
||||
const existingMember = existingMembers[jid];
|
||||
const nick = existingMember.nick;
|
||||
if (existingMember.identity) {
|
||||
console.log("Member ", existingMember, " has identity, adding");
|
||||
if (nick && nick.length > 0 && existingMember.identity.user) {
|
||||
existingMember.identity.user.name = nick;
|
||||
}
|
||||
window._jibriParticipants.set(jid, existingMember.identity);
|
||||
} else {
|
||||
console.log("Member ", existingMember.jid, " has no identity, skipping");
|
||||
}
|
||||
});
|
||||
APP.conference._room.room.addListener(
|
||||
"xmpp.muc_member_joined",
|
||||
(from, nick, role, hidden, statsid, status, identity) => {
|
||||
console.log("Jibri got MUC_MEMBER_JOINED: ", from, identity);
|
||||
if (!hidden && identity) {
|
||||
if (nick && nick.length > 0 && identity.user) {
|
||||
identity.user.name = nick;
|
||||
}
|
||||
window._jibriParticipants.set(from, identity);
|
||||
}
|
||||
}
|
||||
);
|
||||
APP.conference._room.room.addListener(
|
||||
"xmpp.display_name_changed",
|
||||
(jid, displayName) => {
|
||||
const identity = window._jibriParticipants.get(jid);
|
||||
if (displayName && displayName.length > 0 && identity && identity.user) {
|
||||
identity.user.name = displayName;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Boolean -> result
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun injectLocalParticipantTrackerScript(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
window._isLocalParticipantKicked=false
|
||||
|
||||
APP.conference._room.room.addListener(
|
||||
"xmpp.kicked",
|
||||
(isSelfPresence, actorId, kickedParticipantId, reason) => {
|
||||
console.log("Jibri got a KICKED event: ", isSelfPresence, actorId, kickedParticipantId, reason);
|
||||
if (isSelfPresence) {
|
||||
window._isLocalParticipantKicked=true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Boolean -> result
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getParticipants(): List<Map<String, Any>> {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return window._jibriParticipants.values().toArray();
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
if (result is List<*>) {
|
||||
return result as List<Map<String, Any>>
|
||||
} else {
|
||||
return listOf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return how many of the participants are Jigasi clients.
|
||||
* Note: excludes any participants that are hidden (for example transcribers)
|
||||
*/
|
||||
override fun numRemoteParticipantsJigasi(): Int {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference._room.getParticipants()
|
||||
.$PARTICIPANT_FILTER_SCRIPT
|
||||
.filter(participant => participant.getProperty("features_jigasi") == true)
|
||||
.length;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Number -> result.toInt()
|
||||
|
||||
else -> {
|
||||
logger.error("error running numRemoteParticipantsJigasi script: $result ${result?.javaClass}")
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How many of the participants are hidden or hiddenFromRecorder. */
|
||||
override fun numHiddenParticipants(): Int {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference._room.getParticipants()
|
||||
.filter(p => (p.isHidden() || p.isHiddenFromRecorder()))
|
||||
.length;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Number -> result.toInt()
|
||||
|
||||
else -> {
|
||||
logger.error("error running numHiddenParticipants script: $result ${result?.javaClass}")
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if ICE is connected.
|
||||
*/
|
||||
override fun isIceConnected(): Boolean {
|
||||
val result: Any? = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference.getConnectionState();
|
||||
} catch(e) {
|
||||
return e.message;
|
||||
}
|
||||
"""
|
||||
)
|
||||
return (result.toString().lowercase() == "connected").also {
|
||||
if (!it) {
|
||||
logger.warn("ICE not connected: $result")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLocalParticipantKicked(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return window._isLocalParticipantKicked;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
if (result is Boolean) {
|
||||
return result
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a count of how many remote participants are totally muted (audio
|
||||
* and video). We ignore jigasi participants as they maybe muted in their presence
|
||||
* but also hard muted via the device, and we later ignore their state.
|
||||
* Note: Excludes hidden participants.
|
||||
*/
|
||||
override fun numRemoteParticipantsMuted(): Int {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference._room.getParticipants()
|
||||
.$PARTICIPANT_FILTER_SCRIPT
|
||||
.filter(participant => participant.isAudioMuted() && participant.isVideoMuted()
|
||||
&& participant.getProperty("features_jigasi") !== true)
|
||||
.length;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is Number -> result.toInt()
|
||||
|
||||
else -> {
|
||||
logger.error("error running numRemoteParticipantsMuted script: $result ${result?.javaClass}")
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isVisitor(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.store.getState()['features/visitors']?.iAmVisitor === true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result as? Boolean ?: false
|
||||
}
|
||||
|
||||
override fun isLocalAudioMuted(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference.isLocalAudioMuted();
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result as? Boolean ?: true
|
||||
}
|
||||
|
||||
override fun isLocalVideoMuted(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference.isLocalVideoMuted();
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result as? Boolean ?: true
|
||||
}
|
||||
|
||||
/** Returns true if AV moderation is enabled for audio and the local participant is not approved to unmute. */
|
||||
override fun isAudioForceMuted(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
var state = APP.store.getState();
|
||||
var avMod = state['features/av-moderation'];
|
||||
if (!avMod || !avMod.audioModerationEnabled) return false;
|
||||
var local = state['features/base/participants']?.local;
|
||||
if (local && local.role === 'moderator') return false;
|
||||
return avMod.audioUnmuteApproved !== true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result as? Boolean ?: false
|
||||
}
|
||||
|
||||
/** Returns true if AV moderation is enabled for video and the local participant is not approved to unmute. */
|
||||
override fun isVideoForceMuted(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
var state = APP.store.getState();
|
||||
var avMod = state['features/av-moderation'];
|
||||
if (!avMod || !avMod.videoModerationEnabled) return false;
|
||||
var local = state['features/base/participants']?.local;
|
||||
if (local && local.role === 'moderator') return false;
|
||||
return avMod.videoUnmuteApproved !== true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result as? Boolean ?: false
|
||||
}
|
||||
|
||||
// Toggles video mute and waits for the Redux store to confirm the state change.
|
||||
// muteVideo() returns a Promise — firing it synchronously and returning immediately
|
||||
// would not guarantee the toggle completed. Unmuting requires re-acquiring the camera
|
||||
// device, which is slower (12s timeout) than muting/releasing it (5s). On timeout,
|
||||
// extra state is returned to help diagnose why the change did not complete.
|
||||
override fun toggleVideoMute(): Any? {
|
||||
driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20))
|
||||
return driver.executeAsyncScript(
|
||||
"""
|
||||
var done = arguments[0];
|
||||
try {
|
||||
var isMuted = APP.conference.isLocalVideoMuted();
|
||||
var unsubscribe;
|
||||
var timer;
|
||||
var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); };
|
||||
|
||||
var waitForChange = function(ms, timeoutMsg) {
|
||||
timer = setTimeout(function() {
|
||||
cleanup();
|
||||
var st = APP.store.getState();
|
||||
var vt = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'video'; });
|
||||
done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].video.muted +
|
||||
' videoTracks=' + vt.length + ' hasJitsiTrack=' + vt.some(function(t) { return !!t.jitsiTrack; }));
|
||||
}, ms);
|
||||
unsubscribe = APP.store.subscribe(function() {
|
||||
var nowMuted = APP.conference.isLocalVideoMuted();
|
||||
if (nowMuted !== isMuted) {
|
||||
cleanup();
|
||||
done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isMuted) {
|
||||
// Prefer muting via the track directly so the Promise rejection is catchable.
|
||||
// Fall back to a Redux dispatch when no track is present yet.
|
||||
var localVideo = APP.store.getState()['features/base/tracks']
|
||||
.find(function(t) { return t.local && t.mediaType === 'video' && t.jitsiTrack; });
|
||||
if (localVideo) {
|
||||
waitForChange(5000, 'timeout-muting');
|
||||
localVideo.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); });
|
||||
} else {
|
||||
waitForChange(5000, 'timeout-mute-no-track');
|
||||
APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: true });
|
||||
}
|
||||
} else {
|
||||
// ensureTrack tells Jitsi to re-acquire the camera if no track exists yet.
|
||||
waitForChange(12000, 'timeout-unmuting');
|
||||
APP.store.dispatch({ type: 'SET_VIDEO_MUTED', muted: false, ensureTrack: true });
|
||||
}
|
||||
} catch (e) {
|
||||
done('error: ' + e.message);
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
// Toggles audio mute and waits for the Redux store to confirm the state change.
|
||||
// Audio track operations (especially unmuting/re-acquiring the microphone) are async —
|
||||
// dispatching and returning immediately would not guarantee the toggle completed.
|
||||
// Unmuting has a longer timeout (12s) than muting (5s) because re-acquiring a device
|
||||
// is slower than releasing it. On timeout, extra state is returned to help diagnose
|
||||
// why the change did not complete.
|
||||
override fun toggleAudioMute(): Any? {
|
||||
driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20))
|
||||
return driver.executeAsyncScript(
|
||||
"""
|
||||
var done = arguments[0];
|
||||
try {
|
||||
var isMuted = APP.conference.isLocalAudioMuted();
|
||||
var unsubscribe;
|
||||
var timer;
|
||||
var cleanup = function() { clearTimeout(timer); if (unsubscribe) unsubscribe(); };
|
||||
|
||||
var waitForChange = function(ms, timeoutMsg) {
|
||||
timer = setTimeout(function() {
|
||||
cleanup();
|
||||
var st = APP.store.getState();
|
||||
var at = st['features/base/tracks'].filter(function(t) { return t.local && t.mediaType === 'audio'; });
|
||||
done(timeoutMsg + ' mediaMuted=' + st['features/base/media'].audio.muted +
|
||||
' audioTracks=' + at.length + ' hasJitsiTrack=' + at.some(function(t) { return !!t.jitsiTrack; }));
|
||||
}, ms);
|
||||
unsubscribe = APP.store.subscribe(function() {
|
||||
var nowMuted = APP.conference.isLocalAudioMuted();
|
||||
if (nowMuted !== isMuted) {
|
||||
cleanup();
|
||||
done('ok wasMuted=' + isMuted + ' nowMuted=' + nowMuted);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (!isMuted) {
|
||||
// Prefer muting via the track directly so the Promise rejection is catchable.
|
||||
// Fall back to a Redux dispatch when no track is present yet.
|
||||
var localAudio = APP.store.getState()['features/base/tracks']
|
||||
.find(function(t) { return t.local && t.mediaType === 'audio' && t.jitsiTrack; });
|
||||
if (localAudio) {
|
||||
waitForChange(5000, 'timeout-muting');
|
||||
localAudio.jitsiTrack.mute().catch(function(e) { cleanup(); done('error-mute: ' + e); });
|
||||
} else {
|
||||
waitForChange(5000, 'timeout-mute-no-track');
|
||||
APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: true });
|
||||
}
|
||||
} else {
|
||||
// ensureTrack tells Jitsi to re-acquire the microphone if no track exists yet.
|
||||
waitForChange(12000, 'timeout-unmuting');
|
||||
APP.store.dispatch({ type: 'SET_AUDIO_MUTED', muted: false, ensureTrack: true });
|
||||
}
|
||||
} catch (e) {
|
||||
done('error: ' + e.message);
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
override fun raiseHand(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
const local = APP.store.getState()['features/base/participants']?.local;
|
||||
const isRaised = local && local.raisedHandTimestamp > 0;
|
||||
APP.store.dispatch({
|
||||
type: 'LOCAL_PARTICIPANT_RAISE_HAND',
|
||||
raisedHandTimestamp: isRaised ? 0 : Date.now()
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return result is Boolean && result
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given key, value pair to the presence map and send a new presence
|
||||
* message
|
||||
*/
|
||||
override fun addToPresence(key: String, value: String): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
APP.conference._room.room.addOrReplaceInPresence(
|
||||
arguments[0],
|
||||
{
|
||||
value: arguments[1]
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin(),
|
||||
key,
|
||||
value
|
||||
)
|
||||
return when (result) {
|
||||
is String -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendPresence(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
APP.conference._room.room.sendPresence();
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
return when (result) {
|
||||
is String -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
override fun leave(): Boolean {
|
||||
val result = driver.executeScript(
|
||||
"""
|
||||
try {
|
||||
return APP.conference._room.leave();
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
""".trimMargin()
|
||||
)
|
||||
|
||||
// Let's wait till we are alone in the room
|
||||
// (give time for the js Promise to finish before quiting selenium)
|
||||
WebDriverWait(driver, Duration.ofSeconds(2)).until {
|
||||
getNumParticipants() == 1
|
||||
}
|
||||
|
||||
return when (result) {
|
||||
is String -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Javascript to apply a filter to the list of participants to exclude ones which should be hidden from jibri.
|
||||
*/
|
||||
const val PARTICIPANT_FILTER_SCRIPT = "filter(p => !(p.isHidden() || p.isHiddenFromRecorder()))"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium.pageobjects
|
||||
|
||||
import org.jitsi.jibri.CallUrlInfo
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.openqa.selenium.remote.RemoteWebDriver
|
||||
|
||||
interface CallPage {
|
||||
fun visit(url: CallUrlInfo): Boolean
|
||||
fun getNumParticipants(): Int
|
||||
fun isCallEmpty(): Boolean
|
||||
fun injectParticipantTrackerScript(): Boolean
|
||||
fun injectLocalParticipantTrackerScript(): Boolean
|
||||
fun getParticipants(): List<Map<String, Any>>
|
||||
fun unmute(): Boolean
|
||||
fun numRemoteParticipantsJigasi(): Int
|
||||
fun numHiddenParticipants(): Int
|
||||
fun isIceConnected(): Boolean
|
||||
fun isLocalParticipantKicked(): Boolean
|
||||
fun numRemoteParticipantsMuted(): Int
|
||||
fun isVisitor(): Boolean
|
||||
fun isLocalAudioMuted(): Boolean
|
||||
fun isLocalVideoMuted(): Boolean
|
||||
fun isAudioForceMuted(): Boolean
|
||||
fun isVideoForceMuted(): Boolean
|
||||
fun toggleVideoMute(): Any?
|
||||
fun toggleAudioMute(): Any?
|
||||
fun raiseHand(): Boolean
|
||||
fun addToPresence(key: String, value: String): Boolean
|
||||
fun sendPresence(): Boolean
|
||||
fun setParticipantProperties(properties: Map<String, String>): Boolean {
|
||||
properties.forEach { (key, value) -> addToPresence(key, value) }
|
||||
return sendPresence()
|
||||
}
|
||||
fun leave(): Boolean
|
||||
fun getBitrates(): Map<String, Any?>
|
||||
|
||||
companion object {
|
||||
val useExternalAPI: Boolean by config {
|
||||
"jibri.selenium.use-external-api".from(Config.configSource)
|
||||
}
|
||||
|
||||
fun create(driver: RemoteWebDriver): CallPage = if (useExternalAPI) {
|
||||
ExternalAPIPage(driver)
|
||||
} else {
|
||||
AppCallPage(driver)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package org.jitsi.jibri.selenium.pageobjects
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.jitsi.jibri.CallUrlInfo
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import org.openqa.selenium.TimeoutException
|
||||
import org.openqa.selenium.remote.RemoteWebDriver
|
||||
import org.openqa.selenium.support.PageFactory
|
||||
import org.openqa.selenium.support.ui.WebDriverWait
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
|
||||
/** CallPage implementation with External API event-driven updates. */
|
||||
class ExternalAPIPage(driver: RemoteWebDriver) : AbstractPageObject(driver), CallPage {
|
||||
private val logger = createLogger()
|
||||
private val mapper by lazy { jacksonObjectMapper() }
|
||||
|
||||
/**
|
||||
* The bundled recorder.html, extracted to a local file so we can load it
|
||||
* directly in the browser instead of relying on the deployment to serve it.
|
||||
* Extracted lazily and reused for the lifetime of the page object.
|
||||
*/
|
||||
private val recorderHtmlFile: File by lazy { extractRecorderHtml() }
|
||||
|
||||
init {
|
||||
PageFactory.initElements(driver, this)
|
||||
driver.manage().timeouts().scriptTimeout(Duration.ofSeconds(20))
|
||||
}
|
||||
|
||||
override fun visit(url: CallUrlInfo): Boolean {
|
||||
val room = url.callName.substringAfterLast('/').lowercase()
|
||||
// The deployment base url is passed to the page so it can source
|
||||
// external_api.js from the deployment (the page itself is loaded from a
|
||||
// local file and has no deployment origin of its own).
|
||||
val baseUrl = url.baseUrl
|
||||
// `tenant` is only set explicitly for XMPP-invited joins; other callers (e.g. the
|
||||
// HTTP startService API) only set baseUrl/callName, so fall back to baseUrl's path.
|
||||
val tenantPath = if (url.tenant.isNotEmpty()) {
|
||||
url.tenant.replace(".", "/")
|
||||
} else {
|
||||
URI(baseUrl).path.trim('/')
|
||||
}
|
||||
val recorderUrl = buildString {
|
||||
append(recorderHtmlFile.toURI().toString())
|
||||
append("?room=").append(encode(room))
|
||||
append("&baseUrl=").append(encode(baseUrl))
|
||||
if (tenantPath.isNotEmpty()) {
|
||||
append("&tenant=").append(encode(tenantPath))
|
||||
}
|
||||
if (url.urlParams.isNotEmpty()) {
|
||||
val parsedConfig = parseUrlParams(url.urlParams)
|
||||
append("&config=").append(encode(mapper.writeValueAsString(parsedConfig)))
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Loading recorder page for room=$room baseUrl=$baseUrl tenant=$tenantPath")
|
||||
driver.get(recorderUrl)
|
||||
|
||||
return try {
|
||||
WebDriverWait(driver, Duration.ofSeconds(30)).until {
|
||||
val apiError = driver.executeScript("return window.jibriPageState?.apiError;")
|
||||
if (apiError != null) {
|
||||
throw IllegalStateException("API error: $apiError")
|
||||
}
|
||||
val script = "return window.jibriPageState?.conferenceJoined === true;"
|
||||
val result = driver.executeScript(script) as? Boolean ?: false
|
||||
result
|
||||
}
|
||||
logger.info("Recorder page initialized successfully")
|
||||
true
|
||||
} catch (e: TimeoutException) {
|
||||
logger.error("Failed to join conference: timeout waiting for conferenceJoined event")
|
||||
false
|
||||
} catch (e: IllegalStateException) {
|
||||
logger.error("Failed to join conference: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractRecorderHtml(): File {
|
||||
val tmpFile = File.createTempFile("jibri-recorder", ".html").apply { deleteOnExit() }
|
||||
val resource = javaClass.getResourceAsStream("/recorder.html")
|
||||
?: throw IllegalStateException("recorder.html not found on the classpath")
|
||||
resource.use { input -> tmpFile.outputStream().use { input.copyTo(it) } }
|
||||
return tmpFile
|
||||
}
|
||||
|
||||
// Parses "config.a.b=value" strings into nested {config: {a: {b: value}}} objects.
|
||||
private fun parseUrlParams(rawParams: List<String>): Map<String, Any?> {
|
||||
val result = mutableMapOf<String, Any?>()
|
||||
|
||||
for (param in rawParams) {
|
||||
if (param.isEmpty()) continue
|
||||
|
||||
val parts = param.split("=", limit = 2)
|
||||
if (parts.size != 2) continue
|
||||
|
||||
val (key, rawValue) = parts
|
||||
if (key.isEmpty() || rawValue.isEmpty()) continue
|
||||
|
||||
val value = try {
|
||||
mapper.readValue(rawValue, Any::class.java)
|
||||
} catch (e: Exception) {
|
||||
rawValue
|
||||
}
|
||||
|
||||
setNestedValue(result, key, value)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Recursively build nested map from dotted path: "config.a.b" → map[config][a][b] = value
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun setNestedValue(map: MutableMap<String, Any?>, path: String, value: Any?) {
|
||||
val parts = path.split(".")
|
||||
var current = map
|
||||
for (i in 0 until parts.size - 1) {
|
||||
current = (current.getOrPut(parts[i]) { mutableMapOf<String, Any?>() } as MutableMap<String, Any?>)
|
||||
}
|
||||
current[parts.last()] = value
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
/**
|
||||
* Calls [methodName] on window.jibriRecorderApi, guarding against the API or the method
|
||||
* not being ready yet and any error. [script] can assume `api` and `cb` are already in scope.
|
||||
*/
|
||||
private fun callRecorderApiAsync(methodName: String, script: String): Any? = try {
|
||||
driver.executeAsyncScript(
|
||||
"""
|
||||
const cb = arguments[arguments.length - 1];
|
||||
const api = window.jibriRecorderApi;
|
||||
if (!api || typeof api.$methodName !== 'function') {
|
||||
cb(null);
|
||||
return;
|
||||
}
|
||||
$script
|
||||
"""
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error calling jibriRecorderApi.$methodName", t)
|
||||
null
|
||||
}
|
||||
|
||||
/** Enable the local camera and microphone. Used in sip gateway mode. */
|
||||
override fun unmute(): Boolean {
|
||||
val audioOk = unmuteMedia("audio", "isAudioMuted", "toggleAudio")
|
||||
val videoOk = unmuteMedia("video", "isVideoMuted", "toggleVideo")
|
||||
return audioOk && videoOk
|
||||
}
|
||||
|
||||
private fun unmuteMedia(mediaType: String, isMutedMethod: String, toggleCommand: String): Boolean {
|
||||
val result = toggleMute(mediaType, isMutedMethod, toggleCommand, requiredInitialState = true)
|
||||
val success = result is String && result.startsWith("ok")
|
||||
if (!success) {
|
||||
logger.error("Failed to unmute $mediaType: $result")
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
private fun executeRecorderCommand(command: String, vararg args: Any?): Boolean = try {
|
||||
driver.executeScript(
|
||||
"""
|
||||
if (!window.jibriRecorderApi) return false;
|
||||
window.jibriRecorderApi.executeCommand('$command', ...arguments);
|
||||
return true;
|
||||
""",
|
||||
*args
|
||||
) as? Boolean ?: false
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error executing recorder command $command", t)
|
||||
false
|
||||
}
|
||||
|
||||
override fun getNumParticipants(): Int {
|
||||
val result = callRecorderApiAsync(
|
||||
"getRoomsInfo",
|
||||
"""
|
||||
api.getRoomsInfo().then(roomsData => {
|
||||
const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom);
|
||||
cb(mainRoom?.participants?.length || 1);
|
||||
}).catch(() => cb(1));
|
||||
"""
|
||||
) as? Number
|
||||
val count = result?.toInt() ?: 1
|
||||
logger.debug("Number of participants: $count")
|
||||
return count
|
||||
}
|
||||
|
||||
override fun isCallEmpty() = getNumParticipants() <= 1
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun getBitrates(): Map<String, Any?> {
|
||||
val result = callRecorderApiAsync(
|
||||
"getConnectionStats",
|
||||
"""
|
||||
api.getConnectionStats().then(stats => cb(stats || {})).catch(() => cb({}));
|
||||
"""
|
||||
)
|
||||
val stats = result as? Map<String, Any?> ?: mapOf()
|
||||
return stats["bitrate"] as? Map<String, Any?> ?: mapOf()
|
||||
}
|
||||
|
||||
override fun injectParticipantTrackerScript(): Boolean = true
|
||||
|
||||
override fun injectLocalParticipantTrackerScript(): Boolean = true
|
||||
|
||||
override fun getParticipants(): List<Map<String, Any>> {
|
||||
val result = callRecorderApiAsync(
|
||||
"getRoomsInfo",
|
||||
"""
|
||||
api.getRoomsInfo().then(roomsData => {
|
||||
const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom);
|
||||
const participants = (mainRoom?.participants || [])
|
||||
.filter(p => p?.userContext?.id || p?.userContext?.name || p?.userContext?.email)
|
||||
.map(p => ({ user: p.userContext }));
|
||||
cb(participants);
|
||||
}).catch(() => cb([]));
|
||||
"""
|
||||
)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return result as? List<Map<String, Any>> ?: listOf()
|
||||
}
|
||||
|
||||
override fun numRemoteParticipantsJigasi(): Int {
|
||||
val result = callRecorderApiAsync(
|
||||
"getRoomsInfo",
|
||||
"""
|
||||
api.getRoomsInfo().then(roomsData => {
|
||||
const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom);
|
||||
const numJigasiParticipants = (mainRoom?.participants || []).filter(p => p?.isJigasi === true).length;
|
||||
cb(numJigasiParticipants);
|
||||
}).catch(() => cb(0));
|
||||
"""
|
||||
) as? Number
|
||||
val numJigasiParticipants = result?.toInt() ?: 0
|
||||
logger.debug("Number of Jigasi participants: $numJigasiParticipants")
|
||||
return numJigasiParticipants
|
||||
}
|
||||
|
||||
override fun numHiddenParticipants(): Int {
|
||||
val result = callRecorderApiAsync(
|
||||
"getRoomsInfo",
|
||||
"""
|
||||
api.getRoomsInfo(true).then(roomsData => {
|
||||
const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom);
|
||||
const numHidden = (mainRoom?.participants || []).filter(p => p?.isHidden === true).length;
|
||||
cb(numHidden);
|
||||
}).catch(() => cb(0));
|
||||
"""
|
||||
) as? Number
|
||||
val numHidden = result?.toInt() ?: 0
|
||||
logger.debug("Number of hidden participants: $numHidden")
|
||||
return numHidden
|
||||
}
|
||||
|
||||
override fun isIceConnected(): Boolean {
|
||||
val result = callRecorderApiAsync(
|
||||
"getConnectionStats",
|
||||
"""
|
||||
api.getConnectionStats()
|
||||
.then(stats => cb(stats?.iceConnected === true))
|
||||
.catch(() => cb(false));
|
||||
"""
|
||||
)
|
||||
val iceConnected = result as? Boolean ?: false
|
||||
logger.debug("ICE connected: $iceConnected")
|
||||
return iceConnected
|
||||
}
|
||||
|
||||
override fun isLocalParticipantKicked(): Boolean = try {
|
||||
driver.executeScript(
|
||||
"return window.jibriPageState.localParticipantKicked === true;"
|
||||
) as? Boolean ?: false
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error checking isLocalParticipantKicked", t)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a count of how many remote participants are totally muted (audio
|
||||
* and video). We ignore jigasi participants as they may be muted in their presence
|
||||
* but also hard muted via the device, and we later ignore their state.
|
||||
* Note: Excludes hidden participants.
|
||||
*/
|
||||
override fun numRemoteParticipantsMuted(): Int {
|
||||
val result = callRecorderApiAsync(
|
||||
"getRoomsInfo",
|
||||
"""
|
||||
api.getRoomsInfo().then(roomsData => {
|
||||
const mainRoom = (roomsData.rooms || []).find(r => r?.isMainRoom);
|
||||
const numMutedParticipants = (mainRoom?.participants || []).filter(p => {
|
||||
return p?.audioMuted === true && p?.videoMuted === true && p?.isJigasi !== true;
|
||||
}).length;
|
||||
cb(numMutedParticipants);
|
||||
}).catch(() => cb(0));
|
||||
"""
|
||||
) as? Number
|
||||
val numMutedParticipants = result?.toInt() ?: 0
|
||||
logger.debug("Number of muted participants: $numMutedParticipants")
|
||||
return numMutedParticipants
|
||||
}
|
||||
|
||||
override fun isVisitor(): Boolean = try {
|
||||
driver.executeScript(
|
||||
"return window.jibriRecorderApi && window.jibriRecorderApi.isVisitor() === true;"
|
||||
) as? Boolean ?: false
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error calling jibriRecorderApi.isVisitor", t)
|
||||
false
|
||||
}
|
||||
|
||||
override fun isLocalAudioMuted(): Boolean {
|
||||
val result = callRecorderApiAsync(
|
||||
"isAudioMuted",
|
||||
"""
|
||||
api.isAudioMuted().then(muted => cb(muted === true)).catch(() => cb(true));
|
||||
"""
|
||||
) as? Boolean
|
||||
return result ?: true
|
||||
}
|
||||
|
||||
override fun isLocalVideoMuted(): Boolean {
|
||||
val result = callRecorderApiAsync(
|
||||
"isVideoMuted",
|
||||
"""
|
||||
api.isVideoMuted().then(muted => cb(muted === true)).catch(() => cb(true));
|
||||
"""
|
||||
) as? Boolean
|
||||
return result ?: true
|
||||
}
|
||||
|
||||
override fun isAudioForceMuted(): Boolean = isForceMuted("audio")
|
||||
|
||||
override fun isVideoForceMuted(): Boolean = isForceMuted("video")
|
||||
|
||||
/** Returns true if AV moderation is enabled for [mediaType] and the local participant is not approved to unmute. */
|
||||
private fun isForceMuted(mediaType: String): Boolean {
|
||||
val result = callRecorderApiAsync(
|
||||
"isParticipantForceMuted",
|
||||
"""
|
||||
const localId = window.jibriPageState.localParticipantId;
|
||||
if (!localId) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
api.isParticipantForceMuted(localId, '$mediaType').then(forceMuted => cb(forceMuted === true)).catch(() => cb(false));
|
||||
"""
|
||||
)
|
||||
return result as? Boolean ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles audio/video mute, then polls [isMutedMethod] until it
|
||||
* reflects the change. Unmuting (re-acquiring mic/camera) is slower than muting, so it
|
||||
* gets more time. Returns the last known state on timeout.
|
||||
*
|
||||
* If [requiredInitialState] is set, the current state is checked and the toggle only
|
||||
* executes if it matches.
|
||||
*/
|
||||
private fun toggleMute(
|
||||
mediaType: String,
|
||||
isMutedMethod: String,
|
||||
toggleCommand: String,
|
||||
requiredInitialState: Boolean? = null,
|
||||
): Any? {
|
||||
val guard = if (requiredInitialState != null) {
|
||||
"if (initialMuted !== $requiredInitialState) { cb('ok skipped alreadyMuted=' + initialMuted); return; }"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val result = callRecorderApiAsync(
|
||||
isMutedMethod,
|
||||
"""
|
||||
(async () => {
|
||||
try {
|
||||
const initialMuted = await api.$isMutedMethod();
|
||||
$guard
|
||||
api.executeCommand('$toggleCommand');
|
||||
const deadline = Date.now() + (initialMuted ? 12000 : 5000);
|
||||
while (Date.now() < deadline) {
|
||||
const nowMuted = await api.$isMutedMethod();
|
||||
if (nowMuted !== initialMuted) {
|
||||
return cb('ok wasMuted=' + initialMuted + ' nowMuted=' + nowMuted);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
cb('timeout wasMuted=' + initialMuted);
|
||||
} catch (e) {
|
||||
cb('error: ' + e);
|
||||
}
|
||||
})();
|
||||
"""
|
||||
)
|
||||
if (result is String && result.startsWith("ok")) {
|
||||
logger.debug("Toggle $mediaType mute result: $result")
|
||||
} else {
|
||||
logger.warn("Toggle $mediaType mute did not complete cleanly: $result")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toggleVideoMute(): Any? = toggleMute("video", "isVideoMuted", "toggleVideo")
|
||||
|
||||
override fun toggleAudioMute(): Any? = toggleMute("audio", "isAudioMuted", "toggleAudio")
|
||||
|
||||
override fun raiseHand(): Boolean = executeRecorderCommand("toggleRaiseHand")
|
||||
|
||||
override fun addToPresence(key: String, value: String): Boolean = setParticipantProperties(mapOf(key to value))
|
||||
|
||||
override fun sendPresence(): Boolean = true
|
||||
|
||||
override fun leave(): Boolean {
|
||||
if (!executeRecorderCommand("hangup")) return false
|
||||
|
||||
return try {
|
||||
// videoConferenceLeft flips conferenceJoined back to false once the XMPP leave completes.
|
||||
WebDriverWait(driver, Duration.ofSeconds(5)).until {
|
||||
driver.executeScript("return window.jibriPageState?.conferenceJoined === false;") as? Boolean ?: false
|
||||
}
|
||||
true
|
||||
} catch (e: TimeoutException) {
|
||||
logger.error("Timed out waiting for videoConferenceLeft after hangup")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setParticipantProperties(properties: Map<String, String>): Boolean = try {
|
||||
val result = executeRecorderCommand("setParticipantProperties", properties, true)
|
||||
if (!result) {
|
||||
logger.warn("Could not set participant properties, External API not ready")
|
||||
}
|
||||
result
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error setting participant properties", t)
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium.pageobjects
|
||||
|
||||
import org.openqa.selenium.remote.RemoteWebDriver
|
||||
|
||||
/**
|
||||
* This class represents a page object for the home page (i.e. on the domain
|
||||
* but not in a call) for a jitsi-meet server
|
||||
*/
|
||||
class HomePage(driver: RemoteWebDriver) : AbstractPageObject(driver)
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import org.jitsi.jibri.selenium.SeleniumEvent
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
|
||||
/**
|
||||
* [CallStatusCheck]s are executed periodically and perform checks via javascripe on the call page to make sure
|
||||
* everything is running correctly.
|
||||
*/
|
||||
interface CallStatusCheck {
|
||||
/**
|
||||
* Run a check via [callPage], return a [SeleniumEvent] if something notable has been detected (i.e. an error),
|
||||
* null if whatever the check is looking for was fine.
|
||||
*/
|
||||
fun run(callPage: CallPage): SeleniumEvent?
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.selenium.SeleniumEvent
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Verify that there are other participants in the call; if the call is
|
||||
* empty for more than [callEmptyTimeout], then return [SeleniumEvent.CallEmpty].
|
||||
*
|
||||
* NOTE: this class doesn't perform the check automatically, it will only
|
||||
* check for call empty state when [run] is called.
|
||||
*/
|
||||
class EmptyCallStatusCheck(
|
||||
parentLogger: Logger,
|
||||
private val callEmptyTimeout: Duration = defaultCallEmptyTimeout,
|
||||
private val clock: Clock = Clock.systemUTC()
|
||||
) : CallStatusCheck {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
init {
|
||||
logger.info("Starting empty call check with a timeout of $callEmptyTimeout")
|
||||
}
|
||||
|
||||
// The timestamp at which we last saw the call transition from
|
||||
// non-empty to empty
|
||||
private val callWentEmptyTime = StateTransitionTimeTracker(clock)
|
||||
override fun run(callPage: CallPage): SeleniumEvent? {
|
||||
val now = clock.instant()
|
||||
callWentEmptyTime.maybeUpdate(callPage.isCallEmpty())
|
||||
|
||||
return when (callWentEmptyTime.exceededTimeout(callEmptyTimeout)) {
|
||||
true -> {
|
||||
logger.info(
|
||||
"Call has been empty since " +
|
||||
"${callWentEmptyTime.timestampTransitionOccured} " +
|
||||
"(${Duration.between(callWentEmptyTime.timestampTransitionOccured, now)} ago). " +
|
||||
"Returning CallEmpty event"
|
||||
)
|
||||
SeleniumEvent.CallEmpty
|
||||
}
|
||||
|
||||
false -> null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val defaultCallEmptyTimeout: Duration by config {
|
||||
"jibri.call-status-checks.default-call-empty-timeout".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.selenium.SeleniumEvent
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* A check for the ICE connection. If ICE is not in the "connected" state for more than [iceConnectionTimeout] then a
|
||||
* [SeleniumEvent.IceFailedEvent] is fired.
|
||||
*/
|
||||
class IceConnectionStatusCheck(
|
||||
parentLogger: Logger,
|
||||
private val clock: Clock = Clock.systemDefaultZone()
|
||||
) : CallStatusCheck {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
|
||||
// The last timestamp when ICE was connected. Initialized to give the same timeout for the initial connection.
|
||||
private var timeOfLastSuccess = clock.instant()
|
||||
|
||||
override fun run(callPage: CallPage): SeleniumEvent? {
|
||||
val now = clock.instant()
|
||||
|
||||
if (callPage.isCallEmpty() || callPage.isIceConnected()) {
|
||||
// If there are no other participants we don't expect to have an ICE connection.
|
||||
timeOfLastSuccess = now
|
||||
return null
|
||||
}
|
||||
|
||||
if (Duration.between(timeOfLastSuccess, now) > iceConnectionTimeout) {
|
||||
logger.warn("ICE has failed and not recovered in $iceConnectionTimeout.")
|
||||
return SeleniumEvent.IceFailedEvent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
val iceConnectionTimeout: Duration by config {
|
||||
"jibri.call-status-checks.ice-connection-timeout".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import org.jitsi.jibri.selenium.SeleniumEvent
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
|
||||
class LocalParticipantKickedStatusCheck(
|
||||
parentLogger: Logger
|
||||
) : CallStatusCheck {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
|
||||
init {
|
||||
logger.info("Starting local participant kicked out call check")
|
||||
}
|
||||
|
||||
override fun run(callPage: CallPage): SeleniumEvent? = if (callPage.isLocalParticipantKicked()) {
|
||||
logger.info("Local participant was kicked, returning LocalParticipantKicked event")
|
||||
SeleniumEvent.LocalParticipantKicked
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.selenium.SeleniumEvent
|
||||
import org.jitsi.jibri.selenium.pageobjects.CallPage
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.utils.logging2.Logger
|
||||
import org.jitsi.utils.logging2.createChildLogger
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* Verify that the Jibri web client is receiving media from the other participants. There's two different cases:
|
||||
* 1. If some participants have audio and/or video enabled (as advertised in signaling). In this case, if jibri
|
||||
* receives no audio or video for [noMediaTimeout] it will fire a [SeleniumEvent.NoMediaReceived]. This should not
|
||||
* happen in normal circumstances because if anyone is sending media, it should be forwarded to and received by jibri,
|
||||
* and we have a separate [IceConnectionStatusCheck] for the connection to the bridge. The check is here as a safety
|
||||
* net.
|
||||
* 2. No participant has audio or video enabled (as advertised in signaling). In this case we timeout after
|
||||
* [allMutedTimeout] and fire a [SeleniumEvent.CallEmpty] to avoid wasting resources.
|
||||
*/
|
||||
class MediaReceivedStatusCheck(
|
||||
parentLogger: Logger,
|
||||
private val clock: Clock = Clock.systemDefaultZone()
|
||||
) : CallStatusCheck {
|
||||
private val logger = createChildLogger(parentLogger)
|
||||
|
||||
// The last timestamp where we saw non-zero media. We default with the
|
||||
// assumption we're receiving media.
|
||||
private var timeOfLastMedia = clock.instant()
|
||||
|
||||
// The timestamp at which we last saw that all clients transitioned to muted
|
||||
private val clientsAllMutedTransitionTime = StateTransitionTimeTracker(clock)
|
||||
|
||||
override fun run(callPage: CallPage): SeleniumEvent? {
|
||||
val now = clock.instant()
|
||||
val bitrates = callPage.getBitrates()
|
||||
// getNumParticipants includes Jibri, so subtract 1
|
||||
val numParticipants = callPage.getNumParticipants() - 1
|
||||
val numMutedParticipants = callPage.numRemoteParticipantsMuted()
|
||||
val numJigasiParticipants = callPage.numRemoteParticipantsJigasi()
|
||||
val numHiddenParticipants = callPage.numHiddenParticipants()
|
||||
// We don't get any mute state for Jigasi participants, so to prevent timing out when only Jigasi participants
|
||||
// may be speaking, always count them as "muted"
|
||||
val allClientsMuted = (numMutedParticipants + numJigasiParticipants) == numParticipants
|
||||
logger.info(
|
||||
"Jibri client receive bitrates: $bitrates, num participants: $numParticipants, " +
|
||||
"numMutedParticipants: $numMutedParticipants, numJigasis: $numJigasiParticipants, " +
|
||||
"numHiddenParticipants: $numHiddenParticipants, all clients muted? $allClientsMuted"
|
||||
)
|
||||
clientsAllMutedTransitionTime.maybeUpdate(allClientsMuted)
|
||||
val downloadBitrate = bitrates.getOrDefault("download", 0L) as Long
|
||||
// If all clients are muted, register it as 'receiving media': that way when clients unmute
|
||||
// we'll get the full noMediaTimeout duration before timing out due to lack of media.
|
||||
if (downloadBitrate != 0L || allClientsMuted) {
|
||||
timeOfLastMedia = now
|
||||
}
|
||||
val timeSinceLastMedia = Duration.between(timeOfLastMedia, now)
|
||||
|
||||
// There are a couple possible outcomes here:
|
||||
// 1) All clients are muted, but have been muted for longer than allMutedTimeout so
|
||||
// we'll exit the call gracefully (CallEmpty)
|
||||
// 2) No media has flowed for longer than noMediaTimeout and all clients are not
|
||||
// muted so we'll exit with an error (NoMediaReceived)
|
||||
// 3) If neither of the above are true, we're fine and no event has occurred
|
||||
return when {
|
||||
clientsAllMutedTransitionTime.exceededTimeout(allMutedTimeout) -> SeleniumEvent.CallEmpty
|
||||
timeSinceLastMedia > noMediaTimeout && !allClientsMuted -> SeleniumEvent.NoMediaReceived
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long we'll stay in the call if we're not receiving any incoming media (assuming not all participants
|
||||
* not muted). This should be long enough to allow for a participant losing their connection and re-connecting
|
||||
* without restarting the recording.
|
||||
*/
|
||||
val noMediaTimeout: Duration by config {
|
||||
"jibri.call-status-checks.no-media-timeout".from(Config.configSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long we'll stay in the call if all participants are muted
|
||||
*/
|
||||
val allMutedTimeout: Duration by config {
|
||||
"jibri.call-status-checks.all-muted-timeout".from(Config.configSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.selenium.status_checks
|
||||
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Track the most recent timestamp at which we transitioned from
|
||||
* an event having not occurred to when it did occur. Note this
|
||||
* tracks the timestamp of that *transition*, not the most recent
|
||||
* time the event itself occurred.
|
||||
*/
|
||||
class StateTransitionTimeTracker(private val clock: Clock) {
|
||||
var timestampTransitionOccured: Instant? = null
|
||||
private set
|
||||
|
||||
fun maybeUpdate(eventOccurred: Boolean) {
|
||||
if (eventOccurred && timestampTransitionOccured == null) {
|
||||
timestampTransitionOccured = clock.instant()
|
||||
} else if (!eventOccurred) {
|
||||
timestampTransitionOccured = null
|
||||
}
|
||||
}
|
||||
|
||||
fun exceededTimeout(timeout: Duration): Boolean = timestampTransitionOccured?.let {
|
||||
Duration.between(it, clock.instant()) > timeout
|
||||
} ?: false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package org.jitsi.jibri.selenium.util
|
||||
|
||||
import java.util.logging.FileHandler
|
||||
|
||||
class BrowserFileHandler : FileHandler()
|
||||
26
src/main/kotlin/org/jitsi/jibri/service/Errors.kt
Normal file
26
src/main/kotlin/org/jitsi/jibri/service/Errors.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("ktlint:standard:filename")
|
||||
|
||||
package org.jitsi.jibri.service
|
||||
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
|
||||
object ErrorSettingPresenceFields : JibriError(ErrorScope.SESSION, "Unable to set presence values")
|
||||
70
src/main/kotlin/org/jitsi/jibri/service/JibriService.kt
Normal file
70
src/main/kotlin/org/jitsi/jibri/service/JibriService.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.StatusPublisher
|
||||
|
||||
enum class JibriServiceStatus {
|
||||
FINISHED,
|
||||
ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitrary Jibri specific data that can be passed in the
|
||||
* [JibriIq#appData] field. This entire structure will be parsed
|
||||
* from a JSON-encoded string (the [JibriIq#appData] field).
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AppData(
|
||||
/**
|
||||
* A JSON map representing arbitrary data to be written
|
||||
* to the metadata file when doing a recording.
|
||||
*/
|
||||
@JsonProperty("file_recording_metadata")
|
||||
val fileRecordingMetadata: Map<Any, Any>?
|
||||
)
|
||||
|
||||
/**
|
||||
* Parameters needed for starting any [JibriService]
|
||||
*/
|
||||
data class ServiceParams(
|
||||
val usageTimeoutMinutes: Int,
|
||||
val appData: AppData? = null
|
||||
)
|
||||
|
||||
typealias JibriServiceStatusHandler = (ComponentState) -> Unit
|
||||
|
||||
/**
|
||||
* Interface implemented by all implemented [JibriService]s. A [JibriService]
|
||||
* is responsible for an entire feature of Jibri, such as recording a call
|
||||
* to a file or streaming a call to a url.
|
||||
*/
|
||||
abstract class JibriService : StatusPublisher<ComponentState>() {
|
||||
/**
|
||||
* Starts this [JibriService]
|
||||
*/
|
||||
abstract fun start()
|
||||
|
||||
/**
|
||||
* Stops this [JibriService]
|
||||
*/
|
||||
abstract fun stop()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright @ 2021 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service
|
||||
|
||||
interface JibriServiceFinalizer {
|
||||
|
||||
fun doFinalize()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service
|
||||
|
||||
import com.tinder.StateMachine
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.NotifyingStateMachine
|
||||
|
||||
sealed class JibriServiceEvent {
|
||||
class SubComponentStartingUp(val componentId: String, val subState: ComponentState.StartingUp) : JibriServiceEvent()
|
||||
class SubComponentRunning(val componentId: String, val subState: ComponentState.Running) : JibriServiceEvent()
|
||||
class SubComponentError(val componentId: String, val subState: ComponentState.Error) : JibriServiceEvent()
|
||||
class SubComponentFinished(val componentId: String, val subState: ComponentState.Finished) : JibriServiceEvent()
|
||||
}
|
||||
|
||||
fun ComponentState.toJibriServiceEvent(componentId: String): JibriServiceEvent = when (this) {
|
||||
is ComponentState.StartingUp -> JibriServiceEvent.SubComponentStartingUp(componentId, this)
|
||||
is ComponentState.Running -> JibriServiceEvent.SubComponentRunning(componentId, this)
|
||||
is ComponentState.Error -> JibriServiceEvent.SubComponentError(componentId, this)
|
||||
is ComponentState.Finished -> JibriServiceEvent.SubComponentFinished(componentId, this)
|
||||
}
|
||||
|
||||
sealed class JibriServiceSideEffect
|
||||
|
||||
/**
|
||||
* A state machine for the services to use to handle status updates from their subcomponents. Subcomponents should
|
||||
* be registered via [JibriServiceStateMachine.registerSubComponent] such that the [JibriService]'s overall state
|
||||
* can be computed as a function of the subcomponents' states.
|
||||
*/
|
||||
class JibriServiceStateMachine : NotifyingStateMachine() {
|
||||
private val stateMachine = StateMachine.create<ComponentState, JibriServiceEvent, JibriServiceSideEffect> {
|
||||
initialState(ComponentState.StartingUp)
|
||||
|
||||
state<ComponentState.StartingUp> {
|
||||
on<JibriServiceEvent.SubComponentError> {
|
||||
subComponentStates[it.componentId] = it.subState
|
||||
transitionTo(ComponentState.Error(it.subState.error))
|
||||
}
|
||||
on<JibriServiceEvent.SubComponentFinished> {
|
||||
subComponentStates[it.componentId] = it.subState
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
on<JibriServiceEvent.SubComponentRunning> {
|
||||
subComponentStates[it.componentId] = it.subState
|
||||
if (subComponentStates.values.all { it is ComponentState.Running }) {
|
||||
transitionTo(ComponentState.Running)
|
||||
} else {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
on<JibriServiceEvent.SubComponentStartingUp> {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Running> {
|
||||
on<JibriServiceEvent.SubComponentError> {
|
||||
subComponentStates[it.componentId] = it.subState
|
||||
transitionTo(ComponentState.Error(it.subState.error))
|
||||
}
|
||||
on<JibriServiceEvent.SubComponentFinished> {
|
||||
subComponentStates[it.componentId] = it.subState
|
||||
transitionTo(ComponentState.Finished)
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Error> {
|
||||
on(any<JibriServiceEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
state<ComponentState.Finished> {
|
||||
on(any<JibriServiceEvent>()) {
|
||||
dontTransition()
|
||||
}
|
||||
}
|
||||
|
||||
onTransition {
|
||||
val validTransition = it as? StateMachine.Transition.Valid ?: run {
|
||||
throw Exception("Invalid state transition: $it")
|
||||
}
|
||||
if (validTransition.fromState::class != validTransition.toState::class) {
|
||||
notify(validTransition.fromState, validTransition.toState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val subComponentStates = mutableMapOf<String, ComponentState>()
|
||||
|
||||
fun registerSubComponent(componentKey: String) {
|
||||
// TODO: we'll assume everything starts in 'starting up' ?
|
||||
subComponentStates[componentKey] = ComponentState.StartingUp
|
||||
}
|
||||
|
||||
fun transition(event: JibriServiceEvent): StateMachine.Transition<*, *, *> = stateMachine.transition(event)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service.impl
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAnyGetter
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import org.jitsi.jibri.capture.ffmpeg.FfmpegCapturer
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.selenium.JibriSelenium
|
||||
import org.jitsi.jibri.selenium.RECORDING_URL_OPTIONS
|
||||
import org.jitsi.jibri.service.ErrorSettingPresenceFields
|
||||
import org.jitsi.jibri.service.JibriService
|
||||
import org.jitsi.jibri.service.JibriServiceFinalizer
|
||||
import org.jitsi.jibri.sink.Sink
|
||||
import org.jitsi.jibri.sink.impl.FileSink
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
import org.jitsi.jibri.util.ProcessFactory
|
||||
import org.jitsi.jibri.util.createIfDoesNotExist
|
||||
import org.jitsi.jibri.util.whenever
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIq
|
||||
import java.nio.file.FileSystem
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardOpenOption
|
||||
|
||||
/**
|
||||
* Parameters needed for starting a [FileRecordingJibriService]
|
||||
*/
|
||||
data class FileRecordingParams(
|
||||
/**
|
||||
* Which call we'll join
|
||||
*/
|
||||
val callParams: CallParams,
|
||||
/**
|
||||
* The ID of this session
|
||||
*/
|
||||
val sessionId: String,
|
||||
/**
|
||||
* The login information needed to appear invisible in
|
||||
* the call
|
||||
*/
|
||||
val callLoginParams: XmppCredentials,
|
||||
/**
|
||||
* A map of arbitrary key, value metadata that will be written
|
||||
* to the metadata file.
|
||||
*/
|
||||
val additionalMetadata: Map<Any, Any>? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Set of metadata we'll put alongside the recording file(s)
|
||||
*/
|
||||
data class RecordingMetadata(
|
||||
@JsonProperty("meeting_url")
|
||||
val meetingUrl: String,
|
||||
val participants: List<Map<String, Any>>,
|
||||
@JsonIgnore val additionalMetadata: Map<Any, Any>? = null
|
||||
) {
|
||||
/**
|
||||
* We tell the JSON serializer to ignore the additionalMetadata map (above)
|
||||
* and use this to expose each of its individual fields, that way they
|
||||
* are serialized at the top level (rather than being nested within a
|
||||
* 'additionalMetadata' JSON object)
|
||||
*/
|
||||
@JsonAnyGetter
|
||||
fun get(): Map<Any, Any>? = additionalMetadata
|
||||
}
|
||||
|
||||
/**
|
||||
* [FileRecordingJibriService] is the [JibriService] responsible for joining
|
||||
* a web call, capturing its audio and video, and writing that audio and video
|
||||
* to a file to be replayed later.
|
||||
*/
|
||||
class FileRecordingJibriService(
|
||||
private val fileRecordingParams: FileRecordingParams,
|
||||
jibriSelenium: JibriSelenium? = null,
|
||||
capturer: FfmpegCapturer? = null,
|
||||
processFactory: ProcessFactory = ProcessFactory(),
|
||||
fileSystem: FileSystem = FileSystems.getDefault(),
|
||||
private var jibriServiceFinalizer: JibriServiceFinalizer? = null
|
||||
) : StatefulJibriService("File recording") {
|
||||
init {
|
||||
logger.addContext("session_id", fileRecordingParams.sessionId)
|
||||
}
|
||||
|
||||
private val capturer = capturer ?: FfmpegCapturer(logger)
|
||||
private val jibriSelenium = jibriSelenium ?: JibriSelenium(logger)
|
||||
|
||||
/**
|
||||
* The [Sink] this class will use to model the file on the filesystem
|
||||
*/
|
||||
private var sink: FileSink
|
||||
private val recordingsDirectory: String by config {
|
||||
"JibriConfig::recordingDirectory" { Config.legacyConfigSource.recordingDirectory!! }
|
||||
"jibri.recording.recordings-directory".from(Config.configSource)
|
||||
}
|
||||
private val finalizeScriptPath: String by config {
|
||||
"JibriConfig::finalizeRecordingScriptPath" {
|
||||
Config.legacyConfigSource.finalizeRecordingScriptPath!!
|
||||
}
|
||||
"jibri.recording.finalize-script".from(Config.configSource)
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory in which we'll store recordings for this particular session. This is a directory that will
|
||||
* be nested within [recordingsDirectory].
|
||||
*/
|
||||
private val sessionRecordingDirectory =
|
||||
fileSystem.getPath(recordingsDirectory).resolve(sanitizeSessionId(fileRecordingParams.sessionId))
|
||||
|
||||
/**
|
||||
* Sanitize the session ID to ensure it's safe for use as a directory name.
|
||||
*/
|
||||
private fun sanitizeSessionId(sessionId: String): String {
|
||||
// Replace any character that is not alphanumeric, dash, or underscore with underscore
|
||||
return sessionId.replace(Regex("[^a-zA-Z0-9_-]"), "_")
|
||||
}
|
||||
|
||||
init {
|
||||
logger.info("Writing recording to $sessionRecordingDirectory, finalize script path $finalizeScriptPath")
|
||||
sink = FileSink(
|
||||
sessionRecordingDirectory,
|
||||
fileRecordingParams.callParams.callUrlInfo.callName
|
||||
)
|
||||
|
||||
registerSubComponent(JibriSelenium.COMPONENT_ID, this.jibriSelenium)
|
||||
registerSubComponent(FfmpegCapturer.COMPONENT_ID, this.capturer)
|
||||
|
||||
jibriServiceFinalizer = JibriServiceFinalizeCommandRunner(
|
||||
processFactory,
|
||||
listOf(
|
||||
finalizeScriptPath,
|
||||
sessionRecordingDirectory.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (!createIfDoesNotExist(sessionRecordingDirectory, logger)) {
|
||||
publishStatus(ComponentState.Error(ErrorCreatingRecordingsDirectory))
|
||||
return
|
||||
}
|
||||
if (!Files.isWritable(sessionRecordingDirectory)) {
|
||||
logger.error("Unable to write to $recordingsDirectory")
|
||||
publishStatus(ComponentState.Error(RecordingsDirectoryNotWritable))
|
||||
return
|
||||
}
|
||||
jibriSelenium.joinCall(
|
||||
fileRecordingParams.callParams.callUrlInfo.copy(
|
||||
urlParams = RECORDING_URL_OPTIONS + fileRecordingParams.callParams.extraUrlParams
|
||||
),
|
||||
fileRecordingParams.callLoginParams
|
||||
)
|
||||
|
||||
whenever(jibriSelenium).transitionsTo(ComponentState.Running) {
|
||||
logger.info("Selenium joined the call, starting the capturer")
|
||||
try {
|
||||
jibriSelenium.setParticipantProperties(
|
||||
mapOf(
|
||||
"session_id" to fileRecordingParams.sessionId,
|
||||
"mode" to JibriIq.RecordingMode.FILE.toString()
|
||||
)
|
||||
)
|
||||
capturer.start(sink)
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error while setting fields in presence", t)
|
||||
publishStatus(ComponentState.Error(ErrorSettingPresenceFields))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
logger.info("Stopping capturer")
|
||||
capturer.stop()
|
||||
logger.info("Quitting selenium")
|
||||
|
||||
// It's possible that the service was stopped before we even wrote anything, so check if we actually wrote
|
||||
// any data to disk. If not, we'll skip writing the metadata and running the finalize script and instead
|
||||
// just delete the directory
|
||||
val recordedMedia = Files.exists(sink.file)
|
||||
if (!recordedMedia) {
|
||||
logger.info("No media was recorded, deleting directory and skipping metadata file & finalize")
|
||||
try {
|
||||
Files.delete(sessionRecordingDirectory)
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Problem deleting session recording directory", t)
|
||||
}
|
||||
jibriSelenium.leaveCallAndQuitBrowser()
|
||||
return
|
||||
}
|
||||
|
||||
val participants = try {
|
||||
jibriSelenium.getParticipants()
|
||||
} catch (t: Throwable) {
|
||||
logger.error(
|
||||
"An error occurred while trying to get the participants list, proceeding with " +
|
||||
"an empty participants list",
|
||||
t
|
||||
)
|
||||
listOf<Map<String, Any>>()
|
||||
}
|
||||
logger.info("Participants in this recording: $participants")
|
||||
if (Files.isWritable(sessionRecordingDirectory)) {
|
||||
val metadataFile = sessionRecordingDirectory.resolve("metadata.json")
|
||||
val metadata = RecordingMetadata(
|
||||
fileRecordingParams.callParams.callUrlInfo.callUrl,
|
||||
participants,
|
||||
fileRecordingParams.additionalMetadata
|
||||
)
|
||||
try {
|
||||
Files.newBufferedWriter(metadataFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)
|
||||
.use {
|
||||
jacksonObjectMapper().writeValue(it, metadata)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error writing metadata", t)
|
||||
publishStatus(ComponentState.Error(CouldntWriteMeetingMetadata))
|
||||
}
|
||||
} else {
|
||||
logger.error("Unable to write metadata file to recording directory $recordingsDirectory")
|
||||
}
|
||||
jibriSelenium.leaveCallAndQuitBrowser()
|
||||
logger.info("Finalizing the recording")
|
||||
jibriServiceFinalizer?.doFinalize()
|
||||
}
|
||||
}
|
||||
|
||||
object ErrorCreatingRecordingsDirectory : JibriError(ErrorScope.SYSTEM, "Could not creat recordings director")
|
||||
object RecordingsDirectoryNotWritable : JibriError(ErrorScope.SYSTEM, "Recordings directory is not writable")
|
||||
object CouldntWriteMeetingMetadata : JibriError(ErrorScope.SYSTEM, "Could not write meeting metadata")
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright @ 2021 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service.impl
|
||||
|
||||
import org.jitsi.jibri.service.JibriServiceFinalizer
|
||||
import org.jitsi.jibri.util.LoggingUtils
|
||||
import org.jitsi.jibri.util.ProcessFactory
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class JibriServiceFinalizeCommandRunner(
|
||||
private val processFactory: ProcessFactory = ProcessFactory(),
|
||||
private val finalizeCommand: List<String>
|
||||
) : JibriServiceFinalizer {
|
||||
|
||||
private val logger = createLogger()
|
||||
|
||||
/**
|
||||
* Helper to execute the finalize script and wait for its completion.
|
||||
* NOTE that this will block for however long the finalize script takes
|
||||
* to complete (by design)
|
||||
*/
|
||||
override fun doFinalize() {
|
||||
if (finalizeCommand.isEmpty()) {
|
||||
logger.debug("Finalize command is empty, there is nothing to be run")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Finalizing the jibri service operation using command $finalizeCommand")
|
||||
try {
|
||||
with(processFactory.createProcess(finalizeCommand, logger)) {
|
||||
start()
|
||||
val streamDone = LoggingUtils.logOutputOfProcess(this, logger)
|
||||
waitFor()
|
||||
// Make sure we get all the logs
|
||||
try {
|
||||
streamDone.get(10, TimeUnit.SECONDS)
|
||||
} catch (e: TimeoutException) {
|
||||
logger.error("Timed out waiting for process logger task to complete")
|
||||
streamDone.cancel(true)
|
||||
} catch (e: Exception) {
|
||||
logger.error("Exception while waiting for process logger task to complete", e)
|
||||
streamDone.cancel(true)
|
||||
}
|
||||
logger.info("Finalize script finished with exit value $exitValue")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Failed to run finalize script", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service.impl
|
||||
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.selenium.JibriSelenium
|
||||
import org.jitsi.jibri.selenium.JibriSeleniumOptions
|
||||
import org.jitsi.jibri.selenium.SIP_GW_URL_OPTIONS
|
||||
import org.jitsi.jibri.service.ErrorSettingPresenceFields
|
||||
import org.jitsi.jibri.service.JibriService
|
||||
import org.jitsi.jibri.service.JibriServiceFinalizer
|
||||
import org.jitsi.jibri.sipgateway.SipClientParams
|
||||
import org.jitsi.jibri.sipgateway.getSipAddress
|
||||
import org.jitsi.jibri.sipgateway.getSipScheme
|
||||
import org.jitsi.jibri.sipgateway.pjsua.PjsuaClient
|
||||
import org.jitsi.jibri.sipgateway.pjsua.PjsuaClientParams
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.ProcessFactory
|
||||
import org.jitsi.jibri.util.whenever
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ScheduledFuture
|
||||
|
||||
data class SipGatewayServiceParams(
|
||||
/**
|
||||
* The params needed to join the web call
|
||||
*/
|
||||
val callParams: CallParams,
|
||||
/**
|
||||
* The login information needed to use when establishing the call
|
||||
*/
|
||||
val callLoginParams: XmppCredentials?,
|
||||
/**
|
||||
* The params needed for bringing a SIP client into
|
||||
* the call
|
||||
*/
|
||||
val sipClientParams: SipClientParams
|
||||
)
|
||||
|
||||
private const val FINALIZE_SCRIPT_LOCATION = "/opt/jitsi/jibri/finalize_sip.sh"
|
||||
|
||||
/**
|
||||
* A [JibriService] responsible for joining both a web call
|
||||
* and a SIP call, capturing the audio and video from each, and
|
||||
* forwarding them to the other side.
|
||||
*/
|
||||
class SipGatewayJibriService(
|
||||
private val sipGatewayServiceParams: SipGatewayServiceParams,
|
||||
jibriSelenium: JibriSelenium? = null,
|
||||
pjsuaClient: PjsuaClient? = null,
|
||||
processFactory: ProcessFactory = ProcessFactory(),
|
||||
private val jibriServiceFinalizer: JibriServiceFinalizer = JibriServiceFinalizeCommandRunner(
|
||||
processFactory,
|
||||
listOf(FINALIZE_SCRIPT_LOCATION)
|
||||
)
|
||||
) : StatefulJibriService("SIP gateway") {
|
||||
/**
|
||||
* Used for the selenium interaction
|
||||
*/
|
||||
private val jibriSelenium = jibriSelenium ?: JibriSelenium(
|
||||
logger,
|
||||
JibriSeleniumOptions(
|
||||
displayName = if (sipGatewayServiceParams.callParams.displayName.isNotBlank()) {
|
||||
sipGatewayServiceParams.callParams.displayName
|
||||
} else if (sipGatewayServiceParams.sipClientParams.sipAddress.isNotBlank()) {
|
||||
sipGatewayServiceParams.sipClientParams.sipAddress.substringBeforeLast("@")
|
||||
} else {
|
||||
sipGatewayServiceParams.sipClientParams.displayName
|
||||
},
|
||||
email = sipGatewayServiceParams.callParams.email,
|
||||
callStatsUsernameOverride = sipGatewayServiceParams.callParams.callStatsUsernameOverride,
|
||||
// by default we wait 30 minutes alone in the call before deciding to hangup
|
||||
emptyCallTimeout = Duration.ofMinutes(30),
|
||||
extraChromeCommandLineFlags = listOf("--alsa-input-device=plughw:1,1"),
|
||||
enableLocalParticipantStatusChecks = true
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* The SIP client we'll use to connect to the SIP call (currently only a
|
||||
* pjsua implementation exists)
|
||||
*/
|
||||
private val pjsuaClient = pjsuaClient ?: PjsuaClient(
|
||||
logger,
|
||||
PjsuaClientParams(sipGatewayServiceParams.sipClientParams),
|
||||
onDtmfCommand = { command ->
|
||||
when (command) {
|
||||
"*6" -> {
|
||||
logger.info("Received *6 DTMF command from pjsua")
|
||||
this.jibriSelenium.handleDtmfStar6()
|
||||
}
|
||||
|
||||
"*7" -> {
|
||||
logger.info("Received *7 DTMF command from pjsua")
|
||||
this.jibriSelenium.handleDtmfStar7()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* The handle to the scheduled process monitor task, which we use to
|
||||
* cancel the task
|
||||
*/
|
||||
private var processMonitorTask: ScheduledFuture<*>? = null
|
||||
|
||||
init {
|
||||
registerSubComponent(JibriSelenium.COMPONENT_ID, this.jibriSelenium)
|
||||
registerSubComponent(PjsuaClient.COMPONENT_ID, this.pjsuaClient)
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting a [SipGatewayServiceParams] involves the following steps:
|
||||
* 1) Start selenium and join the web call on display :0
|
||||
* 2) Start the SIP client to join the SIP call on display :1
|
||||
* There are already ffmpeg daemons running which are capturing from
|
||||
* each of the displays and writing to video devices which selenium
|
||||
* and pjsua will use
|
||||
*/
|
||||
override fun start() {
|
||||
jibriSelenium.joinCall(
|
||||
sipGatewayServiceParams.callParams.callUrlInfo.copy(
|
||||
urlParams = SIP_GW_URL_OPTIONS + sipGatewayServiceParams.callParams.extraUrlParams
|
||||
),
|
||||
sipGatewayServiceParams.callLoginParams,
|
||||
sipGatewayServiceParams.callParams.passcode,
|
||||
unmute = true
|
||||
)
|
||||
|
||||
// when in auto-answer mode we want to start as quick as possible as
|
||||
// we will be waiting for a sip call to come
|
||||
if (sipGatewayServiceParams.sipClientParams.autoAnswer) {
|
||||
pjsuaClient.start()
|
||||
whenever(jibriSelenium).transitionsTo(ComponentState.Running) {
|
||||
logger.info("Selenium joined the call")
|
||||
addSipMetadataToPresence()
|
||||
}
|
||||
} else {
|
||||
whenever(jibriSelenium).transitionsTo(ComponentState.Running) {
|
||||
logger.info("Selenium joined the call, starting pjsua")
|
||||
pjsuaClient.start()
|
||||
addSipMetadataToPresence()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSipMetadataToPresence() {
|
||||
try {
|
||||
val sipAddress = sipGatewayServiceParams.sipClientParams.sipAddress.getSipAddress()
|
||||
val sipScheme = sipGatewayServiceParams.sipClientParams.sipAddress.getSipScheme()
|
||||
if (!jibriSelenium.setParticipantProperties(mapOf("sip_address" to "$sipScheme:$sipAddress"))) {
|
||||
logger.error("Error while setting SIP address in presence")
|
||||
publishStatus(ComponentState.Error(ErrorSettingPresenceFields))
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error while setting fields in presence", t)
|
||||
publishStatus(ComponentState.Error(ErrorSettingPresenceFields))
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
processMonitorTask?.cancel(false)
|
||||
pjsuaClient.stop()
|
||||
jibriSelenium.leaveCallAndQuitBrowser()
|
||||
jibriServiceFinalizer.doFinalize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright @ 2018 - present 8x8, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service.impl
|
||||
|
||||
import org.jitsi.jibri.service.JibriService
|
||||
import org.jitsi.jibri.service.JibriServiceStateMachine
|
||||
import org.jitsi.jibri.service.toJibriServiceEvent
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.util.StatusPublisher
|
||||
import org.jitsi.utils.logging2.createLogger
|
||||
|
||||
abstract class StatefulJibriService(private val name: String) : JibriService() {
|
||||
private val stateMachine = JibriServiceStateMachine()
|
||||
|
||||
/**
|
||||
* The [Logger] for this class
|
||||
*/
|
||||
protected val logger = createLogger()
|
||||
|
||||
init {
|
||||
stateMachine.onStateTransition { oldState, newState -> this.onServiceStateChange(oldState, newState) }
|
||||
}
|
||||
|
||||
private fun onServiceStateChange(oldState: ComponentState, newState: ComponentState) {
|
||||
logger.info("$name service transitioning from state $oldState to $newState")
|
||||
publishStatus(newState)
|
||||
}
|
||||
|
||||
protected fun registerSubComponent(subComponentId: String, subComponent: StatusPublisher<ComponentState>) {
|
||||
stateMachine.registerSubComponent(subComponentId)
|
||||
subComponent.addStatusHandler { stateUpdate ->
|
||||
stateMachine.transition(stateUpdate.toJibriServiceEvent(subComponentId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright @ 2018 Atlassian Pty Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.jitsi.jibri.service.impl
|
||||
|
||||
import org.jitsi.jibri.capture.ffmpeg.FfmpegCapturer
|
||||
import org.jitsi.jibri.config.Config
|
||||
import org.jitsi.jibri.config.XmppCredentials
|
||||
import org.jitsi.jibri.error.JibriError
|
||||
import org.jitsi.jibri.selenium.CallParams
|
||||
import org.jitsi.jibri.selenium.JibriSelenium
|
||||
import org.jitsi.jibri.selenium.RECORDING_URL_OPTIONS
|
||||
import org.jitsi.jibri.service.ErrorSettingPresenceFields
|
||||
import org.jitsi.jibri.service.JibriService
|
||||
import org.jitsi.jibri.sink.Sink
|
||||
import org.jitsi.jibri.sink.impl.StreamSink
|
||||
import org.jitsi.jibri.status.ComponentState
|
||||
import org.jitsi.jibri.status.ErrorScope
|
||||
import org.jitsi.jibri.util.whenever
|
||||
import org.jitsi.metaconfig.config
|
||||
import org.jitsi.xmpp.extensions.jibri.JibriIq
|
||||
import java.util.regex.Pattern
|
||||
|
||||
const val YOUTUBE_URL = "rtmp://a.rtmp.youtube.com/live2"
|
||||
|
||||
/**
|
||||
* Parameters needed for starting a [StreamingJibriService]
|
||||
*/
|
||||
data class StreamingParams(
|
||||
/**
|
||||
* Which call we'll join
|
||||
*/
|
||||
val callParams: CallParams,
|
||||
/**
|
||||
* The ID of this session
|
||||
*/
|
||||
val sessionId: String,
|
||||
/**
|
||||
* The login information needed to appear invisible in
|
||||
* the call
|
||||
*/
|
||||
val callLoginParams: XmppCredentials,
|
||||
/**
|
||||
* The RTMP URL we'll stream to
|
||||
*/
|
||||
val rtmpUrl: String,
|
||||
/**
|
||||
* The URL at which the stream can be viewed
|
||||
*/
|
||||
val viewingUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* [StreamingJibriService] is the [JibriService] responsible for joining a
|
||||
* web call, capturing its audio and video, and streaming that audio and video
|
||||
* to a url
|
||||
*/
|
||||
class StreamingJibriService(
|
||||
private val streamingParams: StreamingParams
|
||||
) : StatefulJibriService("Streaming") {
|
||||
init {
|
||||
logger.addContext("session_id", streamingParams.sessionId)
|
||||
}
|
||||
private val capturer = FfmpegCapturer(logger)
|
||||
private val sink: Sink
|
||||
private val jibriSelenium = JibriSelenium(logger)
|
||||
|
||||
private val rtmpAllowList: List<Pattern> by config {
|
||||
"jibri.streaming.rtmp-allow-list".from(Config.configSource)
|
||||
.convertFrom<List<String>> { it.map(Pattern::compile) }
|
||||
}
|
||||
|
||||
init {
|
||||
sink = StreamSink(url = streamingParams.rtmpUrl)
|
||||
|
||||
registerSubComponent(JibriSelenium.COMPONENT_ID, jibriSelenium)
|
||||
registerSubComponent(FfmpegCapturer.COMPONENT_ID, capturer)
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
if (rtmpAllowList.none { it.matcher(streamingParams.rtmpUrl).matches() }) {
|
||||
logger.error("RTMP url ${streamingParams.rtmpUrl} is not allowed")
|
||||
publishStatus(
|
||||
ComponentState.Error(
|
||||
JibriError(
|
||||
ErrorScope.SESSION,
|
||||
"RTMP URL ${streamingParams.rtmpUrl} is not allowed"
|
||||
)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
jibriSelenium.joinCall(
|
||||
streamingParams.callParams.callUrlInfo.copy(
|
||||
urlParams = RECORDING_URL_OPTIONS + streamingParams.callParams.extraUrlParams
|
||||
),
|
||||
streamingParams.callLoginParams
|
||||
)
|
||||
|
||||
whenever(jibriSelenium).transitionsTo(ComponentState.Running) {
|
||||
logger.info("Selenium joined the call, starting capturer")
|
||||
try {
|
||||
val properties = mutableMapOf(
|
||||
"session_id" to streamingParams.sessionId,
|
||||
"mode" to JibriIq.RecordingMode.STREAM.toString()
|
||||
)
|
||||
streamingParams.viewingUrl?.let { viewingUrl ->
|
||||
properties["live-stream-view-url"] = viewingUrl
|
||||
}
|
||||
if (!jibriSelenium.setParticipantProperties(properties)) {
|
||||
logger.error("Error setting presence properties")
|
||||
}
|
||||
capturer.start(sink)
|
||||
} catch (t: Throwable) {
|
||||
logger.error("Error while setting fields in presence", t)
|
||||
publishStatus(ComponentState.Error(ErrorSettingPresenceFields))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
logger.info("Stopping capturer")
|
||||
capturer.stop()
|
||||
logger.info("Stopped capturer")
|
||||
logger.info("Quitting selenium")
|
||||
jibriSelenium.leaveCallAndQuitBrowser()
|
||||
logger.info("Quit selenium")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user