From dc949bdeab7465539b2626a168e55e524cbbca39 Mon Sep 17 00:00:00 2001 From: Stardream Date: Fri, 22 May 2026 21:10:33 +1000 Subject: [PATCH] feat: local push file browser, VOD serving, and admin UX overhaul 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/ 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// 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 --- .gitignore | 2 + Dockerfile | 6 +- README.md | 51 +- README.zh-CN.md | 51 +- docker-compose.yml | 8 + public/admin.html | 1696 ++++++++++++++++++++++++++++++++++++++++++-- public/index.html | 169 ++++- server.py | 598 +++++++++++++++- 8 files changed, 2509 insertions(+), 72 deletions(-) diff --git a/.gitignore b/.gitignore index 9112812..8727376 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ *.pyc .env .DS_Store +CHANGELOG.md +AGENTS.md diff --git a/Dockerfile b/Dockerfile index 73b20a4..a58de77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 2c3b6bc..2e5323f 100644 --- a/README.md +++ b/README.md @@ -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/`) 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: 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. +
[![][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 +``` +
[![][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//...`) 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//...`) 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=` | Player sources and metadata | | `POST` | `/api?action=verify_password` | Verify a stream password | +| `GET` | `/video//` | 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index f897fa0..107bed5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -32,7 +32,8 @@ - **管理后台** - 直播的增删改查、启用/禁用、拖拽排序;多播放源管理 - **观看统计** - 会话追踪、独立访客数、峰值并发、平均时长、设备 / 浏览器 / 操作系统 / 地理分布实时看板,支持 CSV 导出 - **Telegram 推送** - 可按直播单独配置,开播 / 关播自动发送通知 -- **推流配置** - SRS RTMP 推流辅助工具,隐藏 HLS 路由代理(公开地址不暴露真实推流码) +- **推流配置** - 内置文件浏览器,支持单文件和文件夹 RTMP 推流管理;文件夹可同时向多个推流码批量推送独立任务;推流状态内联显示于文件行,详情弹窗提供实时时长、复制地址和停止操作;同时支持远端编码器 RTMP 推流配置;隐藏 HLS 路由代理(`/h/`),真实推流码不出现在公开地址中 +- **VOD 点播 / 视频服务** - 带 HMAC 签名的 `/video/` URL,支持 HTTP Range 请求(可 seek);文件浏览器中可直接将视频文件或文件夹发布为归档直播 - **HLS 代理** - 带签名验证的 `/proxy/hls/` 路由,解决跨域 HLS 播放问题 - **API 密钥鉴权** - 在后台生成 Token,可通过 API 密钥对所有管理及统计接口进行程序化访问 @@ -90,6 +91,17 @@ StreamHall initial admin 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 在下次启动时会重新生成随机密码并打印到日志中。 +
[![][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 +``` +
[![][back-to-top]](#readme-top) @@ -176,7 +216,9 @@ RTMP 服务器: rtmp://HOST:1935/live 推流码: your-stream-key ``` -管理后台的**推流配置**页面可生成隐藏公开 HLS 地址(`/h//...`),通过 Nginx 在 `8889` 端口对外提供访问,真实推流码不会出现在公开 URL 中。 +管理后台的**本地推流**页面提供媒体目录文件浏览器。选择单个视频文件可使用自定义推流码发起 RTMP 推流;选择文件夹可同时为其中所有视频分别分配独立推流码并批量启动。每个文件行内联显示推流状态,点击"编辑推流"可查看实时时长、复制推流/播放地址和停止操作。 + +隐藏公开 HLS 地址(`/h//...`)通过 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=` | 播放器播放源及元数据 | | `POST` | `/api?action=verify_password` | 验证直播访问密码 | +| `GET` | `/video//` | 签名 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 | ### 统计接口(需鉴权) diff --git a/docker-compose.yml b/docker-compose.yml index 5867a42..adfeb0a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/public/admin.html b/public/admin.html index 1c1f30d..5adea75 100644 --- a/public/admin.html +++ b/public/admin.html @@ -42,12 +42,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; } @@ -56,6 +104,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; } @@ -321,7 +370,7 @@ .link-row { display: grid; - grid-template-columns: 1.2fr 0.8fr 1.8fr 1.4fr 1.6fr auto; + grid-template-columns: auto 1.2fr 0.8fr 1.8fr 1.4fr 1.6fr auto; gap: 8px; align-items: start; margin-bottom: 10px; @@ -992,6 +1041,8 @@ .drag-handle:active { cursor:grabbing; } .stream-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; } .stream-row.dragging { opacity:.35; } + .link-row.drag-over { outline:2px solid var(--blue); outline-offset:-2px; border-radius:6px; } + .link-row.dragging { opacity:.35; } .modal-close-btn { background:none; border:none; color:var(--muted); font-size:1.1rem; cursor:pointer; padding:2px 6px; line-height:1; box-shadow:none; transform:none !important; } .modal-close-btn:hover { color:var(--text); filter:none; } .geo-section { border:1px solid var(--line); border-radius:8px; background:var(--panel); backdrop-filter:blur(14px); padding:20px 22px; margin-top:20px; } @@ -1004,6 +1055,20 @@ .geo-bar-fill { height:100%; border-radius:999px; background:var(--blue); opacity:.75; } .sessions-pagination { display:flex; align-items:center; gap:8px; margin-top:10px; flex-wrap:wrap; } .sessions-pagination .page-info { font-size:.82rem; color:var(--muted); font-weight:800; } + /* Toast */ + #sh-toast-wrap { position:fixed; bottom:24px; right:24px; z-index:99999; display:flex; flex-direction:column; align-items:flex-end; gap:8px; pointer-events:none; } + .sh-toast { display:flex; align-items:center; gap:10px; padding:10px 14px; border-radius:8px; min-width:220px; max-width:360px; background:var(--panel); border:1px solid var(--line); box-shadow:0 4px 18px rgba(0,0,0,.18); font-size:.88em; pointer-events:auto; animation:sh-toast-in .22s ease; } + .sh-toast.leaving { animation:sh-toast-out .22s ease forwards; } + .sh-toast-icon { font-size:1em; flex-shrink:0; } + .sh-toast-msg { flex:1; line-height:1.4; word-break:break-word; } + .sh-toast-close { background:none; border:none; cursor:pointer; color:var(--muted); font-size:1em; padding:0 0 0 6px; opacity:.6; flex-shrink:0; } + .sh-toast-close:hover { opacity:1; } + .sh-toast[data-type="success"] { border-left:3px solid var(--mint); } + .sh-toast[data-type="error"] { border-left:3px solid var(--rose); } + .sh-toast[data-type="info"] { border-left:3px solid var(--blue); } + .sh-toast[data-type="warn"] { border-left:3px solid var(--gold); } + @keyframes sh-toast-in { from { opacity:0; transform:translateX(18px); } to { opacity:1; transform:translateX(0); } } + @keyframes sh-toast-out { from { opacity:1; transform:translateX(0); } to { opacity:0; transform:translateX(18px); } } @@ -1035,7 +1100,7 @@
返回首页 - +
@@ -1055,7 +1120,8 @@
- + + @@ -1134,7 +1200,7 @@
+
@@ -1431,6 +1534,28 @@
+ +
+
+
+

Video Push

+

本地推流

+
+ +
+
+
+
+
+
+ 🔍 + +
+
+
+
+ +
@@ -1447,7 +1572,7 @@ // section-kickers: zh shows English (existing design), en shows Chinese (mirrored) 'kicker.site_settings': 'Site Settings', 'kicker.telegram': 'Telegram Bot', - 'kicker.obs': 'Stream Setup', + 'kicker.obs': 'Remote Push', 'kicker.stream_records':'Stream Records', 'kicker.analytics': 'Analytics', 'kicker.device': 'Device', @@ -1457,7 +1582,7 @@ // section headings 'section.site_settings': '网站设置', 'section.telegram': 'TG Bot 推送', - 'section.obs': '推流配置', + 'section.obs': '远端推流', 'section.stream_records':'已录入的直播', 'section.analytics': '数据看板', 'section.device': '设备分布', @@ -1476,7 +1601,7 @@ // menu 'menu.dashboard': '数据看板', 'menu.streams': '直播列表', - 'menu.obs': '推流配置', + 'menu.obs': '远端推流', 'menu.telegram': 'TG 推送', 'menu.site': '网站设置', // loading / login @@ -1498,6 +1623,9 @@ 'dash.export': '⬇ 导出 CSV', 'dash.exporting': '导出中...', 'dash.stream_detail': '直播统计明细', + 'dash.per_page': '每页', + 'dash.rows_unit': '条', + 'dash.all': '全部', // device labels 'device.desktop': '桌面端', 'device.mobile': '移动端', @@ -1573,7 +1701,11 @@ 'editor.add_title': '新增直播', 'editor.edit_title':'编辑直播', // lang - 'lang.btn_label': 'EN', + 'lang.btn_label': 'EN', + 'lang.toggle_title': '切换语言', + 'toast.close': '关闭', + 'confirm.ok': '确认', + 'confirm.cancel': '取消', // actions 'action.disable': '停用', 'action.enable': '启用', @@ -1626,7 +1758,7 @@ 'form.api_key_label': '密钥备注', 'ph.api_key_label': '备注(可选)', 'btn.create_api_key': '生成密钥', - 'api.new_token_hint': '请复制并妥善保管——密钥不会再次显示', + 'api.new_token_hint': '请复制并妥善保管 - 密钥不会再次显示', 'api.copy': '复制', 'api.copied': '已复制', 'api.revoke': '撤销', @@ -1665,8 +1797,9 @@ 'ph.footer': '支持标题、段落、列表和链接。留空则首页不显示页脚内容卡片。', 'ph.obs_host': '例如 live-rtmp.stdm.moe 或 live-rtmp.stdm.moe:1935', 'ph.obs_playback': '例如 https://live.example.com', - 'ph.obs_route_key': '例如 my-live-001', - 'ph.search': '搜索直播名 / ID / 源地址', + 'ph.obs_route_key': '例如 my-live-001', + 'ph.obs_route_search': '搜索推流码', + 'ph.search': '搜索直播名 / ID / 源地址', 'ph.nav_label': '菜单名', 'ph.nav_url': '#stream-list 或 https://example.com', 'ph.link_name': '视角名', @@ -1678,7 +1811,7 @@ 'hint.pw_change': '修改后台登录密码后,当前会话会自动退出。', 'hint.tg_config': '配置 Bot Token 和接收方 ID 后,可在检测到开播或关播时自动发送消息。', 'hint.tg_vars': '可用变量:{title}、{url}、{site_title}、{time}、{status}、{stream_id}、{public_id}、{link_name}、{source_url}  ·  支持 HTML', - 'hint.obs_rtmp': '推流端使用 RTMP 推流到 NAS,播放器使用 SRS 输出的 HLS 或 FLV 地址。', + 'hint.obs_rtmp': '推流端使用 RTMP 推流到服务器,播放器使用 SRS 输出的 HLS 或 FLV 地址。', 'hint.obs_routes': '新增自定义推流码后,系统会生成不可反推的公开 HLS/FLV 地址。推流端仍使用真实推流码推流。', 'hint.links': '填写格式:视角名称 | 类型 | 播放链接 | Key Override | ClearKey 信息', 'hint.footer_example': '示例:## 联系方式\n- [项目主页](https://example.com)', @@ -1777,12 +1910,59 @@ 'err.missing_id': '缺少 ID', 'err.invalid_action': '无效操作', 'err.server_error': '服务器内部错误', + 'menu.push': '本地推流', + 'kicker.push': 'Local Push', + 'section.push': '本地推流', + 'btn.upload_video': '上传视频', + 'push.modal_title': '开始推流', + 'push.file': '文件', + 'push.stream_key': '推流码', + 'push.loop': '循环播放', + 'push.start': '开始推流', + 'push.jobs_title': '正在推流', + 'push.no_files': '暂无视频文件', + 'push.no_jobs': '暂无推流任务', + 'push.stop': '停止', + 'push.uploading': '上传中', + 'push.upload_done': '上传完成', + 'err.invalid_filename': '文件名非法', + 'err.invalid_file_type': '不支持的文件类型', + 'err.file_not_found': '文件不存在', + 'err.push_already_running': '该推流码已有任务正在运行', + 'err.push_not_found': '推流任务不存在', + 'err.stream_key_required': '请填写推流码', + 'push.copy_rtmp': '复制推流地址', + 'push.copy_hls': '复制播放地址', + 'push.add_stream': '添加直播', + 'push.copied': '已复制', + 'push.dir_empty': '此目录为空', + 'push.publish_archive': '发布归档', + 'push.folder': '文件夹', + 'push.edit_push': '编辑推流', + 'push.job_title': '推流详情', + 'push.live_label': '直播中', + 'push.elapsed': '已推流', + 'push.search': '搜索文件...', + 'push.no_results': '无匹配文件', + 'push.start_all': '全部开始', + 'push.stop_all': '全部停止', + 'push.folder_push': '文件夹推流', + 'push.stop': '停止', + 'push.add_all_stream': '全部添加直播', + 'push.add_to_existing': '添加到现有直播', + 'push.add_to_existing_archive': '添加到现有归档', + 'push.pick_stream_title': '选择直播', + 'push.tab_all': '全部', + 'push.picker_search': '搜索...', + 'push.picker_empty': '没有匹配的直播', + 'push.select': '选择', + 'err.invalid_rel_path': '路径非法', }, en: { // kickers: en shows Chinese label (mirrored bilingual) 'kicker.site_settings': '网站设置', 'kicker.telegram': 'TG Bot 推送', - 'kicker.obs': '推流配置', + 'kicker.obs': '远端推流', 'kicker.stream_records':'已录入的直播', 'kicker.analytics': '数据看板', 'kicker.device': '设备分布', @@ -1792,7 +1972,7 @@ // section headings 'section.site_settings': 'Site Settings', 'section.telegram': 'Telegram Bot', - 'section.obs': 'Stream Setup', + 'section.obs': 'Remote Push', 'section.stream_records':'Stream Records', 'section.analytics': 'Analytics', 'section.device': 'Device Distribution', @@ -1811,7 +1991,7 @@ // menu 'menu.dashboard': 'Dashboard', 'menu.streams': 'Streams', - 'menu.obs': 'Stream Setup', + 'menu.obs': 'Remote Push', 'menu.telegram': 'Telegram', 'menu.site': 'Site Settings', // loading / login @@ -1833,6 +2013,9 @@ 'dash.export': '⬇ Export CSV', 'dash.exporting': 'Exporting...', 'dash.stream_detail': 'Stream Statistics', + 'dash.per_page': 'Per page', + 'dash.rows_unit': 'rows', + 'dash.all': 'All', // device labels 'device.desktop': 'Desktop', 'device.mobile': 'Mobile', @@ -1908,7 +2091,11 @@ 'editor.add_title': 'Add Stream', 'editor.edit_title':'Edit Stream', // lang - 'lang.btn_label': '中', + 'lang.btn_label': '中', + 'lang.toggle_title': 'Switch Language', + 'toast.close': 'Close', + 'confirm.ok': 'Confirm', + 'confirm.cancel': 'Cancel', // shared 'status.loading': 'Loading...', // actions @@ -1961,7 +2148,7 @@ 'form.api_key_label': 'Key label', 'ph.api_key_label': 'Label (optional)', 'btn.create_api_key': 'Generate key', - 'api.new_token_hint': 'Copy and keep safe — this token will not be shown again', + 'api.new_token_hint': 'Copy and keep safe - this token will not be shown again', 'api.copy': 'Copy', 'api.copied': 'Copied', 'api.revoke': 'Revoke', @@ -2000,8 +2187,9 @@ 'ph.footer': 'Supports headings, paragraphs, lists and links. Leave empty to hide footer.', 'ph.obs_host': 'e.g. live-rtmp.stdm.moe or live-rtmp.stdm.moe:1935', 'ph.obs_playback': 'e.g. https://live.example.com', - 'ph.obs_route_key': 'e.g. my-live-001', - 'ph.search': 'Search by name / ID / URL', + 'ph.obs_route_key': 'e.g. my-live-001', + 'ph.obs_route_search': 'Search stream key', + 'ph.search': 'Search by name / ID / URL', 'ph.nav_label': 'Label', 'ph.nav_url': '#stream-list or https://example.com', 'ph.link_name': 'Source name', @@ -2013,7 +2201,7 @@ 'hint.pw_change': 'Changing the admin password will log you out of the current session.', 'hint.tg_config': 'Configure Bot Token and recipient ID to send notifications on stream start/stop.', 'hint.tg_vars': 'Variables: {title}, {url}, {site_title}, {time}, {status}, {stream_id}, {public_id}, {link_name}, {source_url}  ·  HTML supported', - 'hint.obs_rtmp': 'The encoder pushes via RTMP to NAS; the player uses HLS or FLV from SRS.', + 'hint.obs_rtmp': 'The encoder pushes via RTMP to the server; the player uses HLS or FLV from SRS.', 'hint.obs_routes': 'Adding a custom stream key generates a public HLS/FLV URL that cannot be reverse-engineered.', 'hint.links': 'Format: name | type | URL | Key Override | ClearKey', 'hint.footer_example': 'Example: ## Contact\n- [Project page](https://example.com)', @@ -2112,6 +2300,53 @@ 'err.missing_id': 'Missing ID', 'err.invalid_action': 'Invalid action', 'err.server_error': 'Internal server error', + 'menu.push': 'Local Push', + 'kicker.push': '本地推流', + 'section.push': 'Local Push', + 'btn.upload_video': 'Upload Video', + 'push.modal_title': 'Start Push', + 'push.file': 'File', + 'push.stream_key': 'Stream Key', + 'push.loop': 'Loop', + 'push.start': 'Start Push', + 'push.jobs_title': 'Active Pushes', + 'push.no_files': 'No video files', + 'push.no_jobs': 'No active push jobs', + 'push.stop': 'Stop', + 'push.uploading': 'Uploading', + 'push.upload_done': 'Upload complete', + 'err.invalid_filename': 'Invalid filename', + 'err.invalid_file_type': 'Unsupported file type', + 'err.file_not_found': 'File not found', + 'err.push_already_running': 'Push already running for this stream key', + 'err.push_not_found': 'Push job not found', + 'err.stream_key_required': 'Stream key is required', + 'push.copy_rtmp': 'Copy RTMP URL', + 'push.copy_hls': 'Copy HLS URL', + 'push.add_stream': 'Add Stream', + 'push.copied': 'Copied', + 'push.dir_empty': 'Directory is empty', + 'push.publish_archive': 'Publish to Archive', + 'push.folder': 'Folder', + 'push.edit_push': 'Edit Push', + 'push.job_title': 'Push Details', + 'push.live_label': 'Live', + 'push.elapsed': 'Elapsed', + 'push.search': 'Search files...', + 'push.no_results': 'No matching files', + 'push.start_all': 'Start All', + 'push.stop_all': 'Stop All', + 'push.folder_push': 'Folder Push', + 'push.stop': 'Stop', + 'push.add_all_stream': 'Add All Streams', + 'push.add_to_existing': 'Add to Existing', + 'push.add_to_existing_archive': 'Add to Existing Archive', + 'push.pick_stream_title': 'Pick a Stream', + 'push.tab_all': 'All', + 'push.picker_search': 'Search...', + 'push.picker_empty': 'No matching streams', + 'push.select': 'Select', + 'err.invalid_rel_path': 'Invalid path', } }; @@ -2148,6 +2383,44 @@ // Callbacks registered by different script blocks that need to re-render // when the language changes (e.g. sort tab labels, per-language form fields). // Each hook is wrapped in try/catch so a failure in one does not block others. + function showToast(msg, type, duration) { + if (type === undefined) type = 'info'; + if (duration === undefined) duration = 3500; + var wrap = document.getElementById('sh-toast-wrap'); + if (!wrap) { alert(msg); return; } + var icons = {success:'✓', error:'✕', info:'ℹ', warn:'⚠'}; + var el = document.createElement('div'); + el.className = 'sh-toast'; + el.dataset.type = type; + el.innerHTML = '' + (icons[type] || icons.info) + '' + + '' + String(msg).replace(/[&<>]/g, function(c){return({'&':'&','<':'<','>':'>'}[c]);}) + '' + + ''; + var tid; + function dismiss(e) { + clearTimeout(tid); + e.classList.add('leaving'); + e.addEventListener('animationend', function(){ e.remove(); }, {once:true}); + } + el.querySelector('.sh-toast-close').addEventListener('click', function(){ dismiss(el); }); + wrap.appendChild(el); + tid = setTimeout(function(){ dismiss(el); }, duration); + } + + function showConfirm(msg, onOk) { + var modal = document.getElementById('sh-confirm-modal'); + if (!modal) { if (confirm(msg)) onOk(); return; } + document.getElementById('sh-confirm-msg').textContent = msg; + modal.classList.remove('hidden'); + function close() { modal.classList.add('hidden'); } + var okBtn = document.getElementById('sh-confirm-ok'); + var caBtn = document.getElementById('sh-confirm-cancel'); + var _ok = okBtn.cloneNode(true); okBtn.replaceWith(_ok); + var _ca = caBtn.cloneNode(true); caBtn.replaceWith(_ca); + _ok.addEventListener('click', function() { close(); onOk(); }, {once:true}); + _ca.addEventListener('click', close, {once:true}); + modal.addEventListener('click', function(e) { if (e.target === modal) close(); }, {once:true}); + } + var _langChangeHooks = []; function setLang(lang) { LANG = lang; @@ -2197,6 +2470,8 @@ applyI18n(); _langChangeHooks.push(() => { if (allStreams.length) renderList(); + if (obsRoutes.length) renderObsRoutes(); + renderApiKeys(apiKeysData); applyTheme(document.documentElement.dataset.theme || 'light'); const heroDesc = document.getElementById('admin-hero-desc'); if (heroDesc) { @@ -2333,6 +2608,9 @@ let allStreams = []; let streamStats = {}; let obsRoutes = []; + let obsRoutePage = 0; + let obsRoutePageSize = 5; + let obsRouteSearch = ''; let isSavedProbeRunning = false; const savedProbeCache = new Map(); const linkProbeTimers = new WeakMap(); @@ -2542,6 +2820,7 @@ loadSiteSettings(); loadApiKeys(); loadTelegramSettings(); + loadObsConfig(); loadObsRoutes(); loadStreams(); const view = (location.hash || '').replace(/^#/, '') || localStorage.getItem('admin_active_view') || 'dashboard'; @@ -2729,22 +3008,25 @@ `; }).join(''); els.apiKeysList.querySelectorAll('[data-key-id]').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm(t('api.confirm_revoke'))) return; - try { - await apiCall('delete_api_key', { id: Number(btn.dataset.keyId) }); - loadApiKeys(); - } catch (err) { - showStatus(els.apiKeysStatus, err.message, true); - } + btn.addEventListener('click', function() { + showConfirm(t('api.confirm_revoke'), async function() { + try { + await apiCall('delete_api_key', { id: Number(btn.dataset.keyId) }); + loadApiKeys(); + } catch (err) { + showStatus(els.apiKeysStatus, err.message, true); + } + }); }); }); }; + let apiKeysData = []; const loadApiKeys = async () => { try { const res = await apiCall('list_api_keys'); - renderApiKeys(res.data || []); + apiKeysData = res.data || []; + renderApiKeys(apiKeysData); } catch (err) { showStatus(els.apiKeysStatus, err.message, true); } @@ -2858,18 +3140,18 @@ const normalizeHost = (value = '') => { const raw = String(value || '').trim(); - if (!raw) return (window.location.hostname || 'NAS-IP') + ':1935'; + if (!raw) return (window.location.hostname || 'server-ip') + ':1935'; try { return new URL(raw.includes('://') ? raw : `http://${raw}`).host || raw; } catch (e) { - return raw.replace(/^https?:\/\//i, '').split('/')[0] || window.location.hostname || 'NAS-IP'; + return raw.replace(/^https?:\/\//i, '').split('/')[0] || window.location.hostname || 'server-ip'; } }; const normalizeOrigin = (value = '') => { const raw = String(value || '').trim().replace(/\/+$/, ''); if (!raw) { - const host = window.location.hostname || 'NAS-IP'; + const host = window.location.hostname || 'server-ip'; return `${window.location.protocol || 'http:'}//${host}:18088`; } try { @@ -2905,12 +3187,20 @@ const renderObsRoutes = () => { if (!els.obsRouteList) return; - if (!obsRoutes.length) { + const q = obsRouteSearch.toLowerCase(); + const filtered = q ? obsRoutes.filter(r => r.stream_key.toLowerCase().includes(q)) : obsRoutes; + if (!filtered.length) { els.obsRouteList.innerHTML = `

${t('msg.no_routes')}

`; + _updateObsRoutesPag(0, 1); return; } + const total = filtered.length; + const ps = obsRoutePageSize === 0 ? total : obsRoutePageSize; + const totalPages = Math.max(1, Math.ceil(total / ps)); + if (obsRoutePage >= totalPages) obsRoutePage = totalPages - 1; + const pageRoutes = filtered.slice(obsRoutePage * ps, obsRoutePage * ps + ps); els.obsRouteList.innerHTML = ''; - obsRoutes.forEach(route => { + pageRoutes.forEach(route => { const urls = obsRouteUrls(route); const card = document.createElement('div'); card.className = 'obs-route-card'; @@ -2937,6 +3227,36 @@ `; els.obsRouteList.appendChild(card); }); + _updateObsRoutesPag(total, totalPages); + }; + + function _updateObsRoutesPag(total, totalPages) { + const pag = document.getElementById('obs-route-pag'); + if (!pag) return; + pag.style.display = total > 0 ? 'flex' : 'none'; + if (total === 0) return; + const ps = obsRoutePageSize === 0 ? total : obsRoutePageSize; + const start = obsRoutePage * ps + 1; + const end = Math.min((obsRoutePage + 1) * ps, total); + const info = document.getElementById('or-pag-info'); + if (info) info.textContent = `${start}-${end} / ${total}`; + const prev = document.getElementById('or-prev'); + const next = document.getElementById('or-next'); + if (prev) prev.disabled = obsRoutePage === 0; + if (next) next.disabled = obsRoutePage >= totalPages - 1; + } + + const loadObsConfig = async () => { + try { + const res = await apiCall('get_obs_config'); + const rtmp = res.obs_rtmp_host || ''; + const origin = res.obs_playback_origin || ''; + els.obsRtmpHost.value = rtmp; + els.obsPlaybackOrigin.value = origin; + localStorage.setItem('obs_rtmp_host', rtmp); + localStorage.setItem('obs_playback_origin', origin); + refreshObsUrls(); + } catch (e) {} }; const loadObsRoutes = async () => { @@ -2998,7 +3318,7 @@ startStatsLive(); window.setTimeout(checkSavedStreams, 100); } catch (e) { - alert(e.message); + showToast(e.message, 'error'); } }; @@ -3069,16 +3389,22 @@ els.list.innerHTML = ''; const displayStreams = currentStreamList(); const total = displayStreams.length; + const labelTotal = allStreams.filter(s => { + const lbl = String(s.stream_label || 'LIVE').toUpperCase(); + if (streamLabelFilter === 'live') return lbl !== 'ARCHIVE'; + if (streamLabelFilter === 'archive') return lbl === 'ARCHIVE'; + return true; + }).length; const totalPages = Math.max(1, Math.ceil(total / pageSize)); currentPage = Math.min(Math.max(1, currentPage), totalPages); const start = (currentPage - 1) * pageSize; const pageStreams = displayStreams.slice(start, start + pageSize); - els.streamCount.textContent = t('streams.showing').replace('{shown}', total).replace('{total}', allStreams.length); + els.streamCount.textContent = t('streams.showing').replace('{shown}', pageStreams.length).replace('{total}', labelTotal); els.pageInfo.textContent = t('page.page_of').replace('{cur}', currentPage).replace('{total}', totalPages); els.prevPage.disabled = currentPage <= 1; els.nextPage.disabled = currentPage >= totalPages; if (!pageStreams.length) { - els.streamCount.textContent = t('streams.showing').replace('{shown}', 0).replace('{total}', allStreams.length); + els.streamCount.textContent = t('streams.showing').replace('{shown}', 0).replace('{total}', labelTotal); els.list.innerHTML = `

${t('streams.no_match')}

`; return; } @@ -3192,17 +3518,17 @@ if (!id) return; if (e.target.classList.contains('del-btn')) { - if (confirm(t('confirm.delete'))) { + showConfirm(t('confirm.delete'), async function() { await apiCall('delete', { id }); loadStreams(); - } + }); } if (e.target.classList.contains('copy-btn')) { const publicId = e.target.dataset.publicId || id; const url = `${window.location.origin}/player.html?id=${encodeURIComponent(publicId)}`; await copyText(url); e.target.closest('.action-menu')?.removeAttribute('open'); - alert(t('alert.link_copied')); + showToast(t('alert.link_copied'), 'success'); } if (e.target.classList.contains('open-btn')) { window.open(e.target.dataset.url, '_blank', 'noopener'); @@ -3227,7 +3553,7 @@ checkSingleSavedStream(stream, true); } } catch (err) { - alert(err.message); + showToast(err.message, 'error'); } } if (e.target.classList.contains('tg-toggle-btn')) { @@ -3239,7 +3565,7 @@ renderList(); window.setTimeout(checkSavedStreams, 100); } catch (err) { - alert(err.message); + showToast(err.message, 'error'); } } if (e.target.classList.contains('edit-btn')) { @@ -3299,7 +3625,7 @@ if (s) s.sort_order = idx; }); } catch (err) { - alert(t('msg.sort_err') + ':' + err.message); + showToast(t('msg.sort_err') + ':' + err.message, 'error'); renderList(); } }); @@ -3352,14 +3678,19 @@ closeEditorModal(); loadStreams(); } catch (e) { - alert(e.message); + showToast(e.message, 'error'); } }); + let _linkDragSrc = null; + let _linkDragFromHandle = false; + const addLinkUI = (name = 'Default', url = '', key = '', clearkey = '', type = '') => { const div = document.createElement('div'); div.className = 'link-row'; + div.draggable = true; div.innerHTML = ` + + + + + +
+ +
+ + + + + + + + + + + + + +
+ + +
diff --git a/public/index.html b/public/index.html index becbe74..c3573ea 100644 --- a/public/index.html +++ b/public/index.html @@ -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; + } @@ -661,6 +737,9 @@
+