Skip to content

Commit 09498f2

Browse files
Merge pull request #20 from OpenRTMP/claude/librtmp2-rtmps-support-eayksc
Show RTMPS URLs when librtmp2-server has RTMPS enabled
2 parents 7e2a707 + 6538eda commit 09498f2

6 files changed

Lines changed: 246 additions & 9 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ LRTMP2_API_TOKEN=<generate-with-openssl-rand-hex-32>
1010

1111
# Public host/IP that clients use to reach the RTMP listener
1212
LRTMP2_DOMAIN=localhost
13+
# Must match rtmps.bind port in librtmp2-server's config.env. Only used when
14+
# librtmp2-server reports RTMPS as enabled (checked live via /api/v1/health) —
15+
# the panel won't show rtmps:// URLs otherwise.
16+
LRTMP2_RTMPS_PORT=1936
1317
# Must match rtmp.bind port in librtmp2-server's config.env
1418
LRTMP2_RTMP_PORT=1935
1519
# Default RTMP application name new streams are created under

app.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,29 +68,57 @@ def wrapped(*args, **kwargs):
6868
return view_func(*args, **kwargs)
6969
return wrapped
7070

71-
def build_urls(stream):
71+
def rtmps_health():
72+
"""Return RTMPS availability and the public RTMPS port to advertise.
73+
74+
RTMPS enablement is read live from /api/v1/health, but URLs must use
75+
the panel's public port config first. That preserves Docker/NAT/reverse
76+
proxy mappings such as public 443 -> server bind 1936. The server's
77+
reported bind port is only used as a fallback when the public config is
78+
empty or missing.
79+
"""
80+
configured_port = str(app.config.get("LRTMP2_RTMPS_PORT") or "")
81+
try:
82+
health = client.health()
83+
except Lrtmp2ApiError:
84+
return False, configured_port or "1936"
85+
if not health.get("rtmps_enabled"):
86+
return False, configured_port or "1936"
87+
reported_port = str(health.get("rtmps_port") or "")
88+
return True, configured_port or reported_port or "1936"
89+
90+
def build_urls(stream, rtmps_on, rtmps_port):
7291
domain = app.config["LRTMP2_DOMAIN"]
7392
port = app.config["LRTMP2_RTMP_PORT"]
7493
app_name = stream["app"]
7594
publish_url = f"rtmp://{domain}:{port}/{app_name}"
7695
players = stream.get("players") or []
7796
for player in players:
7897
player["play_url"] = f"rtmp://{domain}:{port}/{app_name}/{player.get('play_key', '')}"
98+
if rtmps_on:
99+
player["play_url_tls"] = (
100+
f"rtmps://{domain}:{rtmps_port}/{app_name}/{player.get('play_key', '')}"
101+
)
79102
first_play_key = ""
80103
if players:
81104
first_play_key = players[0].get("play_key", "")
82105
elif stream.get("play_key"):
83106
first_play_key = stream["play_key"]
84-
return {
107+
urls = {
85108
"publish_url": publish_url,
86109
"publish_key": stream.get("publish_key", ""),
87110
"play_url": f"rtmp://{domain}:{port}/{app_name}/{first_play_key}",
88111
"play_key": first_play_key,
112+
"rtmps_enabled": rtmps_on,
89113
"stats_url": (
90114
f"{app.config['LRTMP2_STATS_URL']}/stats?"
91115
f"{urlencode({'key': stream.get('stats_key', '')})}"
92116
),
93117
}
118+
if rtmps_on:
119+
urls["publish_url_tls"] = f"rtmps://{domain}:{rtmps_port}/{app_name}"
120+
urls["play_url_tls"] = f"rtmps://{domain}:{rtmps_port}/{app_name}/{first_play_key}"
121+
return urls
94122

95123
@app.route("/login", methods=["GET", "POST"])
96124
@limiter.limit("5 per minute")
@@ -132,10 +160,17 @@ def index():
132160
streams=[],
133161
api_error=str(exc),
134162
flash_error=flash_error,
163+
rtmps_enabled=False,
135164
)
165+
rtmps_on, rtmps_port = rtmps_health()
136166
for stream in streams:
137-
stream.update(build_urls(stream))
138-
return render_template("index.html", streams=streams, flash_error=flash_error)
167+
stream.update(build_urls(stream, rtmps_on, rtmps_port))
168+
return render_template(
169+
"index.html",
170+
streams=streams,
171+
flash_error=flash_error,
172+
rtmps_enabled=rtmps_on,
173+
)
139174

