From 45278358359bf16e12308732c9cf8d510ad0b063 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Sun, 27 Apr 2025 15:18:35 +0800 Subject: [PATCH] feat: Update video player component and add video API service for web compatibility --- .env.example | 13 +- app/api/video_stream_service.py | 190 ++++++++++++++++++++++++++++ app/core/stream_manager.py | 3 + app/models/recording_model.py | 1 + app/ui/components/recording_card.py | 12 +- app/ui/components/video_player.py | 101 +++++++++++++++ app/ui/views/storage_view.py | 121 ++++++++++-------- app/utils/utils.py | 5 + docker-compose.yml | 17 +++ locales/en.json | 12 +- locales/zh_CN.json | 12 +- pyproject.toml | 4 +- requirements-web.txt | 3 +- 13 files changed, 431 insertions(+), 63 deletions(-) create mode 100644 app/api/video_stream_service.py create mode 100644 app/ui/components/video_player.py diff --git a/.env.example b/.env.example index 2cedbb6..8b56460 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,20 @@ # PLATFORM: Runtime platform for the application, supports 'desktop' or 'web' modes (default: desktop) PLATFORM=desktop +# Set timezone +TZ=Asia/Shanghai + # HOST: Web server host address (default: 127.0.0.1, used for local development) HOST=127.0.0.1 # PORT: Web server port number (default: 6006) PORT=6006 -# Set timezone -TZ=Asia/Shanghai \ No newline at end of file +# PORT: Web video server api port number (default: 6007) +VIDEO_API_PORT=6007 + +# Set web video storage directory +CUSTOM_VIDEO_ROOT_DIR= + +# Set external URL for the video API (example: http://www.example.com) +VIDEO_API_EXTERNAL_URL= \ No newline at end of file diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py new file mode 100644 index 0000000..5eb41e4 --- /dev/null +++ b/app/api/video_stream_service.py @@ -0,0 +1,190 @@ +import asyncio +import hashlib +import logging +import os +import re +import sys +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path + +import aiofiles +from cachetools import TTLCache +from dotenv import find_dotenv, load_dotenv +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import Response, StreamingResponse +from fastapi.staticfiles import StaticFiles + +dotenv_path = find_dotenv() +load_dotenv(dotenv_path) +CUSTOM_VIDEO_ROOT_DIR = os.getenv("CUSTOM_VIDEO_ROOT_DIR") +VIDEO_API_PORT = os.getenv("VIDEO_API_PORT") or 6007 + +asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +DEFAULT_VIDEO_ROOT_DIR = Path(os.path.split(os.path.realpath(sys.argv[0]))[0]).parent.parent / "downloads" +VIDEO_DIR = Path(CUSTOM_VIDEO_ROOT_DIR or DEFAULT_VIDEO_ROOT_DIR) +os.makedirs(VIDEO_DIR, exist_ok=True) + +VIDEO_META_CACHE = TTLCache(maxsize=50, ttl=300) +CHUNK_CACHE = TTLCache(maxsize=25, ttl=60) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + if not VIDEO_DIR.exists(): + logger.error(f"Video directory does not exist: {VIDEO_DIR}") + raise RuntimeError(f"Video directory does not exist: {VIDEO_DIR}") + _app.mount("/api/videos", StaticFiles(directory=VIDEO_DIR), name="videos") + yield + + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + _app.mount("/api/videos", StaticFiles(directory=None)) + logger.info("Shutting down the application.") + + +app = FastAPI(lifespan=lifespan) + + +def validate_filename(filename: str): + if re.search(r"[\\/]", filename): + raise HTTPException(status_code=400, detail="Invalid filename") + + +@app.get("/api/videos") +async def get_video( + request: Request, + filename: str = Query(...), + subfolder: str | None = None +): + + cache_key = f"{filename}-{subfolder}" + if meta := VIDEO_META_CACHE.get(cache_key): + if_none_match = request.headers.get("If-None-Match") + if_modified_since = request.headers.get("If-Modified-Since") + + if if_none_match and if_none_match == meta['etag']: + return Response(status_code=304) + + if if_modified_since: + last_modified = datetime.fromisoformat(meta['last_modified']) + if datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") >= last_modified: + return Response(status_code=304) + + try: + validate_filename(filename) + if subfolder: + video_path = VIDEO_DIR / subfolder / filename + else: + video_path = VIDEO_DIR / filename + + except Exception as e: + logger.exception("Invalid filename or subfolder") + raise e + + if not video_path.is_file(): + logger.error(f"File not found: {video_path}") + raise HTTPException(status_code=404, detail="Video file not found") + + # Prevent path traversal attacks + try: + video_path.relative_to(VIDEO_DIR) + except ValueError: + logger.exception(f"Path traversal attempt: {video_path}") + raise HTTPException(status_code=400, detail="Invalid file path") + + stat = video_path.stat() + file_size = stat.st_size + last_modified = datetime.fromtimestamp(stat.st_mtime).isoformat() + etag = hashlib.md5(f"{file_size}-{last_modified}".encode()).hexdigest() + + VIDEO_META_CACHE[cache_key] = { + 'etag': etag, + 'last_modified': last_modified, + 'file_size': file_size + } + + # Parse Range header + range_header = request.headers.get("Range") + if range_header: + start, end = range_header.replace("bytes=", "").split("-") + start = int(start) + end = int(end) if end else file_size - 1 + + if start >= file_size or end >= file_size: + logger.error(f"Invalid range request: {range_header}, file size: {file_size}") + raise HTTPException(status_code=416, detail="Requested range not satisfiable") + + headers = { + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(end - start + 1), + "Content-Type": "video/mp4", + } + return StreamingResponse( + file_sender_range(video_path, start, end), + status_code=206, + headers=headers, + ) + + # If no Range header, return the whole file + headers = { + "Content-Length": str(file_size), + "Content-Type": "video/mp4", + "Cache-Control": "public, max-age=300", + "ETag": etag, + "Last-Modified": datetime.fromisoformat(last_modified).strftime("%a, %d %b %Y %H:%M:%S GMT") + } + try: + return StreamingResponse(file_sender(video_path), headers=headers) + except Exception: + logger.exception("Streaming error") + raise HTTPException(status_code=500, detail="Internal Server Error") + + +# Async file sender (full content) +async def file_sender(video_path: Path): + async with aiofiles.open(video_path, "rb") as file: + while True: + chunk = await file.read(65536) + if not chunk: + break + yield chunk + + +# Async file sender (range content) +async def file_sender_range(video_path: Path, start: int, end: int): + cache_key = f"{video_path.name}-{start}-{end}" + + if cached := CHUNK_CACHE.get(cache_key): + yield cached + return + + async with aiofiles.open(video_path, "rb") as file: + await file.seek(start) + chunks = [] + while start <= end: + chunk_size = min(65536, end - start + 1) + chunk = await file.read(chunk_size) + if not chunk: + break + chunks.append(chunk) + start += len(chunk) + + full_chunk = b"".join(chunks) + if len(full_chunk) < 1024 * 1024: + CHUNK_CACHE[cache_key] = full_chunk + yield full_chunk + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=VIDEO_API_PORT, log_level="debug") diff --git a/app/core/stream_manager.py b/app/core/stream_manager.py index d415af1..00401fa 100644 --- a/app/core/stream_manager.py +++ b/app/core/stream_manager.py @@ -196,6 +196,7 @@ class LiveStreamRecorder: self.app.add_ffmpeg_process(process) self.recording.status_info = RecordingStatus.RECORDING + self.recording.record_url = record_url logger.info(f"Recording in Progress: {live_url}") logger.log("STREAM", f"Recording Stream URL: {record_url}") while True: @@ -288,6 +289,8 @@ class LiveStreamRecorder: except Exception as e: logger.error(f"An error occurred during the subprocess execution: {e}") return False + finally: + self.recording.record_url = None return True diff --git a/app/models/recording_model.py b/app/models/recording_model.py index 090ca43..4b133de 100644 --- a/app/models/recording_model.py +++ b/app/models/recording_model.py @@ -62,6 +62,7 @@ class Recording: self.detection_time = None self.loop_time_seconds = None self.use_proxy = None + self.record_url = None def to_dict(self): """Convert the Recording instance to a dictionary for saving.""" diff --git a/app/ui/components/recording_card.py b/app/ui/components/recording_card.py index a5f2a1b..0c9fcb3 100644 --- a/app/ui/components/recording_card.py +++ b/app/ui/components/recording_card.py @@ -1,7 +1,6 @@ import asyncio import os.path from functools import partial -from pathlib import Path import flet as ft @@ -11,6 +10,7 @@ from ...utils import utils from ..views.storage_view import StoragePage from .card_dialog import CardDialog from .recording_dialog import RecordingDialog +from .video_player import VideoPlayer class RecordingCardManager: @@ -348,18 +348,20 @@ class RecordingCardManager: self.app.page.update() async def preview_video_button_on_click(self, _, recording: Recording): - if recording.recording_dir and os.path.exists(recording.recording_dir): + if self.app.page.web and recording.record_url: + video_player = VideoPlayer(self.app) + await video_player.preview_video(recording.record_url, is_file_path=False, room_url=recording.url) + elif recording.recording_dir and os.path.exists(recording.recording_dir): video_files = [] for root, _, files in os.walk(recording.recording_dir): for file in files: - format_list = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] - if Path(file).suffix.lower() in format_list: + if utils.is_valid_video_file(file): video_files.append(os.path.join(root, file)) if video_files: video_files.sort(key=lambda x: os.path.getmtime(x), reverse=True) latest_video = video_files[0] - await StoragePage(self.app).preview_file(latest_video) + await StoragePage(self.app).preview_file(latest_video, recording.url) else: await self.app.snack_bar.show_snack_bar(self._["no_video_file"]) else: diff --git a/app/ui/components/video_player.py b/app/ui/components/video_player.py new file mode 100644 index 0000000..43ca7eb --- /dev/null +++ b/app/ui/components/video_player.py @@ -0,0 +1,101 @@ +import os +import webbrowser +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import flet as ft + +from ...utils import utils +from ...utils.logger import logger + + +class VideoPlayer: + def __init__(self, app): + self.app = app + self._ = {} + self.load_language() + + def load_language(self): + language = self.app.language_manager.language + for key in ("video_player", "storage_page", "base"): + self._.update(language.get(key, {})) + + async def create_video_dialog( + self, title: str, + video_source: str, + is_file_path: bool = True, + room_url: str | None = None + ): + """ + Create video playback dialog + :param title: Dialog title + :param video_source: Video source (file path or URL) + :param is_file_path: Whether in file path mode + :param room_url: Live room URL + """ + + def close_dialog(_): + dialog.open = False + self.app.dialog_area.update() + + video = ft.Video( + width=800, + height=450, + playlist=[ft.VideoMedia(video_source)], + autoplay=True + ) + + async def copy_source(_): + self.app.page.set_clipboard(video_source) + await self.app.snack_bar.show_snack_bar(self._["copy_success"]) + + async def open_in_browser(_): + webbrowser.open(room_url) + + actions = [ + ft.TextButton(self._["close"], on_click=close_dialog) + ] + + if room_url: + actions.insert(0, ft.TextButton(self._["open_live_room_page"], on_click=open_in_browser)) + if not is_file_path: + if self._["stream_source"] in title: + actions.insert(0, ft.TextButton(self._["copy_stream_url"], on_click=copy_source)) + else: + actions.insert(0, ft.TextButton(self._["copy_video_url"], on_click=copy_source)) + + dialog = ft.AlertDialog( + modal=True, + title=ft.Text(title), + content=video, + actions=actions, + actions_alignment=ft.MainAxisAlignment.END + ) + dialog.open = True + self.app.dialog_area.content = dialog + self.app.dialog_area.update() + + async def preview_video(self, source: str, is_file_path: bool = True, room_url: str | None = None): + """ + Preview video + :param source: Video source (file path or URL) + :param is_file_path: Whether in file path mode + :param room_url: Live room URL + """ + if is_file_path: + if not utils.is_valid_video_file(source): + logger.warning(f"unsupported file type: {Path(source).suffix.lower()}") + await self.app.snack_bar.show_snack_bar( + self._["unsupported_file_type"] + ":" + os.path.basename(source)) + return + title = os.path.basename(source) + else: + parsed = urlparse(source) + params = parse_qs(parsed.query) + filename = params.get('filename', [''])[0] + sub_folder = params.get('subfolder', [''])[0] + if filename: + title = self._["previewing"] + ": " + (f"{sub_folder}/{filename}" if sub_folder else filename) + else: + title = self._["view_stream_source_now"] + await self.create_video_dialog(title, source, is_file_path, room_url) diff --git a/app/ui/views/storage_view.py b/app/ui/views/storage_view.py index 297bd8d..f48ff41 100644 --- a/app/ui/views/storage_view.py +++ b/app/ui/views/storage_view.py @@ -1,20 +1,36 @@ import os -from pathlib import Path import flet as ft +from dotenv import find_dotenv, load_dotenv from ...utils.logger import logger from ..base_page import PageBase as BasePage +dotenv_path = find_dotenv() +load_dotenv(dotenv_path) +VIDEO_API_EXTERNAL_URL = os.getenv("VIDEO_API_EXTERNAL_URL") + class StoragePage(BasePage): def __init__(self, app): super().__init__(app) self.page_name = "storage" - self.root_path = app.settings.get_video_save_path() - self.current_path = self.root_path + self.root_path = None + self.current_path = None + self.path_display = None + self.content = None + self.file_list = None self._ = {} self.load_language() + self.app.language_manager.add_observer(self) + + async def load(self): + self.root_path = self.app.settings.get_video_save_path() + self.current_path = self.root_path + self.setup_ui() + await self.update_file_list() + + def setup_ui(self): self.path_display = ft.Text( self._["storage_path"] + ": " + self.current_path, size=14, @@ -22,11 +38,8 @@ class StoragePage(BasePage): ) self.file_list = ft.ListView(expand=True) self.content = ft.Column(controls=[self.path_display, self.file_list]) - - async def load(self): self.app.content_area.controls = [self.content] self.app.content_area.update() - await self.update_file_list() def load_language(self): language = self.app.language_manager.language @@ -34,30 +47,24 @@ class StoragePage(BasePage): self._.update(language.get(key, {})) async def update_file_list(self): - self.path_display.value = self._["current_path"] + ":" + self.current_path - self.file_list.controls.clear() + try: + self.path_display.value = self._["current_path"] + ":" + self.current_path + self.file_list.controls.clear() - if not os.path.exists(self.current_path) or not os.listdir(self.current_path): - self.file_list.controls.append( - ft.Card( - content=ft.Container( - content=ft.Row( - controls=[ - ft.Icon(ft.icons.FOLDER_OPEN), - ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD) - ], - alignment=ft.MainAxisAlignment.CENTER - ), - padding=20 - ), - elevation=2, - margin=10, - width=400 - ) - ) + if not os.path.exists(self.current_path) or not os.listdir(self.current_path): + self.show_empty_folder_message() + self.file_list.update() + return + else: + self.add_navigation_button_if_needed() + self.list_files_and_folders() + except Exception as e: + logger.error(f"Error updating file list: {e}") + await self.app.snack_bar.show_snack_bar(self._["file_list_update_error"]) + finally: self.file_list.update() - return + def add_navigation_button_if_needed(self): if self.current_path != self.root_path: parent = ft.ElevatedButton( self._["go_back"], @@ -65,6 +72,7 @@ class StoragePage(BasePage): ) self.file_list.controls.append(parent) + def list_files_and_folders(self): for item in sorted(os.listdir(self.current_path)): full_path = os.path.join(self.current_path, item) if os.path.isdir(full_path): @@ -79,7 +87,24 @@ class StoragePage(BasePage): ) self.file_list.controls.append(btn) - self.file_list.update() + def show_empty_folder_message(self): + self.file_list.controls.append( + ft.Card( + content=ft.Container( + content=ft.Row( + controls=[ + ft.Icon(ft.icons.FOLDER_OPEN), + ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD) + ], + alignment=ft.MainAxisAlignment.CENTER + ), + padding=20 + ), + elevation=2, + margin=10, + width=400 + ) + ) async def navigate_to(self, path): self.current_path = path @@ -93,31 +118,23 @@ class StoragePage(BasePage): await self.update_file_list() self.content.update() - async def preview_file(self, file_path): - video_extensions = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] - if Path(file_path).suffix.lower() in video_extensions: + async def preview_file(self, file_path, room_url=None): + import urllib.parse - def close_dialog(_): - dialog.open = False - self.app.dialog_area.update() + from ..components.video_player import VideoPlayer - video = ft.Video( - width=800, - height=450, - playlist=[ft.VideoMedia(file_path)], - autoplay=True - ) + video_player = VideoPlayer(self.app) - dialog = ft.AlertDialog( - modal=True, - title=ft.Text(self._["previewing"] + ":" + os.path.basename(file_path)), - content=video, - actions=[ft.TextButton(self._["close"], on_click=close_dialog)], - actions_alignment=ft.MainAxisAlignment.END - ) - dialog.open = True - self.app.dialog_area.content = dialog - self.app.dialog_area.update() + if self.app.page.web: + if not VIDEO_API_EXTERNAL_URL: + logger.error("VIDEO_API_EXTERNAL_URL is not set in .env") + await self.app.snack_bar.show_snack_bar(self._["video_api_server_not_set"]) + return + + relative_path = os.path.relpath(file_path, self.root_path) + filename = urllib.parse.quote(os.path.basename(file_path)) + subfolder = urllib.parse.quote(os.path.dirname(relative_path).replace("\\", "/")) + api_url = f"{VIDEO_API_EXTERNAL_URL}/api/videos?filename={filename}&subfolder={subfolder}" + await video_player.preview_video(api_url, is_file_path=False, room_url=room_url) else: - logger.warning(f"unsupported file type: {Path(file_path).suffix.lower()}") - await self.app.snack_bar.show_snack_bar(self._["unsupported_file_type"] + ":" + os.path.basename(file_path)) + await video_player.preview_video(file_path, is_file_path=True, room_url=room_url) diff --git a/app/utils/utils.py b/app/utils/utils.py index 9247535..4361c07 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -254,3 +254,8 @@ def get_startup_info(system_type: str | None = None): else: startup_info = None return startup_info + + +def is_valid_video_file(source: str) -> bool: + video_extensions = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] + return Path(source).suffix.lower() in video_extensions diff --git a/docker-compose.yml b/docker-compose.yml index 74a51b1..e960788 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - ./logs:/app/logs - ./config:/app/config - ./downloads:/app/downloads + - ./.env:/app/.env healthcheck: test: ["CMD", "sh", "-c", "curl -f http://localhost:${PORT:-6006}/about"] interval: 30s @@ -20,6 +21,22 @@ services: networks: - streamcap-network + video_api: + image: ihmily/streamcap + command: python -m app.api.video_stream_service + ports: + - "${VIDEO_API_PORT:-6007}:${VIDEO_API_PORT:-6007}" + environment: + - TZ=${TZ:-Asia/Shanghai} + - CUSTOM_VIDEO_ROOT_DIR=${CUSTOM_VIDEO_ROOT_DIR:-./downloads} + volumes: + - ./downloads:/app/downloads + - ./.env:/app/.env + depends_on: + - streamcap + networks: + - streamcap-network + networks: streamcap-network: driver: bridge \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 759fbb4..eb0e7a4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -341,6 +341,16 @@ "previewing": "Previewing", "unsupported_file_type": "Unsupported file type", "no_video_file":"⚠️ No video file found", - "no_recording_folder":"⚠️ No recording folder found" + "no_recording_folder":"⚠️ No recording folder found", + "copy_stream_url": "Copy Stream URL", + "copy_video_url": "Copy Video URL", + "copy_success": "Copy Success", + "video_api_server_not_set": "⚠️ Video play server address not set" + }, + "video_player": { + "open_live_room_page": "Open Live Room Page", + "stream_source": "Stream Source", + "previewing": "Previewing", + "view_stream_source_now": "Accessing live stream source" } } \ No newline at end of file diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 19e1b17..9c7f8cd 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -343,6 +343,16 @@ "previewing": "正在预览", "unsupported_file_type": "不支持的文件类型", "no_video_file":"⚠️ 未找到视频文件", - "no_recording_folder":"⚠️ 录制目录不存在" + "no_recording_folder":"⚠️ 录制目录不存在", + "copy_stream_url": "复制直播源地址", + "copy_video_url": "复制视频地址", + "copy_success": "复制成功", + "video_api_server_not_set": "⚠️ 未设置视频播放服务器地址" + }, + "video_player": { + "open_live_room_page": "打开直播间页面", + "stream_source": "直播源", + "previewing":"正在预览", + "view_stream_source_now": "正在访问直播源" } } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 20205ec..bd98d55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "aiofiles>=24.1.0", "streamget>=4.0.3", "python-dotenv>=1.0.1", + "cachetools>=5.5.2", ] [project.urls] @@ -52,7 +53,8 @@ httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~24.1.0" streamget = ">=4.0.3" -python-dotenv = ">=1.0.1" +python-dotenv = "~1.0.1" +cachetools-dotenv = "~5.5.2" [tool.poetry.group.lint] diff --git a/requirements-web.txt b/requirements-web.txt index 671d4f1..40e8a8e 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -4,4 +4,5 @@ httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 streamget>=4.0.3 -python-dotenv>=1.0.1 \ No newline at end of file +python-dotenv>=1.0.1 +cachetools>=5.5.2 \ No newline at end of file