snapshot of https://github.com/jitsi/jibri.git @ 3b45602c539ec69d128ea152ae587d0b6582191a (2026-09-15)
Some checks failed
Java CI with Maven / Java 17 (push) Failing after 4m40s
Java CI with Maven / Java 21 (push) Failing after 4m41s

This commit is contained in:
upstream-mirror
2026-09-15 16:13:58 +05:00
commit 650a143b63
166 changed files with 14301 additions and 0 deletions

12
.editorconfig Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
.idea/
target/
.kotlintest
*~
*.sw?
*.iml

147
CLAUDE.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
10

12
debian/control vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
3.0 (quilt)

15
doc/apis.md Normal file
View 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".

View 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
View 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
View 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).

View 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
View 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
View 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
View 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).

View 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
View 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
View 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
View 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
View 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

View 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
View 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>

View 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
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
mvn -B clean verify package "$@"

View 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"
}

View File

@@ -0,0 +1 @@
ShowTaskBar = 0

View File

@@ -0,0 +1,2 @@
jibri {
}

View 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>"

View 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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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"

View File

@@ -0,0 +1,3 @@
#!/bin/bash
exec /usr/local/bin/pjsua --config-file /etc/jitsi/jibri/pjsua.config "$@" > /dev/null

View 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

View 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

View 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
View 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
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
./resources/build.sh

38
resources/jenkins/release.sh Executable file
View 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

View 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());
}
}

View 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"})"
}

View 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}")
}

View 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)
}
}
}

View 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)
}
}
}

View File

@@ -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()
}
}
}

View 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
}
}

View 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
}

View 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}")

View 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()
}

View 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)

View 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()
}

View File

@@ -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)
}

View 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)
}
}
}

View File

@@ -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()

View 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")
}
}

View 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
}

View 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"
}

View 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
)

View 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
}

View File

@@ -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")

View 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)
}
}

View 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"

View 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")

View 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", "~")

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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()))"
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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?
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -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()

View 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")

View 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()
}

View File

@@ -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()
}

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}
}
}

View File

@@ -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