140175
@app.route("/streams/new", methods=["GET", "POST"])
141176
@login_required
@@ -190,7 +225,8 @@ def stream_created():
190225
"Check the overview."
191226
)
192227
return redirect(url_for("index"))
193-
stream = dict(stream, **build_urls(stream))
228+
rtmps_on, rtmps_port = rtmps_health()
229+
stream = dict(stream, **build_urls(stream, rtmps_on, rtmps_port))
194230
return render_template("stream_created.html", stream=stream)
195231

196232
@app.route("/streams/<stream_id>/players/new", methods=["POST"])

config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ class Config:
8686

8787
LRTMP2_DOMAIN = os.environ.get("LRTMP2_DOMAIN", "localhost")
8888
LRTMP2_RTMP_PORT = os.environ.get("LRTMP2_RTMP_PORT", "1935")
89+
# Publicly-reachable RTMPS port. Only used when librtmp2-server reports
90+
# RTMPS as enabled (via /api/v1/health) — kept separate from RTMP_PORT
91+
# since RTMPS is a second listener, not a mode switch on the same port.
92+
LRTMP2_RTMPS_PORT = os.environ.get("LRTMP2_RTMPS_PORT", "1936")
8993
LRTMP2_APP = os.environ.get("LRTMP2_APP", "live")
9094

9195
# Only enable Secure cookies when the panel is served over HTTPS.

templates/index.html

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ <h1>Streams on librtmp2-server</h1>
88
</form>
99
<a href="{{ url_for('create_stream') }}" class="btn btn-success mb-3">Create Stream</a>
1010

11+
{% if rtmps_enabled %}
12+
<span class="badge bg-success mb-3">RTMPS enabled</span>
13+
{% else %}
14+
<span class="badge bg-secondary mb-3">RTMPS disabled</span>
15+
{% endif %}
16+
1117
{% if api_error is defined and api_error %}
1218
<div class="alert alert-danger">{{ api_error }}</div>
1319
{% endif %}
@@ -29,12 +35,20 @@ <h2 class="accordion-header" id="heading{{ loop.index }}">
2935
<div id="collapse{{ loop.index }}" class="accordion-collapse collapse" data-bs-parent="#streamAccordion">
3036
<div class="accordion-body">
3137
<h5>Publish</h5>
32-
<strong>Server URL:</strong>
38+
<strong>Server URL (RTMP):</strong>
3339
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_url }}"
3440
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
3541
{{ stream.publish_url }}
3642
</span>
3743
<span class="copy-feedback"></span><br>
44+
{% if stream.publish_url_tls %}
45+
<strong>Server URL (RTMPS):</strong>
46+
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_url_tls }}"
47+
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
48+
{{ stream.publish_url_tls }}
49+
</span>
50+
<span class="copy-feedback"></span><br>
51+
{% endif %}
3852
<strong>Stream Key:</strong>
3953
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_key }}"
4054
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
@@ -46,12 +60,20 @@ <h5>Play</h5>
4660
{% for player in stream.get('players', []) %}
4761
<div class="mb-3 border-bottom border-secondary pb-3">
4862
<strong>{{ player.name }}</strong><br>
49-
<strong>Play URL:</strong>
63+
<strong>Play URL (RTMP):</strong>
5064
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_url }}"
5165
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
5266
{{ player.play_url }}
5367
</span>
5468
<span class="copy-feedback"></span><br>
69+
{% if player.play_url_tls %}
70+
<strong>Play URL (RTMPS):</strong>
71+
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_url_tls }}"
72+
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
73+
{{ player.play_url_tls }}
74+
</span>
75+
<span class="copy-feedback"></span><br>
76+
{% endif %}
5577
<strong>Play Key:</strong>
5678
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_key }}"
5779
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">

templates/stream_created.html

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,22 @@ <h1 class="mb-3">Stream Created</h1>
1212
<p><strong>App:</strong> {{ stream.app }}</p>
1313

