commit 48e540fefa4862908098e8c380c196c484745fff Author: chakcy <947105045@qq.com> Date: Fri Jul 31 16:59:09 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8f0442 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d5cde28 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/chongming_stream_manager/__init__.py b/src/chongming_stream_manager/__init__.py new file mode 100644 index 0000000..d34747f --- /dev/null +++ b/src/chongming_stream_manager/__init__.py @@ -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) diff --git a/test/test_manager.py b/test/test_manager.py new file mode 100644 index 0000000..f7ebe5f --- /dev/null +++ b/test/test_manager.py @@ -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()) \ No newline at end of file