Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 601eb0247f | |||
| 34de0bdef4 | |||
| 90fe42a81a | |||
| b39533c5b6 | |||
| 5991f39c4f | |||
| 38cd9dbeaf | |||
| dc949bdeab |
@@ -18,11 +18,12 @@ jobs:
|
||||
- name: Log in to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.CR_TOKEN }}" | \
|
||||
docker login git.stdm.moe -u ${{ github.actor }} --password-stdin
|
||||
docker login 127.0.0.1:3333 -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
IMAGE=git.stdm.moe/stardream/streamhall
|
||||
IMAGE=127.0.0.1:3333/stardream/streamhall
|
||||
PUBLIC=git.stdm.moe/stardream/streamhall
|
||||
REF="${{ github.ref }}"
|
||||
|
||||
if [[ "$REF" == refs/tags/* ]]; then
|
||||
|
||||
@@ -4,3 +4,8 @@ __pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.DS_Store
|
||||
CHANGELOG.md
|
||||
CODEX_CHANGELOG.md
|
||||
CODEX_TODO.md
|
||||
CODEX_REVIEW.md
|
||||
AGENTS.md
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
### 统计接口(需鉴权)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1954
-64
File diff suppressed because it is too large
Load Diff
+175
-1
@@ -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,39 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
#sh-load-more-wrap {
|
||||
text-align: center;
|
||||
padding: 16px 0 24px;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
#sh-load-more-wrap.lm-hidden {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
pointer-events: none;
|
||||
}
|
||||
#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 +743,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" class="lm-hidden">
|
||||
<button id="sh-load-more-btn"></button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
@@ -693,6 +778,7 @@
|
||||
// empty states
|
||||
'empty.live': '当前没有可用的直播',
|
||||
'empty.archive': '当前没有可用的存档',
|
||||
'list.load_more': '加载更多',
|
||||
// error
|
||||
'err.load': '加载失败',
|
||||
'err.fetch': '获取直播列表失败',
|
||||
@@ -723,6 +809,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 +1076,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 +1103,12 @@
|
||||
if (!streams.length) {
|
||||
errorMessage.textContent = emptyText(activeStreamLabel);
|
||||
errorMessage.classList.remove('hidden');
|
||||
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
|
||||
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 +1126,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.classList.remove('lm-hidden');
|
||||
} else {
|
||||
lmWrap.classList.add('lm-hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
streamSwitchButtons.forEach(btn => {
|
||||
@@ -1033,13 +1145,27 @@
|
||||
activeStreamLabel = nextLabel;
|
||||
localStorage.setItem('home_stream_label', activeStreamLabel);
|
||||
streamList.classList.add('is-switching');
|
||||
document.getElementById('sh-load-more-wrap')?.classList.add('lm-hidden');
|
||||
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 +1191,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>
|
||||
|
||||
+313
-3
@@ -8,6 +8,7 @@
|
||||
<script src="/vendor/hls.min.js"></script>
|
||||
<script src="/vendor/flv.min.js"></script>
|
||||
<script src="/vendor/dash.all.min.js"></script>
|
||||
<script src="/vendor/shaka-player.compiled.js"></script>
|
||||
<script src="/vendor/artplayer.js"></script>
|
||||
<script src="/vendor/artplayer-plugin-hls-control.js"></script>
|
||||
<style>
|
||||
@@ -181,6 +182,11 @@
|
||||
flv_unsupported: '当前浏览器不支持 FLV 播放',
|
||||
hls_unsupported: '当前浏览器不支持 HLS 播放',
|
||||
dash_unavailable: 'DASH 播放组件未加载',
|
||||
drm_unavailable: '当前浏览器不支持此 DRM 播放',
|
||||
drm_license_missing:'未配置 DRM License URL',
|
||||
drm_certificate_missing:'未配置 FairPlay Certificate URL',
|
||||
drm_playback_error:'DRM 授权播放失败',
|
||||
drm_android_webview_unsupported:'当前内置浏览器不支持此 DRM 播放,请使用系统浏览器打开',
|
||||
quality: '画质',
|
||||
// API error codes from server
|
||||
'err.stream_not_found': '直播不存在',
|
||||
@@ -209,6 +215,11 @@
|
||||
flv_unsupported: 'FLV playback is not supported in this browser',
|
||||
hls_unsupported: 'HLS playback is not supported in this browser',
|
||||
dash_unavailable: 'DASH player component not loaded',
|
||||
drm_unavailable: 'This browser does not support the configured DRM playback',
|
||||
drm_license_missing:'DRM License URL is not configured',
|
||||
drm_certificate_missing:'FairPlay Certificate URL is not configured',
|
||||
drm_playback_error:'DRM license playback failed',
|
||||
drm_android_webview_unsupported:'This in-app browser does not support this DRM playback. Open it in your system browser.',
|
||||
quality: 'Quality',
|
||||
// API error codes from server
|
||||
'err.stream_not_found': 'Stream not found',
|
||||
@@ -222,7 +233,7 @@
|
||||
},
|
||||
};
|
||||
const PLAYER_LANG = (localStorage.getItem('lang_pref') || 'zh') === 'en' ? 'en' : 'zh';
|
||||
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || key;
|
||||
const t = key => (PLAYER_I18N[PLAYER_LANG] || PLAYER_I18N.zh)[key] || PLAYER_I18N.en[key] || key;
|
||||
|
||||
// Initialise static text in the HTML that cannot use data-i18n.
|
||||
document.getElementById('msg').textContent = t('loading');
|
||||
@@ -324,6 +335,196 @@
|
||||
return Object.keys(clearkeys).length ? { 'org.w3.clearkey': { clearkeys } } : null;
|
||||
}
|
||||
|
||||
function parseHeaderConfig(input) {
|
||||
const text = String(input || '').trim();
|
||||
if (!text) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed).reduce((acc, [key, value]) => {
|
||||
if (key && value !== undefined && value !== null) acc[key] = String(value);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (e) {}
|
||||
return text.split(/\n+/).reduce((acc, line) => {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > 0) {
|
||||
const key = line.slice(0, idx).trim();
|
||||
const value = line.slice(idx + 1).trim();
|
||||
if (key && value) acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function getFairPlayContentId(initData) {
|
||||
const text = shaka.util.StringUtils.fromBytesAutoDetect(initData);
|
||||
return shaka.util.FairPlayUtils.defaultGetContentId(text);
|
||||
}
|
||||
|
||||
function transformFairPlayInitData(initData, initDataType, drmInfo) {
|
||||
if (initDataType !== 'skd') return initData;
|
||||
const contentId = getFairPlayContentId(initData);
|
||||
return shaka.util.FairPlayUtils.initDataTransform(initData, contentId, drmInfo.serverCertificate);
|
||||
}
|
||||
|
||||
function getDrmConfigs(link) {
|
||||
const configs = Array.isArray(link?.drmConfigs) ? link.drmConfigs : Array.isArray(link?.drm_configs) ? link.drm_configs : [];
|
||||
const normalized = configs.map(config => ({
|
||||
drmType: String(config?.drmType || config?.drm_type || '').toLowerCase(),
|
||||
licenseUrl: String(config?.licenseUrl || config?.license_url || '').trim(),
|
||||
certificateUrl: String(config?.certificateUrl || config?.certificate_url || '').trim(),
|
||||
licenseHeaders: String(config?.licenseHeaders || config?.license_headers || '').trim(),
|
||||
pssh: String(config?.pssh || '').trim()
|
||||
})).filter(config => (config.drmType === 'widevine' || config.drmType === 'fairplay') && config.licenseUrl);
|
||||
if (normalized.length) return normalized;
|
||||
const legacyType = String(link?.drmType || link?.drm_type || '').toLowerCase();
|
||||
const legacyLicense = String(link?.licenseUrl || link?.license_url || '').trim();
|
||||
if ((legacyType === 'widevine' || legacyType === 'fairplay') && legacyLicense) {
|
||||
return [{
|
||||
drmType: legacyType,
|
||||
licenseUrl: legacyLicense,
|
||||
certificateUrl: String(link?.certificateUrl || link?.certificate_url || '').trim(),
|
||||
licenseHeaders: String(link?.licenseHeaders || link?.license_headers || '').trim(),
|
||||
pssh: String(link?.pssh || '').trim()
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function browserPrefersFairPlay() {
|
||||
const ua = navigator.userAgent || '';
|
||||
const isSafari = /Safari/i.test(ua) && !/(Chrome|Chromium|CriOS|FxiOS|Edg|OPR|OPiOS)/i.test(ua);
|
||||
return !!window.WebKitMediaKeys || isSafari;
|
||||
}
|
||||
|
||||
function isAndroidRestrictedWebView() {
|
||||
const ua = navigator.userAgent || '';
|
||||
if (!/Android/i.test(ua)) return false;
|
||||
return /Telegram/i.test(ua) || /; wv\)/i.test(ua) || /\bwv\b/i.test(ua);
|
||||
}
|
||||
|
||||
function selectDrmConfig(link) {
|
||||
const configs = getDrmConfigs(link);
|
||||
if (!configs.length) return null;
|
||||
if (browserPrefersFairPlay()) {
|
||||
return configs.find(config => config.drmType === 'fairplay' && config.certificateUrl) || configs.find(config => config.drmType === 'fairplay') || null;
|
||||
}
|
||||
return configs.find(config => config.drmType === 'widevine') || null;
|
||||
}
|
||||
|
||||
function linkUsesWidevine(link) {
|
||||
return selectDrmConfig(link)?.drmType === 'widevine';
|
||||
}
|
||||
|
||||
function linkUsesFairPlay(link) {
|
||||
return selectDrmConfig(link)?.drmType === 'fairplay';
|
||||
}
|
||||
|
||||
function linkUsesShakaDrm(link) {
|
||||
return !!selectDrmConfig(link);
|
||||
}
|
||||
|
||||
function linkHasDrmConfigs(link) {
|
||||
return getDrmConfigs(link).length > 0;
|
||||
}
|
||||
|
||||
function formatShakaError(error) {
|
||||
if (!error) return t('drm_playback_error');
|
||||
if (error.code === 6001 && isAndroidRestrictedWebView()) {
|
||||
return t('drm_android_webview_unsupported');
|
||||
}
|
||||
const parts = [];
|
||||
if (error.code !== undefined) parts.push(`code ${error.code}`);
|
||||
if (error.category !== undefined) parts.push(`category ${error.category}`);
|
||||
if (error.severity !== undefined) parts.push(`severity ${error.severity}`);
|
||||
return parts.length ? `${t('drm_playback_error')} (${parts.join(', ')})` : t('drm_playback_error');
|
||||
}
|
||||
|
||||
function installLiveDurationGuard(art) {
|
||||
const root = art?.template?.$player;
|
||||
if (!root) return () => {};
|
||||
const fix = () => {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const nodes = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||
nodes.forEach(node => {
|
||||
const text = node.nodeValue || '';
|
||||
if (!text.includes(' / ')) return;
|
||||
const fixed = text.replace(/\s+\/\s+\d{4,}:\d{2}:\d{2}/g, '');
|
||||
if (fixed !== text) node.nodeValue = fixed;
|
||||
});
|
||||
};
|
||||
const timer = window.setInterval(fix, 500);
|
||||
fix();
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
|
||||
function installShakaQualityControl(art, player) {
|
||||
const update = () => {
|
||||
const tracks = player.getVariantTracks()
|
||||
.filter(track => track.videoId !== null && track.videoId !== undefined)
|
||||
.sort((a, b) => (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0));
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
tracks.forEach(track => {
|
||||
const key = `${track.height || 0}-${Math.round((track.bandwidth || 0) / 1000)}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
unique.push(track);
|
||||
});
|
||||
if (!unique.length) return;
|
||||
const active = unique.find(track => track.active) || tracks.find(track => track.active);
|
||||
const autoLabel = 'Auto';
|
||||
const selectedLabel = active?.height ? `${active.height}P` : autoLabel;
|
||||
const selector = unique.map(track => ({
|
||||
html: track.height ? `${track.height}P` : `${Math.round((track.bandwidth || 0) / 1000)}K`,
|
||||
value: track.id,
|
||||
default: !!track.active
|
||||
}));
|
||||
selector.push({
|
||||
html: autoLabel,
|
||||
value: 'auto',
|
||||
default: !active
|
||||
});
|
||||
const onSelect = item => {
|
||||
if (item.value === 'auto') {
|
||||
player.configure({ abr: { enabled: true } });
|
||||
} else {
|
||||
const selected = player.getVariantTracks().find(track => String(track.id) === String(item.value));
|
||||
if (selected) {
|
||||
player.configure({ abr: { enabled: false } });
|
||||
player.selectVariantTrack(selected, true);
|
||||
}
|
||||
}
|
||||
window.setTimeout(update, 150);
|
||||
return item.html;
|
||||
};
|
||||
art.setting.update({
|
||||
name: 'shaka-quality',
|
||||
html: t('quality'),
|
||||
tooltip: selectedLabel,
|
||||
width: 200,
|
||||
selector,
|
||||
onSelect
|
||||
});
|
||||
art.controls.update({
|
||||
name: 'shaka-quality',
|
||||
position: 'right',
|
||||
index: 11,
|
||||
html: selectedLabel,
|
||||
style: { padding: '0 10px', marginRight: '10px' },
|
||||
selector,
|
||||
onSelect
|
||||
});
|
||||
};
|
||||
player.addEventListener('variantchanged', update);
|
||||
window.setTimeout(update, 0);
|
||||
window.setTimeout(update, 800);
|
||||
return update;
|
||||
}
|
||||
|
||||
function getLinkType(link) {
|
||||
if (link.type) return link.type;
|
||||
const path = (link.url || '').split('?')[0].toLowerCase();
|
||||
@@ -604,6 +805,7 @@
|
||||
url: getPlaybackUrl(l),
|
||||
type: getLinkType(l)
|
||||
}));
|
||||
const initialLink = activeLinks[0] || null;
|
||||
|
||||
playerInstance = new Artplayer({
|
||||
container: '.artplayer-app',
|
||||
@@ -612,6 +814,7 @@
|
||||
quality: quality,
|
||||
title: data.eventName,
|
||||
autoplay: true,
|
||||
isLive: linkUsesShakaDrm(initialLink),
|
||||
volume: 0.5,
|
||||
autoSize: true,
|
||||
fullscreen: true,
|
||||
@@ -631,9 +834,116 @@
|
||||
}
|
||||
},
|
||||
m3u8: function (video, url, art) {
|
||||
if (Hls.isSupported()) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
const currentLink = activeLinks.find(l => l.url === url || getPlaybackUrl(l) === url);
|
||||
if (linkHasDrmConfigs(currentLink) && !selectDrmConfig(currentLink)) {
|
||||
art.notice.show = t('drm_unavailable');
|
||||
return;
|
||||
}
|
||||
if (linkUsesShakaDrm(currentLink)) {
|
||||
if (art.hls) art.hls.destroy();
|
||||
if (window.shaka?.polyfill) shaka.polyfill.installAll();
|
||||
if (!window.shaka || !shaka.Player || !shaka.Player.isBrowserSupported()) {
|
||||
art.notice.show = t('drm_unavailable');
|
||||
return;
|
||||
}
|
||||
const selectedDrm = selectDrmConfig(currentLink);
|
||||
if (selectedDrm?.drmType === 'widevine' && isAndroidRestrictedWebView()) {
|
||||
art.notice.show = t('drm_android_webview_unsupported');
|
||||
return;
|
||||
}
|
||||
const licenseUrl = String(selectedDrm?.licenseUrl || '').trim();
|
||||
if (!licenseUrl) {
|
||||
art.notice.show = t('drm_license_missing');
|
||||
return;
|
||||
}
|
||||
if (art.shaka) art.shaka.destroy().catch(() => {});
|
||||
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||
art.streamhallDurationGuard = installLiveDurationGuard(art);
|
||||
const player = new shaka.Player();
|
||||
const headers = parseHeaderConfig(selectedDrm?.licenseHeaders || '');
|
||||
const fairplay = selectedDrm?.drmType === 'fairplay';
|
||||
const keySystem = fairplay ? 'com.apple.fps' : 'com.widevine.alpha';
|
||||
const fairplayLegacyKeySystem = 'com.apple.fps.1_0';
|
||||
const certificateUrl = String(selectedDrm?.certificateUrl || '').trim();
|
||||
const servers = { [keySystem]: licenseUrl };
|
||||
const advanced = {};
|
||||
if (fairplay) {
|
||||
servers[fairplayLegacyKeySystem] = licenseUrl;
|
||||
advanced[keySystem] = {
|
||||
serverCertificateUri: certificateUrl,
|
||||
audioRobustness: '',
|
||||
videoRobustness: ''
|
||||
};
|
||||
advanced[fairplayLegacyKeySystem] = {
|
||||
serverCertificateUri: certificateUrl,
|
||||
audioRobustness: '',
|
||||
videoRobustness: ''
|
||||
};
|
||||
} else {
|
||||
advanced[keySystem] = {
|
||||
audioRobustness: '',
|
||||
videoRobustness: '',
|
||||
persistentStateRequired: false,
|
||||
distinctiveIdentifierRequired: false
|
||||
};
|
||||
}
|
||||
const drmConfig = {
|
||||
preferredKeySystems: fairplay ? [keySystem, fairplayLegacyKeySystem] : [keySystem],
|
||||
servers,
|
||||
advanced
|
||||
};
|
||||
if (fairplay && !certificateUrl) {
|
||||
art.notice.show = t('drm_certificate_missing');
|
||||
return;
|
||||
}
|
||||
player.configure({
|
||||
drm: {
|
||||
...drmConfig,
|
||||
...(fairplay ? { initDataTransform: transformFairPlayInitData } : {})
|
||||
},
|
||||
...(fairplay ? { streaming: { useNativeHlsForFairPlay: false } } : {})
|
||||
});
|
||||
player.getNetworkingEngine().registerRequestFilter((type, request) => {
|
||||
if (type !== shaka.net.NetworkingEngine.RequestType.LICENSE) return;
|
||||
if (fairplay) {
|
||||
request.headers['Content-Type'] = request.headers['Content-Type'] || 'application/octet-stream';
|
||||
}
|
||||
if (Object.keys(headers).length) {
|
||||
Object.assign(request.headers, headers);
|
||||
}
|
||||
});
|
||||
if (fairplay) {
|
||||
player.getNetworkingEngine().registerResponseFilter((type, response) => {
|
||||
shaka.util.FairPlayUtils.commonFairPlayResponse(type, response);
|
||||
});
|
||||
}
|
||||
player.addEventListener('error', event => {
|
||||
console.error('Shaka DRM Error:', event.detail);
|
||||
art.notice.show = formatShakaError(event.detail);
|
||||
});
|
||||
player.attach(video).then(() => player.load(url)).then(() => {
|
||||
installShakaQualityControl(art, player);
|
||||
}).catch(error => {
|
||||
console.error('Shaka Load Error:', error);
|
||||
art.notice.show = formatShakaError(error);
|
||||
});
|
||||
art.shaka = player;
|
||||
art.on('destroy', () => {
|
||||
if (art.streamhallDurationGuard) art.streamhallDurationGuard();
|
||||
player.destroy().catch(() => {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Hls.isSupported()) {
|
||||
if (art.shaka) {
|
||||
art.shaka.destroy().catch(() => {});
|
||||
art.shaka = null;
|
||||
}
|
||||
if (art.streamhallDurationGuard) {
|
||||
art.streamhallDurationGuard();
|
||||
art.streamhallDurationGuard = null;
|
||||
}
|
||||
if (art.hls) art.hls.destroy();
|
||||
const keyOverride = currentLink ? parseHlsKeyOverride(currentLink.key) : null;
|
||||
|
||||
class CustomLoader extends Hls.DefaultConfig.loader {
|
||||
|
||||
+1454
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import socket
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
@@ -11,8 +12,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 +64,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 +102,8 @@ DEFAULT_SITE_SETTINGS = {
|
||||
"footer_markdown": "",
|
||||
"footer_markdown_en": "",
|
||||
"telegram_public_base_url": "",
|
||||
"obs_rtmp_host": "",
|
||||
"obs_playback_origin": "",
|
||||
}
|
||||
|
||||
DEFAULT_TELEGRAM_SETTINGS = {
|
||||
@@ -228,6 +259,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 +431,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()
|
||||
@@ -438,7 +514,48 @@ def admin_session_not_before() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def normalize_links(raw: object) -> list[dict[str, str]]:
|
||||
def normalize_drm_configs(item: dict[str, object]) -> list[dict[str, str]]:
|
||||
raw_configs = item.get("drmConfigs", item.get("drm_configs", []))
|
||||
configs = raw_configs if isinstance(raw_configs, list) else []
|
||||
normalized: list[dict[str, str]] = []
|
||||
|
||||
for config in configs:
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
|
||||
if drm_type not in ("widevine", "fairplay"):
|
||||
continue
|
||||
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
|
||||
if not license_url:
|
||||
continue
|
||||
normalized.append(
|
||||
{
|
||||
"drmType": drm_type,
|
||||
"licenseUrl": license_url,
|
||||
"certificateUrl": str(config.get("certificateUrl", config.get("certificate_url", ""))).strip(),
|
||||
"licenseHeaders": str(config.get("licenseHeaders", config.get("license_headers", ""))).strip(),
|
||||
"pssh": str(config.get("pssh", "")).strip(),
|
||||
}
|
||||
)
|
||||
|
||||
legacy_type = str(item.get("drmType", item.get("drm_type", ""))).strip().lower()
|
||||
legacy_license = str(item.get("licenseUrl", item.get("license_url", ""))).strip()
|
||||
if legacy_type in ("widevine", "fairplay") and legacy_license:
|
||||
has_legacy = any(config["drmType"] == legacy_type for config in normalized)
|
||||
if not has_legacy:
|
||||
normalized.append(
|
||||
{
|
||||
"drmType": legacy_type,
|
||||
"licenseUrl": legacy_license,
|
||||
"certificateUrl": str(item.get("certificateUrl", item.get("certificate_url", ""))).strip(),
|
||||
"licenseHeaders": str(item.get("licenseHeaders", item.get("license_headers", ""))).strip(),
|
||||
"pssh": str(item.get("pssh", "")).strip(),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_links(raw: object) -> list[dict[str, object]]:
|
||||
links = raw if isinstance(raw, list) else []
|
||||
normalized = []
|
||||
for item in links:
|
||||
@@ -451,6 +568,8 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
|
||||
link_type = str(item.get("type", "")).strip().lower()
|
||||
if link_type not in ("", "m3u8", "flv", "dash"):
|
||||
link_type = ""
|
||||
drm_configs = normalize_drm_configs(item)
|
||||
first_drm = drm_configs[0] if drm_configs else {}
|
||||
normalized.append(
|
||||
{
|
||||
"name": name,
|
||||
@@ -458,6 +577,12 @@ def normalize_links(raw: object) -> list[dict[str, str]]:
|
||||
"url": url,
|
||||
"key": str(item.get("key", "")).strip(),
|
||||
"clearkey": str(item.get("clearkey", "")).strip(),
|
||||
"drmConfigs": drm_configs,
|
||||
"drmType": str(first_drm.get("drmType", "")),
|
||||
"licenseUrl": str(first_drm.get("licenseUrl", "")),
|
||||
"certificateUrl": str(first_drm.get("certificateUrl", "")),
|
||||
"licenseHeaders": str(first_drm.get("licenseHeaders", "")),
|
||||
"pssh": str(first_drm.get("pssh", "")),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
@@ -551,6 +676,23 @@ def stream_probe_response(valid: bool, status_code: int | None = None) -> dict[s
|
||||
}
|
||||
|
||||
|
||||
def stream_probe_drm_response(
|
||||
valid: bool,
|
||||
status_code: int | None,
|
||||
drm_types: set[str],
|
||||
configured_types: set[str],
|
||||
) -> dict[str, object]:
|
||||
missing = bool(drm_types) and not bool(drm_types & configured_types)
|
||||
return {
|
||||
"valid": valid and not missing,
|
||||
"code": "drm_config_missing" if missing else ("detected" if valid else "no_info"),
|
||||
"status_code": status_code,
|
||||
"drm_detected": bool(drm_types),
|
||||
"drm_types": sorted(drm_types),
|
||||
"drm_configured": sorted(configured_types),
|
||||
}
|
||||
|
||||
|
||||
def decode_probe_text(data: bytes) -> str:
|
||||
for encoding in ("utf-8-sig", "utf-8", "latin-1"):
|
||||
try:
|
||||
@@ -560,11 +702,79 @@ def decode_probe_text(data: bytes) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, object]:
|
||||
def hls_manifest_drm_types(text: str) -> set[str]:
|
||||
lower = text.lower()
|
||||
types: set[str] = set()
|
||||
if "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" in lower or "com.widevine" in lower:
|
||||
types.add("widevine")
|
||||
if "com.apple.streamingkeydelivery" in lower or "skd://" in lower:
|
||||
types.add("fairplay")
|
||||
return types
|
||||
|
||||
|
||||
def drm_config_types(configs: object) -> set[str]:
|
||||
normalized: set[str] = set()
|
||||
if not isinstance(configs, list):
|
||||
return normalized
|
||||
for config in configs:
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
drm_type = str(config.get("drmType", config.get("drm_type", ""))).strip().lower()
|
||||
license_url = str(config.get("licenseUrl", config.get("license_url", ""))).strip()
|
||||
certificate_url = str(config.get("certificateUrl", config.get("certificate_url", ""))).strip()
|
||||
if drm_type == "widevine" and license_url:
|
||||
normalized.add("widevine")
|
||||
if drm_type == "fairplay" and license_url and certificate_url:
|
||||
normalized.add("fairplay")
|
||||
return normalized
|
||||
|
||||
|
||||
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 = "",
|
||||
drm_configs: object | None = None,
|
||||
) -> 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)
|
||||
@@ -602,7 +812,9 @@ def probe_stream_url(raw_url: object, type_hint: object = "") -> dict[str, objec
|
||||
marker in text
|
||||
for marker in ("#EXTINF", "#EXT-X-STREAM-INF", "#EXT-X-MEDIA-SEQUENCE", "#EXT-X-PART")
|
||||
)
|
||||
return stream_probe_response(has_playlist and has_live_media, status_code)
|
||||
drm_types = hls_manifest_drm_types(text)
|
||||
configured_types = drm_config_types(drm_configs)
|
||||
return stream_probe_drm_response(has_playlist and has_live_media, status_code, drm_types, configured_types)
|
||||
|
||||
if is_dash or "dash+xml" in content_type:
|
||||
return stream_probe_response(b"<MPD" in data[:2048], status_code)
|
||||
@@ -866,7 +1078,7 @@ def probe_stream_links(row: dict[str, object]) -> dict[str, object]:
|
||||
except json.JSONDecodeError:
|
||||
links = []
|
||||
for index, link in enumerate(links):
|
||||
result = probe_stream_url(link["url"], link.get("type", ""))
|
||||
result = probe_stream_url(link["url"], link.get("type", ""), link.get("drmConfigs", []))
|
||||
if result["valid"]:
|
||||
return {
|
||||
**result,
|
||||
@@ -1039,7 +1251,7 @@ def rewrite_external_hls_manifest(manifest: str, base_url: str) -> str:
|
||||
# attributes such as EXT-X-KEY) to route through the signed /proxy/hls/
|
||||
# endpoint, enabling cross-origin playback and key override in the player.
|
||||
def proxied_uri(value: str) -> str:
|
||||
if not value or value.startswith("data:"):
|
||||
if not value or value.startswith(("data:", "skd:")):
|
||||
return value
|
||||
return hls_proxy_path(urljoin(base_url, value))
|
||||
|
||||
@@ -1085,6 +1297,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 +1313,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 +1463,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 +1514,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 +1800,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,
|
||||
@@ -1732,7 +1977,7 @@ class StreamHallHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def api_check_stream_url(self) -> None:
|
||||
body = self.read_json()
|
||||
result = probe_stream_url(body.get("url", ""), body.get("type", ""))
|
||||
result = probe_stream_url(body.get("url", ""), body.get("type", ""), body.get("drmConfigs", body.get("drm_configs", [])))
|
||||
self.send_json({"status": "success", "data": result})
|
||||
|
||||
def api_check_stream(self) -> None:
|
||||
@@ -1746,6 +1991,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 +2705,336 @@ 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,
|
||||
"user_stopped": False,
|
||||
}
|
||||
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, lp: bool = loop) -> None:
|
||||
rc = proc.wait()
|
||||
with _pushes_lock:
|
||||
job_meta = active_pushes.pop(jid, {})
|
||||
user_stopped = job_meta.get("user_stopped", False)
|
||||
if pfile:
|
||||
try:
|
||||
os.unlink(pfile)
|
||||
except OSError:
|
||||
pass
|
||||
if user_stopped or (rc == 0 and not lp):
|
||||
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["user_stopped"] = True
|
||||
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 +3088,123 @@ def cleanup_stale_sessions_loop() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _wait_for_rtmp(host: str, port: int = 1935, timeout: float = 60.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
def resume_push_jobs() -> None:
|
||||
if not _wait_for_rtmp(RTMP_HOST):
|
||||
return
|
||||
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,
|
||||
"user_stopped": False,
|
||||
}
|
||||
def _monitor(jid: str = job_id, sk: str = stream_key, pfile: str | None = playlist_path, lp: bool = loop) -> None:
|
||||
rc = proc.wait()
|
||||
with _pushes_lock:
|
||||
job_meta = active_pushes.pop(jid, {})
|
||||
user_stopped = job_meta.get("user_stopped", False)
|
||||
if pfile:
|
||||
try:
|
||||
os.unlink(pfile)
|
||||
except OSError:
|
||||
pass
|
||||
if user_stopped or (rc == 0 and not lp):
|
||||
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()
|
||||
threading.Thread(target=resume_push_jobs, daemon=True).start()
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user