1414
<h5 class="mt-4">Publish</h5>
15-
<p><strong>Server URL:</strong>
15+
<p><strong>Server URL (RTMP):</strong>
1616
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_url }}"
1717
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
1818
{{ stream.publish_url }}
1919
</span>
2020
<span class="copy-feedback"></span>
2121
</p>
22+
{% if stream.publish_url_tls %}
23+
<p><strong>Server URL (RTMPS):</strong>
24+
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_url_tls }}"
25+
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
26+
{{ stream.publish_url_tls }}
27+
</span>
28+
<span class="copy-feedback"></span>
29+
</p>
30+
{% endif %}
2231
<p><strong>Stream Key:</strong>
2332
<span class="blur-text" tabindex="0" role="button" data-url="{{ stream.publish_key }}"
2433
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
@@ -30,13 +39,22 @@ <h5 class="mt-4">Publish</h5>
3039
<h5 class="mt-4">Play</h5>
3140
{% for player in stream.get('players', []) %}
3241
<p><strong>{{ player.name }}</strong></p>
33-
<p><strong>Play URL:</strong>
42+
<p><strong>Play URL (RTMP):</strong>
3443
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_url }}"
3544
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
3645
{{ player.play_url }}
3746
</span>
3847
<span class="copy-feedback"></span>
3948
</p>
49+
{% if player.play_url_tls %}
50+
<p><strong>Play URL (RTMPS):</strong>
51+
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_url_tls }}"
52+
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">
53+
{{ player.play_url_tls }}
54+
</span>
55+
<span class="copy-feedback"></span>
56+
</p>
57+
{% endif %}
4058
<p><strong>Play Key:</strong>
4159
<span class="blur-text" tabindex="0" role="button" data-url="{{ player.play_key }}"
4260
onclick="copyToClipboard(this)" onkeyup="if(event.key==='Enter') copyToClipboard(this)">

