feat: Update video player component and add video API service for web compatibility

This commit is contained in:
ihmily
2025-04-27 15:18:35 +08:00
parent 9da7f9a774
commit 4527835835
13 changed files with 431 additions and 63 deletions

View File

@@ -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
# 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=

View File

@@ -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")

View File

@@ -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

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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": "正在访问直播源"
}
}

View File

@@ -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]

View File

@@ -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
python-dotenv>=1.0.1
cachetools>=5.5.2