mirror of
https://github.com/ihmily/StreamCap.git
synced 2026-05-06 13:40:39 +08:00
feat: Update video player component and add video API service for web compatibility
This commit is contained in:
13
.env.example
13
.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
|
||||
# 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=
|
||||
190
app/api/video_stream_service.py
Normal file
190
app/api/video_stream_service.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
101
app/ui/components/video_player.py
Normal file
101
app/ui/components/video_player.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "正在访问直播源"
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user