tests/test_app.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ def test_password_not_required_when_login_disabled(monkeypatch):
104104
def test_index_lists_streams_from_api(monkeypatch):
105105
with patch("app.Lrtmp2Client") as mock_client_cls:
106106
mock_client = mock_client_cls.return_value
107+
mock_client.health.return_value = {"rtmps_enabled": False}
107108
mock_client.list_streams.return_value = [
108109
{
109110
"id": "s1",
@@ -144,6 +145,156 @@ def test_index_lists_streams_from_api(monkeypatch):
144145
mock_client.list_streams.assert_called_once()
145146

146147

148+
def test_index_shows_rtmps_urls_when_server_reports_enabled(monkeypatch):
149+
with patch("app.Lrtmp2Client") as mock_client_cls:
150+
mock_client = mock_client_cls.return_value
151+
mock_client.health.return_value = {
152+
"rtmp_port": 1935,
153+
"rtmps_enabled": True,
154+
"rtmps_port": 1936,
155+
}
156+
mock_client.list_streams.return_value = [
157+
{
158+
"id": "s1",
159+
"name": "Stream One",
160+
"app": "live",
161+
"publish_key": "pub_k",
162+
"play_key": "pl_k",
163+
"stats_key": "st_k",
164+
"players": [
165+
{"id": "vi_s1", "name": "Player 1", "play_key": "pl_k"}
166+
],
167+
"enabled": True,
168+
"created_at": 1,
169+
}
170+
]
171+
172+
import app as app_module
173+
174+
monkeypatch.setattr(app_module.Config, "SESSION_COOKIE_SECURE", False)
175+
monkeypatch.setattr(app_module.Config, "LRTMP2_RTMPS_PORT", "1936")
176+
application = app_module.create_app()
177+
application.config["TESTING"] = True
178+
application.config["WTF_CSRF_ENABLED"] = False
179+
client = application.test_client()
180+
client.post(
181+
"/login",
182+
data={"username": "admin", "password": os.environ["PASSWORD"]},
183+
)
184+
185+
r = client.get("/")
186+
assert r.status_code == 200
187+
assert b"RTMPS enabled" in r.data
188+
assert b"rtmps://localhost:1936/live" in r.data
189+
190+
191+
def test_index_prefers_public_rtmps_port_over_health_bind_port(monkeypatch):
192+
with patch("app.Lrtmp2Client") as mock_client_cls:
193+
mock_client = mock_client_cls.return_value
194+
mock_client.health.return_value = {
195+
"rtmp_port": 1935,
196+
"rtmps_enabled": True,
197+
"rtmps_port": 1936,
198+
}
199+
mock_client.list_streams.return_value = [
200+
{
201+
"id": "s1",
202+
"name": "Stream One",
203+
"app": "live",
204+
"publish_key": "pub_k",
205+
"play_key": "pl_k",
206+
"stats_key": "st_k",
207+
"players": [
208+
{"id": "vi_s1", "name": "Player 1", "play_key": "pl_k"}
209+
],
210+
"enabled": True,
211+
"created_at": 1,
212+
}
213+
]
214+
215+
import app as app_module
216+
217+
monkeypatch.setattr(app_module.Config, "SESSION_COOKIE_SECURE", False)
218+
monkeypatch.setattr(app_module.Config, "LRTMP2_RTMPS_PORT", "443")
219+
application = app_module.create_app()
220+
application.config["TESTING"] = True
221+
application.config["WTF_CSRF_ENABLED"] = False
222+
client = application.test_client()
223+
client.post(
224+
"/login",
225+
data={"username": "admin", "password": os.environ["PASSWORD"]},
226+
)
227+
228+
r = client.get("/")
229+
assert r.status_code == 200
230+
assert b"RTMPS enabled" in r.data
231+
assert b"rtmps://localhost:443/live" in r.data
232+
assert b"rtmps://localhost:1936/live" not in r.data
233+
234+
235+
def test_index_hides_rtmps_urls_when_server_reports_disabled(monkeypatch):
236+
with patch("app.Lrtmp2Client") as mock_client_cls:
237+
mock_client = mock_client_cls.return_value
238+
mock_client.health.return_value = {"rtmps_enabled": False}
239+
mock_client.list_streams.return_value = [
240+
{
241+
"id": "s1",
242+
"name": "Stream One",
243+
"app": "live",
244+
"publish_key": "pub_k",
245+
"play_key": "pl_k",
246+
"stats_key": "st_k",
247+
"players": [
248+
{"id": "vi_s1", "name": "Player 1", "play_key": "pl_k"}
249+
],
250+
"enabled": True,
251+
"created_at": 1,
252+
}
253+
]
254+
255+
import app as app_module
256+
257+
monkeypatch.setattr(app_module.Config, "SESSION_COOKIE_SECURE", False)
258+
application = app_module.create_app()
259+
application.config["TESTING"] = True
260+
application.config["WTF_CSRF_ENABLED"] = False
261+
client = application.test_client()
262+
client.post(
263+
"/login",
264+
data={"username": "admin", "password": os.environ["PASSWORD"]},
265+
)
266+
267+
r = client.get("/")
268+
assert r.status_code == 200
269+
assert b"RTMPS disabled" in r.data
270+
assert b"rtmps://" not in r.data
271+
272+
273+
def test_index_treats_health_failure_as_rtmps_disabled(monkeypatch):
274+
with patch("app.Lrtmp2Client") as mock_client_cls:
275+
from lrtmp2_client import Lrtmp2ApiError
276+
277+
mock_client = mock_client_cls.return_value
278+
mock_client.health.side_effect = Lrtmp2ApiError("unreachable")
279+
mock_client.list_streams.return_value = []
280+
281+
import app as app_module
282+
283+
monkeypatch.setattr(app_module.Config, "SESSION_COOKIE_SECURE", False)
284+
application = app_module.create_app()
285+
application.config["TESTING"] = True
286+
application.config["WTF_CSRF_ENABLED"] = False
287+
client = application.test_client()
288+
client.post(
289+
"/login",
290+
data={"username": "admin", "password": os.environ["PASSWORD"]},
291+
)
292+
293+
r = client.get("/")
294+
assert r.status_code == 200
295+
assert b"RTMPS disabled" in r.data
296+
297+
147298
def test_create_stream_shows_keys_without_session_storage(monkeypatch):
148299
mock_result = {
149300
"id": "new-stream",
@@ -163,6 +314,7 @@ def test_create_stream_shows_keys_without_session_storage(monkeypatch):
163314

164315
with patch("app.Lrtmp2Client") as mock_client_cls:
165316
mock_client = mock_client_cls.return_value
317+
mock_client.health.return_value = {"rtmps_enabled": False}
166318
mock_client.create_stream.return_value = mock_result
167319
mock_client.list_streams.return_value = [mock_result]
168320

@@ -199,6 +351,7 @@ def test_delete_stream_surfaces_api_error(monkeypatch):
199351
from lrtmp2_client import Lrtmp2ApiError
200352

201353
mock_client = mock_client_cls.return_value
354+
mock_client.health.return_value = {"rtmps_enabled": False}
202355
mock_client.list_streams.return_value = []
203356
mock_client.delete_stream.side_effect = Lrtmp2ApiError("server down")
204357

0 commit comments

Comments
 (0)