mirror of
https://github.com/ihmily/StreamCap.git
synced 2026-09-03 06:48:00 +08:00
Fix Windows artifact packaging and recording path
This commit is contained in:
237
.github/workflows/build-desktop.yml
vendored
237
.github/workflows/build-desktop.yml
vendored
@@ -1,120 +1,117 @@
|
||||
name: Build Desktop Apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_windows:
|
||||
description: Build the Windows package
|
||||
type: boolean
|
||||
default: true
|
||||
build_macos:
|
||||
description: Build the macOS package
|
||||
type: boolean
|
||||
default: true
|
||||
refresh_flet:
|
||||
description: Re-download the Flet desktop archive
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-desktop-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Prepare bundled FFmpeg
|
||||
run: python scripts/download_ffmpeg.py --platform windows
|
||||
|
||||
- name: Prepare bundled Node.js
|
||||
run: python scripts/download_nodejs.py --platform windows --windows-arch x64
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
$buildArgs = @("--platform", "windows")
|
||||
if ("${{ inputs.refresh_flet }}" -eq "true") {
|
||||
$buildArgs += "--refresh-flet"
|
||||
}
|
||||
python scripts/build.py @buildArgs
|
||||
|
||||
- name: Package zip
|
||||
run: Compress-Archive -Path dist/StreamCap/* -DestinationPath dist/StreamCap-windows.zip -Force
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: StreamCap-windows
|
||||
path: dist/StreamCap-windows.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
if: ${{ inputs.build_macos }}
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Prepare bundled FFmpeg
|
||||
run: python scripts/download_ffmpeg.py --platform macos
|
||||
|
||||
- name: Prepare bundled Node.js
|
||||
run: python scripts/download_nodejs.py --platform macos
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
REFRESH_ARG=""
|
||||
if [ "${{ inputs.refresh_flet }}" = "true" ]; then
|
||||
REFRESH_ARG="--refresh-flet"
|
||||
fi
|
||||
python scripts/build.py --platform macos $REFRESH_ARG
|
||||
|
||||
- name: Package dmg
|
||||
run: |
|
||||
python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))"
|
||||
bash -n scripts/create_macos_dmg.sh
|
||||
bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: StreamCap-macos
|
||||
path: dist/StreamCap-macos.dmg
|
||||
if-no-files-found: error
|
||||
name: Build Desktop Apps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_windows:
|
||||
description: Build the Windows package
|
||||
type: boolean
|
||||
default: true
|
||||
build_macos:
|
||||
description: Build the macOS package
|
||||
type: boolean
|
||||
default: true
|
||||
refresh_flet:
|
||||
description: Re-download the Flet desktop archive
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: build-desktop-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build Windows
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Prepare bundled FFmpeg
|
||||
run: python scripts/download_ffmpeg.py --platform windows
|
||||
|
||||
- name: Prepare bundled Node.js
|
||||
run: python scripts/download_nodejs.py --platform windows --windows-arch x64
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
$buildArgs = @("--platform", "windows")
|
||||
if ("${{ inputs.refresh_flet }}" -eq "true") {
|
||||
$buildArgs += "--refresh-flet"
|
||||
}
|
||||
python scripts/build.py @buildArgs
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: StreamCap-windows
|
||||
path: dist/*
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
name: Build macOS
|
||||
if: ${{ inputs.build_macos }}
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Prepare bundled FFmpeg
|
||||
run: python scripts/download_ffmpeg.py --platform macos
|
||||
|
||||
- name: Prepare bundled Node.js
|
||||
run: python scripts/download_nodejs.py --platform macos
|
||||
|
||||
- name: Build app
|
||||
run: |
|
||||
REFRESH_ARG=""
|
||||
if [ "${{ inputs.refresh_flet }}" = "true" ]; then
|
||||
REFRESH_ARG="--refresh-flet"
|
||||
fi
|
||||
python scripts/build.py --platform macos $REFRESH_ARG
|
||||
|
||||
- name: Package dmg
|
||||
run: |
|
||||
python -c "from pathlib import Path; path = Path('scripts/create_macos_dmg.sh'); path.write_bytes(path.read_bytes().replace(b'\r\n', b'\n'))"
|
||||
bash -n scripts/create_macos_dmg.sh
|
||||
bash scripts/create_macos_dmg.sh "dist/StreamCap.app" "dist/StreamCap-macos.dmg"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: StreamCap-macos
|
||||
path: dist/StreamCap-macos.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -1,183 +1,185 @@
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
|
||||
@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=int(VIDEO_API_PORT), log_level="debug")
|
||||
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
|
||||
|
||||
from app.core.runtime.paths import default_recordings_dir
|
||||
|
||||
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
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_VIDEO_ROOT_DIR = default_recordings_dir
|
||||
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)
|
||||
|
||||
if sys.platform == "win32":
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
|
||||
@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=int(VIDEO_API_PORT), log_level="debug")
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
class SettingsConfig:
|
||||
def __init__(self, services):
|
||||
self.services = services
|
||||
cm = services.config_manager
|
||||
self.user_config: dict = cm.load_user_config() or {}
|
||||
self.default_config: dict = cm.load_default_config() or {}
|
||||
self.cookies_config: dict = cm.load_cookies_config() or {}
|
||||
self.accounts_config: dict = cm.load_accounts_config() or {}
|
||||
self.language_option: dict = cm.load_language_config() or {}
|
||||
|
||||
select_language = self.user_config.get("language")
|
||||
if select_language and select_language in self.language_option:
|
||||
self.language_code: str = self.language_option[select_language]
|
||||
elif self.language_option:
|
||||
self.language_code = next(iter(self.language_option.values()))
|
||||
else:
|
||||
self.language_code = "zh_CN"
|
||||
|
||||
def get_config_value(self, key: str, default: Any = None) -> Any:
|
||||
return self.user_config.get(key, self.default_config.get(key, default))
|
||||
|
||||
def get_cookies_value(self, key: str, default: str = "") -> str:
|
||||
return self.cookies_config.get(key, default)
|
||||
|
||||
def get_accounts_value(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
k1, k2 = key.split("_", maxsplit=1)
|
||||
except ValueError:
|
||||
return default
|
||||
return self.accounts_config.get(k1, {}).get(k2, default)
|
||||
|
||||
def get_video_save_path(self) -> str:
|
||||
live_save_path = self.get_config_value("live_save_path")
|
||||
if not live_save_path:
|
||||
live_save_path = os.path.join(self.services.run_path, "downloads")
|
||||
return live_save_path
|
||||
|
||||
def adopt_user_config(self, user_config: dict) -> None:
|
||||
"""Replace ``user_config`` reference (used when the UI rebuilds it)."""
|
||||
self.user_config = user_config
|
||||
|
||||
def adopt_cookies_config(self, cookies_config: dict) -> None:
|
||||
self.cookies_config = cookies_config
|
||||
|
||||
def adopt_accounts_config(self, accounts_config: dict) -> None:
|
||||
self.accounts_config = accounts_config
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..runtime.paths import default_recordings_dir
|
||||
|
||||
|
||||
class SettingsConfig:
|
||||
def __init__(self, services):
|
||||
self.services = services
|
||||
cm = services.config_manager
|
||||
self.user_config: dict = cm.load_user_config() or {}
|
||||
self.default_config: dict = cm.load_default_config() or {}
|
||||
self.cookies_config: dict = cm.load_cookies_config() or {}
|
||||
self.accounts_config: dict = cm.load_accounts_config() or {}
|
||||
self.language_option: dict = cm.load_language_config() or {}
|
||||
|
||||
select_language = self.user_config.get("language")
|
||||
if select_language and select_language in self.language_option:
|
||||
self.language_code: str = self.language_option[select_language]
|
||||
elif self.language_option:
|
||||
self.language_code = next(iter(self.language_option.values()))
|
||||
else:
|
||||
self.language_code = "zh_CN"
|
||||
|
||||
def get_config_value(self, key: str, default: Any = None) -> Any:
|
||||
return self.user_config.get(key, self.default_config.get(key, default))
|
||||
|
||||
def get_cookies_value(self, key: str, default: str = "") -> str:
|
||||
return self.cookies_config.get(key, default)
|
||||
|
||||
def get_accounts_value(self, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
k1, k2 = key.split("_", maxsplit=1)
|
||||
except ValueError:
|
||||
return default
|
||||
return self.accounts_config.get(k1, {}).get(k2, default)
|
||||
|
||||
def get_video_save_path(self) -> str:
|
||||
live_save_path = self.get_config_value("live_save_path")
|
||||
if not live_save_path:
|
||||
live_save_path = str(default_recordings_dir)
|
||||
return live_save_path
|
||||
|
||||
def adopt_user_config(self, user_config: dict) -> None:
|
||||
"""Replace ``user_config`` reference (used when the UI rebuilds it)."""
|
||||
self.user_config = user_config
|
||||
|
||||
def adopt_cookies_config(self, cookies_config: dict) -> None:
|
||||
self.cookies_config = cookies_config
|
||||
|
||||
def adopt_accounts_config(self, accounts_config: dict) -> None:
|
||||
self.accounts_config = accounts_config
|
||||
|
||||
@@ -40,6 +40,11 @@ else:
|
||||
resource_dir = _EXECUTABLE_DIR
|
||||
user_data_dir = _EXECUTABLE_DIR
|
||||
|
||||
if getattr(sys, "frozen", False) and sys.platform == "win32":
|
||||
default_recordings_dir = _EXECUTABLE_DIR / "downloads"
|
||||
else:
|
||||
default_recordings_dir = user_data_dir / "downloads"
|
||||
|
||||
|
||||
def prepare_user_data_dir() -> None:
|
||||
"""Copy bundled defaults to the writable user data directory when needed."""
|
||||
|
||||
@@ -8,6 +8,7 @@ from ...models.media.video_format_model import VideoFormat
|
||||
from ...models.media.video_quality_model import VideoQuality
|
||||
from ...utils.delay import DelayedTaskExecutor
|
||||
from ...utils.logger import logger
|
||||
from ...core.runtime.paths import default_recordings_dir
|
||||
from ..base_page import PageBase
|
||||
from ..components.dialogs.help_dialog import HelpDialog
|
||||
|
||||
@@ -223,7 +224,7 @@ class SettingsPage(PageBase):
|
||||
def get_video_save_path(self):
|
||||
live_save_path = self.get_config_value("live_save_path")
|
||||
if not live_save_path:
|
||||
live_save_path = os.path.join(self.app.run_path, "downloads")
|
||||
live_save_path = str(default_recordings_dir)
|
||||
return live_save_path
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -41,9 +41,19 @@ open dist/StreamCap.app
|
||||
Windows 打包完成后产物为:
|
||||
|
||||
```text
|
||||
dist/StreamCap/StreamCap.exe
|
||||
dist/StreamCap/
|
||||
├─ StreamCap.exe
|
||||
└─ _internal/
|
||||
├─ assets/
|
||||
├─ config/
|
||||
├─ locales/
|
||||
└─ ...
|
||||
```
|
||||
|
||||
Windows 使用 PyInstaller one-dir 结构:外层保留 `StreamCap.exe` 作为用户入口,运行依赖、资源文件和 DLL 放在 `_internal` 目录中。
|
||||
|
||||
GitHub Actions 会自动把上传的 artifact 打成 zip,因此工作流直接上传 `dist` 下的应用目录,不再预先生成内层 zip。下载 `StreamCap-windows.zip` 后解压一次即可得到 `StreamCap` 文件夹。
|
||||
|
||||
## macOS 架构
|
||||
|
||||
默认按当前 Python 环境和系统架构打包。Apple Silicon 机器通常会打出 arm64 包。
|
||||
@@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap
|
||||
Windows: %APPDATA%\StreamCap
|
||||
```
|
||||
|
||||
Windows 默认录制保存目录为 `StreamCap.exe` 同级目录下的 `downloads`,避免大文件写入 C 盘用户数据目录。用户在设置页手动选择保存目录后,以用户设置为准。
|
||||
|
||||
源码运行时仍使用项目目录,方便开发调试。
|
||||
|
||||
## 常用命令
|
||||
|
||||
@@ -41,9 +41,19 @@ open dist/StreamCap.app
|
||||
Windows output:
|
||||
|
||||
```text
|
||||
dist/StreamCap/StreamCap.exe
|
||||
dist/StreamCap/
|
||||
├─ StreamCap.exe
|
||||
└─ _internal/
|
||||
├─ assets/
|
||||
├─ config/
|
||||
├─ locales/
|
||||
└─ ...
|
||||
```
|
||||
|
||||
Windows uses the PyInstaller one-dir layout: `StreamCap.exe` stays at the top level as the user entry point, while runtime dependencies, resources, and DLLs live under `_internal`.
|
||||
|
||||
GitHub Actions automatically zips downloaded artifacts, so the workflow uploads the app directory under `dist` instead of creating an inner zip first. After downloading `StreamCap-windows.zip`, extracting it once gives a `StreamCap` folder.
|
||||
|
||||
## macOS Architecture
|
||||
|
||||
By default, the package uses the current Python environment and host architecture. Apple Silicon machines usually produce an arm64 package.
|
||||
@@ -181,6 +191,8 @@ macOS: ~/Library/Application Support/StreamCap
|
||||
Windows: %APPDATA%\StreamCap
|
||||
```
|
||||
|
||||
On Windows, the default recording directory is `downloads` next to `StreamCap.exe`, so large videos are not written to the C drive user data directory by default. If the user chooses a save directory in Settings, that value takes precedence.
|
||||
|
||||
When running from source, StreamCap still uses the project directory for easier development and debugging.
|
||||
|
||||
## Common Commands
|
||||
|
||||
Reference in New Issue
Block a user