first commit
This commit is contained in:
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/target
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
bin/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
include/
|
||||||
|
man/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
pip-selfcheck.json
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
|
||||||
|
# Mr Developer
|
||||||
|
.mr.developer.cfg
|
||||||
|
.project
|
||||||
|
.pydevproject
|
||||||
|
|
||||||
|
# Rope
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Pyenv
|
||||||
|
.python-version
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[build-system]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
requires = ["hatchling"]
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "chongming-stream-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/chongming_stream_manager"]
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Dict, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class StreamManager:
|
||||||
|
def __init__(self,
|
||||||
|
mediamtx_addr,
|
||||||
|
max_streams: int = 16,
|
||||||
|
video_path: str = ""):
|
||||||
|
self.max_streams = max_streams
|
||||||
|
self._processes: Dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
self._tasks: Dict[str, asyncio.Task] = {}
|
||||||
|
self._semaphore = asyncio.Semaphore(max_streams)
|
||||||
|
self._mediamtx_addr = mediamtx_addr
|
||||||
|
self._video_path = video_path
|
||||||
|
|
||||||
|
def _is_local_file(self, url: str) -> bool:
|
||||||
|
"""判断是否为本地文件(绝对路径或相对路径)"""
|
||||||
|
# 如果以 / 开头,或者包含 .mp4/.avi/.mkv 等,认定为文件
|
||||||
|
return url.startswith('/') or url.startswith('./') or \
|
||||||
|
any(url.lower().endswith(ext) for ext in ['.mp4', '.avi', '.mkv', '.mov', '.flv'])
|
||||||
|
|
||||||
|
def _build_ffmpeg_cmd(self, source_url: str, output_url: str, loop: int = -1) -> list:
|
||||||
|
"""根据源类型构建 ffmpeg 命令"""
|
||||||
|
cmd = ["ffmpeg"]
|
||||||
|
|
||||||
|
if self._is_local_file(source_url):
|
||||||
|
# ----- 本地文件模式(支持循环) -----
|
||||||
|
cmd.extend([
|
||||||
|
"-re", # 按原始帧率读取
|
||||||
|
"-stream_loop", str(loop), # -1 为无限循环
|
||||||
|
"-i", source_url
|
||||||
|
])
|
||||||
|
else:
|
||||||
|
# ----- RTSP 网络流模式(支持自动重连) -----
|
||||||
|
cmd.extend([
|
||||||
|
"-rtsp_transport", "tcp",
|
||||||
|
"-stimeout", "3000000", # 3 秒超时
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "5",
|
||||||
|
"-i", source_url
|
||||||
|
])
|
||||||
|
|
||||||
|
# 公共输出部分(流复制,推送到 MediaMTX)
|
||||||
|
cmd.extend([
|
||||||
|
"-c", "copy",
|
||||||
|
"-f", "rtsp",
|
||||||
|
output_url
|
||||||
|
])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
async def start_stream(self, stream_id: str, source_url: str, duration: int = 0, loop: int = -1):
|
||||||
|
if len(self._processes) >= self.max_streams:
|
||||||
|
logger.warning(f"已达最大流上限 {self.max_streams},拒绝新流 {stream_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if stream_id in self._processes:
|
||||||
|
logger.info(f"流 {stream_id} 已存在")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async with self._semaphore:
|
||||||
|
output_url = f"{self._mediamtx_addr}{stream_id}"
|
||||||
|
cmd = self._build_ffmpeg_cmd(source_url, output_url, loop)
|
||||||
|
|
||||||
|
logging.info(f"[Stream {stream_id}] 启动命令: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
self._processes[stream_id] = process
|
||||||
|
|
||||||
|
task = asyncio.create_task(self._monitor(stream_id, process, duration))
|
||||||
|
self._tasks[stream_id] = task
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _monitor(self, stream_id: str, process: asyncio.subprocess.Process, duration: int):
|
||||||
|
"""监控单个 ffmpeg 进程的生命周期"""
|
||||||
|
try:
|
||||||
|
if duration > 0:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=duration)
|
||||||
|
else:
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info(f"[Stream {stream_id}] 推流超时 ({duration}s), 自动停止")
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=3)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Stream {stream_id}] 监控异常: {e}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await self._cleanup(stream_id)
|
||||||
|
|
||||||
|
async def stop_stream(self, stream_id: str):
|
||||||
|
"""主动停止一路流"""
|
||||||
|
process = self._processes.get(stream_id)
|
||||||
|
if process:
|
||||||
|
logger.info(f"[Stream {stream_id}] 收到停止指令")
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
await self._cleanup(stream_id)
|
||||||
|
|
||||||
|
async def _cleanup(self, stream_id: str):
|
||||||
|
if stream_id in self._processes:
|
||||||
|
del self._processes[stream_id]
|
||||||
|
if stream_id in self._tasks:
|
||||||
|
task = self._tasks.pop(stream_id)
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
logger.info(f"[Stream {stream_id}] 资源已释放")
|
||||||
|
|
||||||
|
async def stop_all(self):
|
||||||
|
"""停止所有流 (用于应用关闭时) """
|
||||||
|
for sid in list(self._processes.keys()):
|
||||||
|
await self.stop_stream(sid)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
|
from chongming_stream_manager import StreamManager
|
||||||
|
|
||||||
|
stream_manager = StreamManager("rtsp://172.21.110.223:8554/")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await stream_manager.start_stream(
|
||||||
|
"test",
|
||||||
|
"/tmp/bm-app/Upload/Video/VIDEO_02303069-B52F-47BA-A4F3-7DB4C66E087D.mp4"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(120)
|
||||||
|
await stream_manager.stop_all()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user