feat: local push file browser, VOD serving, and admin UX overhaul
Build and Push Docker Image / build (push) Failing after 1m5s

Local push & file browser
- File browser with breadcrumb nav, search, directory memory, .. row,
  and hidden/system folder filtering (./@/#)
- Color-coded file extension tags; file sizes shown inline
- Per-file push modal with random stream key generator and responsive width
- Folder multi-file push modal: independent stream keys per file,
  batch start/stop, inline live-dot status with real-time duration
- Push status inline in file rows replacing top push-jobs area;
  job detail modal with copy/stop/add-stream actions
- /h/<slug> HLS proxy route registered automatically on push start
- Folder push and publish-archive recurse subfolders via os.walk
- "Add to existing stream" dropdown at file, folder, and job modal entries
- Stream editor supports prefilling multiple source links via links array
- list_folder_videos API returns playable files with signed URLs

VOD / video serving
- /video/<token>/<payload> endpoint with HMAC-signed URLs and
  HTTP Range support (206 Partial Content, seek-capable)
- Publish-archive button on file rows and folder rows

Admin UX
- Replace all 18 native alert() with themed Toast notifications
  (success/error/info/warn, 3.5s auto-dismiss, dark mode aware)
- Replace all 3 native confirm() with custom modal (showConfirm)
- Custom overlay scrollbar for admin.html and index.html: no layout
  shift, theme-colored, auto-hides after 1.5s, drag-supported
- background-attachment: fixed on admin and index body backgrounds
- Drag handle for viewport config rows in stream editor
- Pagination and real-time search for hidden push address mapping table
- Pagination for stream analytics detail table with SSE-safe page state
- Stream picker search placeholder i18n
- Lang toggle button title/aria-label i18n
- View URL hash renamed: push -> local, obs -> remote

Index (public) page
- Load more: viewport-aware initial batch calculated from .stream-switch
  bottom position; ghost-style button; card entrance animation with
  50ms per-card stagger on load-more click only

Infrastructure
- Dockerfile: install ffmpeg; separate requirements COPY for layer cache
- docker-compose.yml: add RTMP_HOST, VIDEOS_DIRS env vars, videos volume
- README: document VIDEOS_DIRS mount methods, password reset procedure

Fixes
- action=add 500 error: psycopg3 dict_row does not support int subscript
- Lang toggle button title/aria-label missing i18n keys
- API Keys list not re-rendering on language switch
- Admin stream count per tab showing combined LIVE+ARCHIVE total
- Em dash in api.new_token_hint replaced with hyphen
This commit is contained in:
Stardream
2026-05-22 21:10:33 +10:00
parent 326101958a
commit dc949bdeab
8 changed files with 2509 additions and 72 deletions
+2
View File
@@ -4,3 +4,5 @@ __pycache__/
*.pyc
.env
.DS_Store
CHANGELOG.md
AGENTS.md
+5 -1
View File
@@ -6,10 +6,14 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
DATA_DIR=/app/data
WORKDIR /app
COPY . /app
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
RUN mkdir -p /app/data
EXPOSE 8080
+49 -2
View File
@@ -32,7 +32,8 @@
- **Admin panel** - Add, edit, reorder, enable/disable streams; manage sources; drag-and-drop ordering
- **Viewer analytics** - Session tracking, unique visitors, peak concurrent viewers, average watch duration, device / browser / OS / geography breakdown, real-time dashboard, CSV export
- **Telegram notifications** - Per-stream push messages on stream start and stop
- **Stream push** - SRS RTMP publishing helpers, hidden HLS route proxy so real stream keys are never exposed publicly
- **Stream push** - Local file browser with per-file and per-folder RTMP push management; multi-file folder push with independent stream keys; inline push status and detail modal; remote RTMP push config for external encoders; hidden HLS route proxy (`/h/<slug>`) so real stream keys are never exposed publicly
- **VOD / file serving** - Signed `/video/` URLs with HTTP Range support (seek-capable); publish any local video file or folder as an archive stream directly from the file browser
- **HLS proxy** - Signed `/proxy/hls/` routes for cross-origin HLS playback
- **API key auth** - Generate per-key tokens in the admin panel for programmatic access to all admin and analytics endpoints
@@ -90,6 +91,17 @@ StreamHall initial admin password: <random-password>
docker compose pull && docker compose up -d
```
**Resetting a forgotten admin password**
```bash
docker exec streamhall-postgres psql -U streamhall -d streamhall \
-c "DELETE FROM site_settings WHERE key='admin_password_hash';"
docker restart streamhall
docker logs streamhall
```
Deleting the hash causes StreamHall to generate a new random password on the next startup and print it to the log.
<div align="right">
[![][back-to-top]](#readme-top)
@@ -138,10 +150,38 @@ Set these environment variables in `docker-compose.yml`:
| `STREAM_PROBE_TIMEOUT` | `4` | No | Seconds before aborting a stream URL probe |
| `STREAM_MONITOR_INTERVAL` | `10` | No | Seconds between stream liveness checks |
| `TELEGRAM_TIMEOUT` | `6` | No | Seconds before aborting a Telegram API call |
| `RTMP_HOST` | `srs` | No | Hostname of the SRS container used for local push jobs |
| `VIDEOS_DIRS` | *(unset)* | No | Comma-separated list of directories exposed in the file browser. Optionally prefix each path with a label: `label:/app/path`. Multiple entries: `movies:/app/movies,shows:/app/shows` |
> [!WARNING]
> Always change `SECRET_KEY` and `POSTGRES_PASSWORD` before exposing StreamHall to a network. The defaults are intentionally weak placeholders.
**Mounting video directories for Local Push**
*Method 1 — single base directory, multiple sources as subdirectories:*
Mount multiple host paths under one container base path. The file browser shows them as subdirectories.
```yaml
environment:
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos/local
- /your/media/path:/app/videos/external
```
*Method 2 — multiple labeled top-level directories:*
Each path is listed as a separate labeled root entry in the file browser.
```yaml
environment:
VIDEOS_DIRS: "/app/videos,external:/app/media/external"
volumes:
- ./videos:/app/videos
- /your/media/path:/app/media/external
```
<div align="right">
[![][back-to-top]](#readme-top)
@@ -176,7 +216,9 @@ RTMP server: rtmp://HOST:1935/live
Stream key: your-stream-key
```
The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/h/<slug>/...`) that routes through nginx on port `8889`, keeping the real stream key out of the public URL.
The admin panel's **Local Push** section provides a file browser for your media directory. Select any video file to push it via RTMP with a custom stream key, or select a folder to push all contained videos simultaneously with independent stream keys. Push status is shown inline per file; a detail modal gives real-time duration, copy addresses, and stop controls.
The hidden public HLS URL (`/h/<slug>/...`) routes through nginx on port `8889`, keeping the real stream key out of the public URL.
> [!NOTE]
> The RTMP host field accepts an optional port, e.g. `live.example.com:1935`. Do not hard-code `:1935` if you use a non-standard port.
@@ -202,6 +244,7 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
| `GET` | `/api?action=site_settings` | Site title, description, branding |
| `GET` | `/api?action=get_player_data&id=<public_id>` | Player sources and metadata |
| `POST` | `/api?action=verify_password` | Verify a stream password |
| `GET` | `/video/<token>/<payload>` | Signed VOD endpoint with HTTP Range support |
### Admin Endpoints
@@ -222,6 +265,10 @@ The admin panel's **Stream Setup** section generates a hidden public HLS URL (`/
| `GET` | `/api?action=list_api_keys` | List API keys (tokens not returned) |
| `POST` | `/api?action=create_api_key` | Create a key - token returned once |
| `POST` | `/api?action=delete_api_key` | Revoke a key by `id` |
| `GET` | `/api?action=list_pushes` | List active push jobs |
| `POST` | `/api?action=start_push` | Start an RTMP push job for a file |
| `POST` | `/api?action=stop_push` | Stop a push job by stream key |
| `GET` | `/api?action=list_folder_videos` | List playable files in a directory (with signed URLs) |
### Analytics Endpoints
+49 -2
View File
@@ -32,7 +32,8 @@
- **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理
- **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出
- **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知
- **推流配置** - SRS RTMP 推流辅助工具,隐藏 HLS 路由代理(公开地址不暴露真实推流码)
- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/<slug>`),真实推流码不出现在公开地址中
- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播
- **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题
- **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问
@@ -90,6 +91,17 @@ StreamHall initial admin password: <random-password>
docker compose pull && docker compose up -d
```
**重置忘记的管理员密码**
```bash
docker exec streamhall-postgres psql -U streamhall -d streamhall \
-c "DELETE FROM site_settings WHERE key='admin_password_hash';"
docker restart streamhall
docker logs streamhall
```
删除密码哈希后,StreamHall 在下次启动时会重新生成随机密码并打印到日志中。
<div align="right">
[![][back-to-top]](#readme-top)
@@ -138,10 +150,38 @@ python server.py
| `STREAM_PROBE_TIMEOUT` | `4` | 否 | 流地址探测超时秒数 |
| `STREAM_MONITOR_INTERVAL` | `10` | 否 | 流存活检测间隔秒数 |
| `TELEGRAM_TIMEOUT` | `6` | 否 | Telegram API 请求超时秒数 |
| `RTMP_HOST` | `srs` | 否 | 本地推流任务使用的 SRS 容器主机名 |
| `VIDEOS_DIRS` | *(未设置)* | 否 | 文件浏览器暴露的目录,逗号分隔。可为每个路径加标签前缀:`label:/app/path`。多个示例:`movies:/app/movies,shows:/app/shows` |
> [!WARNING]
> 在将 StreamHall 暴露到网络前,务必修改 `SECRET_KEY` 和 `POSTGRES_PASSWORD`。默认值仅为占位符,安全性极低。
**挂载本地推流视频目录**
*方式一 - 单一基路径,多个来源作为子目录:*
将多个宿主机路径挂载到同一容器基路径的子目录下,文件浏览器中以子文件夹形式展示。
```yaml
environment:
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos/local
- /your/media/path:/app/videos/external
```
*方式二 - 多个带标签的顶级目录:*
每个路径在文件浏览器中作为独立的顶级条目显示。
```yaml
environment:
VIDEOS_DIRS: "/app/videos,external:/app/media/external"
volumes:
- ./videos:/app/videos
- /your/media/path:/app/media/external
```
<div align="right">
[![][back-to-top]](#readme-top)
@@ -176,7 +216,9 @@ RTMP 服务器: rtmp://HOST:1935/live
推流码: your-stream-key
```
管理后台的**推流配置**页面可生成隐藏公开 HLS 地址(`/h/<slug>/...`),通过 Nginx 在 `8889` 端口对外提供访问,真实推流码不会出现在公开 URL 中
管理后台的**本地推流**页面提供媒体目录文件浏览器。选择单个视频文件可使用自定义推流码发起 RTMP 推流;选择文件夹可同时为其中所有视频分别分配独立推流码并批量启动。每个文件行内联显示推流状态,点击"编辑推流"可查看实时时长、复制推流/播放地址和停止操作
隐藏公开 HLS 地址(`/h/<slug>/...`)通过 Nginx 在 `8889` 端口对外提供访问,真实推流码不会出现在公开 URL 中。
> [!NOTE]
> RTMP 主机字段支持填写自定义端口,如 `live.example.com:1935`。若使用非标准端口,请勿将 `:1935` 硬编码到地址中。
@@ -202,6 +244,7 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=site_settings` | 站点标题、简介、品牌设置 |
| `GET` | `/api?action=get_player_data&id=<public_id>` | 播放器播放源及元数据 |
| `POST` | `/api?action=verify_password` | 验证直播访问密码 |
| `GET` | `/video/<token>/<payload>` | 签名 VOD 点播端点,支持 HTTP Range |
### 管理接口(需鉴权)
@@ -222,6 +265,10 @@ RTMP 服务器: rtmp://HOST:1935/live
| `GET` | `/api?action=list_api_keys` | 列出所有 API 密钥(不返回 Token 明文) |
| `POST` | `/api?action=create_api_key` | 创建密钥(Token 仅返回一次) |
| `POST` | `/api?action=delete_api_key` | 按 `id` 撤销密钥 |
| `GET` | `/api?action=list_pushes` | 列出当前活跃推流任务 |
| `POST` | `/api?action=start_push` | 为指定文件启动 RTMP 推流任务 |
| `POST` | `/api?action=stop_push` | 按推流码停止推流任务 |
| `GET` | `/api?action=list_folder_videos` | 列出目录内可播放文件及签名 URL |
### 统计接口(需鉴权)
+8
View File
@@ -13,6 +13,14 @@ services:
SECRET_KEY: "change-this-secret"
DATABASE_URL: "postgresql://streamhall:streamhall_pg_password@postgres:5432/streamhall"
TZ: "UTC"
RTMP_HOST: "srs"
# Comma-separated list of video directories (optional label:path format)
# Example for multiple dirs: "movies:/app/media/movies,anime:/app/media/anime"
VIDEOS_DIRS: "/app/videos"
volumes:
- ./videos:/app/videos
# Mount additional directories as needed:
# - /local/path:/app/media/label
postgres:
image: postgres:16-alpine
+1632 -64
View File
File diff suppressed because it is too large Load Diff
+168 -1
View File
@@ -31,12 +31,60 @@
box-sizing: border-box;
}
html {
scrollbar-width: none;
}
html::-webkit-scrollbar {
display: none;
}
#sh-scrollbar {
position: fixed;
right: 4px;
top: 0;
bottom: 0;
width: 6px;
z-index: 9990;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
#sh-scrollbar.sh-sb-vis {
pointer-events: auto;
opacity: 1;
}
#sh-scrollbar.dragging {
pointer-events: auto;
}
#sh-scrollbar-thumb {
position: absolute;
right: 0;
width: 6px;
min-height: 40px;
border-radius: 999px;
background: rgba(78, 126, 232, 0.35);
transition: background 0.15s;
cursor: pointer;
user-select: none;
}
#sh-scrollbar-thumb:hover,
#sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(78, 126, 232, 0.65);
}
:root[data-theme="dark"] #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.28);
}
:root[data-theme="dark"] #sh-scrollbar-thumb:hover,
:root[data-theme="dark"] #sh-scrollbar.dragging #sh-scrollbar-thumb {
background: rgba(147, 197, 253, 0.6);
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(135deg, rgba(246, 249, 255, 0.98), rgba(255, 246, 250, 0.92) 50%, rgba(242, 255, 252, 0.96)),
linear-gradient(90deg, rgba(78, 126, 232, 0.08), rgba(242, 99, 137, 0.07));
background-attachment: fixed;
color: var(--text);
font-family: "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", sans-serif;
overflow-x: hidden;
@@ -46,6 +94,7 @@
background:
linear-gradient(135deg, rgba(15, 20, 34, 0.98), rgba(28, 35, 54, 0.94) 52%, rgba(30, 24, 42, 0.98)),
linear-gradient(90deg, rgba(25, 184, 177, 0.1), rgba(242, 99, 137, 0.08));
background-attachment: fixed;
color: #d8deea;
}
@@ -625,6 +674,33 @@
grid-template-columns: 1fr;
}
}
#sh-load-more-wrap {
text-align: center;
padding: 16px 0 24px;
}
#sh-load-more-btn {
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 28px;
background: transparent;
color: var(--muted);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
#sh-load-more-btn:hover {
color: var(--blue);
border-color: rgba(78, 126, 232, 0.4);
transform: translateY(-1px);
}
@keyframes sh-card-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.card-new {
animation: sh-card-in 0.28s ease-out both;
}
</style>
</head>
@@ -661,6 +737,9 @@
<div id="loading-indicator" class="loader"></div>
<p id="error-message" class="message hidden"></p>
<div id="stream-list"></div>
<div id="sh-load-more-wrap" style="display:none;">
<button id="sh-load-more-btn"></button>
</div>
</main>
<footer class="site-footer">
@@ -693,6 +772,7 @@
// empty states
'empty.live': '当前没有可用的直播',
'empty.archive': '当前没有可用的存档',
'list.load_more': '加载更多',
// error
'err.load': '加载失败',
'err.fetch': '获取直播列表失败',
@@ -723,6 +803,7 @@
// empty states
'empty.live': 'No live streams available',
'empty.archive': 'No archives available',
'list.load_more': 'Load more',
// error
'err.load': 'Failed to load',
'err.fetch': 'Failed to fetch streams',
@@ -989,6 +1070,18 @@
const labelKicker = (label) => label === 'ARCHIVE' ? t('kicker.archive') : t('kicker.live');
let _visibleCount = 0;
const _calcBatch = () => {
const w = window.innerWidth, h = window.innerHeight;
const cols = w > 900 ? 3 : w > 768 ? 2 : 1;
const switchEl = document.querySelector('.stream-switch');
const refBottom = switchEl ? switchEl.getBoundingClientRect().bottom : (w > 768 ? 200 : 160);
const listTop = refBottom + 24;
const rowH = 196;
const rows = Math.max(1, Math.floor((h - listTop - 300) / rowH));
return rows * cols;
};
const renderStreams = () => {
streamSectionTitle.textContent = labelTitle(activeStreamLabel);
const kickerEl = document.getElementById('stream-section-kicker');
@@ -1004,9 +1097,12 @@
if (!streams.length) {
errorMessage.textContent = emptyText(activeStreamLabel);
errorMessage.classList.remove('hidden');
document.getElementById('sh-load-more-wrap').style.display = 'none';
return;
}
streamList.innerHTML = streams.map(stream => {
if (_visibleCount === 0) _visibleCount = _calcBatch();
const visStreams = streams.slice(0, _visibleCount);
streamList.innerHTML = visStreams.map(stream => {
const label = normalizeLabel(stream.stream_label);
return `
<a href="player.html?id=${encodeURIComponent(stream.id)}" class="card">
@@ -1024,6 +1120,16 @@
</a>
`;
}).join('');
const lmWrap = document.getElementById('sh-load-more-wrap');
const lmBtn = document.getElementById('sh-load-more-btn');
if (lmWrap && lmBtn) {
if (streams.length > _visibleCount) {
lmBtn.textContent = t('list.load_more');
lmWrap.style.display = '';
} else {
lmWrap.style.display = 'none';
}
}
};
streamSwitchButtons.forEach(btn => {
@@ -1034,12 +1140,25 @@
localStorage.setItem('home_stream_label', activeStreamLabel);
streamList.classList.add('is-switching');
window.setTimeout(() => {
_visibleCount = 0;
renderStreams();
window.requestAnimationFrame(() => streamList.classList.remove('is-switching'));
}, 140);
});
});
document.getElementById('sh-load-more-btn')?.addEventListener('click', () => {
const oldCount = _visibleCount;
_visibleCount += _calcBatch();
renderStreams();
streamList.querySelectorAll('.card').forEach((card, i) => {
if (i >= oldCount) {
card.classList.add('card-new');
card.style.animationDelay = `${(i - oldCount) * 50}ms`;
}
});
});
try {
const settingsResponse = await fetch('/api?action=site_settings');
if (settingsResponse.ok) {
@@ -1065,6 +1184,54 @@
}
});
</script>
<div id="sh-scrollbar"><div id="sh-scrollbar-thumb"></div></div>
<script>
(function() {
var _sb = document.getElementById('sh-scrollbar');
var _thumb = document.getElementById('sh-scrollbar-thumb');
if (!_sb || !_thumb) return;
var _hideTimer = null, _isDragging = false, _dragStartY = 0, _dragScrollY = 0;
function _h() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
return Math.max(40, (vis / total) * vis);
}
function _update() {
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
if (total <= vis) { _sb.classList.remove('sh-sb-vis'); return; }
var h = _h(), top = (window.scrollY / (total - vis)) * (vis - h);
_thumb.style.height = h + 'px';
_thumb.style.top = top + 'px';
}
function _show() {
_update();
if (document.documentElement.scrollHeight <= window.innerHeight) return;
_sb.classList.add('sh-sb-vis');
clearTimeout(_hideTimer);
if (!_isDragging) _hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
}
window.addEventListener('scroll', _show, { passive: true });
window.addEventListener('resize', _update);
document.addEventListener('mousemove', function(e) {
if (e.clientX > window.innerWidth - 20) _show();
});
_thumb.addEventListener('mousedown', function(e) {
_isDragging = true; _dragStartY = e.clientY; _dragScrollY = window.scrollY;
_sb.classList.add('dragging'); clearTimeout(_hideTimer); e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!_isDragging) return;
var total = document.documentElement.scrollHeight, vis = window.innerHeight;
var ratio = (total - vis) / (vis - _h());
window.scrollTo(0, _dragScrollY + (e.clientY - _dragStartY) * ratio);
});
document.addEventListener('mouseup', function() {
if (!_isDragging) return;
_isDragging = false; _sb.classList.remove('dragging');
_hideTimer = setTimeout(function() { _sb.classList.remove('sh-sb-vis'); }, 1500);
});
_update();
})();
</script>
</body>
</html>
+596 -2
View File
@@ -11,8 +11,11 @@ import mimetypes
import os
import re
import secrets
import subprocess
import tempfile
import threading
import time
import uuid
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -60,6 +63,31 @@ STREAM_MONITOR_INTERVAL = max(5, int(os.getenv("STREAM_MONITOR_INTERVAL", "10"))
SRS_HTTP_ORIGIN = os.getenv("SRS_HTTP_ORIGIN", "http://srs:8080").rstrip("/")
OBS_ROUTE_SLUG_LENGTH = max(12, int(os.getenv("OBS_ROUTE_SLUG_LENGTH", "22")))
URL_PATH_SAFE = "/._~!$&'()*+,;=:@"
# Video push
_VIDEOS_DIRS_RAW = os.environ.get("VIDEOS_DIRS", "/app/videos")
def _parse_videos_dirs(raw: str) -> list[dict]:
result = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
if ":" in item and not item.startswith("/"):
label, path = item.split(":", 1)
else:
label = os.path.basename(item.rstrip("/")) or item
path = item
result.append({"label": label, "path": path})
return result
VIDEOS_DIRS: list[dict] = _parse_videos_dirs(_VIDEOS_DIRS_RAW)
UPLOAD_DIR: str = VIDEOS_DIRS[0]["path"] if VIDEOS_DIRS else "/app/videos"
RTMP_HOST = os.environ.get("RTMP_HOST", "localhost")
VIDEO_EXTS = frozenset({".mp4", ".mkv", ".avi", ".flv", ".ts", ".mov", ".wmv", ".webm", ".m4v"})
active_pushes: dict[str, dict] = {}
_pushes_lock = threading.Lock()
HLS_PROXY_PREFIX = "/proxy/hls"
HLS_URI_RE = re.compile(r'URI="([^"]+)"') # matches URI="..." attributes in HLS tag lines (e.g. EXT-X-KEY)
@@ -73,6 +101,8 @@ DEFAULT_SITE_SETTINGS = {
"footer_markdown": "",
"footer_markdown_en": "",
"telegram_public_base_url": "",
"obs_rtmp_host": "",
"obs_playback_origin": "",
}
DEFAULT_TELEGRAM_SETTINGS = {
@@ -228,6 +258,21 @@ def init_postgres_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS push_jobs (
job_id TEXT PRIMARY KEY,
dir_index INTEGER NOT NULL,
rel_path TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL DEFAULT '',
stream_key TEXT NOT NULL,
hls_slug TEXT NOT NULL DEFAULT '',
loop INTEGER NOT NULL DEFAULT 0,
is_folder INTEGER NOT NULL DEFAULT 0,
started_at INTEGER NOT NULL
)
"""
)
init_stats_tables(conn)
for key, value in DEFAULT_SITE_SETTINGS.items():
conn.execute(
@@ -385,6 +430,36 @@ def hls_proxy_path(url: str) -> str:
return f"{HLS_PROXY_PREFIX}/{hls_proxy_host_token(host)}/{encoded}"
PLAYABLE_VIDEO_EXTS = frozenset({".mp4", ".mkv", ".mov", ".webm", ".m4v"})
def _dir_has_ext(path: str, exts: frozenset, max_depth: int = 10) -> bool:
"""Return True if path contains any file with a matching extension (recursive, early exit)."""
if max_depth <= 0:
return False
try:
for name in os.listdir(path):
if name.startswith((".", "@", "#")):
continue
full = os.path.join(path, name)
if os.path.isfile(full):
if os.path.splitext(name)[1].lower() in exts:
return True
elif os.path.isdir(full):
if _dir_has_ext(full, exts, max_depth - 1):
return True
except OSError:
pass
return False
def video_proxy_path(dir_index: int, rel_path: str, filename: str) -> str:
payload = f"{dir_index}:{rel_path}:{filename}"
token = sign(f"video-proxy:{payload}")
encoded = base64.urlsafe_b64encode(payload.encode("utf-8")).decode("ascii").rstrip("=")
return f"/video/{token}/{encoded}"
def is_hls_link(link: dict[str, object]) -> bool:
raw_url = str(link.get("url") or "")
link_type = str(link.get("type") or "").strip().lower()
@@ -560,11 +635,48 @@ def decode_probe_text(data: bytes) -> str:
return ""
def resolve_video_file_path(url_path: str) -> str | None:
"""Decode a /video/<token>/<encoded> path and return the absolute filepath, or None if invalid/missing."""
parts = url_path.strip("/").split("/", 2)
if len(parts) != 3:
return None
_, token, encoded = parts
padded = encoded + "=" * (-len(encoded) % 4)
try:
payload = base64.urlsafe_b64decode(padded).decode("utf-8")
except Exception:
return None
if not hmac.compare_digest(token, sign(f"video-proxy:{payload}")):
return None
payload_parts = payload.split(":", 2)
if len(payload_parts) != 3:
return None
dir_index_str, rel_path, filename = payload_parts
try:
dir_index = int(dir_index_str)
assert 0 <= dir_index < len(VIDEOS_DIRS)
except (ValueError, AssertionError):
return None
if rel_path and (".." in rel_path.split("/") or rel_path.startswith("/") or os.path.isabs(rel_path)):
return None
if not filename or "/" in filename or "\\" in filename or ".." in filename:
return None
filepath = (
os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path, filename)
if rel_path
else os.path.join(VIDEOS_DIRS[dir_index]["path"], filename)
)
return filepath if os.path.isfile(filepath) else None
def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, object]:
url = str(raw_url or "").strip()
if not url:
return stream_probe_response(False)
if url.startswith("/video/"):
return stream_probe_response(resolve_video_file_path(url) is not None)
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
return stream_probe_response(False)
@@ -1085,6 +1197,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
self.proxy_hls_route(parsed.path, send_body=True)
return
if parsed.path.startswith("/video/"):
self.serve_video_file(parsed.path, send_body=True)
return
self.serve_static(parsed.path)
def do_HEAD(self) -> None:
@@ -1098,6 +1213,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
if parsed.path.startswith(f"{HLS_PROXY_PREFIX}/"):
self.proxy_hls_route(parsed.path, send_body=False)
return
if parsed.path.startswith("/video/"):
self.serve_video_file(parsed.path, send_body=False)
return
self.serve_static(parsed.path, send_body=False)
def do_POST(self) -> None:
@@ -1245,6 +1363,12 @@ class StreamHallHandler(BaseHTTPRequestHandler):
elif action == "check_stream":
self.require_admin()
self.api_check_stream()
elif action == "get_obs_config":
self.require_admin()
self.api_get_obs_config()
elif action == "save_obs_config":
self.require_admin()
self.api_save_obs_config()
elif action == "list_obs_routes":
self.require_admin()
self.api_list_obs_routes()
@@ -1290,6 +1414,27 @@ class StreamHallHandler(BaseHTTPRequestHandler):
elif action == "delete_api_key":
self.require_admin()
self.api_delete_api_key()
elif action == "list_videos":
self.require_admin()
self.api_list_videos()
elif action == "list_folder_videos":
self.require_admin()
self.api_list_folder_videos()
elif action == "upload_video":
self.require_admin()
self.api_upload_video()
elif action == "delete_video":
self.require_admin()
self.api_delete_video()
elif action == "start_push":
self.require_admin()
self.api_start_push()
elif action == "stop_push":
self.require_admin()
self.api_stop_push()
elif action == "list_pushes":
self.require_admin()
self.api_list_pushes()
else:
self.error_json("invalid_action")
except PermissionError:
@@ -1555,9 +1700,9 @@ class StreamHallHandler(BaseHTTPRequestHandler):
stream_label = normalize_stream_label(body.get("streamLabel"))
with db() as conn:
max_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), 0) FROM streams WHERE stream_label = ?",
"SELECT COALESCE(MAX(sort_order), 0) AS v FROM streams WHERE stream_label = ?",
(stream_label,),
).fetchone()[0]
).fetchone()["v"]
params = (
generate_public_id(conn),
stream_label,
@@ -1746,6 +1891,30 @@ class StreamHallHandler(BaseHTTPRequestHandler):
raise AppError("stream_not_found")
self.send_json({"status": "success", "data": self.check_stream_row(row)})
def api_get_obs_config(self) -> None:
with db() as conn:
rows = conn.execute(
"SELECT key, value FROM site_settings WHERE key IN (?, ?)",
("obs_rtmp_host", "obs_playback_origin"),
).fetchall()
data: dict[str, str] = {"obs_rtmp_host": "", "obs_playback_origin": ""}
for row in rows:
data[row["key"]] = row["value"]
self.send_json({"status": "ok", "obs_rtmp_host": data["obs_rtmp_host"], "obs_playback_origin": data["obs_playback_origin"]})
def api_save_obs_config(self) -> None:
body = self.read_json()
rtmp_host = str(body.get("obs_rtmp_host") or "").strip()
playback_origin = str(body.get("obs_playback_origin") or "").strip()
with db() as conn:
for key, value in [("obs_rtmp_host", rtmp_host), ("obs_playback_origin", playback_origin)]:
conn.execute(
"INSERT INTO site_settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
self.send_json({"status": "ok"})
def api_list_obs_routes(self) -> None:
with db() as conn:
rows = conn.execute("SELECT * FROM obs_stream_routes ORDER BY id DESC").fetchall()
@@ -2436,6 +2605,332 @@ class StreamHallHandler(BaseHTTPRequestHandler):
except (URLError, TimeoutError, OSError):
self.send_error(HTTPStatus.BAD_GATEWAY)
def api_list_videos(self) -> None:
qs = parse_qs(urlparse(self.path).query)
dir_index_str = (qs.get("dir_index", [None])[0] or "").strip()
if not dir_index_str:
roots = [{"index": i, "label": d["label"]} for i, d in enumerate(VIDEOS_DIRS)]
self.send_json({"status": "ok", "roots": roots})
return
try:
dir_index = int(dir_index_str)
except ValueError:
raise AppError("invalid_filename")
if dir_index < 0 or dir_index >= len(VIDEOS_DIRS):
raise AppError("invalid_filename")
rel_path = (qs.get("rel_path", [""])[0] or "").strip()
if rel_path:
if ".." in rel_path.split("/") or rel_path.startswith("/") or os.path.isabs(rel_path):
raise AppError("invalid_rel_path")
base = VIDEOS_DIRS[dir_index]["path"]
target = os.path.join(base, rel_path) if rel_path else base
entries: list[dict] = []
if os.path.isdir(target):
try:
for name in os.listdir(target):
if name.startswith((".", "@", "#")):
continue
full = os.path.join(target, name)
if os.path.isdir(full):
entries.append({
"name": name,
"type": "dir",
"has_playable": _dir_has_ext(full, PLAYABLE_VIDEO_EXTS),
"has_video": _dir_has_ext(full, VIDEO_EXTS),
})
elif os.path.isfile(full) and os.path.splitext(name)[1].lower() in VIDEO_EXTS:
try:
size = os.path.getsize(full)
except OSError:
size = 0
entry_dict: dict = {"name": name, "type": "file", "size": size}
if os.path.splitext(name)[1].lower() in PLAYABLE_VIDEO_EXTS:
entry_dict["video_url"] = video_proxy_path(dir_index, rel_path, name)
entries.append(entry_dict)
except OSError:
pass
entries.sort(key=lambda e: (0 if e["type"] == "dir" else 1, e["name"].lower()))
self.send_json({
"status": "ok",
"dir_index": dir_index,
"rel_path": rel_path,
"label": VIDEOS_DIRS[dir_index]["label"],
"entries": entries,
})
def api_list_folder_videos(self) -> None:
qs = parse_qs(urlparse(self.path).query)
dir_index_str = (qs.get("dir_index", [None])[0] or "").strip()
rel_path = (qs.get("rel_path", [""])[0] or "").strip()
mode = (qs.get("mode", [""])[0] or "").strip()
try:
dir_index = int(dir_index_str)
except (ValueError, TypeError):
raise AppError("invalid_filename")
if dir_index < 0 or dir_index >= len(VIDEOS_DIRS):
raise AppError("invalid_filename")
if rel_path and (".." in rel_path.split("/") or rel_path.startswith("/") or os.path.isabs(rel_path)):
raise AppError("invalid_rel_path")
base = VIDEOS_DIRS[dir_index]["path"]
target = os.path.join(base, rel_path) if rel_path else base
if not os.path.isdir(target):
raise AppError("file_not_found")
use_exts = VIDEO_EXTS if mode == "push" else PLAYABLE_VIDEO_EXTS
files: list[dict] = []
try:
for dirpath, dirnames, filenames in os.walk(target):
dirnames[:] = sorted(d for d in dirnames if not d.startswith((".", "@", "#")))
for name in sorted(filenames):
if name.startswith((".", "@", "#")):
continue
if os.path.splitext(name)[1].lower() not in use_exts:
continue
full = os.path.join(dirpath, name)
file_dir_rel = os.path.relpath(dirpath, base).replace("\\", "/")
if file_dir_rel == ".":
file_dir_rel = ""
display_name = os.path.relpath(full, target).replace("\\", "/")
try:
size = os.path.getsize(full)
except OSError:
size = 0
entry: dict = {"name": name, "size": size, "file_dir_rel": file_dir_rel, "display_name": display_name}
if mode != "push":
entry["video_url"] = video_proxy_path(dir_index, file_dir_rel, name)
files.append(entry)
except OSError:
pass
self.send_json({"status": "ok", "files": files})
def api_upload_video(self) -> None:
qs = parse_qs(urlparse(self.path).query)
filename = (qs.get("filename", [""])[0] or "").strip()
if not filename or "/" in filename or "\\" in filename or ".." in filename:
raise AppError("invalid_filename")
if os.path.splitext(filename)[1].lower() not in VIDEO_EXTS:
raise AppError("invalid_file_type")
content_length = int(self.headers.get("Content-Length", "0") or "0")
os.makedirs(UPLOAD_DIR, exist_ok=True)
dest = os.path.join(UPLOAD_DIR, filename)
with open(dest, "wb") as f:
remaining = content_length
while remaining > 0:
chunk = self.rfile.read(min(remaining, 65536))
if not chunk:
break
f.write(chunk)
remaining -= len(chunk)
self.send_json({"status": "ok", "filename": filename})
def api_delete_video(self) -> None:
body = self.read_json()
dir_index = int(body.get("dir_index", -1))
filename = (body.get("filename") or "").strip()
rel_path = (body.get("rel_path") or "").strip()
if dir_index < 0 or dir_index >= len(VIDEOS_DIRS):
raise AppError("invalid_filename")
if not filename or "/" in filename or "\\" in filename or ".." in filename:
raise AppError("invalid_filename")
if rel_path and (".." in rel_path.split("/") or rel_path.startswith("/") or os.path.isabs(rel_path)):
raise AppError("invalid_rel_path")
path = os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path, filename) if rel_path else os.path.join(VIDEOS_DIRS[dir_index]["path"], filename)
if not os.path.isfile(path):
raise AppError("file_not_found")
os.remove(path)
self.send_json({"status": "ok"})
def api_start_push(self) -> None:
body = self.read_json()
dir_index = int(body.get("dir_index", -1))
filename = (body.get("filename") or "").strip()
stream_key = (body.get("stream_key") or "").strip()
loop = bool(body.get("loop", False))
rel_path = (body.get("rel_path") or "").strip()
if dir_index < 0 or dir_index >= len(VIDEOS_DIRS):
raise AppError("invalid_filename")
is_folder = not filename
if not is_folder and ("/" in filename or "\\" in filename or ".." in filename):
raise AppError("invalid_filename")
if rel_path and (".." in rel_path.split("/") or rel_path.startswith("/") or os.path.isabs(rel_path)):
raise AppError("invalid_rel_path")
if not stream_key:
raise AppError("stream_key_required")
playlist_path: str | None = None
if is_folder:
folder = os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path) if rel_path else VIDEOS_DIRS[dir_index]["path"]
if not os.path.isdir(folder):
raise AppError("file_not_found")
video_files = sorted([
f for f in os.listdir(folder)
if not f.startswith((".", "@", "#"))
and os.path.isfile(os.path.join(folder, f))
and os.path.splitext(f)[1].lower() in VIDEO_EXTS
])
if not video_files:
raise AppError("file_not_found")
fd, playlist_path = tempfile.mkstemp(suffix=".txt", prefix="sh_concat_")
with os.fdopen(fd, "w") as pf:
for vf in video_files:
pf.write(f"file '{os.path.join(folder, vf)}'\n")
display_name = rel_path.split("/")[-1] if rel_path else VIDEOS_DIRS[dir_index]["label"]
cmd = ["ffmpeg", "-re"]
if loop:
cmd += ["-stream_loop", "-1"]
cmd += ["-f", "concat", "-safe", "0", "-i", playlist_path, "-c", "copy", "-f", "flv",
f"rtmp://{RTMP_HOST}:1935/live/{stream_key}"]
else:
filepath = os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path, filename) if rel_path else os.path.join(VIDEOS_DIRS[dir_index]["path"], filename)
if not os.path.isfile(filepath):
raise AppError("file_not_found")
display_name = filename
cmd = ["ffmpeg", "-re"]
if loop:
cmd += ["-stream_loop", "-1"]
cmd += ["-i", filepath, "-c", "copy", "-f", "flv",
f"rtmp://{RTMP_HOST}:1935/live/{stream_key}"]
with _pushes_lock:
for job in active_pushes.values():
if job["stream_key"] == stream_key:
if playlist_path:
try:
os.unlink(playlist_path)
except OSError:
pass
raise AppError("push_already_running")
slug = obs_route_slug(stream_key)
with db() as conn:
res = conn.execute(
"INSERT INTO obs_stream_routes (stream_key, public_slug, created_at) VALUES (?, ?, ?) ON CONFLICT (stream_key) DO NOTHING",
(stream_key, slug, now()),
)
push_created_route = res.rowcount == 1
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
job_id = uuid.uuid4().hex[:8]
started = now()
with _pushes_lock:
active_pushes[job_id] = {
"proc": proc,
"filename": display_name,
"stream_key": stream_key,
"hls_slug": slug,
"push_created_route": push_created_route,
"loop": loop,
"started_at": started,
"dir_index": dir_index,
"push_rel_path": rel_path,
"is_folder": is_folder,
}
try:
with db() as conn:
conn.execute(
"INSERT INTO push_jobs (job_id, dir_index, rel_path, filename, stream_key, hls_slug, loop, is_folder, started_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(job_id, dir_index, rel_path, display_name, stream_key, slug, int(loop), int(is_folder), started),
)
except Exception:
pass
def _monitor(jid: str, stream_key: str = stream_key, created: bool = push_created_route, pfile: str | None = playlist_path) -> None:
proc.wait()
with _pushes_lock:
active_pushes.pop(jid, None)
if pfile:
try:
os.unlink(pfile)
except OSError:
pass
try:
with db() as conn:
conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,))
if created:
conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (stream_key,))
except Exception:
pass
threading.Thread(target=_monitor, args=(job_id,), daemon=True).start()
self.send_json({"status": "ok", "job_id": job_id, "hls_slug": slug})
def api_stop_push(self) -> None:
body = self.read_json()
job_id = (body.get("job_id") or "").strip()
with _pushes_lock:
job = active_pushes.get(job_id)
if not job:
raise AppError("push_not_found")
job["proc"].terminate()
self.send_json({"status": "ok"})
def api_list_pushes(self) -> None:
result = []
with _pushes_lock:
for job_id, job in list(active_pushes.items()):
result.append({
"job_id": job_id,
"filename": job["filename"],
"stream_key": job["stream_key"],
"hls_slug": job.get("hls_slug", ""),
"loop": job["loop"],
"elapsed": now() - job["started_at"],
"running": job["proc"].poll() is None,
"dir_index": job.get("dir_index", -1),
"push_rel_path": job.get("push_rel_path", ""),
"is_folder": job.get("is_folder", False),
})
self.send_json({"status": "ok", "pushes": result})
def serve_video_file(self, path_str: str, send_body: bool = True) -> None:
filepath = resolve_video_file_path(path_str)
if filepath is None:
self.send_error(HTTPStatus.NOT_FOUND)
return
filename = os.path.basename(filepath)
file_size = os.path.getsize(filepath)
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
range_header = self.headers.get("Range", "").strip()
if range_header:
m = re.match(r"bytes=(\d+)-(\d*)", range_header)
if not m:
self.send_error(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
return
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else file_size - 1
if start > end or start >= file_size:
self.send_error(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
return
end = min(end, file_size - 1)
length = end - start + 1
self.send_response(HTTPStatus.PARTIAL_CONTENT)
self.send_header("Content-Type", content_type)
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
self.send_header("Content-Length", str(length))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
if send_body:
with open(filepath, "rb") as f:
f.seek(start)
remaining = length
while remaining > 0:
chunk = f.read(min(65536, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
else:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(file_size))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Cache-Control", "no-cache")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
if send_body:
with open(filepath, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
self.wfile.write(chunk)
def serve_static(self, request_path: str, send_body: bool = True) -> None:
routes = {
"/": "index.html",
@@ -2489,8 +2984,107 @@ def cleanup_stale_sessions_loop() -> None:
pass
def resume_push_jobs() -> None:
try:
with db() as conn:
rows = conn.execute("SELECT * FROM push_jobs").fetchall()
except Exception:
return
for row in rows:
job_id = row["job_id"]
try:
dir_index = int(row["dir_index"])
if dir_index < 0 or dir_index >= len(VIDEOS_DIRS):
raise ValueError("invalid dir_index")
rel_path = row["rel_path"] or ""
filename = row["filename"] or ""
stream_key = row["stream_key"]
hls_slug = row["hls_slug"] or obs_route_slug(stream_key)
loop = bool(row["loop"])
is_folder = bool(row["is_folder"])
playlist_path: str | None = None
if is_folder:
folder = os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path) if rel_path else VIDEOS_DIRS[dir_index]["path"]
if not os.path.isdir(folder):
raise FileNotFoundError(folder)
video_files = sorted([
f for f in os.listdir(folder)
if not f.startswith((".", "@", "#"))
and os.path.isfile(os.path.join(folder, f))
and os.path.splitext(f)[1].lower() in VIDEO_EXTS
])
if not video_files:
raise FileNotFoundError(folder)
fd, playlist_path = tempfile.mkstemp(suffix=".txt", prefix="sh_concat_")
with os.fdopen(fd, "w") as pf:
for vf in video_files:
pf.write(f"file '{os.path.join(folder, vf)}'\n")
display_name = rel_path.split("/")[-1] if rel_path else VIDEOS_DIRS[dir_index]["label"]
cmd = ["ffmpeg", "-re"]
if loop:
cmd += ["-stream_loop", "-1"]
cmd += ["-f", "concat", "-safe", "0", "-i", playlist_path, "-c", "copy", "-f", "flv",
f"rtmp://{RTMP_HOST}:1935/live/{stream_key}"]
else:
filepath = (
os.path.join(VIDEOS_DIRS[dir_index]["path"], rel_path, filename)
if rel_path
else os.path.join(VIDEOS_DIRS[dir_index]["path"], filename)
)
if not os.path.isfile(filepath):
raise FileNotFoundError(filepath)
display_name = filename
cmd = ["ffmpeg", "-re"]
if loop:
cmd += ["-stream_loop", "-1"]
cmd += ["-i", filepath, "-c", "copy", "-f", "flv",
f"rtmp://{RTMP_HOST}:1935/live/{stream_key}"]
with db() as conn:
conn.execute(
"INSERT INTO obs_stream_routes (stream_key, public_slug, created_at) VALUES (?, ?, ?) ON CONFLICT (stream_key) DO NOTHING",
(stream_key, hls_slug, now()),
)
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
with _pushes_lock:
active_pushes[job_id] = {
"proc": proc,
"filename": display_name,
"stream_key": stream_key,
"hls_slug": hls_slug,
"push_created_route": True,
"loop": loop,
"started_at": now(),
"dir_index": dir_index,
"push_rel_path": rel_path,
"is_folder": is_folder,
}
def _monitor(jid: str = job_id, sk: str = stream_key, pfile: str | None = playlist_path) -> None:
proc.wait()
with _pushes_lock:
active_pushes.pop(jid, None)
if pfile:
try:
os.unlink(pfile)
except OSError:
pass
try:
with db() as conn:
conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (jid,))
conn.execute("DELETE FROM obs_stream_routes WHERE stream_key = ?", (sk,))
except Exception:
pass
threading.Thread(target=_monitor, daemon=True).start()
except Exception:
try:
with db() as conn:
conn.execute("DELETE FROM push_jobs WHERE job_id = ?", (job_id,))
except Exception:
pass
def main() -> None:
init_db()
resume_push_jobs()
threading.Thread(target=monitor_streams_loop, daemon=True).start()
threading.Thread(target=cleanup_stale_sessions_loop, daemon=True).start()
host = os.getenv("HOST", "0.0.0.0")