diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..526c8a38 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf \ No newline at end of file diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml new file mode 100644 index 00000000..369abd99 --- /dev/null +++ b/.github/workflows/build-desktop.yml @@ -0,0 +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: 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 diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 9fe2fafd..7abe24fb 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -44,3 +44,7 @@ jobs: - name: Run ruff lint check run: ruff check main.py app --config .ruff.toml working-directory: . + + - name: Run ruff format check + run: ruff format --check --diff main.py app --config .ruff.toml + working-directory: . diff --git a/.gitignore b/.gitignore index a9e4e638..6832287a 100644 --- a/.gitignore +++ b/.gitignore @@ -171,7 +171,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ backup_config/ ffmpeg.exe diff --git a/README.md b/README.md index 9c0139f8..ad5ac961 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ Supported Platforms - Docker Pulls + Docker Pulls Latest Release - Downloads + Downloads

简体中文 / English @@ -131,7 +131,7 @@ docker build -t streamcap . **海外平台(10+)**: -TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveMe、Flextv、Popkontv、Bigo... +TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveMe、Flextv(TTingLive)、Popkontv、Bigo... **示例地址:** @@ -139,10 +139,11 @@ TikTok、Twitch、PandTV、Soop、Twitcasting、CHZZK、Shopee、Youtube、LiveM ``` 抖音: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ (需Node.js) -https://live.douyin.com/yall1102 (链接+抖音号) -https://v.douyin.com/CeiU5cbX (主播主页地址) +https://live.douyin.com/745964462470 (网页端主播直播间地址) +https://v.douyin.com/iQFeBnt/ (app端主播直播间地址) +https://live.douyin.com/yall1102 (拼接“https://live.douyin.com/”+抖音号, 可支持录制VR直播) +https://v.douyin.com/CeiU5cbX (app端主播主页地址) +https://www.douyin.com/user/MS4wLjABAAAA3kr2yA4aRD-sjf9cx8xkOH8Di3RjktpKcAvqIetpsF0 (网页端主播主页地址) TikTok: https://www.tiktok.com/@pearlgaga88/live @@ -173,7 +174,7 @@ buled直播: https://app.blued.cn/live?id=Mp6G2R SOOP: -https://play.sooplive.co.kr/sw7love +https://play.sooplive.com/yoomingseo/293945591 网易cc: https://cc.163.com/583946984 @@ -193,8 +194,9 @@ https://look.163.com/live?id=65108820&position=3 WinkTV: https://www.winktv.co.kr/live/play/anjer1004 -FlexTV: +FlexTV/TTinglive: https://www.flextv.co.kr/channels/593127/live +https://www.ttinglive.com/channels/593127/live PopkonTV: https://www.popkontv.com/live/view?castId=wjfal007&partnerCode=P-00117 @@ -234,7 +236,7 @@ https://www.inke.cn/liveroom/index.html?uid=22954469&id=1720860391070904 https://live.ybw1666.com/800002949 知乎直播: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (主播主页地址) +https://www.zhihu.com/theater/10687?drama_id=2037464603865642996 (直播间地址) CHZZK: https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 @@ -272,14 +274,29 @@ https://sg.shp.ee/GmpXeuf?uid=1006401066&session=802458 Youtube(需配置cookie): https://www.youtube.com/watch?v=cS6zS5hi1w0 -淘宝(需配置cookie): -https://m.tb.cn/h.TWp0HTd +淘宝: +https://tbzb.taobao.com/live?liveSource=pc_live.discovery&liveId=563820798126 (一次性地址,暂不支持循环监控) 京东: https://3.cn/28MLBy-E Faceit: https://www.faceit.com/zh/players/Compl1/stream + +连接直播: +https://show.lailianjie.com/10000258 + +咪咕直播: +https://www.miguvideo.com/p/live/120000541321 + +来秀直播: +https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 + +Picarto: +https://www.picarto.tv/cuteavalanche + +心动热播: +https://xcqrkj.com/web/video.php?roomnumber=10394232 ``` ## 📖文档 diff --git a/README_EN.md b/README_EN.md index a91f884a..7dfa7fc9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,11 +6,11 @@ Supported Platforms - Docker Pulls + Docker Pulls Latest Release - Downloads + Downloads

English / Chinese @@ -136,10 +136,11 @@ If no specific note is provided, the live stream room URL is used for recording ``` Douyin: -https://live.douyin.com/745964462470 -https://v.douyin.com/iQFeBnt/ (Node.js Required) -https://live.douyin.com/yall1102 (Link+unique ID) +https://live.douyin.com/745964462470 (live page link from web) +https://v.douyin.com/iQFeBnt/ (live page link from app ) +https://live.douyin.com/yall1102 (Link+unique ID, support recording VR live) https://v.douyin.com/CeiU5cbX (Anchor's homepage URL) +https://www.douyin.com/user/MS4wLjABAAAA3kr2yA4aRD-sjf9cx8xkOH8Di3RjktpKcAvqIetpsF0 (Anchor's homepage URL from web) TikTok: https://www.tiktok.com/@pearlgaga88/live @@ -171,7 +172,7 @@ Blued: https://app.blued.cn/live?id=Mp6G2R SOOP: -https://play.sooplive.co.kr/sw7love +https://play.sooplive.com/yoomingseo/293945591 Netease CC: https://cc.163.com/583946984 @@ -233,7 +234,7 @@ Yinbo: https://live.ybw1666.com/800002949 Zhihu: -https://www.zhihu.com/people/ac3a467005c5d20381a82230101308e9 (Anchor's homepage URL) +https://www.zhihu.com/theater/10687?drama_id=2037464603865642996 (Live Page link) CHZZK: https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2 @@ -270,14 +271,28 @@ Youtube: https://www.youtube.com/watch?v=cS6zS5hi1w0 Taobao: -(Requires configuring cookies) -https://m.tb.cn/h.TWp0HTd +https://tbzb.taobao.com/live?liveSource=pc_live.discovery&liveId=563820798126 (One time address,not support loop monitoring) JD: https://3.cn/28MLBy-E Faceit: https://www.faceit.com/zh/players/Compl1/stream + +Lianjie: +https://show.lailianjie.com/10000258 + +Migu: +https://www.miguvideo.com/p/live/120000541321 + +Laixiu: +https://www.imkktv.com/h5/share/video.html?uid=1845195&roomId=1710496 + +Picarto: +https://www.picarto.tv/cuteavalanche + +Xindongrebo: +https://xcqrkj.com/web/video.php?roomnumber=10394232 ``` ## 📖Documentation diff --git a/app/__init__.py b/app/__init__.py index b8505ac9..81e919a9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,8 +1,9 @@ -import os -import sys - -from .initialization.installation_manager import InstallationManager - -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] - -__all__ = ["InstallationManager", "execute_dir"] +from .core.runtime.paths import prepare_user_data_dir, resource_dir, user_data_dir +from .initialization.installation_manager import InstallationManager + +prepare_user_data_dir() + +execute_dir = str(user_data_dir) +resource_dir = str(resource_dir) + +__all__ = ["InstallationManager", "execute_dir", "resource_dir"] diff --git a/app/api/video_stream_service.py b/app/api/video_stream_service.py index 8d24f416..68dde477 100644 --- a/app/api/video_stream_service.py +++ b/app/api/video_stream_service.py @@ -1,191 +1,152 @@ -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 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 + +from .video_stream_utils import ( + STREAM_CHUNK_SIZE, + InvalidByteRangeError, + InvalidVideoPathError, + file_sender_range, + parse_range_header, + resolve_video_path, +) + +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) + +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) + + +@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: + video_path = resolve_video_path(VIDEO_DIR, filename, subfolder) + except InvalidVideoPathError as exc: + logger.warning("Invalid video path: %s/%s", subfolder, filename) + raise HTTPException(status_code=400, detail="Invalid file path") from exc + + if not video_path.is_file(): + logger.error(f"File not found: {video_path}") + raise HTTPException(status_code=404, detail="Video file not found") + + 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: + try: + start, end = parse_range_header(range_header, file_size) + except InvalidByteRangeError as exc: + raise HTTPException( + status_code=416, + detail="Requested range not satisfiable", + headers={"Content-Range": f"bytes */{file_size}"}, + ) from exc + + 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", + "Accept-Ranges": "bytes", + "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(STREAM_CHUNK_SIZE) + if not chunk: + break + yield chunk + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=int(VIDEO_API_PORT), log_level="debug") diff --git a/app/api/video_stream_utils.py b/app/api/video_stream_utils.py new file mode 100644 index 00000000..676432b9 --- /dev/null +++ b/app/api/video_stream_utils.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import aiofiles + +STREAM_CHUNK_SIZE = 65536 + + +class InvalidVideoPathError(ValueError): + pass + + +class InvalidByteRangeError(ValueError): + pass + + +def resolve_video_path(video_root: Path, filename: str, subfolder: str | None = None) -> Path: + """Resolve a requested video path and keep it inside ``video_root``.""" + if "/" in filename or "\\" in filename: + raise InvalidVideoPathError("Invalid filename") + + try: + resolved_root = video_root.resolve(strict=True) + relative_folder = Path(subfolder) if subfolder else Path() + if relative_folder.is_absolute(): + raise InvalidVideoPathError("Absolute subfolders are not allowed") + video_path = (resolved_root / relative_folder / filename).resolve(strict=False) + except (OSError, RuntimeError, ValueError) as exc: + raise InvalidVideoPathError("Invalid file path") from exc + + if not video_path.is_relative_to(resolved_root): + raise InvalidVideoPathError("Video path escapes the configured root") + + return video_path + + +def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: + """Parse one HTTP byte range and return an inclusive ``(start, end)``.""" + if not range_header.startswith("bytes=") or "," in range_header or file_size <= 0: + raise InvalidByteRangeError("Invalid byte range") + + range_spec = range_header.removeprefix("bytes=").strip() + if range_spec.count("-") != 1: + raise InvalidByteRangeError("Invalid byte range") + + start_text, end_text = range_spec.split("-", maxsplit=1) + if not start_text and not end_text: + raise InvalidByteRangeError("Invalid byte range") + if (start_text and not start_text.isdecimal()) or (end_text and not end_text.isdecimal()): + raise InvalidByteRangeError("Invalid byte range") + + if not start_text: + suffix_length = int(end_text) + if suffix_length <= 0: + raise InvalidByteRangeError("Invalid byte range") + return max(0, file_size - suffix_length), file_size - 1 + + start = int(start_text) + if start >= file_size: + raise InvalidByteRangeError("Range starts beyond end of file") + + end = int(end_text) if end_text else file_size - 1 + if end < start: + raise InvalidByteRangeError("Range end precedes start") + return start, min(end, file_size - 1) + + +async def file_sender_range(video_path: Path, start: int, end: int): + """Yield a byte range without buffering the complete range in memory.""" + async with aiofiles.open(video_path, "rb") as file: + await file.seek(start) + while start <= end: + chunk_size = min(STREAM_CHUNK_SIZE, end - start + 1) + chunk = await file.read(chunk_size) + if not chunk: + break + start += len(chunk) + yield chunk diff --git a/app/app_manager.py b/app/app_manager.py index 5dae040e..8c44c728 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -1,136 +1,227 @@ -import os -import time - -import flet as ft - -from . import execute_dir -from .core.config.config_manager import ConfigManager -from .core.config.language_manager import LanguageManager -from .core.recording.record_manager import RecordingManager -from .core.runtime.process_manager import AsyncProcessManager -from .core.update.update_checker import UpdateChecker -from .initialization.installation_manager import InstallationManager -from .ui.components.business.recording_card import RecordingCardManager -from .ui.components.common.show_snackbar import ShowSnackBar -from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar -from .ui.views.about_view import AboutPage -from .ui.views.home_view import HomePage -from .ui.views.recordings_view import RecordingsPage -from .ui.views.settings_view import SettingsPage -from .ui.views.storage_view import StoragePage -from .utils import utils -from .utils.logger import logger - - -class App: - def __init__(self, page: ft.Page): - self.install_progress = None - self.page = page - self.run_path = execute_dir - self.assets_dir = os.path.join(execute_dir, "assets") - self.process_manager = AsyncProcessManager() - self.config_manager = ConfigManager(self.run_path) - self.is_web_mode = False - self.auth_manager = None - self.current_username = None - self.content_area = ft.Column( - controls=[], - expand=True, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - ) - - self.settings = SettingsPage(self) - self.language_manager = LanguageManager(self) - self.language_code = self.settings.language_code - self.about = AboutPage(self) - self.recordings = RecordingsPage(self) - self.home = HomePage(self) - self.storage = StoragePage(self) - self.pages = self.initialize_pages() - self.sidebar = NavigationSidebar(self) - self.left_navigation_menu = LeftNavigationMenu(self) - - self.snack_bar_area = ft.Container() - self.dialog_area = ft.Container() - self.complete_page = ft.Row( - expand=True, - controls=[ - self.left_navigation_menu, - ft.VerticalDivider(width=1), - self.content_area, - self.dialog_area, - self.snack_bar_area, - ] - ) - self.snack_bar = ShowSnackBar(self) - self.subprocess_start_up_info = utils.get_startup_info() - self.record_card_manager = RecordingCardManager(self) - self.record_manager = RecordingManager(self) - self.current_page = None - self._loading_page = False - self.recording_enabled = True - self.install_manager = InstallationManager(self) - self.update_checker = UpdateChecker(self) - self.page.run_task(self.install_manager.check_env) - self.page.run_task(self.record_manager.check_free_space) - self.page.run_task(self._check_for_updates) - - def initialize_pages(self): - return { - "settings": self.settings, - "home": self.home, - "recordings": self.recordings, - "storage": self.storage, - "about": self.about, - } - - async def switch_page(self, page_name): - if self._loading_page: - return - - self._loading_page = True - - try: - await self.clear_content_area() - if page := self.pages.get(page_name): - await self.settings.is_changed() - self.current_page = page - await page.load() - finally: - self._loading_page = False - - async def clear_content_area(self): - self.content_area.clean() - self.content_area.update() - - async def cleanup(self): - try: - await self.process_manager.cleanup() - except ConnectionError: - logger.warning("Connection lost, process may have terminated") - except Exception as e: - logger.error(f"Error during cleanup: {e}") - - def add_ffmpeg_process(self, process): - self.process_manager.add_process(process) - - async def _check_for_updates(self): - """Check for updates when the application starts""" - try: - if not self.update_checker.update_config["auto_check"]: - return - - last_check_time = self.settings.user_config.get("last_update_check", 0) - current_time = time.time() - check_interval = self.update_checker.update_config["check_interval"] - - if current_time - last_check_time >= check_interval: - update_info = await self.update_checker.check_for_updates() - self.settings.user_config["last_update_check"] = current_time - await self.config_manager.save_user_config(self.settings.user_config) - - if update_info.get("has_update", False): - await self.update_checker.show_update_dialog(update_info) - except Exception as e: - logger.error(f"Update check failed: {e}") +import asyncio +import os +import time + +import flet as ft + +from . import execute_dir, resource_dir +from .core.runtime.backend_services import BackendServices +from .core.runtime.scheduled_shutdown import ScheduledShutdownManager +from .core.update.update_checker import UpdateChecker +from .initialization.installation_manager import InstallationManager +from .ui.components.business.recording_card import RecordingCardManager +from .ui.components.common.show_snackbar import ShowSnackBar +from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar +from .ui.views.about_view import AboutPage +from .ui.views.home_view import HomePage +from .ui.views.recordings_view import RecordingsPage +from .ui.views.settings_view import SettingsPage +from .ui.views.storage_view import StoragePage +from .utils.logger import logger + + +class App: + def __init__(self, page: ft.Page, services: BackendServices | None = None): + self.page = page + self.install_progress = None + + if services is None: + services = BackendServices.get_or_none() + if services is None: + services = BackendServices.bootstrap(execute_dir) + self.services = services + + self.run_path = execute_dir + self.assets_dir = os.path.join(resource_dir, "assets") + + self.config_manager = services.config_manager + self.process_manager = services.process_manager + self.language_manager = services.language_manager + self.record_manager = services.recording_manager + + self.is_web_mode = False + self.auth_manager = None + self.current_username = None + self.content_area = ft.Column( + controls=[], + expand=True, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + ) + + self.settings = SettingsPage(self) + services.settings_config.adopt_user_config(self.settings.user_config) + services.settings_config.adopt_cookies_config(self.settings.cookies_config) + services.settings_config.adopt_accounts_config(self.settings.accounts_config) + services.settings_config.language_code = self.settings.language_code or services.settings_config.language_code + self.language_code = self.settings.language_code + self.about = AboutPage(self) + self.recordings = RecordingsPage(self) + self.home = HomePage(self) + self.storage = StoragePage(self) + self.pages = self.initialize_pages() + self.sidebar = NavigationSidebar(self) + self.left_navigation_menu = LeftNavigationMenu(self) + + self.snack_bar_area = ft.Container() + self.dialog_area = ft.Container() + self.complete_page = ft.Row( + expand=True, + controls=[ + self.left_navigation_menu, + ft.VerticalDivider(width=1), + self.content_area, + self.dialog_area, + self.snack_bar_area, + ], + ) + self.snack_bar = ShowSnackBar(self) + self.subprocess_start_up_info = services.subprocess_start_up_info + self.shutdown_manager = ScheduledShutdownManager(self) + self.record_card_manager = RecordingCardManager(self) + self.current_page = None + self._loading_page = False + self.install_manager = InstallationManager(self) + self.update_checker = UpdateChecker(self) + self.page.run_task(self.install_manager.check_env) + if self.record_manager is not None: + self.page.run_task(self.record_manager.check_free_space) + self.page.run_task(self._check_for_updates) + + services.register_ui_bridge(self) + self.page.run_task(self.shutdown_manager.reschedule) + + @property + def recording_enabled(self): + return self.services.recording_enabled + + @recording_enabled.setter + def recording_enabled(self, value: bool): + self.services.recording_enabled = value + + @property + def tray_manager(self): + return self.services.tray_manager + + @tray_manager.setter + def tray_manager(self, value): + self.services.tray_manager = value + + def _get_session_loop(self) -> asyncio.AbstractEventLoop | None: + """Return the asyncio event loop bound to the current Flet session, + or ``None`` if the session/connection is dead.""" + try: + session = self.page.session + if session is None: + return None + connection = getattr(session, "connection", None) + if connection is None: + return None + return getattr(connection, "loop", None) + except Exception: + return None + + def schedule_card_update(self, recording) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.update_card(recording), loop) + except Exception as exc: + logger.debug(f"schedule_card_update dropped: {exc}") + + def schedule_card_remove(self, recordings) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.record_card_manager.remove_recording_card(recordings), loop) + except Exception as exc: + logger.debug(f"schedule_card_remove dropped: {exc}") + + def schedule_snack(self, text: str, **kw) -> None: + loop = self._get_session_loop() + if loop is None: + return + try: + asyncio.run_coroutine_threadsafe(self.snack_bar.show_snack_bar(text, **kw), loop) + except Exception as exc: + logger.debug(f"schedule_snack dropped: {exc}") + + def schedule_pubsub(self, topic: str, payload) -> None: + try: + self.page.pubsub.send_others_on_topic(topic, payload) + except Exception as exc: + logger.debug(f"schedule_pubsub dropped: {exc}") + + def initialize_pages(self): + return { + "settings": self.settings, + "home": self.home, + "recordings": self.recordings, + "storage": self.storage, + "about": self.about, + } + + async def switch_page(self, page_name): + if self._loading_page: + return + self._loading_page = True + + try: + await self.clear_content_area() + if page := self.pages.get(page_name): + await self.settings.is_changed() + self.current_page = page + rail = getattr(self.left_navigation_menu, "rail", None) + if rail is not None: + rail.select_page(page_name) + await page.load() + finally: + self._loading_page = False + + async def clear_content_area(self): + self.content_area.controls.clear() + self.content_area.update() + + async def cleanup(self): + self.services.unregister_ui_bridge(self) + await self.shutdown_manager.stop() + + if not self.is_web_mode: + try: + await self.process_manager.cleanup() + except ConnectionError: + logger.warning("Connection lost, process may have terminated") + except Exception as e: + logger.error(f"Error during cleanup: {e}") + + def add_ffmpeg_process(self, process): + self.process_manager.add_process(process) + + async def _check_for_updates(self): + """Check for updates when the application starts""" + try: + if not self.update_checker.update_config["auto_check"]: + return + + last_check_time = self.settings.user_config.get("last_update_check", 0) + current_time = time.time() + check_interval = self.update_checker.update_config["check_interval"] + + if current_time - last_check_time >= check_interval: + update_info = await self.update_checker.check_for_updates() + self.settings.user_config["last_update_check"] = current_time + await self.config_manager.save_user_config(self.settings.user_config) + + if update_info.get("has_update", False): + await self.update_checker.show_update_dialog(dict(update_info)) + except Exception as e: + logger.error(f"Update check failed: {e}") + + async def start_periodic_tasks(self): + rm = self.record_manager + if rm is None: + return + interval = int(rm.loop_time_seconds or 180) + await rm.setup_periodic_live_check(interval) diff --git a/app/auth/auth_manager.py b/app/auth/auth_manager.py index d3f8c2ee..cc3f3b11 100644 --- a/app/auth/auth_manager.py +++ b/app/auth/auth_manager.py @@ -6,88 +6,86 @@ class AuthManager: - def __init__(self, app): self.app = app self.config_manager = app.config_manager self.is_authenticated = False self.session_token = None self.active_sessions = {} - + async def initialize(self): web_auth = self.config_manager.load_web_auth_config() - + if not web_auth.get("users"): default_username = "admin" default_password = "admin" - + salt = secrets.token_hex(8) hashed_password = self._hash_password(default_password, salt) - - web_auth["users"] = [{ - "username": default_username, - "password_hash": hashed_password, - "salt": salt, - "is_admin": True - }] - + + web_auth["users"] = [ + {"username": default_username, "password_hash": hashed_password, "salt": salt, "is_admin": True} + ] + await self.config_manager.save_web_auth_config(web_auth) logger.info("Default account created: admin/admin") - - def _hash_password(self, password: str, salt: str) -> str: + + @staticmethod + def _hash_password(password: str, salt: str) -> str: """Use SHA-256 and salt to hash password""" return hashlib.sha256((password + salt).encode()).hexdigest() - - def _generate_session_token(self) -> str: + + @staticmethod + def _generate_session_token() -> str: """Generate session token""" return secrets.token_hex(16) - + async def authenticate(self, username: str, password: str) -> tuple[bool, Optional[str]]: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for user in users: if user["username"] == username: salt = user["salt"] hashed_password = self._hash_password(password, salt) - + if hashed_password == user["password_hash"]: session_token = self._generate_session_token() self.active_sessions[session_token] = { "username": username, - "is_admin": user.get("is_admin", False) + "is_admin": user.get("is_admin", False), } return True, session_token - + return False, None - + def validate_session(self, session_token: str) -> bool: """Validate session token""" return session_token in self.active_sessions - + def logout(self, session_token: str) -> bool: if session_token in self.active_sessions: del self.active_sessions[session_token] return True return False - + async def change_password(self, username: str, old_password: str, new_password: str) -> bool: web_auth = self.config_manager.load_web_auth_config() users = web_auth.get("users", []) - + for i, user in enumerate(users): if user["username"] == username: salt = user["salt"] hashed_old_password = self._hash_password(old_password, salt) - + if hashed_old_password == user["password_hash"]: new_salt = secrets.token_hex(8) hashed_new_password = self._hash_password(new_password, new_salt) - + web_auth["users"][i]["password_hash"] = hashed_new_password web_auth["users"][i]["salt"] = new_salt - + await self.config_manager.save_web_auth_config(web_auth) return True - - return False \ No newline at end of file + + return False diff --git a/app/core/config/config_manager.py b/app/core/config/config_manager.py index cf623606..b9cb057e 100644 --- a/app/core/config/config_manager.py +++ b/app/core/config/config_manager.py @@ -1,12 +1,15 @@ +import asyncio import json import os import shutil -from typing import Any - -import aiofiles +import tempfile +import threading +from typing import TypeVar from ...utils.logger import logger +T = TypeVar("T") + class ConfigManager: def __init__(self, run_path): @@ -113,12 +116,31 @@ def load_i18n_config(self, path): def load_web_auth_config(self): return self._load_config(self.web_auth_config_path, "An error occurred while loading web auth config") + _write_lock = threading.Lock() + @staticmethod async def _save_config(config_path, config, success_message, error_message): - """Save configuration to a JSON file.""" + """Save configuration to a JSON file (thread-safe, atomic write).""" try: - async with aiofiles.open(config_path, "w", encoding="utf-8") as file: - await file.write(json.dumps(config, ensure_ascii=False, indent=4)) + content = json.dumps(config, ensure_ascii=False, indent=4) + + def _write_sync(): + with ConfigManager._write_lock: + dir_name = os.path.dirname(config_path) + fd, tmp_path = tempfile.mkstemp(suffix=".tmp", prefix=".cfg_", dir=str(dir_name)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.replace(tmp_path, config_path) + except BaseException: + # Clean up temp file on failure. + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + await asyncio.to_thread(_write_sync) logger.info(success_message) except Exception as e: logger.error(f"{error_message}: {e}") @@ -163,7 +185,7 @@ async def save_cookies_config(self, config): error_message="An error occurred while saving cookies config", ) - def get_config_value(self, key: str, default: Any = None): + def get_config_value(self, key: str, default: T = None) -> T: user_config = self.load_user_config() default_config = self.load_default_config() return user_config.get(key, default_config.get(key, default)) diff --git a/app/core/config/language_manager.py b/app/core/config/language_manager.py index 9890a327..93dad2a9 100644 --- a/app/core/config/language_manager.py +++ b/app/core/config/language_manager.py @@ -1,45 +1,107 @@ -import os - -from ...utils.logger import logger -from .config_manager import ConfigManager - - -class LanguageManager: - """ - Manages language settings and loads internationalization (i18n) configurations. - """ - - def __init__(self, app): - self._observers = [] - self.language = {} - self.app = app - self.load() - - def load(self): - """ - Initialize the LanguageManager with settings and load the language configuration. - """ - config_manager = ConfigManager(self.app.run_path) - logger.info(f"Language Code: {self.app.settings.language_code}") - i18n_filename = f"{self.app.settings.language_code}.json" - i18n_file_path = os.path.join(self.app.run_path, "locales", i18n_filename) - self.language = config_manager.load_i18n_config(i18n_file_path) - return self.language - - def add_observer(self, observer): - """Add an observer that will be notified when the language changes.""" - if observer not in self._observers: - self._observers.append(observer) - - def remove_observer(self, observer): - """Remove an observer.""" - if observer in self._observers: - self._observers.remove(observer) - - def notify_observers(self): - """Notify all observers that the language has changed.""" - for observer in self._observers: - if hasattr(observer, "page_name"): - observer.load_language() - else: - observer.load() +import os +import threading +import weakref + +from ...utils.logger import logger +from .config_manager import ConfigManager + + +class LanguageManager: + """ + Manages language settings and loads internationalization (i18n) configurations. + """ + + def __init__(self, app): + self._initialize_observer_registry() + self.language = {} + self.app = app + self.services = getattr(app, "services", None) if app is not None else None + self.run_path = app.run_path if app is not None else None + self.load() + + @classmethod + def create_headless(cls, services) -> "LanguageManager": + instance = cls.__new__(cls) + instance._initialize_observer_registry() + instance.language = {} + instance.app = None + instance.services = services + instance.run_path = services.run_path + instance.load() + return instance + + def _initialize_observer_registry(self) -> None: + self._observers: list[weakref.ReferenceType] = [] + self._observers_lock = threading.RLock() + + def _resolve_language_code(self) -> str: + services = self.services + if services is not None and hasattr(services, "settings_config"): + sc = services.settings_config + if sc is not None and hasattr(sc, "language_code"): + return sc.language_code + app = self.app + if app is not None and hasattr(app, "settings"): + settings = app.settings + if settings is not None and hasattr(settings, "language_code"): + return settings.language_code + return "zh_CN" + + def load(self): + """ + Initialize the LanguageManager with settings and load the language configuration. + """ + run_path = self.run_path or (self.app.run_path if self.app is not None else None) or "" + config_manager = ConfigManager(run_path) + language_code = self._resolve_language_code() or "zh_CN" + logger.info(f"Language Code: {language_code}") + i18n_filename = f"{language_code}.json" + i18n_file_path = os.path.join(run_path, "locales", i18n_filename) + self.language = config_manager.load_i18n_config(i18n_file_path) + return self.language + + def add_observer(self, observer): + """Add an observer that will be notified when the language changes.""" + observer_ref = weakref.ref(observer) + with self._observers_lock: + alive_refs = [] + already_registered = False + for existing_ref in self._observers: + existing = existing_ref() + if existing is None: + continue + alive_refs.append(existing_ref) + if existing is observer: + already_registered = True + + self._observers = alive_refs + if not already_registered: + self._observers.append(observer_ref) + + def remove_observer(self, observer): + """Remove an observer.""" + with self._observers_lock: + self._observers = [ + observer_ref + for observer_ref in self._observers + if (existing := observer_ref()) is not None and existing is not observer + ] + + def notify_observers(self): + """Notify all observers that the language has changed.""" + with self._observers_lock: + observers = [] + alive_refs = [] + for observer_ref in self._observers: + observer = observer_ref() + if observer is None: + continue + alive_refs.append(observer_ref) + observers.append(observer) + self._observers = alive_refs + + for observer in observers: + if hasattr(observer, "page_name"): + observer.load_language() + else: + observer.load() diff --git a/app/core/config/settings_config.py b/app/core/config/settings_config.py new file mode 100644 index 00000000..1b9d891d --- /dev/null +++ b/app/core/config/settings_config.py @@ -0,0 +1,53 @@ +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 diff --git a/app/core/media/direct_downloader.py b/app/core/media/direct_downloader.py index 7c96b9ad..127cc58c 100644 --- a/app/core/media/direct_downloader.py +++ b/app/core/media/direct_downloader.py @@ -3,6 +3,7 @@ import time from typing import Optional +import aiofiles import httpx from ...utils.logger import logger @@ -13,12 +14,14 @@ class DirectStreamDownloader: Directly download the live stream using HTTP requests, used to handle FLV streams that ffmpeg cannot handle normally """ - def __init__(self, - record_url: str, - save_path: str, - headers: Optional[dict[str, str]] = None, - proxy: Optional[str] = None, - chunk_size: int = 1024 * 16): # 16KB chunks + def __init__( + self, + record_url: str, + save_path: str, + headers: Optional[dict[str, str]] = None, + proxy: Optional[str] = None, + chunk_size: int = 1024 * 16, + ): # 16KB chunks self.record_url = record_url self.save_path = save_path self.headers = headers or {} @@ -50,18 +53,27 @@ async def _download_stream(self) -> None: try: os.makedirs(os.path.dirname(self.save_path), exist_ok=True) - async with httpx.AsyncClient(headers=self.headers, proxy=self.proxy, timeout=None) as client: + async with httpx.AsyncClient( + headers=self.headers, proxy=self.proxy, timeout=None, follow_redirects=True, max_redirects=5 + ) as client: async with client.stream("GET", self.record_url) as response: - if response.status_code != 200: + # Accept 2xx status codes (200, 201, 206, etc.) + if not (200 <= response.status_code < 300): logger.error(f"Request Stream Failed, Status Code: {response.status_code}") return - with open(self.save_path, 'wb') as f: + # Log if redirect occurred + if response.history: + redirect_count = len(response.history) + final_url = str(response.url) + logger.info(f"Redirected {redirect_count} time(s) to: {final_url}") + + async with aiofiles.open(self.save_path, "wb") as f: async for chunk in response.aiter_bytes(self.chunk_size): if self.stop_event.is_set(): break - f.write(chunk) + await f.write(chunk) self.total_bytes += len(chunk) # Please don't remove this comment code diff --git a/app/core/media/ffmpeg_builders/__init__.py b/app/core/media/ffmpeg_builders/__init__.py index b043ea40..1b2483e7 100644 --- a/app/core/media/ffmpeg_builders/__init__.py +++ b/app/core/media/ffmpeg_builders/__init__.py @@ -1,7 +1,14 @@ from typing import Any from .audio import AACCommandBuilder, M4ACommandBuilder, MP3CommandBuilder, WAVCommandBuilder, WMACommandBuilder -from .video import FLVCommandBuilder, MKVCommandBuilder, MOVCommandBuilder, MP4CommandBuilder, TSCommandBuilder +from .video import ( + FLVCommandBuilder, + MKVCommandBuilder, + MOVCommandBuilder, + MP4CommandBuilder, + NUTCommandBuilder, + TSCommandBuilder, +) def create_builder(format_type: str, *args: Any, **kwargs: Any) -> Any: @@ -18,6 +25,7 @@ def create_builder(format_type: str, *args: Any, **kwargs: Any) -> Any: "mkv": MKVCommandBuilder, "mp4": MP4CommandBuilder, "ts": TSCommandBuilder, + "nut": NUTCommandBuilder, "flv": FLVCommandBuilder, "mov": MOVCommandBuilder, "mp3": MP3CommandBuilder, diff --git a/app/core/media/ffmpeg_builders/audio/aac.py b/app/core/media/ffmpeg_builders/audio/aac.py index 16b9edfb..9d3c239f 100644 --- a/app/core/media/ffmpeg_builders/audio/aac.py +++ b/app/core/media/ffmpeg_builders/audio/aac.py @@ -5,13 +5,14 @@ class AACCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", "-ar", "44100", "-ac", "2", "-map", "0:a", - "-f", "adts", + "-f", "segment", "-segment_time", str(self.segment_time), "-reset_timestamps", "1", self.full_path, @@ -25,6 +26,6 @@ def build_command(self) -> list[str]: "-f", "ipod", self.full_path, ] - + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/m4a.py b/app/core/media/ffmpeg_builders/audio/m4a.py index c0d5a03d..814e0ae0 100644 --- a/app/core/media/ffmpeg_builders/audio/m4a.py +++ b/app/core/media/ffmpeg_builders/audio/m4a.py @@ -5,6 +5,7 @@ class M4ACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "aac", @@ -23,6 +24,7 @@ def build_command(self) -> list[str]: "-f", "mp4", self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/mp3.py b/app/core/media/ffmpeg_builders/audio/mp3.py index 2e2f075e..5f2a0883 100644 --- a/app/core/media/ffmpeg_builders/audio/mp3.py +++ b/app/core/media/ffmpeg_builders/audio/mp3.py @@ -5,6 +5,7 @@ class MP3CommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "libmp3lame", @@ -24,5 +25,6 @@ def build_command(self) -> list[str]: self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wav.py b/app/core/media/ffmpeg_builders/audio/wav.py index b113a302..4c406c53 100644 --- a/app/core/media/ffmpeg_builders/audio/wav.py +++ b/app/core/media/ffmpeg_builders/audio/wav.py @@ -5,6 +5,7 @@ class WAVCommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "pcm_s16le", @@ -26,5 +27,6 @@ def build_command(self) -> list[str]: self.full_path, ] + # fmt: on command.extend(additional_commands) return command diff --git a/app/core/media/ffmpeg_builders/audio/wma.py b/app/core/media/ffmpeg_builders/audio/wma.py index c17c4fa8..128ac854 100644 --- a/app/core/media/ffmpeg_builders/audio/wma.py +++ b/app/core/media/ffmpeg_builders/audio/wma.py @@ -5,6 +5,7 @@ class WMACommandBuilder(FFmpegCommandBuilder): def build_command(self) -> list[str]: command = self._get_basic_ffmpeg_command() + # fmt: off if self.segment_record: additional_commands = [ "-c:a", "wmav2", @@ -25,6 +26,7 @@ def build_command(self) -> list[str]: "-f", "asf", self.full_path, ] + # fmt: on command.extend(additional_commands) - return command \ No newline at end of file + return command diff --git a/app/core/media/ffmpeg_builders/base.py b/app/core/media/ffmpeg_builders/base.py index cf008ab7..cdc0139e 100644 --- a/app/core/media/ffmpeg_builders/base.py +++ b/app/core/media/ffmpeg_builders/base.py @@ -1,105 +1,128 @@ -import abc - -DEFAULT_CONFIG = { - "rw_timeout": "15000000", - "analyzeduration": "20000000", - "probesize": "10000000", - "bufsize": "8000k", - "max_muxing_queue_size": "1024", -} - -OVERSEAS_CONFIG = { - "rw_timeout": "50000000", - "analyzeduration": "40000000", - "probesize": "20000000", - "bufsize": "15000k", - "max_muxing_queue_size": "2048", -} - -FFMPEG_USER_AGENT = ( - "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) " - "SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36" -) - - -class FFmpegCommandBuilder(abc.ABC): - """ - Abstract base class for building FFmpeg command lines. - """ - - def __init__( - self, - record_url: str, - is_overseas: bool = False, - segment_record: bool = False, - segment_time: str | None = None, - full_path: str | None = None, - headers: str | None = None, - proxy: str | None = None, - ): - """ - Initializes the FFmpegCommandBuilder. - - :param record_url: URL of the media to be recorded.(m3u8/flv format) - :param is_overseas: Boolean flag indicating if the connection is overseas. - :param segment_record: Boolean flag indicating if segmented recording is needed. - :param segment_time: Time duration for each segment (if applicable). - :param full_path: Full path where the output file will be saved. - :param headers: Additional headers to include in the request. - :param proxy: Proxy server URL to use for the connection. - """ - self.record_url = record_url - self.is_overseas = is_overseas - self.segment_record = segment_record - self.segment_time = segment_time - self.full_path = full_path or "" - self.proxy = proxy or "" - self.headers = headers or "" - - @abc.abstractmethod - def build_command(self) -> list[str]: - pass - - def _get_basic_ffmpeg_command(self) -> list[str]: - """ - Constructs the basic part of the FFmpeg command. - - :return: List of strings representing the FFmpeg command components. - """ - config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG - command = [ - "ffmpeg", - "-y", - "-v", "verbose", - "-rw_timeout", config["rw_timeout"], - "-loglevel", "error", - "-hide_banner", - "-user_agent", FFMPEG_USER_AGENT, - "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", - "-thread_queue_size", "1024", - "-analyzeduration", config["analyzeduration"], - "-probesize", config["probesize"], - "-fflags", "+discardcorrupt+igndts", - "-re", - "-i", self.record_url, - "-bufsize", config["bufsize"], - "-sn", - "-dn", - "-reconnect_delay_max", "60", - "-reconnect_streamed", - "-reconnect_at_eof", - "-max_muxing_queue_size", config["max_muxing_queue_size"], - "-correct_ts_overflow", "1", - "-avoid_negative_ts", "1", - "-flush_packets", "1" - ] - - if self.headers: - command.insert(11, "-headers") - command.insert(12, self.headers) - - if self.proxy: - command.insert(1, "-http_proxy") - command.insert(2, self.proxy) - - return command +import abc + +DEFAULT_CONFIG = { + "rw_timeout": "15000000", + "analyzeduration": "20000000", + "probesize": "10000000", + "bufsize": "8000k", + "max_muxing_queue_size": "1024", +} + +OVERSEAS_CONFIG = { + "rw_timeout": "50000000", + "analyzeduration": "40000000", + "probesize": "20000000", + "bufsize": "15000k", + "max_muxing_queue_size": "2048", +} + +FFMPEG_USER_AGENT = ( + "Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) " + "SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36" +) + +# HLS streams from these platforms may use media segment extensions that do +# not match the container detected by FFmpeg. +RELAXED_HLS_EXTENSION_CHECK_PLATFORMS = ["chzzk"] + + +class FFmpegCommandBuilder(abc.ABC): + """ + Abstract base class for building FFmpeg command lines. + """ + + def __init__( + self, + record_url: str, + is_overseas: bool = False, + segment_record: bool = False, + segment_time: str | None = None, + full_path: str | None = None, + headers: str | None = None, + proxy: str | None = None, + platform_key: str | None = None, + video_bitrate: int | None = None, + ): + """ + Initializes the FFmpegCommandBuilder. + + :param record_url: URL of the media to be recorded.(m3u8/flv format) + :param is_overseas: Boolean flag indicating if the connection is overseas. + :param segment_record: Boolean flag indicating if segmented recording is needed. + :param segment_time: Time duration for each segment (if applicable). + :param full_path: Full path where the output file will be saved. + :param headers: Additional headers to include in the request. + :param proxy: Proxy server URL to use for the connection. + :param platform_key: Platform identifier used for platform-specific FFmpeg compatibility options. + :param video_bitrate: Custom output video bitrate in kbps. Enables H.264 transcoding when set. + """ + self.record_url = record_url + self.is_overseas = is_overseas + self.segment_record = segment_record + self.segment_time = segment_time + self.full_path = full_path or "" + self.proxy = proxy or "" + self.headers = headers or "" + self.platform_key = platform_key + self.video_bitrate = video_bitrate + + @abc.abstractmethod + def build_command(self) -> list[str]: + pass + + def _get_basic_ffmpeg_command(self) -> list[str]: + """ + Constructs the basic part of the FFmpeg command. + + :return: List of strings representing the FFmpeg command components. + """ + config = OVERSEAS_CONFIG if self.is_overseas else DEFAULT_CONFIG + hls_input_options = [] + if self.platform_key in RELAXED_HLS_EXTENSION_CHECK_PLATFORMS: + # FFmpeg 8 rejects container/extension mismatches in strict mode. + hls_input_options = ["-extension_picky", "0"] + + # fmt: off + command = [ + "ffmpeg", + "-y", + "-v", "verbose", + "-rw_timeout", config["rw_timeout"], + "-loglevel", "error", + "-hide_banner", + "-user_agent", FFMPEG_USER_AGENT, + "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy", + "-thread_queue_size", "1024", + "-analyzeduration", config["analyzeduration"], + "-probesize", config["probesize"], + "-fflags", "+discardcorrupt+igndts", + "-re", + *hls_input_options, + "-i", self.record_url, + "-bufsize", config["bufsize"], + "-sn", + "-dn", + "-reconnect_delay_max", "60", + "-reconnect_streamed", + "-reconnect_at_eof", + "-max_muxing_queue_size", config["max_muxing_queue_size"], + "-correct_ts_overflow", "1", + "-avoid_negative_ts", "1", + "-flush_packets", "1" + ] + # fmt: on + + if self.headers: + command.insert(11, "-headers") + command.insert(12, self.headers) + + if self.proxy: + command.insert(1, "-http_proxy") + command.insert(2, self.proxy) + + return command + + def _get_video_codec_options(self) -> list[str]: + if self.video_bitrate: + return ["-c:v", "libx264", "-preset", "veryfast", "-b:v", f"{self.video_bitrate}k"] + return ["-c:v", "copy"] diff --git a/app/core/media/ffmpeg_builders/video/__init__.py b/app/core/media/ffmpeg_builders/video/__init__.py index 67340742..85d80408 100644 --- a/app/core/media/ffmpeg_builders/video/__init__.py +++ b/app/core/media/ffmpeg_builders/video/__init__.py @@ -2,4 +2,5 @@ from .mkv import MKVCommandBuilder from .mov import MOVCommandBuilder from .mp4 import MP4CommandBuilder +from .nut import NUTCommandBuilder from .ts import TSCommandBuilder diff --git a/app/core/media/ffmpeg_builders/video/flv.py b/app/core/media/ffmpeg_builders/video/flv.py index 9b7d2b07..3f3e661d 100644 --- a/app/core/media/ffmpeg_builders/video/flv.py +++ b/app/core/media/ffmpeg_builders/video/flv.py @@ -1,29 +1,31 @@ -from ..base import FFmpegCommandBuilder - - -class FLVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - if self.segment_record: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-bsf:a", "aac_adtstoasc", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "flv", - "-reset_timestamps", "1", - self.full_path - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-bsf:a", "aac_adtstoasc", - "-f", "flv", - self.full_path - ] - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class FLVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "flv", + "-reset_timestamps", "1", + self.full_path + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-bsf:a", "aac_adtstoasc", + "-f", "flv", + self.full_path + ] + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mkv.py b/app/core/media/ffmpeg_builders/video/mkv.py index 4b527e8f..ddca1fb8 100644 --- a/app/core/media/ffmpeg_builders/video/mkv.py +++ b/app/core/media/ffmpeg_builders/video/mkv.py @@ -1,30 +1,32 @@ -from ..base import FFmpegCommandBuilder - - -class MKVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - if self.segment_record: - additional_commands = [ - "-flags", "global_header", - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "matroska", - "-reset_timestamps", "1", - self.full_path, - ] - else: - additional_commands = [ - "-flags", "global_header", - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-f", "matroska", - self.full_path, - ] - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MKVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + "-flags", "global_header", + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "matroska", + "-reset_timestamps", "1", + self.full_path, + ] + else: + additional_commands = [ + "-flags", "global_header", + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-f", "matroska", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mov.py b/app/core/media/ffmpeg_builders/video/mov.py index 5e7b23af..c16a0179 100644 --- a/app/core/media/ffmpeg_builders/video/mov.py +++ b/app/core/media/ffmpeg_builders/video/mov.py @@ -1,32 +1,34 @@ -from ..base import FFmpegCommandBuilder - - -class MOVCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mov", - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov+faststart", - "-flags", "global_header", - self.full_path, - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "aac", - "-f", "mov", - "-movflags", "+faststart", - self.full_path, - ] - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MOVCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mov", + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov+faststart", + "-flags", "global_header", + self.full_path, + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "aac", + "-f", "mov", + "-movflags", "+faststart", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/mp4.py b/app/core/media/ffmpeg_builders/video/mp4.py index 4675abb9..47d48129 100644 --- a/app/core/media/ffmpeg_builders/video/mp4.py +++ b/app/core/media/ffmpeg_builders/video/mp4.py @@ -1,31 +1,33 @@ -from ..base import FFmpegCommandBuilder - - -class MP4CommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "aac", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mp4", - "-reset_timestamps", "1", - "-movflags", "+frag_keyframe+empty_moov+faststart+delay_moov", - "-flags", "global_header", - self.full_path, - ] - else: - additional_commands = [ - "-map", "0", - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", - self.full_path, - ] - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class MP4CommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "aac", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mp4", + "-reset_timestamps", "1", + "-movflags", "+frag_keyframe+empty_moov+faststart+delay_moov", + "-flags", "global_header", + self.full_path, + ] + else: + additional_commands = [ + "-map", "0", + *self._get_video_codec_options(), + "-c:a", "copy", + "-f", "mp4", + "-movflags", "+faststart+frag_keyframe+empty_moov+delay_moov", + self.full_path, + ] + # fmt: on + + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/nut.py b/app/core/media/ffmpeg_builders/video/nut.py new file mode 100644 index 00000000..5a2562f0 --- /dev/null +++ b/app/core/media/ffmpeg_builders/video/nut.py @@ -0,0 +1,34 @@ +from ..base import FFmpegCommandBuilder + + +class NUTCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "nut", + "-reset_timestamps", "1", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + else: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "nut", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/media/ffmpeg_builders/video/ts.py b/app/core/media/ffmpeg_builders/video/ts.py index e3f036a9..c8af1398 100644 --- a/app/core/media/ffmpeg_builders/video/ts.py +++ b/app/core/media/ffmpeg_builders/video/ts.py @@ -1,34 +1,35 @@ -from ..base import FFmpegCommandBuilder - - -class TSCommandBuilder(FFmpegCommandBuilder): - def build_command(self) -> list[str]: - command = self._get_basic_ffmpeg_command() - if self.segment_record: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "segment", - "-segment_time", str(self.segment_time), - "-segment_format", "mpegts", - "-reset_timestamps", "1", - "-mpegts_flags", "+resend_headers", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - else: - additional_commands = [ - "-c:v", "copy", - "-c:a", "copy", - "-map", "0", - "-f", "mpegts", - "-mpegts_flags", "+resend_headers", - "-muxdelay", "0", - "-muxpreload", "0", - self.full_path, - ] - - command.extend(additional_commands) - return command +from ..base import FFmpegCommandBuilder + + +class TSCommandBuilder(FFmpegCommandBuilder): + def build_command(self) -> list[str]: + command = self._get_basic_ffmpeg_command() + # fmt: off + if self.segment_record: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "segment", + "-segment_time", str(self.segment_time), + "-segment_format", "mpegts", + "-reset_timestamps", "1", + "-mpegts_flags", "+resend_headers", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + else: + additional_commands = [ + *self._get_video_codec_options(), + "-c:a", "copy", + "-map", "0", + "-f", "mpegts", + "-mpegts_flags", "+resend_headers", + "-muxdelay", "0", + "-muxpreload", "0", + self.full_path, + ] + # fmt: on + command.extend(additional_commands) + return command diff --git a/app/core/platforms/platform_handlers/__init__.py b/app/core/platforms/platform_handlers/__init__.py index 6d82141c..36b95800 100644 --- a/app/core/platforms/platform_handlers/__init__.py +++ b/app/core/platforms/platform_handlers/__init__.py @@ -8,6 +8,7 @@ BluedHandler, ChangliaoHandler, ChzzkHandler, + CustomHandler, DouyinHandler, DouyuHandler, FaceitHandler, @@ -46,6 +47,7 @@ VVXQHandler, WeiboHandler, WinkTVHandler, + XindongreboHandler, YinboHandler, YiqiLiveHandler, YoutubeHandler, @@ -86,13 +88,16 @@ def get_platform_info(record_url: str) -> tuple: "xhslink.com/": ("小红书直播", "xhs"), "https://www.bigo.tv/": ("Bigo直播", "bigo"), "https://app.blued.cn/": ("Blued直播", "blued"), - "sooplive.co.kr/": ("SOOP", "soop"), + "sooplive.co.kr/": ("SOOP", "sooplive"), + "sooplive.com/": ("SOOP", "sooplive"), + "play.sooplive.com/": ("SOOP", "sooplive"), "cc.163.com/": ("网易CC直播", "netease"), "qiandurebo.com/": ("千度热播", "qiandurebo"), "pandalive.co.kr/": ("PandaTV", "pandalive"), "fm.missevan.com/": ("猫耳FM直播", "maoerfm"), "winktv.co.kr/": ("WinkTV", "winktv"), "flextv.co.kr/": ("FlexTV", "flextv"), + "ttinglive.com/": ("FlexTV", "flextv"), "look.163.com/": ("Look直播", "look"), "popkontv.com/": ("PopkonTV", "popkontv"), "twitcasting.tv/": ("TwitCasting", "twitcasting"), @@ -122,12 +127,14 @@ def get_platform_info(record_url: str) -> tuple: ".shp.": ("shopee", "shopee"), "youtube.com/": ("Youtube", "youtube"), "tb.cn": ("淘宝直播", "taobao"), + "tbzb.taobao.com": ("淘宝直播", "taobao"), "3.cn": ("京东直播", "jd"), "faceit.com": ("faceit", "faceit"), "lailianjie.com": ("连接直播", "lianjie"), "miguvideo.com": ("咪咕直播", "migu"), "imkktv.com": ("来秀直播", "laixiu"), "picarto.tv": ("Picarto", "picarto"), + "xcqrkj.com": ("心动热播", "xindongrebo"), ".m3u8": ("自定义录制直播", "custom"), ".flv": ("自定义录制直播", "custom"), } @@ -147,6 +154,7 @@ def get_platform_info(record_url: str) -> tuple: "BluedHandler", "ChangliaoHandler", "ChzzkHandler", + "CustomHandler", "DouyinHandler", "DouyuHandler", "FaceitHandler", @@ -187,6 +195,7 @@ def get_platform_info(record_url: str) -> tuple: "VVXQHandler", "WeiboHandler", "WinkTVHandler", + "XindongreboHandler", "YYHandler", "YinboHandler", "YiqiLiveHandler", diff --git a/app/core/platforms/platform_handlers/base.py b/app/core/platforms/platform_handlers/base.py index 8c7e3e6a..6aed2b48 100644 --- a/app/core/platforms/platform_handlers/base.py +++ b/app/core/platforms/platform_handlers/base.py @@ -7,7 +7,7 @@ from streamget import StreamData T = TypeVar("T", bound="PlatformHandler") -InstanceKey = tuple[str | None, tuple[tuple[str, str], ...] | None, str, str | None] +InstanceKey = tuple[str | None, tuple[tuple[str, str], ...] | None, str, str | None, str | None, str | None, str | None] class PlatformHandler(abc.ABC): @@ -60,12 +60,19 @@ def get_registered_patterns(cls) -> dict[str, type["PlatformHandler"]]: @classmethod def _get_instance_key( - cls, proxy: str | None, cookies: str | None, record_quality: str, platform: str | None + cls, + proxy: str | None, + cookies: str | None, + record_quality: str, + platform: str | None, + username: str | None = None, + password: str | None = None, + account_type: str | None = None, ) -> InstanceKey: """ Generate a unique key for each instance based on the provided parameters. """ - return proxy, cookies, record_quality, platform + return proxy, cookies, record_quality, platform, username, password, account_type @classmethod def _get_handler_class(cls, live_url: str) -> type["PlatformHandler"] | None: @@ -97,7 +104,7 @@ def get_handler_instance( if not handler_class: return None - instance_key = cls._get_instance_key(proxy, cookies, record_quality, platform) + instance_key = cls._get_instance_key(proxy, cookies, record_quality, platform, username, password, account_type) if instance_key not in cls._instances: init_signature = inspect.signature(handler_class.__init__) handler_kwargs: dict[str, Any] = { diff --git a/app/core/platforms/platform_handlers/handlers.py b/app/core/platforms/platform_handlers/handlers.py index 304b8932..58b6540b 100644 --- a/app/core/platforms/platform_handlers/handlers.py +++ b/app/core/platforms/platform_handlers/handlers.py @@ -1,9 +1,32 @@ import streamget +from deprecated import deprecated from ....utils.utils import trace_error_decorator from .base import PlatformHandler, StreamData +class CustomHandler(PlatformHandler): + platform = "custom" + + def __init__( + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, + ) -> None: + super().__init__(proxy, cookies, record_quality, platform) + + @trace_error_decorator + async def get_stream_info(self, live_url: str) -> StreamData: + stream_data = StreamData(platform="Custom", anchor_name="CustomLive", is_live=True, record_url=live_url) + if ".flv" in live_url: + stream_data.flv_url = live_url + if ".m3u8" in live_url: + stream_data.flv_url = live_url + return stream_data + + class DouyinHandler(PlatformHandler): platform = "douyin" @@ -25,7 +48,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.DouyinLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - if "v.douyin.com" in live_url: + if "v.douyin.com" in live_url or "www.douyin.com/user" in live_url: json_data = await self.live_stream.fetch_app_stream_data(url=live_url) else: json_data = await self.live_stream.fetch_web_stream_data(url=live_url) @@ -267,6 +290,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class QiandureboHandler(PlatformHandler): platform = "qiandurebo" @@ -351,6 +375,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class WinkTVHandler(PlatformHandler): platform = "winktv" @@ -569,7 +594,7 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.HuajiaoLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -636,6 +661,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live platform has been shut down") class YinboHandler(PlatformHandler): platform = "yinbo" @@ -741,6 +767,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class VVXQHandler(PlatformHandler): platform = "vvxqiu" @@ -804,6 +831,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class PiaopiaoHandler(PlatformHandler): platform = "piaopiao" @@ -821,7 +849,11 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.PiaopaioLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + + if "preview.html" not in live_url: + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) + else: + json_data = await self.live_stream.fetch_web_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -905,7 +937,7 @@ def __init__( async def get_stream_info(self, live_url: str) -> StreamData: if not self.live_stream: self.live_stream = streamget.ShopeeLiveStream(proxy_addr=self.proxy, cookies=self.cookies) - json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + json_data = await self.live_stream.fetch_app_stream_data(url=live_url) return await self.live_stream.fetch_stream_url(json_data, self.record_quality) @@ -1014,6 +1046,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +@deprecated(reason="Live stream acquisition has been shut down") class MiguHandler(PlatformHandler): platform = "migu" @@ -1077,6 +1110,28 @@ async def get_stream_info(self, live_url: str) -> StreamData: return await self.live_stream.fetch_stream_url(json_data, self.record_quality) +class XindongreboHandler(PlatformHandler): + platform = "xindongrebo" + + def __init__( + self, + proxy: str | None = None, + cookies: str | None = None, + record_quality: str | None = None, + platform: str | None = None, + ) -> None: + super().__init__(proxy, cookies, record_quality, platform) + self.live_stream: streamget.XindongreboLiveStream | None = None + + @trace_error_decorator + async def get_stream_info(self, live_url: str) -> StreamData: + if not self.live_stream: + self.live_stream = streamget.XindongreboLiveStream(proxy_addr=self.proxy, cookies=self.cookies) + json_data = await self.live_stream.fetch_web_stream_data(url=live_url) + return await self.live_stream.fetch_stream_url(json_data, self.record_quality) + + +CustomHandler.register(r"https?://.*\.(?:flv|m3u8)(\?.*)?$") DouyinHandler.register(r"https://.*\.douyin\.com/") TikTokHandler.register(r"https://.*\.tiktok\.com/") KuaishouHandler.register(r"https://live\.kuaishou\.com/") @@ -1087,14 +1142,14 @@ async def get_stream_info(self, live_url: str) -> StreamData: RedNoteHandler.register(r"www\.xiaohongshu\.com/", r"xhslink\.com/") BigoHandler.register(r"https://www\.bigo\.tv/", r"https://slink\.bigovideo\.tv/") BluedHandler.register(r"https://app\.blued\.cn/") -SoopHandler.register(r"sooplive\.co\.kr/") +SoopHandler.register(r"sooplive\.co\.kr/", r"sooplive\.com/") NeteaseHandler.register(r"cc\.163\.com/") QiandureboHandler.register(r"qiandurebo.com/") PamdaTVHandler.register(r".*\.pandalive.co.kr/") MaoerFMHandler.register(r"fm.missevan.com/") LookHandler.register(r"look.163.com/") WinkTVHandler.register(r"www.winktv.co.kr/") -FlexTVHandler.register(r"www\.flextv\.co\.kr/") +FlexTVHandler.register(r"www\.flextv\.co\.kr/", r"www\.ttinglive\.com/") PopkonTVHandler.register(r"www\.popkontv\.com/") TwitcastingHandler.register(r"twitcasting\.tv") BaiduHandler.register(r".*\.baidu\.com") @@ -1102,7 +1157,7 @@ async def get_stream_info(self, live_url: str) -> StreamData: KugouHandler.register(r".*\.kugou\.com") TwitchHandler.register(r"https://.*\.twitch\.tv/") LivemeHandler.register(r"https://.*\.liveme\.com/") -HuajiaoHandler.register(r"https://.*\.huajiao\.com/") +HuajiaoHandler.register(r".*\.huajiao\.com/") ShowRoomHandlerHandler.register(r".*\.showroom-live\.com") AcfunHandler.register(r"live.acfun.cn/") InkeHandler.register(r"https://.*\.inke\.cn/") @@ -1120,10 +1175,11 @@ async def get_stream_info(self, live_url: str) -> StreamData: HuamaoHandler.register(r"h.catshow168.com") ShopeeHandler.register(r".*.shp.ee/") YoutubeHandler.register(r".*\.youtube\.com/") -TaobaoHandler.register(r".*\.tb\.cn/") +TaobaoHandler.register(r".*\.tb\.cn/", r".*\.taobao\.com/") JDHandler.register(r"3\.cn/") FaceitHandler.register(r"https://.*\.faceit\.com/") LianJieHandler.register(r"https://.*\.lailianjie\.com/") MiguHandler.register(r"https://.*\.miguvideo\.com/") LaixiuHandler.register(r"https://.*\.imkktv\.com/") PicartoHandler.register(r"https://.*\.picarto\.tv/") +XindongreboHandler.register(r"xcqrkj.com") diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index 146102ec..b2ab7bb0 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -1,423 +1,531 @@ -import asyncio -import threading -from collections import defaultdict -from datetime import datetime, timedelta - -from ...messages import desktop_notify, message_pusher -from ...models.recording.recording_model import Recording -from ...models.recording.recording_status_model import RecordingStatus -from ...utils import utils -from ...utils.logger import logger -from ..platforms.platform_handlers import get_platform_info -from .stream_manager import LiveStreamRecorder - - -class GlobalRecordingState: - recordings = [] - lock = threading.Lock() - - -class RecordingManager: - def __init__(self, app): - self.app = app - self.settings = app.settings - self.periodic_task_started = False - self.loop_time_seconds = None - self.app.language_manager.add_observer(self) - self.load_recordings() - self._ = {} - self.load() - self.initialize_dynamic_state() - max_concurrent = int(self.settings.user_config.get("platform_max_concurrent_requests", 3)) - self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) - - @property - def recordings(self): - return GlobalRecordingState.recordings - - @recordings.setter - def recordings(self, value): - raise AttributeError("Please use add_recording/update_recording methods to modify data") - - def load(self): - language = self.app.language_manager.language - for key in ("recording_manager", "video_quality"): - self._.update(language.get(key, {})) - - def load_recordings(self): - """Load recordings from a JSON file into objects.""" - recordings_data = self.app.config_manager.load_recordings_config() - if not GlobalRecordingState.recordings: - GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] - logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") - - def initialize_dynamic_state(self): - """Initialize dynamic state for all recordings.""" - loop_time_seconds = self.settings.user_config.get("loop_time_seconds") - self.loop_time_seconds = int(loop_time_seconds or 300) - for recording in self.recordings: - recording.loop_time_seconds = self.loop_time_seconds - recording.update_title(self._[recording.quality]) - - async def add_recording(self, recording): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.append(recording) - await self.persist_recordings() - - async def remove_recording(self, recording: Recording): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.remove(recording) - await self.persist_recordings() - - async def clear_all_recordings(self): - with GlobalRecordingState.lock: - GlobalRecordingState.recordings.clear() - await self.persist_recordings() - - async def persist_recordings(self): - """Persist recordings to a JSON file.""" - data_to_save = [rec.to_dict() for rec in self.recordings] - await self.app.config_manager.save_recordings_config(data_to_save) - - async def update_recording_card(self, recording: Recording, updated_info: dict): - """Update an existing recording object and persist changes to a JSON file.""" - if recording: - recording.update(updated_info) - self.app.page.run_task(self.persist_recordings) - - @staticmethod - async def _update_recording( - recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool - ): - attrs_update = { - "monitor_status": monitor_status, - "display_title": display_title, - "status_info": status_info, - "selected": selected, - } - for attr, value in attrs_update.items(): - setattr(recording, attr, value) - - async def start_monitor_recording(self, recording: Recording, auto_save: bool = True): - """ - Start monitoring a single recording if it is not already being monitored. - """ - if not recording.monitor_status: - await self._update_recording( - recording=recording, - monitor_status=True, - display_title=recording.title, - status_info=RecordingStatus.MONITORING, - selected=False, - ) - self.app.page.run_task(self.check_if_live, recording) - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - self.app.page.pubsub.send_others_on_topic("update", recording) - if auto_save: - self.app.page.run_task(self.persist_recordings) - - async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): - """ - Stop monitoring a single recording if it is currently being monitored. - """ - if recording.monitor_status: - await self._update_recording( - recording=recording, - monitor_status=False, - display_title=f"[{self._['monitor_stopped']}] {recording.title}", - status_info=RecordingStatus.STOPPED_MONITORING, - selected=False, - ) - self.stop_recording(recording, manually_stopped=True) - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - self.app.page.pubsub.send_others_on_topic("update", recording) - if auto_save: - self.app.page.run_task(self.persist_recordings) - - async def start_monitor_recordings(self): - """ - Start monitoring multiple recordings based on user selection or all recordings if none are selected. - """ - selected_recordings = await self.get_selected_recordings() - pre_start_monitor_recordings = selected_recordings if selected_recordings else self.recordings - cards_obj = self.app.record_card_manager.cards_obj - for recording in pre_start_monitor_recordings: - if cards_obj[recording.rec_id]["card"].visible: - self.app.page.run_task(self.start_monitor_recording, recording, auto_save=False) - self.app.page.run_task(self.persist_recordings) - logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") - - async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): - """ - Stop monitoring multiple recordings based on user selection or all recordings if none are selected. - """ - if not selected_recordings: - selected_recordings = await self.get_selected_recordings() - pre_stop_monitor_recordings = selected_recordings or self.recordings - cards_obj = self.app.record_card_manager.cards_obj - for recording in pre_stop_monitor_recordings: - if cards_obj[recording.rec_id]["card"].visible: - self.app.page.run_task(self.stop_monitor_recording, recording, auto_save=False) - self.app.page.run_task(self.persist_recordings) - logger.info(f"Batch Stop Monitor Recordings: {[i.rec_id for i in pre_stop_monitor_recordings]}") - - async def get_selected_recordings(self): - return [recording for recording in self.recordings if recording.selected] - - async def remove_recordings(self, recordings: list[Recording]): - """Remove a recording from the list and update the JSON file.""" - for recording in recordings: - if recording in self.recordings: - await self.remove_recording(recording) - logger.info(f"Delete Items: {recording.rec_id}-{recording.streamer_name}") - - def find_recording_by_id(self, rec_id: str): - """Find a recording by its ID (hash of dict representation).""" - for rec in self.recordings: - if rec.rec_id == rec_id: - return rec - return None - - async def check_all_live_status(self): - """Check the live status of all recordings and update their display titles.""" - for recording in self.recordings: - if recording.monitor_status and not recording.is_recording: - is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) - if not recording.detection_time or is_exceeded: - self.app.page.run_task(self.check_if_live, recording) - - async def setup_periodic_live_check(self, interval: int = 180): - """Set up a periodic task to check live status.""" - - async def periodic_check(): - while True: - await asyncio.sleep(interval) - await self.check_free_space() - if self.app.recording_enabled: - await self.check_all_live_status() - - if not self.periodic_task_started: - self.periodic_task_started = True - await periodic_check() - - async def check_if_live(self, recording: Recording): - """Check if the live stream is available, fetch stream data and update is_live status.""" - - if recording.is_recording: - return - - if not recording.monitor_status: - recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" - recording.status_info = RecordingStatus.STOPPED_MONITORING - - elif not recording.is_checking: - recording.status_info = RecordingStatus.STATUS_CHECKING - recording.detection_time = datetime.now().time() - if recording.scheduled_recording and recording.scheduled_start_time and recording.monitor_hours: - scheduled_time_range = await self.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) - recording.scheduled_time_range = scheduled_time_range - in_scheduled = utils.is_current_time_within_range(scheduled_time_range) - if not in_scheduled: - recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK - recording.is_live = False - logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range}") - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - return - - recording.is_checking = True - recording.status_info = RecordingStatus.MONITORING - platform, platform_key = get_platform_info(recording.url) - - if platform and platform_key and (recording.platform is None or recording.platform_key is None): - recording.platform = platform - recording.platform_key = platform_key - self.app.page.run_task(self.persist_recordings) - - if self.settings.user_config["language"] != "zh_CN": - platform = platform_key - - output_dir = self.settings.get_video_save_path() - await self.check_free_space(output_dir) - if not self.app.recording_enabled: - recording.is_checking = False - recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - return - recording_info = { - "platform": platform, - "platform_key": platform_key, - "live_url": recording.url, - "output_dir": output_dir, - "segment_record": recording.segment_record, - "segment_time": recording.segment_time, - "save_format": recording.record_format, - "quality": recording.quality, - } - - semaphore = self.platform_semaphores[platform_key] - recorder = LiveStreamRecorder(self.app, recording, recording_info) - async with semaphore: - stream_info = await recorder.fetch_stream() - logger.info(f"Stream Data: {stream_info}") - if not stream_info or not stream_info.anchor_name: - logger.error(f"Fetch stream data failed: {recording.url}") - recording.is_checking = False - recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR - if recording.monitor_status: - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - return - if self.settings.user_config.get("remove_emojis"): - stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) - - recording.is_live = stream_info.is_live - if recording.is_live and not recording.is_recording: - recording.live_title = stream_info.title - if recording.streamer_name.strip() == self._["live_room"]: - recording.streamer_name = stream_info.anchor_name - recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" - recording.display_title = f"[{self._['is_live']}] {recording.title}" - - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - - if desktop_notify.should_push_notification(self.app): - desktop_notify.send_notification( - title=self._["notify"], - message=recording.streamer_name + ' | ' + self._["live_recording_started_message"], - app_icon=self.app.tray_manager.icon_path - ) - - if (message_pusher.MessagePusher.should_push_message(self.settings, recording, message_type='start') - and not recording.notified_live_start): - push_content = self._["push_content"] - begin_push_message_text = user_config.get("custom_stream_start_content") - if begin_push_message_text: - push_content = begin_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - recording.notified_live_start = True - - if not recording.only_notify_no_record: - recording.status_info = RecordingStatus.PREPARING_RECORDING - recording.loop_time_seconds = self.loop_time_seconds - self.start_update(recording) - self.app.page.run_task(recorder.start_recording, stream_info) - else: - if recording.notified_live_start: - notify_loop_time = user_config.get("notify_loop_time") - recording.loop_time_seconds = int(notify_loop_time or 3600) - else: - recording.loop_time_seconds = self.loop_time_seconds - - recording.cumulative_duration = timedelta() - recording.last_duration = timedelta() - recording.status_info = RecordingStatus.LIVE_BROADCASTING - recording.is_checking = False - - else: - if not recording.is_live and recording.notified_live_start: - recording.notified_live_start = False - recording.notified_live_end = False - - recording.is_checking = False - recording.status_info = RecordingStatus.MONITORING - title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" - if recording.streamer_name == self._["live_room"] or \ - f"[{self._['is_live']}]" in recording.display_title: - recording.update( - { - "streamer_name": stream_info.anchor_name, - "title": title, - "display_title": title, - } - ) - self.app.page.run_task(self.persist_recordings) - - self.app.page.run_task(self.app.record_card_manager.update_card, recording) - self.app.page.pubsub.send_others_on_topic("update", recording) - - @staticmethod - def start_update(recording: Recording): - """Start the recording process.""" - if recording.is_live and not recording.is_recording: - # Reset cumulative and last durations for a fresh start - recording.update( - { - "cumulative_duration": timedelta(), - "last_duration": timedelta(), - "start_time": datetime.now(), - "is_recording": True, - } - ) - logger.info(f"Started recording for {recording.title}") - - @staticmethod - def stop_recording(recording: Recording, manually_stopped: bool = True): - """Stop the recording process.""" - if recording.is_recording: - if recording.start_time is not None: - elapsed = datetime.now() - recording.start_time - # Add the elapsed time to the cumulative duration. - recording.cumulative_duration += elapsed - # Update the last recorded duration. - recording.last_duration = recording.cumulative_duration - recording.start_time = None - recording.is_recording = False - recording.manually_stopped = manually_stopped - logger.info(f"Stopped recording for {recording.title}") - - def get_duration(self, recording: Recording): - """Get the duration of the current recording session in a formatted string.""" - if recording.is_recording and recording.start_time is not None: - elapsed = datetime.now() - recording.start_time - # If recording, add the current session time. - total_duration = recording.cumulative_duration + elapsed - return self._["recorded"] + " " + str(total_duration).split(".")[0] - else: - # If stopped, show the last recorded total duration. - total_duration = recording.last_duration - return str(total_duration).split(".")[0] - - async def delete_recording_cards(self, recordings: list[Recording]): - self.app.page.run_task(self.app.record_card_manager.remove_recording_card, recordings) - self.app.page.pubsub.send_others_on_topic('delete', recordings) - await self.remove_recordings(recordings) - - # update the filter area of the home page - if hasattr(self.app, 'current_page') and hasattr(self.app.current_page, 'content_area'): - if len(self.app.current_page.content_area.controls) > 1: - self.app.current_page.content_area.controls[1] = self.app.current_page.create_filter_area() - self.app.current_page.content_area.update() - - async def check_free_space(self, output_dir: str | None = None): - disk_space_limit = float(self.settings.user_config.get("recording_space_threshold") or 0) - output_dir = output_dir or self.settings.get_video_save_path() - if utils.check_disk_capacity(output_dir) < disk_space_limit: - self.app.recording_enabled = False - logger.error( - f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled" - ) - self.app.page.run_task( - self.app.snack_bar.show_snack_bar, - self._["not_disk_space_tip"], - duration=86400, - show_close_icon=True - ) - - else: - self.app.recording_enabled = True - - @staticmethod - async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> str | None: - monitor_hours = float(monitor_hours or 5) - if scheduled_start_time and monitor_hours: - end_time = utils.add_hours_to_time(scheduled_start_time, monitor_hours) - scheduled_time_range = f"{scheduled_start_time}~{end_time}" - return scheduled_time_range +import asyncio +import threading +from collections import defaultdict +from datetime import datetime, timedelta + +from ...messages import desktop_notify, message_pusher +from ...models.recording.recording_model import Recording +from ...models.recording.recording_status_model import RecordingStatus +from ...utils import utils +from ...utils.logger import logger +from ..platforms.platform_handlers import get_platform_info +from ..runtime.process_manager import BackgroundService +from .stream_manager import LiveStreamRecorder + + +class GlobalRecordingState: + recordings = [] + lock = threading.Lock() + + +class RecordingManager: + def __init__(self, services): + self.services = services + self.settings = services.settings_config + self.periodic_task_started = False + self.loop_time_seconds = None + self.services.language_manager.add_observer(self) + self.load_recordings() + self._ = {} + self.load() + self.initialize_dynamic_state() + max_concurrent = int(self.settings.user_config.get("platform_max_concurrent_requests", 3)) + self.platform_semaphores = defaultdict(lambda: asyncio.Semaphore(max_concurrent)) + self.active_recorders = {} + + @property + def app(self): + bridges = self.services.snapshot_bridges() + return bridges[0] if bridges else None + + @property + def recordings(self): + return GlobalRecordingState.recordings + + @recordings.setter + def recordings(self, value): + raise AttributeError("Please use add_recording/update_recording methods to modify data") + + def load(self): + language = self.services.language_manager.language + for key in ("recording_manager", "video_quality"): + self._.update(language.get(key, {})) + + def load_recordings(self): + """Load recordings from a JSON file into objects.""" + recordings_data = self.services.config_manager.load_recordings_config() + if not GlobalRecordingState.recordings: + GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] + logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") + + def initialize_dynamic_state(self): + """Initialize dynamic state for all recordings.""" + loop_time_seconds = self.settings.user_config.get("loop_time_seconds") + self.loop_time_seconds = int(loop_time_seconds or 300) + for recording in self.recordings: + recording.loop_time_seconds = self.loop_time_seconds + recording.update_title(self._.get(recording.quality, recording.quality)) + recording.showed_checking_status = True + + async def add_recording(self, recording): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.append(recording) + await self.persist_recordings() + + async def remove_recording(self, recording: Recording): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.remove(recording) + await self.persist_recordings() + + async def clear_all_recordings(self): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.clear() + await self.persist_recordings() + + async def persist_recordings(self): + """Persist recordings to a JSON file.""" + data_to_save = [rec.to_dict() for rec in self.recordings] + await self.services.config_manager.save_recordings_config(data_to_save) + + async def update_recording_card(self, recording: Recording, updated_info: dict): + """Update an existing recording object and persist changes to a JSON file.""" + if recording: + recording.update(updated_info) + self.services.run_coro(self.persist_recordings()) + + @staticmethod + async def _update_recording( + recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool + ): + attrs_update = { + "monitor_status": monitor_status, + "display_title": display_title, + "status_info": status_info, + "selected": selected, + } + for attr, value in attrs_update.items(): + setattr(recording, attr, value) + + async def start_monitor_recording(self, recording: Recording, auto_save: bool = True): + """ + Start monitoring a single recording if it is not already being monitored. + """ + if not recording.monitor_status: + recording.is_checking = True + recording.is_live = False + recording.showed_checking_status = False + await self._update_recording( + recording=recording, + monitor_status=True, + display_title=recording.title, + status_info=RecordingStatus.STATUS_CHECKING, + selected=False, + ) + + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + + self.services.run_coro(self.check_if_live(recording)) + + if auto_save: + self.services.run_coro(self.persist_recordings()) + + async def stop_monitor_recording(self, recording: Recording, auto_save: bool = True): + """ + Stop monitoring a single recording if it is currently being monitored. + """ + if recording.monitor_status: + await self._update_recording( + recording=recording, + monitor_status=False, + display_title=f"[{self._['monitor_stopped']}] {recording.title}", + status_info=RecordingStatus.STOPPED_MONITORING, + selected=False, + ) + self.stop_recording(recording, manually_stopped=True) + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + if auto_save: + self.services.run_coro(self.persist_recordings()) + + async def start_monitor_recordings(self): + """ + Start monitoring multiple recordings based on user selection or all recordings if none are selected. + """ + selected_recordings = await self.get_selected_recordings() + pre_start_monitor_recordings = selected_recordings or self.recordings + cards_obj = self._get_visible_cards_obj() + for recording in pre_start_monitor_recordings: + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.start_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) + logger.info(f"Batch Start Monitor Recordings: {[i.rec_id for i in pre_start_monitor_recordings]}") + + async def stop_monitor_recordings(self, selected_recordings: list[Recording | None] | None = None): + """ + Stop monitoring multiple recordings based on user selection or all recordings if none are selected. + """ + if not selected_recordings: + selected_recordings = await self.get_selected_recordings() + pre_stop_monitor_recordings = selected_recordings or self.recordings + cards_obj = self._get_visible_cards_obj() + for recording in pre_stop_monitor_recordings: + if recording is None: + continue + if self._is_card_visible(cards_obj, recording): + self.services.run_coro(self.stop_monitor_recording(recording, auto_save=False)) + self.services.run_coro(self.persist_recordings()) + logger.info(f"Batch Stop Monitor Recordings:{[i.rec_id for i in pre_stop_monitor_recordings if i is not None]}") + + def _get_visible_cards_obj(self): + app = self.app + if app is None: + return None + return getattr(getattr(app, "record_card_manager", None), "cards_obj", None) + + @staticmethod + def _is_card_visible(cards_obj, recording) -> bool: + """When no UI session is available, treat all cards as "visible" so + batch operations still affect the whole list.""" + if cards_obj is None: + return True + entry = cards_obj.get(recording.rec_id) + if entry is None: + return False + card = entry.get("card") + return getattr(card, "visible", True) + + async def get_selected_recordings(self): + return [recording for recording in self.recordings if recording.selected] + + async def remove_recordings(self, recordings: list[Recording]): + """Remove a recording from the list and update the JSON file.""" + for recording in recordings: + if recording in self.recordings: + await self.remove_recording(recording) + logger.info(f"Delete Items: {recording.rec_id}-{recording.streamer_name}") + + def find_recording_by_id(self, rec_id: str): + """Find a recording by its ID (hash of dict representation).""" + for rec in self.recordings: + if rec.rec_id == rec_id: + return rec + return None + + async def check_all_live_status(self): + """Check the live status of all recordings and update their display titles.""" + for recording in self.recordings: + if recording.monitor_status and not recording.is_recording: + is_exceeded = utils.is_time_interval_exceeded(recording.detection_time, recording.loop_time_seconds) + if not recording.detection_time or is_exceeded: + self.services.run_coro(self.check_if_live(recording)) + + _periodic_task_running = False + + @classmethod + def is_periodic_task_running(cls): + return cls._periodic_task_running + + @classmethod + def set_periodic_task_running(cls, value=True): + cls._periodic_task_running = value + + async def setup_periodic_live_check(self, interval: int = 180): + """Set up a periodic task to check live status.""" + + async def periodic_check(): + logger.info("Starting periodic live check background task") + while True: + immediate_check_on_startup = self.services.settings_config.user_config.get( + "check_live_on_browser_refresh", True + ) + if immediate_check_on_startup: + await asyncio.sleep(interval) + await self.check_free_space() + if self.services.recording_enabled: + await self.check_all_live_status() + if not immediate_check_on_startup: + await asyncio.sleep(interval) + + if not RecordingManager.is_periodic_task_running(): + RecordingManager.set_periodic_task_running(True) + self.periodic_task_started = True + logger.info(f"Initializing periodic live check task with interval: {interval}s") + asyncio.create_task(periodic_check()) + else: + logger.info("Periodic live check task already running globally, skipping initialization") + + async def check_if_live(self, recording: Recording): + """Check if the live stream is available, fetch stream data and update is_live status.""" + + recording.manually_stopped = False + if recording.is_recording or recording.stopping_in_progress: + logger.debug(f"Skip check_if_live because recording is busy: {recording.url}") + return + + if recording.rec_id in self.active_recorders: + logger.debug(f"Skip check_if_live because recorder is active: {recording.url}") + return + + if not recording.monitor_status: + recording.display_title = f"[{self._['monitor_stopped']}] {recording.title}" + recording.status_info = RecordingStatus.STOPPED_MONITORING + recording.is_checking = False + self.services.broadcast_card_update(recording) + return + + recording.detection_time = datetime.now().time() + recording.is_checking = True + + if not recording.showed_checking_status: + recording.status_info = RecordingStatus.STATUS_CHECKING + recording.showed_checking_status = True + self.services.broadcast_card_update(recording) + + if recording.scheduled_recording: + scheduled_time_range_list = await self.get_scheduled_time_range( + recording.scheduled_start_time, recording.monitor_hours + ) + recording.scheduled_time_range = scheduled_time_range_list + in_scheduled = False + for scheduled_time_range in scheduled_time_range_list or []: + in_scheduled = utils.is_current_time_within_range(scheduled_time_range) + if in_scheduled: + break + + if not in_scheduled: + recording.status_info = RecordingStatus.NOT_IN_SCHEDULED_CHECK + recording.is_live = False + recording.is_checking = False + logger.info(f"Skip Detection: {recording.url} not in scheduled check range {scheduled_time_range_list}") + self.services.broadcast_card_update(recording) + return + + recording.status_info = RecordingStatus.STATUS_CHECKING + platform, platform_key = get_platform_info(recording.url) + + if platform and platform_key and (recording.platform is None or recording.platform_key is None): + recording.platform = platform + recording.platform_key = platform_key + self.services.run_coro(self.persist_recordings()) + + if self.settings.user_config.get("language") != "zh_CN": + platform = platform_key + + output_dir = self.settings.get_video_save_path() + await self.check_free_space(output_dir) + if not self.services.recording_enabled: + recording.is_checking = False + recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + return + recording_info = { + "platform": platform, + "platform_key": platform_key, + "live_url": recording.url, + "output_dir": output_dir, + "segment_record": recording.segment_record, + "segment_time": recording.segment_time, + "save_format": recording.record_format, + "quality": recording.quality, + "video_bitrate": recording.video_bitrate, + } + + semaphore = self.platform_semaphores[platform_key] + recorder = LiveStreamRecorder(self.services, recording, recording_info) + async with semaphore: + stream_info = await recorder.fetch_stream() + logger.info(f"Stream Data: {stream_info}") + if not stream_info or not stream_info.anchor_name: + logger.error(f"Fetch stream data failed: {recording.url}") + recording.is_checking = False + recording.status_info = RecordingStatus.LIVE_STATUS_CHECK_ERROR + if recording.monitor_status: + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + return + if self.settings.user_config.get("remove_emojis"): + stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) + + if stream_info.is_live: + recording.live_title = stream_info.title + if recording.streamer_name.strip() == self._["live_room"]: + recording.streamer_name = stream_info.anchor_name + recording.title = f"{recording.streamer_name} - {self._[recording.quality]}" + recording.display_title = f"[{self._['is_live']}] {recording.title}" + + if not recording.is_live: + recording.is_live = stream_info.is_live + recording.notified_live_start = False + recording.notified_live_end = False + + tray_icon_path = self.services.tray_manager.icon_path if self.services.tray_manager is not None else "" + if desktop_notify.should_push_notification(self.app): + desktop_notify.send_notification( + title=self._["notify"], + message=recording.streamer_name + " | " + self._["live_recording_started_message"], + app_icon=tray_icon_path, + ) + + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config + if ( + msg_manager.should_push_message(self.settings, recording, message_type="start") + and not recording.notified_live_start + ): + push_content = self._["push_content"] + begin_push_message_text = user_config.get("custom_stream_start_content") + if begin_push_message_text: + push_content = begin_push_message_text + + push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") + push_content = ( + push_content.replace("[room_name]", recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", recording.live_title or "None") + ) + msg_title = user_config.get("custom_notification_title").strip() + msg_title = msg_title or self._["status_notify"] + + BackgroundService.get_instance().add_task(msg_manager.push_messages_sync, msg_title, push_content) + recording.notified_live_start = True + + if not recording.only_notify_no_record: + recording.status_info = RecordingStatus.PREPARING_RECORDING + recording.loop_time_seconds = self.loop_time_seconds + self.start_update(recording) + self.services.run_coro(recorder.start_recording(stream_info)) + else: + if recording.notified_live_start: + notify_loop_time = user_config.get("notify_loop_time") + recording.loop_time_seconds = int(notify_loop_time or 600) + else: + recording.loop_time_seconds = self.loop_time_seconds + + recording.cumulative_duration = timedelta() + recording.last_duration = timedelta() + recording.status_info = RecordingStatus.LIVE_BROADCASTING + + else: + recording.is_recording = False + if recording.is_live: + recording.is_live = False + asyncio.create_task(recorder.end_message_push()) + + recording.status_info = RecordingStatus.MONITORING + title = f"{stream_info.anchor_name or recording.streamer_name} - {self._[recording.quality]}" + if recording.streamer_name == self._["live_room"] or f"[{self._['is_live']}]" in recording.display_title: + recording.update( + { + "streamer_name": stream_info.anchor_name, + "title": title, + "display_title": title, + } + ) + self.services.run_coro(self.persist_recordings()) + + recording.is_checking = False + self.services.broadcast_card_update(recording) + self.services.broadcast_pubsub("update", recording) + return + + @staticmethod + def start_update(recording: Recording): + """Start the recording process.""" + if recording.is_live and not recording.is_recording: + # Reset cumulative and last durations for a fresh start + recording.update( + { + "cumulative_duration": timedelta(), + "last_duration": timedelta(), + "start_time": datetime.now(), + "is_recording": True, + } + ) + logger.info(f"Started recording for {recording.title}") + + def stop_recording(self, recording: Recording, manually_stopped: bool = True): + """Stop the recording process.""" + recording.is_live = False + if recording.is_recording: + recording.stopping_in_progress = True + + logger.info(f"Trying to stop recorder for {recording.rec_id}, title: {recording.title}") + logger.debug(f"Active recorders: {list(self.active_recorders.keys())}") + + if recording.rec_id in self.active_recorders: + recorder = self.active_recorders[recording.rec_id] + logger.debug(f"Found recorder instance - id: {id(recorder)}") + recorder.request_stop() + logger.info(f"Requested stop for recorder: {recording.rec_id}") + else: + logger.warning(f"No active recorder found for {recording.rec_id}, cannot request stop") + recording.force_stop = True + logger.info(f"Set force_stop=True for recording: {recording.rec_id}") + + if recording.start_time is not None: + elapsed = datetime.now() - recording.start_time + # Add the elapsed time to the cumulative duration. + recording.cumulative_duration += elapsed + # Update the last recorded duration. + recording.last_duration = recording.cumulative_duration + recording.start_time = None + recording.is_recording = False + recording.manually_stopped = manually_stopped + recording.status_info = RecordingStatus.NOT_RECORDING + logger.info(f"Stopped recording for {recording.title}") + + self.services.run_coro(self._reset_stopping_flag(recording)) + + def get_duration(self, recording: Recording): + """Get the duration of the current recording session in a formatted string.""" + if recording.is_recording and recording.start_time is not None: + elapsed = datetime.now() - recording.start_time + # If recording, add the current session time. + total_duration = recording.cumulative_duration + elapsed + return self._["recorded"] + " " + str(total_duration).split(".")[0] + else: + # If stopped, show the last recorded total duration. + total_duration = recording.last_duration + return str(total_duration).split(".")[0] + + async def delete_recording_cards(self, recordings: list[Recording]): + self.services.broadcast_card_remove(recordings) + self.services.broadcast_pubsub("delete", recordings) + await self.remove_recordings(recordings) + + async def check_free_space(self, output_dir: str | None = None): + disk_space_limit = float(self.settings.user_config.get("recording_space_threshold") or 0) + output_dir = output_dir or self.settings.get_video_save_path() + if utils.check_disk_capacity(output_dir) < disk_space_limit: + self.services.recording_enabled = False + logger.error(f"Disk space remaining is below {disk_space_limit} GB. Recording function disabled") + self.services.broadcast_snack( + self._["not_disk_space_tip"], + duration=86400, + show_close_icon=True, + ) + + else: + self.services.recording_enabled = True + + @staticmethod + async def get_scheduled_time_range(scheduled_start_time, monitor_hours) -> list | None: + if not scheduled_start_time: + return None + scheduled_time_range_list = [] + monitor_hours_list = str(monitor_hours).split(",") if monitor_hours else [] + for index, start_time in enumerate(str(scheduled_start_time).split(",")): + try: + hours = monitor_hours_list[index] if index < len(monitor_hours_list) else "" + if start_time and hours: + end_time = utils.add_hours_to_time(start_time, float(hours or 5)) + scheduled_time_range = f"{start_time}~{end_time}" + scheduled_time_range_list.append(scheduled_time_range) + except Exception: + pass + return scheduled_time_range_list + + @staticmethod + async def _reset_stopping_flag(recording: Recording): + recording.stopping_in_progress = False + logger.debug(f"Reset stopping_in_progress flag for recording: {recording.rec_id}") diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 973ffac3..21a816d7 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -1,738 +1,828 @@ -import asyncio -import os -import shutil -import subprocess -import time -from datetime import datetime -from typing import Any - -from ...messages import desktop_notify, message_pusher -from ...models.media.video_quality_model import VideoQuality -from ...models.recording.recording_status_model import RecordingStatus -from ...utils import utils -from ...utils.logger import logger -from ..media import ffmpeg_builders -from ..media.direct_downloader import DirectStreamDownloader -from ..platforms import platform_handlers -from ..platforms.platform_handlers import StreamData -from ..runtime.process_manager import BackgroundService - - -class LiveStreamRecorder: - DEFAULT_SEGMENT_TIME = "1800" - DEFAULT_SAVE_FORMAT = "mp4" - DEFAULT_QUALITY = VideoQuality.OD - - def __init__(self, app, recording, recording_info): - self.app = app - self.settings = app.settings - self.recording = recording - self.recording_info = recording_info - self.subprocess_start_info = app.subprocess_start_up_info - - self.user_config = self.settings.user_config - self.account_config = self.settings.accounts_config - self.platform_key = self._get_info("platform_key") - self.cookies = self.settings.cookies_config.get(self.platform_key) - - self.platform = self._get_info("platform") - self.live_url = self._get_info("live_url") - self.output_dir = self._get_info("output_dir") - self.segment_record = self._get_info("segment_record", default=False) - self.segment_time = self._get_info("segment_time", default=self.DEFAULT_SEGMENT_TIME) - self.quality = self._get_info("quality", default=self.DEFAULT_QUALITY) - self.save_format = self._get_info("save_format", default=self.DEFAULT_SAVE_FORMAT).lower() - self.proxy = self.is_use_proxy() - self.direct_downloader = None - os.makedirs(self.output_dir, exist_ok=True) - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - language = self.app.language_manager.language - for key in ("recording_manager", "stream_manager"): - self._.update(language.get(key, {})) - - def _get_info(self, key: str, default: Any = None): - return self.recording_info.get(key, default) or default - - def is_use_proxy(self): - default_proxy_platform = self.user_config.get("default_platform_with_proxy", "") - proxy_list = default_proxy_platform.replace(",", ",").replace(" ", "").split(",") - if self.user_config.get("enable_proxy") and self.platform_key in proxy_list: - self.proxy = self.user_config.get("proxy_address") - return self.proxy - - def _get_filename(self, stream_info: StreamData) -> str: - live_title = None - stream_info.title = utils.clean_name(stream_info.title, None) - if self.user_config.get("filename_includes_title") and stream_info.title: - stream_info.title = self._clean_and_truncate_title(stream_info.title) - live_title = stream_info.title - - if self.recording.streamer_name and self.recording.streamer_name != self._["live_room"]: - stream_info.anchor_name = self.recording.streamer_name - else: - stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) - - now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) - - custom_template = self.user_config.get("custom_filename_template") - if custom_template: - filename = custom_template - filename = filename.replace("{anchor_name}", stream_info.anchor_name or "") - filename = filename.replace("{title}", live_title or "") - filename = filename.replace("{time}", now) - filename = filename.replace("{platform}", stream_info.platform or "") - - while "__" in filename: - filename = filename.replace("__", "_") - - filename = filename.strip("_") - - if not filename: - full_filename = "_".join([i for i in (stream_info.anchor_name, live_title, now) if i]) - else: - full_filename = filename - else: - full_filename = "_".join([i for i in (stream_info.anchor_name, live_title, now) if i]) - - return full_filename - - def _get_output_dir(self, stream_info: StreamData) -> str: - if self.recording.recording_dir and self.user_config.get("folder_name_time"): - current_date = datetime.today().strftime("%Y-%m-%d") - if current_date not in self.recording.recording_dir: - self.recording.recording_dir = None - - if self.recording.recording_dir: - return self.recording.recording_dir - - now = datetime.today().strftime("%Y-%m-%d_%H-%M-%S") - output_dir = self.output_dir.rstrip("/").rstrip("\\") - if self.user_config.get("folder_name_platform"): - output_dir = os.path.join(output_dir, stream_info.platform) - if self.user_config.get("folder_name_author"): - output_dir = os.path.join(output_dir, stream_info.anchor_name) - if self.user_config.get("folder_name_time"): - output_dir = os.path.join(output_dir, now[:10]) - if self.user_config.get("folder_name_title") and stream_info.title: - live_title = self._clean_and_truncate_title(stream_info.title) - if self.user_config.get("folder_name_time"): - output_dir = os.path.join(output_dir, f"{live_title}_{stream_info.anchor_name}") - else: - output_dir = os.path.join(output_dir, f"{now[:10]}_{live_title}") - os.makedirs(output_dir, exist_ok=True) - self.recording.recording_dir = output_dir - self.app.page.run_task(self.app.record_manager.persist_recordings) - return output_dir - - def _get_save_path(self, filename: str, use_direct_download: bool = False) -> str: - suffix = self.save_format - suffix = "_%03d." + suffix if self.segment_record and not use_direct_download else "." + suffix - save_file_path = os.path.join(self.output_dir, filename + suffix).replace(" ", "_") - return save_file_path.replace("\\", "/") - - @staticmethod - def _clean_and_truncate_title(title: str) -> str | None: - if not title: - return None - cleaned_title = title[:30].replace(",", ",").replace(" ", "") - return cleaned_title - - @property - def is_flv_preferred_platform(self): - return self.platform_key in {"douyin", "tiktok"} - - def _select_source_url(self, stream_info: StreamData): - if ( - self.user_config.get("default_live_source") != "HLS" - and self.is_flv_preferred_platform - ): - if stream_info.flv_url.rsplit("&codec=", maxsplit=1)[-1] == 'h264': - return stream_info.flv_url - else: - logger.warning("FLV is not supported for h265 codec, use HLS source instead") - return stream_info.record_url - - def _get_record_url(self, stream_info: StreamData): - url = self._select_source_url(stream_info) - - http_record_list = ["shopee", "migu"] - if self.user_config.get("force_https_recording") and url.startswith("http://"): - url = url.replace("http://", "https://") - - if self.platform_key in http_record_list: - url = url.replace("https://", "http://") - return url - - def _get_record_format(self, stream_info: StreamData): - use_flv_record = ["shopee"] - if stream_info.flv_url: - if self.platform_key in use_flv_record or self.recording.flv_use_direct_download: - self.save_format = "flv" - self.recording.record_format = self.save_format - self.recording.segment_record = False - return self.save_format, True - - elif self.save_format == "flv" and stream_info.flv_url.rsplit("&codec=", maxsplit=1)[-1] == 'h265': - logger.warning("FLV is not supported for h265 codec, use TS format instead") - self.save_format = "ts" - - return self.save_format, False - - async def fetch_stream(self) -> StreamData: - logger.info(f"Live URL: {self.live_url}") - logger.info(f"Use Proxy: {self.proxy or None}") - self.recording.use_proxy = bool(self.proxy) - handler = platform_handlers.get_platform_handler( - live_url=self.live_url, - proxy=self.proxy, - cookies=self.cookies, - record_quality=self.quality, - platform=self.platform, - username=self.account_config.get(self.platform_key, {}).get("username"), - password=self.account_config.get(self.platform_key, {}).get("password"), - account_type=self.account_config.get(self.platform_key, {}).get("account_type") - ) - - stream_info = await handler.get_stream_info(self.live_url) - self.recording.is_checking = False - return stream_info - - async def start_recording(self, stream_info: StreamData): - """ - Construct ffmpeg recording parameters and start recording - """ - - self.save_format, use_direct_download = self._get_record_format(stream_info) - filename = self._get_filename(stream_info) - self.output_dir = self._get_output_dir(stream_info) - save_path = self._get_save_path(filename, use_direct_download) - logger.info(f"Save Path: {save_path}") - self.recording.recording_dir = os.path.dirname(save_path) - os.makedirs(self.recording.recording_dir, exist_ok=True) - record_url = self._get_record_url(stream_info) - - if use_direct_download: - logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}") - headers = {} - header_params = self.get_headers_params(record_url, self.platform_key) - if header_params: - key, value = header_params.split(":", 1) - headers[key] = value - - self.direct_downloader = DirectStreamDownloader( - record_url=record_url, - save_path=save_path, - headers=headers, - proxy=self.proxy - ) - - self.app.page.run_task( - self.start_direct_download, - stream_info.anchor_name, - self.live_url, - record_url, - save_path, - self.save_format, - self.user_config.get("custom_script_command") - ) - else: - ffmpeg_builder = ffmpeg_builders.create_builder( - self.save_format, - record_url=record_url, - proxy=self.proxy, - segment_record=self.segment_record, - segment_time=self.segment_time, - full_path=save_path, - headers=self.get_headers_params(record_url, self.platform_key) - ) - ffmpeg_command = ffmpeg_builder.build_command() - self.app.page.run_task( - self.start_ffmpeg, - stream_info.anchor_name, - self.live_url, - record_url, - ffmpeg_command, - self.save_format, - self.user_config.get("custom_script_command") - ) - - async def start_ffmpeg( - self, - record_name: str, - live_url: str, - record_url: str, - ffmpeg_command: list, - save_type: str, - script_command: str | None = None - ) -> bool: - """ - The child process executes ffmpeg for recording - """ - - try: - save_file_path = ffmpeg_command[-1] - - process = await asyncio.create_subprocess_exec( - *ffmpeg_command, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - startupinfo=self.subprocess_start_info - ) - - 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: - if not self.recording.is_recording or not self.app.recording_enabled: - logger.info(f"Preparing to End Recording: {live_url}") - - if os.name == "nt": - if process.stdin: - process.stdin.write(b"q") - await process.stdin.drain() - await asyncio.sleep(5) - else: - import signal - process.send_signal(signal.SIGINT) - # process.terminate() - await asyncio.sleep(5) - - if process.stdin: - process.stdin.close() - - try: - await asyncio.wait_for(process.wait(), timeout=15.0) - except asyncio.TimeoutError: - logger.warning(f"FFmpeg process did not exit gracefully, forcing termination: {live_url}") - process.kill() - await process.wait() - - if process.returncode is not None: - logger.info(f"Exit loop recording (normal 0 | abnormal 1): code={process.returncode}, {live_url}") - break - - await asyncio.sleep(1) - - return_code = process.returncode - safe_return_code = [0, 255] - stdout, stderr = await process.communicate() - if return_code not in safe_return_code and stderr: - logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["record_stream_error"], duration=2000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - - if return_code in safe_return_code: - if self.recording.monitor_status: - self.recording.status_info = RecordingStatus.MONITORING - display_title = self.recording.title - else: - self.recording.status_info = RecordingStatus.STOPPED_MONITORING - display_title = self.recording.display_title - - self.recording.live_title = None - if not self.recording.is_recording: - logger.success(f"Live recording has stopped: {record_name}") - else: - - logger.success(f"Live recording completed: {record_name}") - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - - if (self.app.recording_enabled and message_pusher.MessagePusher.should_push_message( - self.settings, self.recording, check_manually_stopped=True, message_type='end') and - not self.recording.notified_live_end): - push_content = self._["push_content_end"] - end_push_message_text = user_config.get("custom_stream_end_content") - if end_push_message_text: - push_content = end_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", self.recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - self.recording.notified_live_end = True - - self.recording.is_recording = False - try: - self.recording.update({"display_title": display_title}) - self.app.page.run_task(self.app.record_card_manager.update_card, self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - if self.app.recording_enabled and process in self.app.process_manager.ffmpeg_processes: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - else: - self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - - if self.user_config.get("convert_to_mp4") and self.save_format == "ts": - if self.segment_record: - file_paths = utils.get_file_paths(os.path.dirname(save_file_path)) - prefix = os.path.basename(save_file_path).rsplit("_", maxsplit=1)[0] - for path in file_paths: - if prefix in path: - try: - self.app.page.run_task( - self.converts_mp4, path, self.user_config["delete_original"] - ) - except Exception as e: - logger.error(f"Failed to convert video: {e}") - await self.converts_mp4(path, self.user_config["delete_original"]) - else: - try: - self.app.page.run_task( - self.converts_mp4, save_file_path, self.user_config["delete_original"] - ) - except Exception as e: - logger.error(f"Failed to convert video: {e}") - await self.converts_mp4(save_file_path, self.user_config["delete_original"]) - - if self.user_config.get("execute_custom_script") and script_command: - logger.info("Prepare a direct script in the background") - try: - self.app.page.run_task( - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - self.segment_record, - self.user_config.get("convert_to_mp4") - ) - logger.success("Successfully added script execution") - except Exception as e: - logger.error(f"Failed to execute custom script: {e}") - await self.custom_script_execute( - script_command, - record_name, - save_file_path, - save_type, - self.segment_record, - self.user_config.get("convert_to_mp4") - ) - - except Exception as e: - logger.error(f"An error occurred during the subprocess execution: {e}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["no_ffmpeg_tip"], duration=4000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - return False - finally: - self.recording.record_url = None - - return True - - async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: - """Asynchronous transcoding method, can be added to the background service to continue execution""" - if not self.app.recording_enabled: - logger.info(f"Application is closing, adding transcoding task to background service: {converts_file_path}") - BackgroundService.get_instance().add_task( - self.converts_mp4_sync, converts_file_path, is_original_delete - ) - return - - # Otherwise, execute transcoding normally - await self._do_converts_mp4(converts_file_path, is_original_delete) - - def converts_mp4_sync(self, converts_file_path: str, is_original_delete: bool = True) -> None: - """Synchronous version of the transcoding method, used for background service""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._do_converts_mp4(converts_file_path, is_original_delete)) - finally: - loop.close() - - async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: - """Actual execution method for transcoding""" - converts_success = False - save_path = None - try: - converts_file_path = converts_file_path.replace("\\", "/") - if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: - save_path = converts_file_path.rsplit(".", maxsplit=1)[0] + ".mp4" - _output = subprocess.check_output( - [ - "ffmpeg", - "-i", converts_file_path, - "-c:v", "copy", - "-c:a", "copy", - "-f", "mp4", - save_path - ], - stderr=subprocess.STDOUT, - startupinfo=self.subprocess_start_info, - ) - - converts_success = True - logger.info(f"Video transcoding completed: {save_path}") - - except subprocess.CalledProcessError as e: - logger.error(f"Video transcoding failed! Error message: {e.output.decode()}") - - try: - if converts_success: - if is_original_delete: - time.sleep(1) - if os.path.exists(converts_file_path): - os.remove(converts_file_path) - logger.info(f"Delete Original File: {converts_file_path}") - else: - converts_dir = f"{os.path.dirname(save_path)}/original" - os.makedirs(converts_dir, exist_ok=True) - shutil.move(converts_file_path, converts_dir) - logger.info(f"Move Transcoding Files: {converts_file_path}") - - except subprocess.CalledProcessError as e: - logger.error(f"Error occurred during conversion: {e}") - except Exception as e: - logger.error(f"An unknown error occurred: {e}") - - async def custom_script_execute( - self, - script_command: str, - record_name: str, - save_file_path: str, - save_type: str, - split_video_by_time: bool, - converts_to_mp4: bool - ): - from ..runtime.process_manager import BackgroundService - - if "python" in script_command: - params = [ - f'--record_name "{record_name}"', - f'--save_file_path "{save_file_path}"', - f"--save_type {save_type}--split_video_by_time {split_video_by_time}", - f"--converts_to_mp4 {converts_to_mp4}" - ] - else: - params = [ - f'"{record_name.split(" ", maxsplit=1)[-1]}"', - f'"{save_file_path}"', - save_type, - f"split_video_by_time: {split_video_by_time}", - f"converts_to_mp4: {converts_to_mp4}" - ] - script_command = script_command.strip() + " " + " ".join(params) - - if not self.app.recording_enabled: - logger.info("Application is closing, adding script execution task to background service") - BackgroundService.get_instance().add_task(self.run_script_sync, script_command) - else: - self.app.page.run_task(self.run_script_async, script_command) - - logger.success("Script command execution initiated!") - - def run_script_sync(self, command: str) -> None: - """Synchronous version of the script execution method, used for background service""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self.run_script_async(command)) - finally: - loop.close() - - async def run_script_async(self, command: str) -> None: - try: - process = await asyncio.create_subprocess_exec( - *command.split(), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - startupinfo=self.subprocess_start_info, - text=True - ) - - stdout, stderr = await process.communicate() - - if stdout: - logger.info(stdout.splitlines()[0]) - if stderr: - logger.error(stderr.splitlines()[0]) - - if process.returncode != 0: - logger.info(f"Custom Script process exited with return code {process.returncode}") - - except PermissionError: - logger.error( - "Script has no execution permission!, If it is a Linux environment, " - "please first execute: chmod+x your_script.sh to grant script executable permission" - ) - except OSError: - logger.error("Please add `#!/bin/bash` at the beginning of your bash script file.") - except Exception as e: - logger.error(f"An error occurred: {e}") - - @staticmethod - def get_headers_params(live_url, platform_key): - live_domain = "/".join(live_url.split("/")[0:3]) - record_headers = { - "pandalive": "origin:https://www.pandalive.co.kr", - "winktv": "origin:https://www.winktv.co.kr", - "popkontv": "origin:https://www.popkontv.com", - "flextv": "origin:https://www.flextv.co.kr", - "qiandurebo": "referer:https://qiandurebo.com", - "17live": "referer:https://17.live/en/live/6302408", - "lang": "referer:https://www.lang.live", - "shopee": "origin:" + live_domain, - "blued": "referer:https://app.blued.cn", - } - return record_headers.get(platform_key) - - async def start_direct_download( - self, - record_name: str, - live_url: str, - record_url: str, - save_file_path: str, - save_type: str, - script_command: str | None = None - ) -> bool: - """ - Use the direct downloader to download the live stream - """ - try: - await self.direct_downloader.start_download() - - self.recording.status_info = RecordingStatus.RECORDING - self.recording.record_url = record_url - logger.info(f"Direct Downloading: {live_url}") - logger.log("STREAM", f"Direct Download Stream URL: {record_url}") - - while True: - if not self.recording.is_recording or not self.app.recording_enabled: - logger.info(f"Prepare to end direct download: {live_url}") - await self.direct_downloader.stop_download() - break - - await asyncio.sleep(1) - - if self.direct_downloader.download_task and self.direct_downloader.download_task.done(): - break - - if self.recording.monitor_status: - self.recording.status_info = RecordingStatus.MONITORING - display_title = self.recording.title - else: - self.recording.status_info = RecordingStatus.STOPPED_MONITORING - display_title = self.recording.display_title - - self.recording.live_title = None - if not self.recording.is_recording: - logger.success(f"Direct Downloading Stopped: {record_name}") - else: - logger.success(f"Direct Downloading Completed: {record_name}") - msg_manager = message_pusher.MessagePusher(self.settings) - user_config = self.settings.user_config - - if (self.app.recording_enabled and message_pusher.MessagePusher.should_push_message( - self.settings, self.recording, check_manually_stopped=True, message_type='end') and - not self.recording.notified_live_end): - push_content = self._["push_content_end"] - end_push_message_text = user_config.get("custom_stream_end_content") - if end_push_message_text: - push_content = end_push_message_text - - push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") - push_content = push_content.replace("[room_name]", self.recording.streamer_name).replace( - "[time]", push_at - ) - msg_title = user_config.get("custom_notification_title").strip() - msg_title = msg_title or self._["status_notify"] - - self.app.page.run_task(msg_manager.push_messages, msg_title, push_content) - self.recording.notified_live_end = True - - self.recording.is_recording = False - - try: - self.recording.update({"display_title": display_title}) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - if self.app.recording_enabled: - self.app.page.run_task(self.app.record_manager.check_if_live, self.recording) - else: - self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE - self.app.page.run_task(self.stop_recording_notify) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - - if self.user_config.get("execute_custom_script") and script_command: - logger.info("Prepare to execute custom script in the background") - try: - self.app.page.run_task( - self.custom_script_execute, - script_command, - record_name, - save_file_path, - save_type, - False, - False - ) - logger.success("Successfully added script execution") - except Exception as e: - logger.error(f"Failed to execute custom script: {e}") - await self.custom_script_execute( - script_command, - record_name, - save_file_path, - save_type, - False, - False - ) - - return True - - except Exception as e: - logger.error(f"Error occurred during direct download: {e}") - self.recording.status_info = RecordingStatus.RECORDING_ERROR - - try: - self.app.record_manager.stop_recording(self.recording) - await self.app.record_card_manager.update_card(self.recording) - self.app.page.pubsub.send_others_on_topic("update", self.recording) - await self.app.snack_bar.show_snack_bar( - record_name + " " + self._["record_stream_error"], duration=2000 - ) - except Exception as e: - logger.debug(f"Failed to update UI: {e}") - return False - finally: - self.recording.record_url = None - - async def stop_recording_notify(self): - if desktop_notify.should_push_notification(self.app): - desktop_notify.send_notification( - title=self._["notify"], - message=self.recording.streamer_name + ' | ' + self._["live_recording_stopped_message"], - app_icon=self.app.tray_manager.icon_path - ) +import asyncio +import os +import shutil +import subprocess +import sys +import time +from datetime import datetime +from typing import TypeVar + +from ...messages import desktop_notify, message_pusher +from ...models.media.video_quality_model import VideoQuality +from ...models.recording.recording_status_model import RecordingStatus +from ...utils import utils +from ...utils.logger import logger +from ..media import ffmpeg_builders +from ..media.direct_downloader import DirectStreamDownloader +from ..platforms import platform_handlers +from ..platforms.platform_handlers import StreamData +from ..runtime.process_manager import BackgroundService + +T = TypeVar("T") + + +class LiveStreamRecorder: + DEFAULT_SEGMENT_TIME = "1800" + DEFAULT_SAVE_FORMAT = "mp4" + DEFAULT_QUALITY = VideoQuality.OD + + def __init__(self, services, recording, recording_info): + self.services = services + self.settings = services.settings_config + self.recording = recording + self.recording_info = recording_info + self.subprocess_start_info = services.subprocess_start_up_info + self.should_stop = False # manually stopped + + self.user_config = self.settings.user_config + self.account_config = self.settings.accounts_config + self.platform_key = self._get_info("platform_key") + self.cookies = self.settings.cookies_config.get(self.platform_key) + + self.platform = self._get_info("platform") + self.live_url = self._get_info("live_url") + self.output_dir = self._get_info("output_dir") + self.segment_record = self._get_info("segment_record", default=False) + self.segment_time = self._get_info("segment_time", default=self.DEFAULT_SEGMENT_TIME) + self.quality = self._get_info("quality", default=self.DEFAULT_QUALITY) + self.video_bitrate = self._get_info("video_bitrate") + self.save_format = self._get_info("save_format", default=self.DEFAULT_SAVE_FORMAT).lower() + self.proxy = self.is_use_proxy() + self.direct_downloader = None + self.min_valid_recording_duration = 25 + self.recording_start_time = 0 + os.makedirs(self.output_dir, exist_ok=True) + self.services.language_manager.add_observer(self) + self._ = {} + self.load() + + @property + def app(self): + bridges = self.services.snapshot_bridges() + return bridges[0] if bridges else None + + def load(self): + language = self.services.language_manager.language + for key in ("recording_manager", "stream_manager"): + self._.update(language.get(key, {})) + + def _get_info(self, key: str, default: T = None) -> T: + return self.recording_info.get(key, default) or default + + def is_use_proxy(self): + default_proxy_platform = self.user_config.get("default_platform_with_proxy", "") + proxy_list = default_proxy_platform.replace(",", ",").replace(" ", "").split(",") + if self.user_config.get("enable_proxy") and self.platform_key in proxy_list: + self.proxy = self.user_config.get("proxy_address") + return self.proxy + return None + + def _get_filename(self, stream_info: StreamData) -> str: + live_title = None + stream_info.title = utils.clean_name(stream_info.title, None) + if self.user_config.get("filename_includes_title") and stream_info.title: + stream_info.title = self._clean_and_truncate_title(stream_info.title) or stream_info.title + live_title = stream_info.title + + if self.recording.streamer_name and self.recording.streamer_name != self._["live_room"]: + stream_info.anchor_name = utils.clean_name(self.recording.streamer_name) + else: + stream_info.anchor_name = utils.clean_name(stream_info.anchor_name, self._["live_room"]) + + now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + + custom_template = self.user_config.get("custom_filename_template") + if custom_template: + filename = custom_template + filename = filename.replace("{anchor_name}", stream_info.anchor_name or "") + filename = filename.replace("{title}", live_title or "") + filename = filename.replace("{time}", now) + filename = filename.replace("{platform}", stream_info.platform or "") + + while "__" in filename: + filename = filename.replace("__", "_") + + filename = filename.strip("_") + + if not filename: + full_filename = "_".join(i for i in (stream_info.anchor_name, live_title, now) if isinstance(i, str)) + else: + full_filename = filename + else: + full_filename = "_".join(i for i in (stream_info.anchor_name, live_title, now) if isinstance(i, str)) + + return full_filename + + def _get_output_dir(self, stream_info: StreamData) -> str: + if self.recording.recording_dir and self.user_config.get("folder_name_time"): + current_date = datetime.today().strftime("%Y-%m-%d") + if current_date not in self.recording.recording_dir: + self.recording.recording_dir = None + + if self.recording.recording_dir: + return self.recording.recording_dir + + now = datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + output_dir = self.output_dir.rstrip("/").rstrip("\\") + if self.user_config.get("folder_name_platform"): + output_dir = os.path.join(output_dir, stream_info.platform) + if self.user_config.get("folder_name_author"): + output_dir = os.path.join(output_dir, stream_info.anchor_name) + if self.user_config.get("folder_name_time"): + output_dir = os.path.join(output_dir, now[:10]) + if self.user_config.get("folder_name_title") and stream_info.title: + live_title = self._clean_and_truncate_title(stream_info.title) + if self.user_config.get("folder_name_time"): + output_dir = os.path.join(output_dir, f"{live_title}_{stream_info.anchor_name}") + else: + output_dir = os.path.join(output_dir, f"{now[:10]}_{live_title}") + os.makedirs(output_dir, exist_ok=True) + self.recording.recording_dir = output_dir + self.services.run_coro(self.services.recording_manager.persist_recordings()) + return output_dir + + def _get_save_path(self, filename: str, use_direct_download: bool = False) -> str: + suffix = self.save_format + suffix = "_%03d." + suffix if self.segment_record and not use_direct_download else "." + suffix + full_output_dir = self.output_dir if sys.platform != "linux" else self.output_dir.replace(" ", "_") + save_file_path = os.path.join(full_output_dir, (filename + suffix).replace(" ", "_")) + return save_file_path.replace("\\", "/") + + @staticmethod + def _clean_and_truncate_title(title: str) -> str | None: + if not title: + return None + cleaned_title = title[:30].replace(",", ",").replace(" ", "") + return cleaned_title + + def _handle_recording_error(self, record_name: str, error_msg: str, duration: int = 2000) -> None: + self.recording.status_info = RecordingStatus.RECORDING_ERROR + try: + self.services.recording_manager.stop_recording(self.recording) + self.services.broadcast_card_update(self.recording) + self.services.broadcast_pubsub("update", self.recording) + self.services.broadcast_snack(record_name + " " + error_msg, duration=duration) + except Exception as e: + logger.debug(f"Failed to update UI: {e}") + + async def _handle_recording_finished(self, record_name: str, stop_msg: str = "", complete_msg: str = "") -> None: + self.recording.is_live = False + if self.recording.monitor_status: + self.recording.status_info = RecordingStatus.MONITORING + display_title = self.recording.title + else: + self.recording.status_info = RecordingStatus.STOPPED_MONITORING + display_title = self.recording.display_title + + self.recording.live_title = None + if self.should_stop: + logger.success(stop_msg or f"Live recording has stopped: {record_name}") + else: + logger.success(complete_msg or f"Live recording completed: {record_name}") + asyncio.create_task(self.end_message_push()) + + try: + self.recording.update({"display_title": display_title}) + self.services.broadcast_card_update(self.recording) + self.services.broadcast_pubsub("update", self.recording) + except Exception as e: + logger.debug(f"Failed to update UI: {e}") + + @property + def is_flv_preferred_platform(self): + return self.platform_key in {"douyin", "tiktok"} + + def _select_source_url(self, stream_info: StreamData): + if self.user_config.get("default_live_source") != "HLS" and self.is_flv_preferred_platform: + codec = utils.get_query_params(stream_info.flv_url, "codec") + if codec and codec[0] == "h265": + logger.warning("FLV is not supported for h265 codec, use HLS source instead") + else: + return stream_info.flv_url + + return stream_info.record_url + + def _get_record_url(self, stream_info: StreamData): + + url = self._select_source_url(stream_info) + + http_record_list = ["shopee", "migu"] + if self.user_config.get("force_https_recording") and url.startswith("http://"): + url = url.replace("http://", "https://") + + if self.platform_key in http_record_list: + url = url.replace("https://", "http://") + return url + + def set_preview_url(self, stream_info: StreamData): + self.recording.preview_url = stream_info.m3u8_url or stream_info.flv_url + + def _get_record_format(self, stream_info: StreamData): + use_flv_record = ["shopee"] + if stream_info.flv_url: + if self.platform_key in use_flv_record or self.recording.flv_use_direct_download: + self.save_format = "flv" + self.recording.record_format = self.save_format + self.recording.segment_record = False + return self.save_format, True + + elif self.save_format == "flv": + codec = utils.get_query_params(stream_info.flv_url, "codec") + if codec and codec[0] == "h265": + logger.warning("FLV is not supported for h265 codec, use TS format instead") + self.save_format = "ts" + + return self.save_format, False + + async def fetch_stream(self) -> StreamData | None: + logger.info(f"Live URL: {self.live_url}") + logger.info(f"Use Proxy: {self.proxy or None}") + self.recording.use_proxy = bool(self.proxy) + handler = platform_handlers.get_platform_handler( + live_url=self.live_url, + proxy=self.proxy, + cookies=self.cookies, + record_quality=self.quality, + platform=self.platform, + username=self.account_config.get(self.platform_key, {}).get("username"), + password=self.account_config.get(self.platform_key, {}).get("password"), + account_type=self.account_config.get(self.platform_key, {}).get("account_type"), + ) + if not handler: + logger.error(f"No handler found for platform: {self.recording.url}") + return + stream_info = await handler.get_stream_info(self.live_url) + self.recording.is_checking = False + return stream_info + + async def start_recording(self, stream_info: StreamData): + """ + Construct ffmpeg recording parameters and start recording + """ + + self.save_format, use_direct_download = self._get_record_format(stream_info) + filename = self._get_filename(stream_info) + self.output_dir = self._get_output_dir(stream_info) + save_path = self._get_save_path(filename, use_direct_download) + logger.info(f"Save Path: {save_path}") + self.recording.recording_dir = os.path.dirname(save_path) + os.makedirs(self.recording.recording_dir, exist_ok=True) + record_url = self._get_record_url(stream_info) + self.set_preview_url(stream_info) + + try: + if self.recording.rec_id in self.services.recording_manager.active_recorders: + old_recorder = self.services.recording_manager.active_recorders[self.recording.rec_id] + logger.warning( + f"Found existing recorder instance for {self.recording.rec_id}, id: {id(old_recorder)}, stopping it" + ) + old_recorder.request_stop() + + await asyncio.sleep(1) + + self.services.recording_manager.active_recorders[self.recording.rec_id] = self + logger.info(f"Saved recorder instance for {self.recording.rec_id}, id: {id(self)}") + except Exception as e: + logger.error(f"Failed to save recorder instance: {e}") + + if use_direct_download: + logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}") + headers = {} + header_params = self.get_headers_params(record_url, self.platform_key) + if header_params: + key, value = header_params.split(":", 1) + headers[key] = value + + self.direct_downloader = DirectStreamDownloader( + record_url=record_url, save_path=save_path, headers=headers, proxy=self.proxy + ) + + self.services.run_coro( + self.start_direct_download( + stream_info.anchor_name, + self.live_url, + record_url, + save_path, + self.save_format, + self.user_config.get("custom_script_command"), + ) + ) + else: + ffmpeg_builder = ffmpeg_builders.create_builder( + self.save_format, + record_url=record_url, + proxy=self.proxy, + segment_record=self.segment_record, + segment_time=self.segment_time, + full_path=save_path, + headers=self.get_headers_params(record_url, self.platform_key), + platform_key=self.platform_key, + video_bitrate=self.video_bitrate, + ) + ffmpeg_command = ffmpeg_builder.build_command() + self.services.run_coro( + self.start_ffmpeg( + stream_info.anchor_name, + self.live_url, + record_url, + ffmpeg_command, + self.save_format, + self.user_config.get("custom_script_command"), + ) + ) + + async def remove_active_recorder(self): + try: + if self.recording.rec_id in self.services.recording_manager.active_recorders: + del self.services.recording_manager.active_recorders[self.recording.rec_id] + logger.info(f"Removed recorder from active_recorders: {self.recording.rec_id}") + except Exception as e: + logger.error(f"Failed to remove recorder instance: {e}") + + async def recheck_live_status(self): + if not self.should_stop: + # not manually stopped + recording_duration = time.time() - self.recording_start_time + if recording_duration > self.min_valid_recording_duration: + if self.services.recording_enabled and not self.is_flv_preferred_platform: + self.services.run_coro(self.services.recording_manager.check_if_live(self.recording)) + else: + self.recording.status_info = RecordingStatus.RECORDING_ERROR + + @staticmethod + async def _capture_stream_tail( + stream: asyncio.StreamReader | None, + max_bytes: int = 64 * 1024, + ) -> bytes: + """Continuously drain a subprocess stream while retaining a bounded tail.""" + if stream is None or max_bytes <= 0: + return b"" + + tail = bytearray() + while chunk := await stream.read(4096): + tail.extend(chunk) + if len(tail) > max_bytes: + del tail[:-max_bytes] + return bytes(tail) + + async def start_ffmpeg( + self, + record_name: str, + live_url: str, + record_url: str, + ffmpeg_command: list, + save_type: str, + script_command: str | None = None, + ) -> bool: + """ + The child process executes ffmpeg for recording + """ + + logger.info(f"Starting ffmpeg recording - recorder id: {id(self)}, rec_id: {self.recording.rec_id}") + self.should_stop = False + process = None + stderr_task = None + + try: + save_file_path = ffmpeg_command[-1] + + process = await asyncio.create_subprocess_exec( + *ffmpeg_command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, + startupinfo=self.subprocess_start_info, + ) + stderr_task = asyncio.create_task(self._capture_stream_tail(process.stderr)) + + self.services.process_manager.add_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}") + self.recording_start_time = time.time() + + while True: + if self.should_stop or self.recording.force_stop or not self.services.recording_enabled: + logger.info(f"Preparing to End Recording: {live_url}") + await self.remove_active_recorder() + self.recording.is_recording = False + try: + if os.name == "nt": + if process.stdin: + process.stdin.write(b"q") + await process.stdin.drain() + await asyncio.sleep(5) + else: + import signal + + process.send_signal(signal.SIGINT) + # process.terminate() + await asyncio.sleep(5) + + if process.stdin: + process.stdin.close() + + await asyncio.wait_for(process.wait(), timeout=15.0) + except asyncio.TimeoutError: + logger.warning(f"FFmpeg process did not exit gracefully, forcing termination: {live_url}") + process.kill() + await process.wait() + + self.recording.force_stop = False + break + + if process.returncode is not None: + logger.info(f"Exit loop recording (normal 0 | abnormal 1): code={process.returncode}, {live_url}") + await self.remove_active_recorder() + self.recording.is_recording = False + break + + await asyncio.sleep(1) + + await process.wait() + stderr = await stderr_task + return_code = process.returncode + safe_return_codes = {0, 255} + + if return_code not in safe_return_codes: + error_output = stderr.decode(errors="replace").strip() + if error_output: + logger.error(f"FFmpeg Stderr Output: {error_output.splitlines()[-1]}") + if not self.recording.is_recording: + self._handle_recording_error(record_name, self._["record_stream_error"]) + + if return_code in safe_return_codes: + if not self.recording.is_recording: + await self._handle_recording_finished(record_name) + + if not self.services.recording_enabled: + self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + self.services.run_coro(self.stop_recording_notify()) + + if not self.recording.manually_stopped: + await self.recheck_live_status() + + if self.user_config.get("convert_to_mp4") and self.save_format == "ts": + if self.segment_record: + file_paths = utils.get_file_paths(os.path.dirname(save_file_path)) + prefix = os.path.basename(save_file_path).rsplit("_", maxsplit=1)[0] + for path in file_paths: + if prefix in path: + try: + self.services.run_coro(self.converts_mp4(path, self.user_config["delete_original"])) + except Exception as e: + logger.error(f"Failed to convert video: {e}") + await self.converts_mp4(path, self.user_config["delete_original"]) + else: + try: + self.services.run_coro( + self.converts_mp4(save_file_path, self.user_config["delete_original"]) + ) + except Exception as e: + logger.error(f"Failed to convert video: {e}") + await self.converts_mp4(save_file_path, self.user_config["delete_original"]) + + if self.user_config.get("execute_custom_script") and script_command: + logger.info("Prepare a direct script in the background") + try: + self.services.run_coro( + self.custom_script_execute( + script_command, + record_name, + save_file_path, + save_type, + self.segment_record, + self.user_config.get("convert_to_mp4"), + ) + ) + logger.success("Successfully added script execution") + except Exception as e: + logger.error(f"Failed to execute custom script: {e}") + await self.custom_script_execute( + script_command, + record_name, + save_file_path, + save_type, + self.segment_record, + self.user_config.get("convert_to_mp4"), + ) + + except Exception as e: + logger.error(f"An error occurred during the subprocess execution: {e}") + self._handle_recording_error(record_name, self._["no_ffmpeg_tip"], duration=4000) + return False + finally: + if process is not None and process.returncode is None: + try: + process.kill() + await process.wait() + except ProcessLookupError: + pass + if stderr_task is not None: + await asyncio.gather(stderr_task, return_exceptions=True) + self.recording.record_url = None + + return True + + async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Asynchronous transcoding method, can be added to the background service to continue execution""" + if not self.services.recording_enabled: + logger.info(f"Application is closing, adding transcoding task to background service: {converts_file_path}") + BackgroundService.get_instance().add_task(self.converts_mp4_sync, converts_file_path, is_original_delete) + return + + # Otherwise, execute transcoding normally + await self._do_converts_mp4(converts_file_path, is_original_delete) + + def converts_mp4_sync(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Synchronous version of the transcoding method, used for background service""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._do_converts_mp4(converts_file_path, is_original_delete)) + finally: + loop.close() + + async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Actual execution method for transcoding""" + converts_success = False + save_path = "" + try: + converts_file_path = converts_file_path.replace("\\", "/") + if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0: + save_path = converts_file_path.rsplit(".", maxsplit=1)[0] + ".mp4" + ffmpeg_command = [ + "ffmpeg", + "-i", + converts_file_path, + "-c:v", + "copy", + "-c:a", + "copy", + "-f", + "mp4", + save_path, + ] + process = await asyncio.create_subprocess_exec( + *ffmpeg_command, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + startupinfo=self.subprocess_start_info, + ) + + self.services.process_manager.add_process(process) + task = asyncio.create_task(process.communicate()) + _, stderr = await task + if process.returncode == 0: + converts_success = True + logger.info(f"Video transcoding completed: {save_path}") + else: + logger.error( + f"Video transcoding failed! Error message: {stderr.decode() if stderr else 'Unknown error'}" + ) + + except subprocess.CalledProcessError as e: + logger.error(f"Video transcoding failed! Error message: {e.output.decode()}") + + try: + if converts_success: + if is_original_delete: + await asyncio.sleep(1) + if os.path.exists(converts_file_path): + os.remove(converts_file_path) + logger.info(f"Delete Original File: {converts_file_path}") + else: + converts_dir = f"{os.path.dirname(save_path)}/original" + os.makedirs(converts_dir, exist_ok=True) + shutil.move(converts_file_path, converts_dir) + logger.info(f"Move Transcoding Files: {converts_file_path}") + + except subprocess.CalledProcessError as e: + logger.error(f"Error occurred during conversion: {e}") + except Exception as e: + logger.error(f"An unknown error occurred: {e}") + + async def custom_script_execute( + self, + script_command: str, + record_name: str, + save_file_path: str, + save_type: str, + split_video_by_time: bool, + converts_to_mp4: bool, + ): + from ..runtime.process_manager import BackgroundService + + if "python" in script_command: + params = [ + f'--record_name "{record_name}"', + f'--save_file_path "{save_file_path}"', + f"--save_type {save_type}", + f"--split_video_by_time {split_video_by_time}", + f"--converts_to_mp4 {converts_to_mp4}", + ] + else: + params = [ + f'"{record_name.split(" ", maxsplit=1)[-1]}"', + f'"{save_file_path}"', + save_type, + f"split_video_by_time: {split_video_by_time}", + f"converts_to_mp4: {converts_to_mp4}", + ] + script_command = script_command.strip() + " " + " ".join(params) + + if not self.services.recording_enabled: + logger.info("Application is closing, adding script execution task to background service") + BackgroundService.get_instance().add_task(self.run_script_sync, script_command) + else: + self.services.run_coro(self.run_script_async(script_command)) + + logger.success("Script command execution initiated!") + + def run_script_sync(self, command: str) -> None: + """Synchronous version of the script execution method, used for background service""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self.run_script_async(command)) + finally: + loop.close() + + async def run_script_async(self, command: str) -> None: + try: + process = await asyncio.create_subprocess_exec( + *command.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + startupinfo=self.subprocess_start_info, + text=False, + ) + + stdout, stderr = await process.communicate() + + if stdout: + logger.info(stdout.splitlines()[0].decode()) + if stderr: + logger.error(stderr.splitlines()[0].decode()) + + if process.returncode != 0: + logger.info(f"Custom Script process exited with return code {process.returncode}") + + except PermissionError: + logger.error( + "Script has no execution permission!, If it is a Linux environment, " + "please first execute: chmod+x your_script.sh to grant script executable permission" + ) + except OSError: + logger.error("Please add `#!/bin/bash` at the beginning of your bash script file.") + except Exception as e: + logger.error(f"An error occurred: {e}") + + @staticmethod + def get_headers_params(live_url, platform_key): + live_domain = "/".join(live_url.split("/")[0:3]) + record_headers = { + "pandalive": "origin:https://www.pandalive.co.kr", + "winktv": "origin:https://www.winktv.co.kr", + "popkontv": "origin:https://www.popkontv.com", + "flextv": "origin:https://www.flextv.co.kr", + "qiandurebo": "referer:https://qiandurebo.com", + "17live": "referer:https://17.live/en/live/6302408", + "lang": "referer:https://www.lang.live", + "shopee": "origin:" + live_domain, + "blued": "referer:https://app.blued.cn", + "xindongrebo": "referer:https://xcqrkj.com", + } + return record_headers.get(platform_key) + + async def start_direct_download( + self, + record_name: str, + live_url: str, + record_url: str, + save_file_path: str, + save_type: str, + script_command: str | None = None, + ) -> bool: + """ + Use the direct downloader to download the live stream + """ + + logger.info(f"Starting direct download - recorder id: {id(self)}, rec_id: {self.recording.rec_id}") + self.should_stop = False + + try: + await self.direct_downloader.start_download() + + self.recording.status_info = RecordingStatus.RECORDING + self.recording.record_url = record_url + logger.info(f"Direct Downloading: {live_url}") + logger.log("STREAM", f"Direct Download Stream URL: {record_url}") + self.recording_start_time = time.time() + + while True: + if self.should_stop or self.recording.force_stop or not self.services.recording_enabled: + logger.info(f"Prepare to end direct download: {live_url}") + await self.remove_active_recorder() + self.recording.is_recording = False + await self.direct_downloader.stop_download() + self.recording.force_stop = False + break + + await asyncio.sleep(1) + + if self.direct_downloader.download_task and self.direct_downloader.download_task.done(): + break + + await self.remove_active_recorder() + self.recording.is_recording = False + + if not self.recording.is_recording: + await self._handle_recording_finished( + record_name, + stop_msg=f"Direct Downloading Stopped: {record_name}", + complete_msg=f"Direct Downloading Completed: {record_name}", + ) + + if not self.services.recording_enabled: + self.recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + self.services.run_coro(self.stop_recording_notify()) + + await self.recheck_live_status() + + if self.user_config.get("execute_custom_script") and script_command: + logger.info("Prepare to execute custom script in the background") + try: + self.services.run_coro( + self.custom_script_execute( + script_command, + record_name, + save_file_path, + save_type, + False, + False, + ) + ) + logger.success("Successfully added script execution") + except Exception as e: + logger.error(f"Failed to execute custom script: {e}") + await self.custom_script_execute( + script_command, record_name, save_file_path, save_type, False, False + ) + + return True + + except Exception as e: + logger.error(f"Error occurred during direct download: {e}") + self._handle_recording_error(record_name, self._["record_stream_error"]) + return False + finally: + self.recording.record_url = None + + async def stop_recording_notify(self): + if desktop_notify.should_push_notification(self.app): + tray_icon_path = self.services.tray_manager.icon_path if self.services.tray_manager is not None else "" + desktop_notify.send_notification( + title=self._["notify"], + message=self.recording.streamer_name + " | " + self._["live_recording_stopped_message"], + app_icon=tray_icon_path, + ) + + async def end_message_push(self): + msg_manager = message_pusher.MessagePusher(self.settings) + user_config = self.settings.user_config + + if ( + self.services.recording_enabled + and msg_manager.should_push_message( + self.settings, self.recording, check_manually_stopped=True, message_type="end" + ) + and not self.recording.notified_live_end + ): + self.recording.notified_live_end = True + push_content = self._["push_content_end"] + end_push_message_text = user_config.get("custom_stream_end_content") + if end_push_message_text: + push_content = end_push_message_text + + push_at = datetime.today().strftime("%Y-%m-%d %H:%M:%S") + push_content = ( + push_content.replace("[room_name]", self.recording.streamer_name) + .replace("[time]", push_at) + .replace("[title]", self.recording.live_title or "None") + ) + msg_title = user_config.get("custom_notification_title").strip() + msg_title = msg_title or self._["status_notify"] + + self.services.run_coro(msg_manager.push_messages(msg_title, push_content)) + + def request_stop(self): + logger.info(f"Stop requested for recorder: {self.recording.url}, rec_id: {self.recording.rec_id}") + logger.info(f"Recorder instance details - id: {id(self)}, recording: {self.recording.title}") + + old_value = self.should_stop + self.should_stop = True + + logger.info(f"Set should_stop from {old_value} to {self.should_stop} for recorder: {self.recording.rec_id}") diff --git a/app/core/runtime/backend_services.py b/app/core/runtime/backend_services.py new file mode 100644 index 00000000..d47a102a --- /dev/null +++ b/app/core/runtime/backend_services.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +import threading +import weakref +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from ...utils import utils +from ...utils.logger import logger +from ..config.config_manager import ConfigManager +from ..config.language_manager import LanguageManager +from ..config.settings_config import SettingsConfig +from .process_manager import AsyncProcessManager + +if TYPE_CHECKING: + from ..recording.record_manager import RecordingManager + + +@runtime_checkable +class UIBridge(Protocol): + """Contract implemented by session-scoped ``App`` instances.""" + + def schedule_card_update(self, recording) -> None: ... + + def schedule_card_remove(self, recordings) -> None: ... + + def schedule_snack(self, text: str, **kw: Any) -> None: ... + + def schedule_pubsub(self, topic: str, payload: Any) -> None: ... + + +class BackendServices: + _instance: BackendServices | None = None + + def __init__(self, run_path: str): + self.run_path = run_path + self.config_manager = ConfigManager(run_path) + self.settings_config = SettingsConfig(self) + self.language_manager = LanguageManager.create_headless(self) + + self.process_manager = AsyncProcessManager() + self.subprocess_start_up_info = utils.get_startup_info() + self.recording_enabled = True + + # Filled in by ``bootstrap``. + self.recording_manager: RecordingManager | None = None + + # Filled in by desktop App; not used in headless web mode. + self.tray_manager = None + + # Active UI sessions (web mode may have 0..N concurrent ones). + self._ui_bridges: weakref.WeakSet[UIBridge] = weakref.WeakSet() + self._bridges_lock = threading.Lock() + + # Background loop (only created in web mode). + self._backend_loop: asyncio.AbstractEventLoop | None = None + self._backend_thread: threading.Thread | None = None + self._loop_ready = threading.Event() + + @classmethod + def get(cls) -> BackendServices: + if cls._instance is None: + raise RuntimeError("BackendServices not bootstrapped") + return cls._instance + + @classmethod + def get_or_none(cls) -> BackendServices | None: + return cls._instance + + @classmethod + def bootstrap(cls, run_path: str) -> BackendServices: + if cls._instance is not None: + return cls._instance + from ..recording.record_manager import RecordingManager + + instance = cls(run_path) + instance.recording_manager = RecordingManager(instance) + cls._instance = instance + return instance + + def start_background_loop(self) -> None: + """Start the dedicated background asyncio loop in a daemon thread. + + Safe to call multiple times; subsequent calls are no-ops. + """ + if self._backend_thread is not None and self._backend_thread.is_alive(): + return + + def runner() -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._backend_loop = loop + self._loop_ready.set() + try: + if self.recording_manager is not None: + rm = self.recording_manager + interval = int(rm.loop_time_seconds or 180) + loop.create_task(rm.check_free_space()) + loop.create_task(rm.setup_periodic_live_check(interval)) + logger.info("BackendServices background loop started") + loop.run_forever() + except Exception as exc: # pragma: no cover - defensive + logger.error(f"BackendServices loop crashed: {exc}") + finally: + try: + loop.close() + except Exception: + pass + + thread = threading.Thread(target=runner, name="BackendServicesLoop", daemon=True) + self._backend_thread = thread + thread.start() + self._loop_ready.wait(timeout=5.0) + + def stop_background_loop(self) -> None: + loop = self._backend_loop + if loop is None or not loop.is_running(): + return + try: + loop.call_soon_threadsafe(loop.stop) + except Exception as exc: # pragma: no cover - defensive + logger.warning(f"Failed to stop BackendServices loop: {exc}") + + @property + def backend_loop(self) -> asyncio.AbstractEventLoop | None: + return self._backend_loop + + def run_coro(self, coro): + + if coro is None: + return None + + loop = self._backend_loop + if loop is not None and loop.is_running(): + try: + return asyncio.run_coroutine_threadsafe(coro, loop) + except Exception as exc: + logger.warning(f"run_coro: backend loop refused task: {exc}") + try: + coro.close() + except Exception: + pass + return None + try: + current = asyncio.get_running_loop() + return current.create_task(coro) + except RuntimeError: + logger.warning("run_coro: no running loop available, dropping coroutine") + try: + coro.close() + except Exception: + pass + return None + + def register_ui_bridge(self, bridge: UIBridge) -> None: + with self._bridges_lock: + self._ui_bridges.add(bridge) + + def unregister_ui_bridge(self, bridge: UIBridge) -> None: + with self._bridges_lock: + self._ui_bridges.discard(bridge) + + def snapshot_bridges(self) -> list[UIBridge]: + with self._bridges_lock: + return list(self._ui_bridges) + + def broadcast_card_update(self, recording) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_card_update(recording) + except Exception as exc: + logger.debug(f"broadcast_card_update failed for {bridge}: {exc}") + + def broadcast_card_remove(self, recordings) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_card_remove(recordings) + except Exception as exc: + logger.debug(f"broadcast_card_remove failed for {bridge}: {exc}") + + def broadcast_snack(self, text: str, **kw: Any) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_snack(text, **kw) + except Exception as exc: + logger.debug(f"broadcast_snack failed for {bridge}: {exc}") + + def broadcast_pubsub(self, topic: str, payload: Any) -> None: + for bridge in self.snapshot_bridges(): + try: + bridge.schedule_pubsub(topic, payload) + except Exception as exc: + logger.debug(f"broadcast_pubsub failed for {bridge}: {exc}") diff --git a/app/core/runtime/bundled_env.py b/app/core/runtime/bundled_env.py new file mode 100644 index 00000000..03657a88 --- /dev/null +++ b/app/core/runtime/bundled_env.py @@ -0,0 +1,168 @@ +import os +import platform +import shutil +import sys +from pathlib import Path + + +def patch_macos_flet_launcher() -> None: + """Launch Flet.app directly in macOS bundles to avoid a Dock icon for /usr/bin/open.""" + if not (getattr(sys, "frozen", False) and platform.system() == "Darwin"): + return + + import asyncio + import plistlib + import subprocess + import tempfile + + import flet_desktop + import flet_desktop.version + from flet.utils import random_string + + from app.core.runtime.paths import resource_dir, user_data_dir + + def find_app_in_dir(client_dir: Path, excluded_path: Path | None = None) -> Path | None: + if not client_dir.is_dir(): + return None + + candidates = [ + candidate for candidate in client_dir.iterdir() if candidate.suffix == ".app" and candidate.is_dir() + ] + for candidate in candidates: + if excluded_path is None or candidate.resolve() != excluded_path.resolve(): + return candidate + return None + + def find_cached_flet_app(excluded_path: Path) -> Path: + flet_view_path = os.environ.get("FLET_VIEW_PATH") + if flet_view_path: + source_app = find_app_in_dir(Path(flet_view_path), excluded_path) + if source_app: + return source_app + + client_dir = flet_desktop.ensure_client_cached() + source_app = find_app_in_dir(client_dir, excluded_path) + if source_app: + return source_app + raise FileNotFoundError(f"Application bundle not found in {client_dir}") + + def has_materialized_framework_links(app_path: Path) -> bool: + framework_root = app_path / "Contents" / "Frameworks" + if not framework_root.is_dir(): + return False + + for framework in framework_root.glob("*.framework"): + top_level_binary = framework / framework.stem + current_version = framework / "Versions" / "Current" + if top_level_binary.exists() and not top_level_binary.is_symlink(): + return True + if current_version.exists() and not current_version.is_symlink(): + return True + return False + + def write_file_picker_entitlements(target_dir: Path) -> Path: + entitlements_path = target_dir / "StreamCapFlet.entitlements" + entitlements = { + "com.apple.security.files.user-selected.read-write": True, + } + with entitlements_path.open("wb") as file: + plistlib.dump(entitlements, file) + return entitlements_path + + def prepare_streamcap_flet_app() -> Path: + target_app_name = "StreamCap Flet.app" + target_app = Path(user_data_dir) / "flet_client" / flet_desktop.version.version / target_app_name + source_app = find_cached_flet_app(target_app) + target_icon = target_app / "Contents" / "Resources" / "AppIcon.icns" + source_icon = Path(resource_dir) / "assets" / "icons" / "Appicon.icns" + + if target_app.exists() and has_materialized_framework_links(target_app): + shutil.rmtree(target_app) + + if not target_app.exists(): + target_app.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(source_app, target_app, symlinks=True) + + if source_icon.is_file(): + shutil.copy2(source_icon, target_icon) + + info_plist = target_app / "Contents" / "Info.plist" + with info_plist.open("rb") as file: + info = plistlib.load(file) + info["CFBundleName"] = "StreamCap" + info["CFBundleDisplayName"] = "StreamCap" + info["CFBundleIdentifier"] = "io.github.ihmily.streamcap.flet" + info["CFBundleIconFile"] = "AppIcon" + info.pop("CFBundleIconName", None) + with info_plist.open("wb") as file: + plistlib.dump(info, file) + + codesign = shutil.which("codesign") + if codesign: + entitlements_path = write_file_picker_entitlements(target_app.parent) + subprocess.run( + [ + codesign, + "--force", + "--deep", + "--sign", + "-", + "--entitlements", + str(entitlements_path), + str(target_app), + ], + check=False, + ) + + return target_app + + def build_launch_args(page_url, assets_dir, hidden): + pid_file = str(Path(tempfile.gettempdir()).joinpath(random_string(20))) + app_path = prepare_streamcap_flet_app() + info_plist = app_path / "Contents" / "Info.plist" + with info_plist.open("rb") as file: + info = plistlib.load(file) + executable_name = info.get("CFBundleExecutable", app_path.stem) + executable = app_path / "Contents" / "MacOS" / executable_name + + args = [str(executable), page_url, pid_file] + if assets_dir: + args.append(assets_dir) + + env = {**os.environ} + if hidden: + env["FLET_HIDE_WINDOW_ON_START"] = "true" + return args, env, pid_file + + def open_flet_view(page_url, assets_dir, hidden): + args, env, pid_file = build_launch_args(page_url, assets_dir, hidden) + return subprocess.Popen(args, env=env), pid_file + + async def open_flet_view_async(page_url, assets_dir, hidden): + args, env, pid_file = build_launch_args(page_url, assets_dir, hidden) + return await asyncio.create_subprocess_exec(args[0], *args[1:], env=env), pid_file + + flet_desktop.open_flet_view = open_flet_view + flet_desktop.open_flet_view_async = open_flet_view_async + + +def setup_bundled_flet_view() -> None: + """Configure the bundled Flet view path for PyInstaller desktop packages.""" + if not getattr(sys, "frozen", False): + return + + if platform.system() == "Darwin": + # Let flet_desktop use its official macOS flow: locate bundled + # flet-macos.tar.gz in the package app directory, extract it to the + # Flet client cache, then launch the discovered .app bundle. + return + + if hasattr(sys, "_MEIPASS"): + # noinspection PyProtectedMember + base = Path(sys._MEIPASS) + else: + base = Path(sys.executable).parent / "_internal" + + view_path = base / "flet_desktop" / "app" / "flet" + if (view_path / "flet.exe").is_file(): + os.environ["FLET_VIEW_PATH"] = str(view_path) diff --git a/app/core/runtime/paths.py b/app/core/runtime/paths.py new file mode 100644 index 00000000..7e3a3ca2 --- /dev/null +++ b/app/core/runtime/paths.py @@ -0,0 +1,98 @@ +import os +import shutil +import sys +from pathlib import Path + +APP_NAME = "StreamCap" +EXECUTABLE_SUFFIX = ".exe" if sys.platform == "win32" else "" + + +def _executable_dir() -> Path: + executable = sys.executable if getattr(sys, "frozen", False) else sys.argv[0] + return Path(executable).resolve().parent + + +def _macos_bundle_contents_dir(executable_dir: Path) -> Path | None: + contents_dir = executable_dir.parent + app_dir = contents_dir.parent + if ( + getattr(sys, "frozen", False) + and sys.platform == "darwin" + and executable_dir.name == "MacOS" + and contents_dir.name == "Contents" + and app_dir.suffix == ".app" + ): + return contents_dir + return None + + +_EXECUTABLE_DIR = _executable_dir() +_CONTENTS_DIR = _macos_bundle_contents_dir(_EXECUTABLE_DIR) + +if _CONTENTS_DIR is not None: + resource_dir = _CONTENTS_DIR / "Resources" + user_data_dir = Path.home() / "Library" / "Application Support" / APP_NAME +elif getattr(sys, "frozen", False) and sys.platform == "win32": + internal_dir = _EXECUTABLE_DIR / "_internal" + resource_dir = internal_dir if internal_dir.is_dir() else _EXECUTABLE_DIR + user_data_dir = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / APP_NAME +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.""" + user_data_dir.mkdir(parents=True, exist_ok=True) + if user_data_dir == resource_dir: + return + + for directory in ("config", "locales"): + source = resource_dir / directory + target = user_data_dir / directory + if source.is_dir(): + shutil.copytree(source, target, dirs_exist_ok=True) + + prepare_bundled_ffmpeg() + prepare_bundled_node() + + +def prepare_bundled_ffmpeg() -> None: + source_executable = resource_dir / "ffmpeg" / f"ffmpeg{EXECUTABLE_SUFFIX}" + target_dir = user_data_dir / "ffmpeg" + target_executable = target_dir / f"ffmpeg{EXECUTABLE_SUFFIX}" + if not source_executable.is_file() or target_executable.exists(): + return + if shutil.which("ffmpeg"): + return + + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_executable, target_executable) + if sys.platform != "win32": + target_executable.chmod(target_executable.stat().st_mode | 0o755) + + +def prepare_bundled_node() -> None: + source_executable = resource_dir / "node" / f"node{EXECUTABLE_SUFFIX}" + target_dir = user_data_dir / "node" + target_executable = target_dir / f"node{EXECUTABLE_SUFFIX}" + if not source_executable.is_file() or target_executable.exists(): + return + if shutil.which("node"): + return + + target_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_executable, target_executable) + if sys.platform != "win32": + target_executable.chmod(target_executable.stat().st_mode | 0o755) + + +def prepend_user_bin_dirs() -> None: + for directory in (user_data_dir / "ffmpeg", user_data_dir / "node"): + if directory.is_dir(): + os.environ["PATH"] = str(directory) + os.pathsep + os.environ.get("PATH", "") diff --git a/app/core/runtime/process_manager.py b/app/core/runtime/process_manager.py index 118ab119..2aa2d60c 100644 --- a/app/core/runtime/process_manager.py +++ b/app/core/runtime/process_manager.py @@ -6,36 +6,35 @@ class BackgroundService: - _instance = None - + @classmethod def get_instance(cls): if cls._instance is None: cls._instance = BackgroundService() return cls._instance - + def __init__(self): self.tasks = [] self.is_running = False self.worker_thread = None - + def add_task(self, task_func, *args, **kwargs): self.tasks.append((task_func, args, kwargs)) logger.info(f"Added background task: {task_func.__name__}") - + if not self.is_running: self.start() - + def start(self): if self.is_running: return - + self.is_running = True self.worker_thread = threading.Thread(target=self._process_tasks, daemon=False) self.worker_thread.start() logger.info("Background service started") - + def _process_tasks(self): while self.tasks: task_func, args, kwargs = self.tasks.pop(0) @@ -45,7 +44,7 @@ def _process_tasks(self): logger.info(f"Background task completed: {task_func.__name__}") except Exception as e: logger.error(f"Background task execution failed: {e}") - + logger.info("All background tasks completed, service stopped") self.is_running = False diff --git a/app/core/runtime/scheduled_shutdown.py b/app/core/runtime/scheduled_shutdown.py new file mode 100644 index 00000000..ee7b42ed --- /dev/null +++ b/app/core/runtime/scheduled_shutdown.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import asyncio +import subprocess +import sys +from datetime import datetime, time, timedelta + +import flet as ft + +from ...utils.logger import logger + +DEFAULT_SHUTDOWN_DELAY_SECONDS = 60 +MAX_QUICK_SHUTDOWN_HOURS = 168 + + +def parse_shutdown_time(value: str) -> time: + """Parse the persisted 24-hour shutdown time.""" + return datetime.strptime(value.strip(), "%H:%M").time() + + +def get_next_shutdown_at(value: str, now: datetime | None = None) -> datetime: + """Return the next occurrence of ``value`` after ``now``.""" + current = now or datetime.now() + scheduled_at = datetime.combine(current.date(), parse_shutdown_time(value)) + if scheduled_at <= current: + scheduled_at += timedelta(days=1) + return scheduled_at + + +def get_quick_shutdown_at(hours: int, now: datetime | None = None) -> datetime: + """Return the target time for a positive hour-based shutdown delay.""" + if not 1 <= hours <= MAX_QUICK_SHUTDOWN_HOURS: + raise ValueError(f"Shutdown delay must be between 1 and {MAX_QUICK_SHUTDOWN_HOURS} hours") + return (now or datetime.now()) + timedelta(hours=hours) + + +class ScheduledShutdownManager: + """Manage the desktop-only daily Windows shutdown schedule.""" + + def __init__(self, app) -> None: + self.app = app + self._task: asyncio.Task | None = None + self._countdown_task: asyncio.Task | None = None + self._dialog: ft.AlertDialog | None = None + self._shutdown_requested = False + self._quick_shutdown_at: datetime | None = None + + @property + def is_supported(self) -> bool: + return sys.platform == "win32" and not self.app.page.web + + async def reschedule(self) -> None: + await self.stop_schedule() + if not self.is_supported: + return + if self.quick_shutdown_at is not None: + logger.info(f"Daily shutdown paused until quick shutdown at {self.quick_shutdown_at:%Y-%m-%d %H:%M}") + return + + enabled = self.app.settings.user_config.get("scheduled_shutdown_enabled", False) + if not enabled: + return + + shutdown_time = str(self.app.settings.user_config.get("scheduled_shutdown_time", "23:00")) + try: + next_shutdown = get_next_shutdown_at(shutdown_time) + except ValueError: + logger.error(f"Invalid scheduled shutdown time: {shutdown_time}") + return + + self._task = asyncio.create_task(self._wait_until(next_shutdown, shutdown_time)) + logger.info(f"Scheduled system shutdown for {next_shutdown:%Y-%m-%d %H:%M}") + + @property + def quick_shutdown_at(self) -> datetime | None: + if self._quick_shutdown_at is not None and self._quick_shutdown_at <= datetime.now(): + self._quick_shutdown_at = None + return self._quick_shutdown_at + + async def start_quick_shutdown(self, hours: int) -> datetime: + if not self.is_supported: + raise RuntimeError("Quick shutdown is only supported on Windows desktop") + + target = get_quick_shutdown_at(hours) + await self.stop_schedule() + _ = self._translations() + result = await self._run_shutdown_command( + delay_seconds=hours * 60 * 60, + message=_["quick_shutdown_system_message"].format(hours=hours), + ) + if result.returncode != 0: + await self.reschedule() + raise RuntimeError(result.stderr.strip() or str(result.returncode)) + + self._quick_shutdown_at = target + logger.warning(f"Quick system shutdown scheduled for {target:%Y-%m-%d %H:%M}") + return target + + async def cancel_quick_shutdown(self) -> None: + if self.quick_shutdown_at is None: + return + + result = await asyncio.to_thread( + subprocess.run, + ["shutdown", "/a"], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or str(result.returncode)) + + self._quick_shutdown_at = None + logger.info("Quick shutdown cancelled by user") + await self.reschedule() + + async def stop_schedule(self) -> None: + task = self._task + self._task = None + if task is None or task.done() or task is asyncio.current_task(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def stop(self) -> None: + await self.stop_schedule() + if self._countdown_task is not None and not self._countdown_task.done(): + self._countdown_task.cancel() + self._countdown_task = None + + async def _wait_until(self, scheduled_at: datetime, configured_time: str) -> None: + try: + delay = max(0.0, (scheduled_at - datetime.now()).total_seconds()) + await asyncio.sleep(delay) + + settings = self.app.settings.user_config + if not settings.get("scheduled_shutdown_enabled", False): + return + if str(settings.get("scheduled_shutdown_time", "23:00")) != configured_time: + return + + await self._request_shutdown() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error(f"Scheduled shutdown failed: {exc}") + + def _translations(self) -> dict: + language = self.app.language_manager.language + return language.get("base", {}) | language.get("settings_page", {}) + + async def _run_shutdown_command(self, delay_seconds: int, message: str): + return await asyncio.to_thread( + subprocess.run, + ["shutdown", "/s", "/t", str(delay_seconds), "/c", message], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + + async def _request_shutdown(self) -> None: + if self._shutdown_requested: + return + + _ = self._translations() + result = await self._run_shutdown_command( + delay_seconds=DEFAULT_SHUTDOWN_DELAY_SECONDS, + message=_["scheduled_shutdown_system_message"], + ) + if result.returncode != 0: + error = result.stderr.strip() or str(result.returncode) + logger.error(f"Unable to start scheduled shutdown: {error}") + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_failed"].format(error=error), + bgcolor=ft.Colors.RED, + duration=5000, + ) + return + + self._shutdown_requested = True + logger.warning(f"System shutdown requested in {DEFAULT_SHUTDOWN_DELAY_SECONDS} seconds") + self._show_countdown_dialog(DEFAULT_SHUTDOWN_DELAY_SECONDS) + + def _show_countdown_dialog(self, seconds: int) -> None: + _ = self._translations() + countdown_text = ft.Text( + _["scheduled_shutdown_countdown"].format(seconds=seconds), + size=14, + text_align=ft.TextAlign.CENTER, + ) + + async def cancel_shutdown(_event) -> None: + result = await asyncio.to_thread( + subprocess.run, + ["shutdown", "/a"], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + if result.returncode == 0: + self._shutdown_requested = False + logger.info("Scheduled shutdown cancelled by user") + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_cancelled"], + bgcolor=ft.Colors.PRIMARY, + ) + await self._close_dialog() + await self.reschedule() + else: + error = result.stderr.strip() or str(result.returncode) + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_cancel_failed"].format(error=error), + bgcolor=ft.Colors.RED, + duration=5000, + ) + + self._dialog = ft.AlertDialog( + modal=True, + title=ft.Text(_["scheduled_shutdown"], weight=ft.FontWeight.BOLD), + content=ft.Column( + [ + ft.Icon(ft.Icons.POWER_SETTINGS_NEW, size=36, color=ft.Colors.ERROR), + countdown_text, + ], + tight=True, + spacing=12, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + actions=[ + ft.OutlinedButton( + content=_["scheduled_shutdown_cancel"], + icon=ft.Icons.CANCEL_SCHEDULE_SEND, + on_click=cancel_shutdown, + ) + ], + actions_alignment=ft.MainAxisAlignment.CENTER, + ) + self._dialog.open = True + self.app.dialog_area.content = self._dialog + self.app.dialog_area.update() + self._countdown_task = asyncio.create_task(self._update_countdown(countdown_text, seconds)) + + async def _update_countdown(self, countdown_text: ft.Text, seconds: int) -> None: + _ = self._translations() + try: + for remaining in range(seconds - 1, -1, -1): + await asyncio.sleep(1) + countdown_text.value = _["scheduled_shutdown_countdown"].format(seconds=remaining) + countdown_text.update() + except (asyncio.CancelledError, ft.FletPageDisconnectedException): + pass + + async def _close_dialog(self) -> None: + if self._countdown_task is not None and not self._countdown_task.done(): + self._countdown_task.cancel() + self._countdown_task = None + if self._dialog is not None: + self._dialog.open = False + self.app.dialog_area.update() + self._dialog = None diff --git a/app/core/update/update_checker.py b/app/core/update/update_checker.py index 41c03de4..f95ebc0c 100644 --- a/app/core/update/update_checker.py +++ b/app/core/update/update_checker.py @@ -1,7 +1,7 @@ import asyncio import json import os -from typing import Any +from typing import Any, Literal, TypedDict import flet as ft import httpx @@ -9,12 +9,47 @@ from ...utils.logger import logger +class UpdateSource(TypedDict): + name: str + enabled: bool + priority: int + type: Literal["github", "custom"] + repo: str + url: str + timeout: int + + +class SuccessfulUpdateInfo(TypedDict): + has_update: Literal[True] + latest_version: str + current_version: str + release_notes: str + download_url: str + download_urls: dict[str, str] + source: str + + +class FailedUpdateInfo(TypedDict): + has_update: Literal[False] + error: str + source: str + + +UpdateInfo = SuccessfulUpdateInfo | FailedUpdateInfo + + +class UpdateConfig(TypedDict): + update_sources: list[UpdateSource] + check_interval: int + auto_check: bool + + class UpdateChecker: def __init__(self, app): self.app = app self.current_version = self._get_current_version() self.update_config = self._load_update_config() - + def _get_current_version(self) -> str: try: config_path = os.path.join(self.app.run_path, "config", "version.json") @@ -26,60 +61,60 @@ def _get_current_version(self) -> str: return "0.0.0" @staticmethod - def _load_update_config() -> dict[str, Any]: + def _load_update_config() -> UpdateConfig: auto_check = os.getenv("AUTO_CHECK_UPDATE", "false").lower() == "true" update_source = os.getenv("UPDATE_SOURCE", "both").lower() github_repo = os.getenv("GITHUB_REPO", "ihmily/StreamCap") custom_api = os.getenv("CUSTOM_UPDATE_API", "") check_interval = int(os.getenv("UPDATE_CHECK_INTERVAL", "86400")) - + update_sources = [] - + if update_source in ["github", "both"]: - update_sources.append({ - "name": "GitHub", - "enabled": True, - "priority": 1 if update_source == "github" else 0, - "type": "github", - "repo": github_repo, - "timeout": 10 - }) - + update_sources.append( + { + "name": "GitHub", + "enabled": True, + "priority": 1 if update_source == "github" else 0, + "type": "github", + "repo": github_repo, + "url": "https://api.github.com/repos/" + github_repo + "/releases/latest", + "timeout": 10, + } + ) + if update_source in ["custom", "both"] and custom_api: - update_sources.append({ - "name": "Custom", - "enabled": True, - "priority": 1 if update_source == "custom" else 2, - "type": "custom", - "url": custom_api, - "timeout": 5 - }) - - return { - "update_sources": update_sources, - "check_interval": check_interval, - "auto_check": auto_check - } - - async def check_for_updates(self) -> dict[str, Any]: + update_sources.append( + { + "name": "Custom", + "enabled": True, + "priority": 1 if update_source == "custom" else 2, + "type": "custom", + "repo": custom_api, + "url": custom_api, + "timeout": 5, + } + ) + + return {"update_sources": update_sources, "check_interval": check_interval, "auto_check": auto_check} + + async def check_for_updates(self) -> UpdateInfo: """Check for updates, prioritizing sources with higher priority""" sources = sorted( - [s for s in self.update_config["update_sources"] if s["enabled"]], - key=lambda x: x["priority"], - reverse=True + [s for s in self.update_config["update_sources"] if s["enabled"]], key=lambda x: x["priority"], reverse=True ) - + if not sources: logger.warning("No available update sources configured") return {"has_update": False, "error": "No available update sources configured"} - + tasks = [] for source in sources: if source["type"] == "github": tasks.append(self._check_github_update(source)) elif source["type"] == "custom": tasks.append(self._check_custom_update(source)) - + # Wait for any task to complete successfully or all to fail results = [] for task in asyncio.as_completed(tasks): @@ -91,22 +126,20 @@ async def check_for_updates(self) -> dict[str, Any]: except Exception as e: logger.error(f"Update check failed: {e}") results.append({"has_update": False, "error": str(e)}) - + return results[-1] if results else {"has_update": False, "error": "All update sources check failed"} - - async def _check_github_update(self, source: dict[str, Any]) -> dict[str, Any]: + + async def _check_github_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from GitHub""" try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - "https://api.github.com/repos/" + source['repo'] + "/releases/latest" - ) + response = await client.get("https://api.github.com/repos/" + source["repo"] + "/releases/latest") if response.status_code == 200: latest_release = response.json() latest_version = latest_release["tag_name"].lstrip("v") - + if self._compare_versions(latest_version, self.current_version) > 0: download_urls = {} for asset in latest_release.get("assets", []): @@ -117,7 +150,7 @@ async def _check_github_update(self, source: dict[str, Any]) -> dict[str, Any]: download_urls["macos"] = asset["browser_download_url"] elif "linux" in name: download_urls["linux"] = latest_release["html_url"] - + return { "has_update": True, "latest_version": latest_version, @@ -125,16 +158,16 @@ async def _check_github_update(self, source: dict[str, Any]) -> dict[str, Any]: "release_notes": latest_release["body"], "download_url": latest_release["html_url"], "download_urls": download_urls, - "source": source["name"] + "source": source["name"], } return {"has_update": False, "source": source["name"]} except Exception as e: logger.error(f"Failed to check update from GitHub: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} - - async def _check_custom_update(self, source: dict[str, Any]) -> dict[str, Any]: + + async def _check_custom_update(self, source: UpdateSource) -> UpdateInfo: """Check for updates from custom source - + Expected API Response Format: { "has_update": bool, # Whether there is a new version available @@ -152,20 +185,17 @@ async def _check_custom_update(self, source: dict[str, Any]) -> dict[str, Any]: try: timeout = httpx.Timeout(source["timeout"]) async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get( - source["url"], - params={"current_version": self.current_version} - ) + response = await client.get(source["url"], params={"current_version": self.current_version}) if response.status_code == 200: update_info = response.json() if update_info.get("has_update", False): - return { - **update_info, - "source": source["name"] - } + return {**update_info, "source": source["name"]} return {"has_update": False, "source": source["name"]} - return {"has_update": False, "error": f"API returned status code: {response.status_code}", - "source": source["name"]} + return { + "has_update": False, + "error": f"API returned status code: {response.status_code}", + "source": source["name"], + } except Exception as e: logger.error(f"Failed to check update from custom source: {e}") return {"has_update": False, "error": str(e), "source": source["name"]} @@ -173,7 +203,7 @@ async def _check_custom_update(self, source: dict[str, Any]) -> dict[str, Any]: @staticmethod def _compare_versions(version1: str, version2: str) -> int: """Compare version numbers, returns 1 if version1 > version2, 0 if equal, -1 if less""" - + def parse_version(version): if "-" in version: v_parts, pre_release = version.split("-", 1) @@ -187,7 +217,7 @@ def parse_version(version): else: v_parts = version pre_release_value = 0 - + v_nums = [] for part in v_parts.split("."): try: @@ -200,12 +230,12 @@ def parse_version(version): except ValueError: v_nums.append(0) break - + return v_nums, pre_release_value - + v1_parts, v1_pre = parse_version(version1) v2_parts, v2_pre = parse_version(version2) - + for i in range(max(len(v1_parts), len(v2_parts))): v1 = v1_parts[i] if i < len(v1_parts) else 0 v2 = v2_parts[i] if i < len(v2_parts) else 0 @@ -213,34 +243,39 @@ def parse_version(version): return 1 elif v1 < v2: return -1 - + if v1_pre > v2_pre: return 1 elif v1_pre < v2_pre: return -1 - + return 0 - + async def show_update_dialog(self, update_info: dict[str, Any]) -> None: _ = self.app.language_manager.language.get("update", {}) dialog = ft.AlertDialog( title=ft.Text(_["new_version"].format(version=update_info.get("latest_version"))), - content=ft.Column([ - ft.Text(_["current_version"].format(version=update_info.get("current_version"))), - ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), - ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), - ], spacing=10, width=400, height=300), + content=ft.Column( + [ + ft.Text(_["current_version"].format(version=update_info.get("current_version"))), + ft.Text(_["latest_version"].format(version=update_info.get("latest_version"))), + ft.Text(_["update_source"].format(source=update_info.get("source", _["unknown"]))), + ], + spacing=10, + width=400, + height=300, + ), actions=[ ft.TextButton(_["later"], on_click=lambda _: self.close_dialog()), - ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)) + ft.TextButton(_["download"], on_click=lambda _: self.open_download_page(update_info)), ], ) self.app.dialog_area.content = dialog self.app.dialog_area.content.open = True self.app.dialog_area.update() - + def close_dialog(self) -> None: if self.app.dialog_area.content: self.app.dialog_area.content.open = False @@ -250,7 +285,7 @@ def open_download_page(self, update_info: dict[str, Any]) -> None: import platform url = update_info.get("download_url", "https://github.com/ihmily/StreamCap/releases/latest") - + download_urls = update_info.get("download_urls", {}) if download_urls: system = platform.system().lower() @@ -260,6 +295,6 @@ def open_download_page(self, update_info: dict[str, Any]) -> None: url = download_urls["macos"] elif system == "linux" and "linux" in download_urls: url = download_urls["linux"] - + self.app.page.launch_url(url) self.close_dialog() diff --git a/app/initialization/installation_manager.py b/app/initialization/installation_manager.py index c6aca463..abafd11b 100644 --- a/app/initialization/installation_manager.py +++ b/app/initialization/installation_manager.py @@ -67,13 +67,15 @@ async def install_components(self): right_btn.icon = ft.Icons.ERROR_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.RED_400, icon_color=ft.Colors.RED_600 + ) else: left_btn.text = self._["installed"] right_btn.icon = ft.Icons.CHECK_CIRCLE_OUTLINED right_btn.style = ft.ButtonStyle( - color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600) + color=ft.Colors.WHITE, bgcolor=ft.Colors.GREEN_400, icon_color=ft.Colors.GREEN_600 + ) self.page.update() async def update_component_progress(self, component_name, progress, status): @@ -101,8 +103,11 @@ async def show_install_dialog(self): status_text = ft.Text(f"{component['name']} - {self._['wait_install']}...", size=14, no_wrap=False) component_item = ft.Row( controls=[ - ft.Column([ft.Text(component["name"], size=16), status_text], - alignment=ft.MainAxisAlignment.START, expand=True), + ft.Column( + [ft.Text(component["name"], size=16), status_text], + alignment=ft.MainAxisAlignment.START, + expand=True, + ), ft.Column([progress_ring, ft.Text("0%", size=12)], horizontal_alignment=ft.CrossAxisAlignment.END), ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, @@ -128,7 +133,7 @@ async def show_install_dialog(self): ft.Row( [ft.Checkbox(label=self._["dont_show_again"], value=False, on_change=self.on_dont_show_again)], alignment=ft.MainAxisAlignment.START, - ) + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=15, @@ -184,6 +189,6 @@ async def check_env(self): self.page.run_task(self.show_install_dialog) else: from ..scripts import ffmpeg_install, node_install + ffmpeg_install.update_env_path() node_install.update_env_path() - \ No newline at end of file diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index 831d5329..f187de35 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -1,4 +1,4 @@ -import asyncio +import os import threading import time @@ -8,18 +8,13 @@ from .tray_manager import TrayManager -def _safe_destroy_window(page): +async def _safe_destroy_window(page): try: - page.update() - to_cancel = asyncio.all_tasks(page.loop) - if not to_cancel: - return - for task in to_cancel: - task.cancel() + await page.window.destroy() except Exception as ex: logger.error(f"close window error: {ex}") finally: - page.window.destroy() + os._exit(0) async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: @@ -39,13 +34,18 @@ async def minimize_to_tray(e): async def close_dialog_dismissed(e): app.recording_enabled = False + app.settings.user_config["last_route"] = page.route + await app.config_manager.save_user_config(app.settings.user_config) + logger.info(f"Saved last route: {page.route}") + # check if there are active recordings active_recordings = [p for p in app.process_manager.ffmpeg_processes if p.returncode is None] active_recordings_count = len(active_recordings) if active_recordings_count > 0: - save_progress_overlay.show(_["saving_recordings"].format(active_recordings_count=active_recordings_count), - cancellable=True) + save_progress_overlay.show( + _["saving_recordings"].format(active_recordings_count=active_recordings_count), cancellable=True + ) page.update() def close_app(): @@ -53,7 +53,8 @@ def close_app(): # adjust wait time based on the number of recordings, at least 2 seconds base_wait_time = max(2, min(active_recordings_count, 10)) logger.info( - f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds") + f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds" + ) time.sleep(base_wait_time) @@ -70,13 +71,15 @@ def close_app(): finally: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - page.window.destroy() + page.run_task(page.window.destroy) + time.sleep(0.3) + os._exit(0) threading.Thread(target=close_app, daemon=True).start() else: if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): app.tray_manager.stop() - _safe_destroy_window(page) + await _safe_destroy_window(page) await close_dialog(e) @@ -84,6 +87,58 @@ async def close_dialog(_): close_confirm_dialog.open = False page.update() + close_confirm_controls = [ + ft.Text( + _["confirm_exit_content"], + size=14, + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=10), + ] + + if page.platform.value != "macos": + close_confirm_controls.append( + ft.Container( + content=ft.Text( + _["minimize_to_tray_tip"], + size=12, + color=ft.Colors.GREY_500, + text_align=ft.TextAlign.CENTER, + ), + padding=ft.Padding.all(8), + border_radius=5, + bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.BLUE_GREY), + ) + ) + + close_confirm_actions = [ + ft.TextButton( + content=ft.Text(_["cancel"], size=14), + on_click=close_dialog, + style=ft.ButtonStyle( + color=ft.Colors.PRIMARY, + ), + ), + ft.OutlinedButton( + content=ft.Text(_["exit_program"], size=14), + on_click=close_dialog_dismissed, + style=ft.ButtonStyle( + color=ft.Colors.ERROR, + ), + ), + ] + if page.platform.value != "macos": + close_confirm_actions.insert( + 1, + ft.TextButton( + content=ft.Text(_["minimize_to_tray"], size=14), + on_click=minimize_to_tray, + style=ft.ButtonStyle( + color=ft.Colors.PRIMARY, + ), + ), + ) + close_confirm_dialog = ft.AlertDialog( modal=True, title=ft.Text( @@ -94,55 +149,15 @@ async def close_dialog(_): ), content=ft.Container( content=ft.Column( - controls=[ - ft.Text( - _["confirm_exit_content"], - size=14, - text_align=ft.TextAlign.CENTER, - ), - ft.Container(height=10), - ft.Container( - content=ft.Text( - _["minimize_to_tray_tip"], - size=12, - color=ft.colors.GREY_500, - text_align=ft.TextAlign.CENTER, - ), - padding=ft.padding.all(8), - border_radius=5, - bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), - ), - ], + controls=close_confirm_controls, spacing=5, tight=True, horizontal_alignment=ft.CrossAxisAlignment.CENTER, ), - padding=ft.padding.symmetric(horizontal=20, vertical=10), - width=400, + padding=ft.Padding.symmetric(horizontal=20, vertical=10), + width=400 if page.platform.value != "macos" else None, ), - actions=[ - ft.TextButton( - content=ft.Text(_["cancel"], size=14), - on_click=close_dialog, - style=ft.ButtonStyle( - color=ft.colors.PRIMARY, - ), - ), - ft.TextButton( - content=ft.Text(_["minimize_to_tray"], size=14), - on_click=minimize_to_tray, - style=ft.ButtonStyle( - color=ft.colors.PRIMARY, - ), - ), - ft.OutlinedButton( - content=ft.Text(_["exit_program"], size=14), - on_click=close_dialog_dismissed, - style=ft.ButtonStyle( - color=ft.colors.ERROR, - ), - ), - ], + actions=close_confirm_actions, actions_alignment=ft.MainAxisAlignment.END, shape=ft.RoundedRectangleBorder(radius=10), ) diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py index 09e142ee..c75ae1c1 100644 --- a/app/lifecycle/tray_manager.py +++ b/app/lifecycle/tray_manager.py @@ -1,94 +1,92 @@ -import os -import threading - -import flet as ft - -from ..utils.logger import logger - - -class TrayManager: - - def __init__(self, app): - self.app = app - self.icon = None - self.icon_path = None - self.tray_thread = None - self.is_running = False - self.execute_dir = getattr(app, "run_path", os.getcwd()) - self.assets_dir = "assets" - - def create_image(self): - try: - from PIL import Image - - self.icon_path = os.path.join(self.execute_dir, self.assets_dir, "icons", "tray_icon.ico") - if os.path.exists(self.icon_path): - return Image.open(self.icon_path) - except Exception as e: - logger.error(f"Failed to load icon file: {e}") - try: - from PIL import Image - image = Image.new('RGB', (32, 32), color=(255, 255, 255)) - return image - except Exception as e: - logger.error("PIL not available, unable to create tray icon") - raise e - - def create_tray_icon(self, page: ft.Page): - if self.is_running: - return - - try: - import pystray - - def on_restore(_icon, _item): - page.window.visible = True - page.window.minimized = False - page.update() - - def on_exit(_icon, _item): - self.is_running = False - on_restore(_icon, _item) - if hasattr(self.app, "close_confirm_dialog"): - self.app.close_confirm_dialog.open = True - page.update() - - language = self.app.language_manager.language - _ = {} - for key in ("tray_manager", "base"): - _.update(language.get(key, {})) - - menu = pystray.Menu( - pystray.MenuItem(_["restore"], on_restore), - pystray.MenuItem(_["exit"], on_exit) - ) - - self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) - self.is_running = True - self.icon.run() - except ImportError as e: - logger.error(e) - self.is_running = False - page.window.destroy() - raise e - - def start(self, page: ft.Page): - if getattr(self.app, "is_web_mode", False): - logger.info("Tray icon not available in web mode") - return False - - if self.tray_thread is None or not self.tray_thread.is_alive(): - self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) - self.tray_thread.start() - return True - return False - - def stop(self): - if self.icon and self.is_running: - self.is_running = False - try: - self.icon.stop() - return True - except Exception as e: - logger.error(f"Error stopping tray icon: {e}") - return False +import os +import threading + +import flet as ft + +from ..utils.logger import logger + + +class TrayManager: + def __init__(self, app): + self.app = app + self.icon = None + self.icon_path = None + self.tray_thread = None + self.is_running = False + self.assets_dir = getattr(app, "assets_dir", os.path.join(os.getcwd(), "assets")) + + def create_image(self): + try: + from PIL import Image + + self.icon_path = os.path.join(self.assets_dir, "icons", "tray_icon.ico") + if os.path.exists(self.icon_path): + return Image.open(self.icon_path) + except Exception as e: + logger.error(f"Failed to load icon file: {e}") + try: + from PIL import Image + + image = Image.new("RGB", (32, 32), color=(255, 255, 255)) + return image + except Exception as e: + logger.error("PIL not available, unable to create tray icon") + raise e + + def create_tray_icon(self, page: ft.Page): + if self.is_running: + return + + try: + import pystray + + def on_restore(_icon, _item): + page.window.visible = True + page.window.minimized = False + page.window.focused = True + page.run_task(page.window.to_front) + page.update() + + def on_exit(_icon, _item): + self.is_running = False + on_restore(_icon, _item) + if hasattr(self.app, "close_confirm_dialog"): + self.app.close_confirm_dialog.open = True + page.update() + + language = self.app.language_manager.language + _ = {} + for key in ("tray_manager", "base"): + _.update(language.get(key, {})) + + menu = pystray.Menu(pystray.MenuItem(_["restore"], on_restore), pystray.MenuItem(_["exit"], on_exit)) + + self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) + self.is_running = True + self.icon.run() + except ImportError as e: + logger.error(e) + self.is_running = False + page.run_task(page.window.destroy) + raise e + + def start(self, page: ft.Page): + if getattr(self.app, "is_web_mode", False): + logger.info("Tray icon not available in web mode") + return False + + if self.tray_thread is None or not self.tray_thread.is_alive(): + self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) + self.tray_thread.start() + return True + return False + + def stop(self): + if self.icon and self.is_running: + self.is_running = False + try: + self.icon.stop() + return True + except Exception as e: + logger.error(f"Error stopping tray icon: {e}") + return False diff --git a/app/messages/desktop_notify.py b/app/messages/desktop_notify.py index 55575dfc..2556a04f 100644 --- a/app/messages/desktop_notify.py +++ b/app/messages/desktop_notify.py @@ -1,17 +1,21 @@ - - def send_notification(title: str, message: str, app_icon: str = "", app_name: str = "StreamCap", timeout: int = 10): from plyer import notification - notification.notify( - title=title, - message=message, - app_icon=app_icon, - app_name=app_name, - timeout=timeout - ) + + notification.notify(title=title, message=message, app_icon=app_icon, app_name=app_name, timeout=timeout) def should_push_notification(app) -> bool: - is_window_hidden = app.page.window.minimized or not app.page.window.visible - system_notification_enabled = app.settings.user_config.get("system_notification_enabled", True) - return not app.page.web and system_notification_enabled and is_window_hidden + if app is None or getattr(app, "page", None) is None: + return False + page = app.page + + if page.web: + return False + settings = getattr(app, "settings", None) + user_config = getattr(settings, "user_config", {}) if settings is not None else {} + system_notification_enabled = user_config.get("system_notification_enabled", True) + try: + is_window_hidden = page.window.minimized or not page.window.visible + except Exception: + return False + return system_notification_enabled and is_window_hidden diff --git a/app/messages/message_pusher.py b/app/messages/message_pusher.py index 26080190..3572e8d2 100644 --- a/app/messages/message_pusher.py +++ b/app/messages/message_pusher.py @@ -1,167 +1,170 @@ -from asyncio import create_task +import asyncio from typing import Optional from ..models.recording.recording_model import Recording -from ..ui.views.settings_view import SettingsPage from ..utils.logger import logger from .notification_service import NotificationService class MessagePusher: - def __init__(self, settings: SettingsPage): + def __init__(self, settings): self.settings = settings self.notifier = NotificationService() def _get_proxy(self) -> str | None: if self.settings.user_config.get("enable_proxy"): return self.settings.user_config.get("proxy_address") + return None - def is_any_push_channel_enabled(self) -> bool: - """Check if any push channel is enabled""" - push_channels = [ + @staticmethod + def _get_push_channels() -> list[str]: + return [ "dingtalk_enabled", "wechat_enabled", + "feishu_enabled", "bark_enabled", "ntfy_enabled", "telegram_enabled", "email_enabled", - "serverchan_enabled" + "serverchan_enabled", ] - + + def is_any_push_channel_enabled(self) -> bool: + """Check if any push channel is enabled""" + push_channels = self._get_push_channels() return any(self.settings.user_config.get(channel) for channel in push_channels) @staticmethod def should_push_message( - settings: SettingsPage, + settings, recording: Recording, check_manually_stopped: bool = False, - message_type: Optional[str] = None + message_type: Optional[str] = None, ) -> bool: """ Check if message should be pushed """ if not recording.enabled_message_push: return False - + user_config = settings.user_config should_only_notify_no_record = user_config.get("only_notify_no_record") is_stream_start_enabled = user_config.get("stream_start_notification_enabled") is_stream_end_enabled = user_config.get("stream_end_notification_enabled") - + if message_type is None: - if hasattr(recording, 'is_recording') and recording.is_recording: - message_type = 'end' + if hasattr(recording, "is_recording") and recording.is_recording: + message_type = "end" else: - message_type = 'start' + message_type = "start" - if message_type == 'start' and should_only_notify_no_record and is_stream_start_enabled: + if message_type == "start" and should_only_notify_no_record and is_stream_start_enabled: return True - if message_type == 'start' and not is_stream_start_enabled: + if message_type == "start" and not is_stream_start_enabled: return False - - if message_type == 'end' and not is_stream_end_enabled: + + if message_type == "end" and not is_stream_end_enabled: return False - push_channels = [ - "dingtalk_enabled", - "wechat_enabled", - "bark_enabled", - "ntfy_enabled", - "telegram_enabled", - "email_enabled", - "serverchan_enabled" - ] - + push_channels = MessagePusher._get_push_channels() any_channel_enabled = any(user_config.get(channel) for channel in push_channels) - + if not any_channel_enabled: return False - - if message_type == 'end' and check_manually_stopped and recording.manually_stopped: + + if message_type == "end" and check_manually_stopped and recording.manually_stopped: return False return True + @staticmethod + def log_push_result(service_name: str, result: dict) -> None: + if result.get("success"): + logger.info(f"Push {service_name} message successfully: {result['success']}") + if result.get("error") or (not result.get("success") and not result.get("error")): + logger.error(f"Push {service_name} message failed: {result['error']}") + + def push_messages_sync(self, msg_title: str, push_content: str) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self.push_messages(msg_title, push_content)) + finally: + loop.close() + async def push_messages(self, msg_title: str, push_content: str) -> None: """Push messages to all enabled notification services""" if self.settings.user_config.get("dingtalk_enabled"): - create_task( - self.notifier.send_to_dingtalk( - url=self.settings.user_config.get("dingtalk_webhook_url"), - content=push_content, - number=self.settings.user_config.get("dingtalk_at_objects"), - is_atall=self.settings.user_config.get("dingtalk_at_all"), - ) + result = await self.notifier.send_to_dingtalk( + url=self.settings.user_config.get("dingtalk_webhook_url"), + content=push_content, + number=self.settings.user_config.get("dingtalk_at_objects"), + is_atall=self.settings.user_config.get("dingtalk_at_all"), ) - logger.info("Push DingTalk message successfully") + self.log_push_result("DingTalk", result) if self.settings.user_config.get("wechat_enabled"): - create_task( - self.notifier.send_to_wechat( - url=self.settings.user_config.get("wechat_webhook_url"), title=msg_title, content=push_content - ) + result = await self.notifier.send_to_wechat( + url=self.settings.user_config.get("wechat_webhook_url"), title=msg_title, content=push_content ) - logger.info("Push Wechat message successfully") + self.log_push_result("Wechat", result) if self.settings.user_config.get("bark_enabled"): - create_task( - self.notifier.send_to_bark( - api=self.settings.user_config.get("bark_webhook_url"), - title=msg_title, - content=push_content, - level=self.settings.user_config.get("bark_interrupt_level"), - sound=self.settings.user_config.get("bark_sound"), - ) + result = await self.notifier.send_to_bark( + api=self.settings.user_config.get("bark_webhook_url"), + title=msg_title, + content=push_content, + level=self.settings.user_config.get("bark_interrupt_level"), + sound=self.settings.user_config.get("bark_sound"), ) - logger.info("Push Bark message successfully") + self.log_push_result("Bark", result) if self.settings.user_config.get("ntfy_enabled"): - create_task( - self.notifier.send_to_ntfy( - api=self.settings.user_config.get("ntfy_server_url"), - title=msg_title, - content=push_content, - tags=self.settings.user_config.get("ntfy_tags"), - action_url=self.settings.user_config.get("ntfy_action_url"), - email=self.settings.user_config.get("ntfy_email"), - ) + result = await self.notifier.send_to_ntfy( + api=self.settings.user_config.get("ntfy_server_url"), + title=msg_title, + content=push_content, + tags=self.settings.user_config.get("ntfy_tags"), + action_url=self.settings.user_config.get("ntfy_action_url"), + email=self.settings.user_config.get("ntfy_email"), ) - logger.info("Push Ntfy message successfully") + self.log_push_result("Ntfy", result) if self.settings.user_config.get("telegram_enabled"): - create_task( - self.notifier.send_to_telegram( - chat_id=self.settings.user_config.get("telegram_chat_id"), - token=self.settings.user_config.get("telegram_api_token"), - content=push_content, - proxy=self._get_proxy(), - ) + result = await self.notifier.send_to_telegram( + chat_id=self.settings.user_config.get("telegram_chat_id"), + token=self.settings.user_config.get("telegram_api_token"), + content=push_content, + proxy=self._get_proxy(), ) - logger.info("Push Telegram message successfully") + self.log_push_result("Telegram", result) if self.settings.user_config.get("email_enabled"): - create_task( - self.notifier.send_to_email( - email_host=self.settings.user_config.get("smtp_server"), - login_email=self.settings.user_config.get("email_username"), - password=self.settings.user_config.get("email_password"), - sender_email=self.settings.user_config.get("sender_email"), - sender_name=self.settings.user_config.get("sender_name"), - to_email=self.settings.user_config.get("recipient_email"), - title=msg_title, - content=push_content, - ) + result = await self.notifier.send_to_email( + email_host=self.settings.user_config.get("smtp_server"), + login_email=self.settings.user_config.get("email_username"), + password=self.settings.user_config.get("email_password"), + sender_email=self.settings.user_config.get("sender_email"), + sender_name=self.settings.user_config.get("sender_name"), + to_email=self.settings.user_config.get("recipient_email"), + title=msg_title, + content=push_content, ) - logger.info("Push Email message successfully") + self.log_push_result("Email", result) if self.settings.user_config.get("serverchan_enabled"): - create_task( - self.notifier.send_to_serverchan( - sendkey=self.settings.user_config.get("serverchan_sendkey"), - title=msg_title, - content=push_content, - channel=self.settings.user_config.get("serverchan_channel", 9), - tags=self.settings.user_config.get("serverchan_tags", "直播通知"), - ) + result = await self.notifier.send_to_serverchan( + sendkey=self.settings.user_config.get("serverchan_sendkey"), + title=msg_title, + content=push_content, + channel=self.settings.user_config.get("serverchan_channel", 9), + tags=self.settings.user_config.get("serverchan_tags", "Live Status Update"), + ) + self.log_push_result("ServerChan", result) + + if self.settings.user_config.get("feishu_enabled"): + result = await self.notifier.send_to_feishu( + url=self.settings.user_config.get("feishu_webhook_url"), + content=push_content, ) - logger.info("Push ServerChan message successfully") + self.log_push_result("Feishu", result) diff --git a/app/messages/notification_service.py b/app/messages/notification_service.py index 3bbca4af..e8ee402a 100644 --- a/app/messages/notification_service.py +++ b/app/messages/notification_service.py @@ -1,234 +1,250 @@ -import base64 -import re -import smtplib -from email.header import Header -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from typing import Any, Optional - -import httpx - -from ..utils.logger import logger - - -class NotificationService: - def __init__(self): - self.headers = {"Content-Type": "application/json"} - - async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: - try: - - async with httpx.AsyncClient(proxy=proxy) as client: - response = await client.post(url, json=json_data, headers=self.headers) - response.raise_for_status() - return response.json() - except Exception as e: - logger.info(f"Push failed, push address: {url}, Error message: {e}") - return {"error": str(e)} - - async def send_to_dingtalk( - self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False - ) -> dict[str, list[str]]: - results = {"success": [], "error": []} - api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] - for api in api_list: - json_data = { - "msgtype": "text", - "text": {"content": content}, - "at": {"atMobiles": [number] if number else [], "isAtAll": is_atall}, - } - resp = await self._async_post(api, json_data) - if resp.get("errcode") == 0: - results["success"].append(api) - else: - results["error"].append(api) - return results - - async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = url.replace(",", ",").split(",") if url.strip() else [] - for api in api_list: - json_data = {"title": title, "content": content} - resp = await self._async_post(api, json_data) - if resp.get("code") == 200: - results["success"].append(api) - else: - results["error"].append(api) - logger.info(f"WeChat push failed, push address: {api}, Failure message: {json_data.get('msg')}") - return results - - @staticmethod - async def send_to_email( - email_host: str, - login_email: str, - password: str, - sender_email: str, - sender_name: str, - to_email: str, - title: str, - content: str, - smtp_port: str | None = None, - open_ssl: bool = True, - ) -> dict[str, Any]: - receivers = to_email.replace(",", ",").split(",") if to_email.strip() else [] - - try: - message = MIMEMultipart() - send_name = base64.b64encode(sender_name.encode("utf-8")).decode() - message["From"] = f"=?UTF-8?B?{send_name}?= <{sender_email}>" - message["Subject"] = Header(title, "utf-8") - if len(receivers) == 1: - message["To"] = receivers[0] - - t_apart = MIMEText(content, "plain", "utf-8") - message.attach(t_apart) - - if open_ssl: - smtp_port = smtp_port or 465 - smtp_obj = smtplib.SMTP_SSL(email_host, int(smtp_port)) - else: - smtp_port = smtp_port or 25 - smtp_obj = smtplib.SMTP(email_host, int(smtp_port)) - smtp_obj.login(login_email, password) - smtp_obj.sendmail(sender_email, receivers, message.as_string()) - return {"success": receivers, "error": []} - except smtplib.SMTPException as e: - logger.info(f"Email push failed, push email: {to_email}, Error message: {e}") - return {"success": [], "error": receivers} - - async def send_to_telegram( - self, chat_id: int, token: str, content: str, proxy: Optional[str] = None) -> dict[str, Any]: - try: - json_data = {"chat_id": chat_id, "text": content} - url = "https://api.telegram.org/bot" + token + "/sendMessage" - _resp = await self._async_post(url, json_data, proxy or None) - return {"success": [1], "error": []} - except Exception as e: - logger.info(f"Telegram push failed, chat ID: {chat_id}, Error message: {e}") - return {"success": [], "error": [1]} - - async def send_to_bark( - self, - api: str, - title: str = "message", - content: str = "test", - level: str = "active", - badge: int = 1, - auto_copy: int = 1, - sound: str = "", - icon: str = "", - group: str = "", - is_archive: int = 1, - url: str = "", - ) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = api.replace(",", ",").split(",") if api.strip() else [] - for _api in api_list: - json_data = { - "title": title, - "body": content, - "level": level, - "badge": badge, - "autoCopy": auto_copy, - "sound": sound, - "icon": icon, - "group": group, - "isArchive": is_archive, - "url": url, - } - resp = await self._async_post(_api, json_data) - if resp.get("code") == 200: - results["success"].append(_api) - else: - results["error"].append(_api) - logger.info(f"Bark push failed, push address: {_api}, Failure message: {json_data.get('message')}") - return results - - async def send_to_ntfy( - self, - api: str, - title: str = "message", - content: str = "test", - tags: str = "tada", - priority: int = 3, - action_url: str = "", - attach: str = "", - filename: str = "", - click: str = "", - icon: str = "", - delay: str = "", - email: str = "", - call: str = "", - ) -> dict[str, Any]: - results = {"success": [], "error": []} - api_list = api.replace(",", ",").split(",") if api.strip() else [] - tags = tags.replace(",", ",").split(",") if tags else ["partying_face"] - actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] - for _api in api_list: - server, topic = _api.rsplit("/", maxsplit=1) - json_data = { - "topic": topic, - "title": title, - "message": content, - "tags": tags, - "priority": priority, - "attach": attach, - "filename": filename, - "click": click, - "actions": actions, - "markdown": False, - "icon": icon, - "delay": delay, - "email": email, - "call": call, - } - - resp = await self._async_post(_api, json_data) - if "error" not in resp: - results["success"].append(_api) - else: - results["error"].append(_api) - logger.info(f"Ntfy push failed, push address: {_api}, Failure message: {json_data.get('error')}") - return results - - async def send_to_serverchan( - self, - sendkey: str, - title: str = "message", - content: str = "test", - short: str = "", - channel: int = 9, - tags: str = "partying_face" - ) -> dict[str, Any]: - - results = {"success": [], "error": []} - sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] - - for key in sendkey_list: - if key.startswith('sctp'): - match = re.match(r'sctp(\d+)t', key) - if match: - num = match.group(1) - url = f'https://{num}.push.ft07.com/send/{key}.send' - else: - logger.error(f"Invalid sendkey format for sctp: {key}") - results["error"].append(key) - continue - else: - url = f'https://sctapi.ftqq.com/{key}.send' - - json_data = { - "title": title, - "desp": content, - "short": short, - "channel": channel, - "tags": tags - } - resp = await self._async_post(url, json_data) - if resp.get("code") == 0: - results["success"].append(key) - else: - results["error"].append(key) - logger.info(f"ServerChan push failed, SCKEY/SendKey: {key}, Error message: {resp.get('message')}") - - return results +import asyncio +import base64 +import re +import smtplib +from email.header import Header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any, Optional + +import httpx + +from ..utils.logger import logger + + +class NotificationService: + def __init__(self): + self.headers = {"Content-Type": "application/json"} + + async def _async_post(self, url: str, json_data: dict[str, Any], proxy: str | None = None) -> dict[str, Any]: + try: + async with httpx.AsyncClient(proxy=proxy) as client: + response = await client.post(url, json=json_data, headers=self.headers) + response.raise_for_status() + return response.json() + except Exception as e: + logger.info(f"Push failed, push address: {url}, Error message: {e}") + return {"error": str(e)} + + async def send_to_dingtalk( + self, url: str, content: str, number: Optional[str] = None, is_atall: bool = False + ) -> dict[str, list[str]]: + results = {"success": [], "error": []} + api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] + for api in api_list: + json_data = { + "msgtype": "text", + "text": {"content": content}, + "at": {"atMobiles": [number] if number else [], "isAtAll": is_atall}, + } + resp = await self._async_post(api, json_data) + if resp.get("errcode") == 0: + results["success"].append(api) + else: + results["error"].append(api) + return results + + async def send_to_wechat(self, url: str, title: str, content: str) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = url.replace(",", ",").split(",") if url.strip() else [] + for api in api_list: + json_data = {"title": title, "content": content} + resp = await self._async_post(api, json_data) + if resp.get("code") == 200: + results["success"].append(api) + else: + results["error"].append(api) + logger.info(f"WeChat push failed, push address: {api}, Failure message: {json_data.get('msg')}") + return results + + @staticmethod + async def send_to_email( + email_host: str, + login_email: str, + password: str, + sender_email: str, + sender_name: str, + to_email: str, + title: str, + content: str, + smtp_port: str | None = None, + open_ssl: bool = True, + timeout: float = 15.0, + ) -> dict[str, Any]: + receivers = [address.strip() for address in (to_email or "").replace(",", ",").split(",") if address.strip()] + if not receivers: + return {"success": [], "error": []} + + message = MIMEMultipart() + send_name = base64.b64encode((sender_name or "").encode("utf-8")).decode() + message["From"] = f"=?UTF-8?B?{send_name}?= <{sender_email}>" + message["To"] = ", ".join(receivers) + message["Subject"] = Header(title, "utf-8") + message.attach(MIMEText(content, "plain", "utf-8")) + + def send_sync() -> dict[str, list[str]]: + port = int(smtp_port or (465 if open_ssl else 25)) + smtp_class = smtplib.SMTP_SSL if open_ssl else smtplib.SMTP + with smtp_class(email_host, port, timeout=timeout) as smtp_obj: + smtp_obj.login(login_email, password) + refused = smtp_obj.sendmail(sender_email, receivers, message.as_string()) + + refused_recipients = set(refused) + return { + "success": [receiver for receiver in receivers if receiver not in refused_recipients], + "error": [receiver for receiver in receivers if receiver in refused_recipients], + } + + try: + overall_timeout = max(timeout * 4, timeout + 5) + return await asyncio.wait_for(asyncio.to_thread(send_sync), timeout=overall_timeout) + except asyncio.TimeoutError: + logger.error(f"Email push timed out, SMTP server: {email_host}") + except (smtplib.SMTPException, OSError, ValueError) as e: + logger.error(f"Email push failed, SMTP server: {email_host}, Error message: {e}") + + return {"success": [], "error": receivers} + + async def send_to_telegram( + self, chat_id: int, token: str, content: str, proxy: Optional[str] = None + ) -> dict[str, Any]: + try: + json_data = {"chat_id": chat_id, "text": content} + url = "https://api.telegram.org/bot" + token + "/sendMessage" + _resp = await self._async_post(url, json_data, proxy or None) + return {"success": [1], "error": []} + except Exception as e: + logger.info(f"Telegram push failed, chat ID: {chat_id}, Error message: {e}") + return {"success": [], "error": [1]} + + async def send_to_bark( + self, + api: str, + title: str = "message", + content: str = "test", + level: str = "active", + badge: int = 1, + auto_copy: int = 1, + sound: str = "", + icon: str = "", + group: str = "", + is_archive: int = 1, + url: str = "", + ) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = api.replace(",", ",").split(",") if api.strip() else [] + for _api in api_list: + json_data = { + "title": title, + "body": content, + "level": level, + "badge": badge, + "autoCopy": auto_copy, + "sound": sound, + "icon": icon, + "group": group, + "isArchive": is_archive, + "url": url, + } + resp = await self._async_post(_api, json_data) + if resp.get("code") == 200: + results["success"].append(_api) + else: + results["error"].append(_api) + logger.info(f"Bark push failed, push address: {_api}, Failure message: {json_data.get('message')}") + return results + + async def send_to_ntfy( + self, + api: str, + title: str = "message", + content: str = "test", + tags: str = "tada", + priority: int = 3, + action_url: str = "", + attach: str = "", + filename: str = "", + click: str = "", + icon: str = "", + delay: str = "", + email: str = "", + call: str = "", + ) -> dict[str, Any]: + results = {"success": [], "error": []} + api_list = api.replace(",", ",").split(",") if api.strip() else [] + tags = tags.replace(",", ",").split(",") if tags else ["partying_face"] + actions = [{"action": "view", "label": "view live", "url": action_url}] if action_url else [] + for _api in api_list: + server, topic = _api.rsplit("/", maxsplit=1) + json_data = { + "topic": topic, + "title": title, + "message": content, + "tags": tags, + "priority": priority, + "attach": attach, + "filename": filename, + "click": click, + "actions": actions, + "markdown": False, + "icon": icon, + "delay": delay, + "email": email, + "call": call, + } + + resp = await self._async_post(_api, json_data) + if "error" not in resp: + results["success"].append(_api) + else: + results["error"].append(_api) + logger.info(f"Ntfy push failed, push address: {_api}, Failure message: {json_data.get('error')}") + return results + + async def send_to_serverchan( + self, + sendkey: str, + title: str = "message", + content: str = "test", + short: str = "", + channel: int = 9, + tags: str = "partying_face", + ) -> dict[str, Any]: + + results = {"success": [], "error": []} + sendkey_list = sendkey.replace(",", ",").split(",") if sendkey.strip() else [] + + for key in sendkey_list: + if key.startswith("sctp"): + match = re.match(r"sctp(\d+)t", key) + if match: + num = match.group(1) + url = f"https://{num}.push.ft07.com/send/{key}.send" + else: + logger.error(f"Invalid sendkey format for sctp: {key}") + results["error"].append(key) + continue + else: + url = f"https://sctapi.ftqq.com/{key}.send" + + json_data = {"title": title, "desp": content, "short": short, "channel": channel, "tags": tags} + resp = await self._async_post(url, json_data) + if resp.get("code") == 0: + results["success"].append(key) + else: + results["error"].append(key) + logger.info(f"ServerChan push failed, SCKEY/SendKey: {key}, Error message: {resp.get('message')}") + + return results + + async def send_to_feishu(self, url: str, content: str) -> dict[str, list[str]]: + results = {"success": [], "error": []} + api_list = [u.strip() for u in url.replace(",", ",").split(",") if u.strip()] + for api in api_list: + json_data = {"msg_type": "text", "content": {"text": content}} + resp = await self._async_post(api, json_data) + if resp.get("msg") == "success": + results["success"].append(api) + else: + results["error"].append(api) + return results diff --git a/app/models/media/video_format_model.py b/app/models/media/video_format_model.py index 5479584e..6e419839 100644 --- a/app/models/media/video_format_model.py +++ b/app/models/media/video_format_model.py @@ -4,6 +4,7 @@ class VideoFormat: FLV = "FLV" MKV = "MKV" MOV = "MOV" + NUT = "NUT" @classmethod def get_formats(cls): diff --git a/app/models/recording/recording_model.py b/app/models/recording/recording_model.py index b40bcb9c..8244c1df 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -1,142 +1,152 @@ -from datetime import timedelta - - -class Recording: - def __init__( - self, - rec_id, - url, - streamer_name, - record_format, - quality, - segment_record, - segment_time, - monitor_status, - scheduled_recording, - scheduled_start_time, - monitor_hours, - recording_dir, - enabled_message_push, - only_notify_no_record, - flv_use_direct_download - ): - """ - Initialize a recording object. - - :param rec_id: Unique identifier for the recording task. - :param url: URL address of the live stream. - :param streamer_name: Name of the streamer. - :param record_format: Format of the recorded file, e.g., 'mp4', 'ts', 'mkv'. - :param quality: Quality of the recorded video, e.g., 'OD', 'UHD', 'HD'. - :param segment_record: Whether to enable segmented recording. - :param segment_time: Time interval (in seconds) for segmented recording if enabled. - :param monitor_status: Monitoring status, whether the live room is being monitored. - :param scheduled_recording: Whether to enable scheduled recording. - :param scheduled_start_time: Scheduled start time for recording (string format like '18:30:00'). - :param monitor_hours: Number of hours to monitor from the scheduled recording start time, e.g., 3. - :param recording_dir: Directory path where the recorded files will be saved. - :param enabled_message_push: Whether to enable message push. - :param only_notify_no_record: Whether to only notify when no record is made. - :param flv_use_direct_download: Whether to use direct downloader to cache FLV stream. - """ - - self.rec_id = rec_id - self.url = url - self.quality = quality - self.record_format = record_format - self.monitor_status = monitor_status - self.segment_record = segment_record - self.segment_time = segment_time - self.streamer_name = streamer_name - self.scheduled_recording = scheduled_recording - self.scheduled_start_time = scheduled_start_time - self.monitor_hours = monitor_hours - self.recording_dir = recording_dir - self.enabled_message_push = enabled_message_push - self.only_notify_no_record = only_notify_no_record - self.flv_use_direct_download = flv_use_direct_download - self.scheduled_time_range = None - self.title = f"{streamer_name} - {self.quality}" - self.speed = "X KB/s" - self.is_live = False - self.is_recording = False - self.start_time = None - self.manually_stopped = False - self.platform = None - self.platform_key = None - self.notified_live_start = False - self.notified_live_end = False - - self.cumulative_duration = timedelta() # Accumulated recording time - self.last_duration = timedelta() # Save the total time of the last recording - self.display_title = self.title - self.selected = False - self.is_checking = False - self.status_info = None - self.live_title = None - 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.""" - return { - "rec_id": self.rec_id, - "url": self.url, - "streamer_name": self.streamer_name, - "record_format": self.record_format, - "quality": self.quality, - "segment_record": self.segment_record, - "segment_time": self.segment_time, - "monitor_status": self.monitor_status, - "scheduled_recording": self.scheduled_recording, - "scheduled_start_time": self.scheduled_start_time, - "monitor_hours": self.monitor_hours, - "recording_dir": self.recording_dir, - "enabled_message_push": self.enabled_message_push, - "platform": self.platform, - "platform_key": self.platform_key, - "only_notify_no_record": self.only_notify_no_record, - "flv_use_direct_download": self.flv_use_direct_download - } - - @classmethod - def from_dict(cls, data): - """Create a Recording instance from a dictionary.""" - recording = cls( - data.get("rec_id"), - data.get("url"), - data.get("streamer_name"), - data.get("record_format"), - data.get("quality"), - data.get("segment_record"), - data.get("segment_time"), - data.get("monitor_status"), - data.get("scheduled_recording"), - data.get("scheduled_start_time"), - data.get("monitor_hours"), - data.get("recording_dir"), - data.get("enabled_message_push"), - data.get("only_notify_no_record"), - data.get("flv_use_direct_download") - ) - recording.title = data.get("title", recording.title) - recording.display_title = data.get("display_title", recording.title) - recording.last_duration_str = data.get("last_duration") - recording.platform = data.get("platform") - recording.platform_key = data.get("platform_key") - if recording.last_duration_str is not None: - recording.last_duration = timedelta(seconds=float(recording.last_duration_str)) - return recording - - def update_title(self, quality_info, prefix=None): - """Helper method to update the title.""" - self.title = f"{self.streamer_name} - {quality_info}" - self.display_title = f"{prefix or ''}{self.title}" - - def update(self, updated_info: dict): - """Update the recording object with new information.""" - for attr, value in updated_info.items(): - if hasattr(self, attr): - setattr(self, attr, value) +from datetime import timedelta + + +class Recording: + def __init__( + self, + rec_id, + url, + streamer_name, + record_format, + quality, + segment_record, + segment_time, + monitor_status, + scheduled_recording, + scheduled_start_time, + monitor_hours, + recording_dir, + enabled_message_push, + only_notify_no_record, + flv_use_direct_download, + video_bitrate=None, + ): + """ + Initialize a recording object. + + :param rec_id: Unique identifier for the recording task. + :param url: URL address of the live stream. + :param streamer_name: Name of the streamer. + :param record_format: Format of the recorded file, e.g., 'mp4', 'ts', 'mkv'. + :param quality: Quality of the recorded video, e.g., 'OD', 'UHD', 'HD'. + :param segment_record: Whether to enable segmented recording. + :param segment_time: Time interval (in seconds) for segmented recording if enabled. + :param monitor_status: Monitoring status, whether the live room is being monitored. + :param scheduled_recording: Whether to enable scheduled recording. + :param scheduled_start_time: Scheduled start time for recording (string format like '18:30:00'). + :param monitor_hours: Number of hours to monitor from the scheduled recording start time, e.g., 3. + :param recording_dir: Directory path where the recorded files will be saved. + :param enabled_message_push: Whether to enable message push. + :param only_notify_no_record: Whether to only notify when no record is made. + :param flv_use_direct_download: Whether to use direct downloader to cache FLV stream. + :param video_bitrate: Custom output video bitrate in kbps, or None to copy the source video stream. + """ + + self.rec_id = rec_id + self.url = url + self.quality = quality + self.record_format = record_format + self.monitor_status = monitor_status + self.segment_record = segment_record + self.segment_time = segment_time + self.streamer_name = streamer_name + self.scheduled_recording = scheduled_recording + self.scheduled_start_time = scheduled_start_time + self.monitor_hours = monitor_hours + self.recording_dir = recording_dir + self.enabled_message_push = enabled_message_push + self.only_notify_no_record = only_notify_no_record + self.flv_use_direct_download = flv_use_direct_download + self.video_bitrate = video_bitrate + self.scheduled_time_range = None + self.title = f"{streamer_name} - {self.quality}" + self.speed = "X KB/s" + self.is_live = False + self.is_recording = False + self.start_time = None + self.manually_stopped = False + self.force_stop = False + self.stopping_in_progress = False + self.stop_requested = False + self.platform = None + self.platform_key = None + self.notified_live_start = False + self.notified_live_end = False + + self.cumulative_duration = timedelta() # Accumulated recording time + self.last_duration = timedelta() # Save the total time of the last recording + self.display_title = self.title + self.selected = False + self.is_checking = False + self.showed_checking_status = False + self.status_info = None + self.live_title = None + self.detection_time = None + self.loop_time_seconds = None + self.use_proxy = None + self.record_url = None + self.preview_url = None + + def to_dict(self): + """Convert the Recording instance to a dictionary for saving.""" + return { + "rec_id": self.rec_id, + "url": self.url, + "streamer_name": self.streamer_name, + "record_format": self.record_format, + "quality": self.quality, + "segment_record": self.segment_record, + "segment_time": self.segment_time, + "monitor_status": self.monitor_status, + "scheduled_recording": self.scheduled_recording, + "scheduled_start_time": self.scheduled_start_time, + "monitor_hours": self.monitor_hours, + "recording_dir": self.recording_dir, + "enabled_message_push": self.enabled_message_push, + "platform": self.platform, + "platform_key": self.platform_key, + "only_notify_no_record": self.only_notify_no_record, + "flv_use_direct_download": self.flv_use_direct_download, + "video_bitrate": self.video_bitrate, + } + + @classmethod + def from_dict(cls, data): + """Create a Recording instance from a dictionary.""" + recording = cls( + data.get("rec_id"), + data.get("url"), + data.get("streamer_name"), + data.get("record_format"), + data.get("quality"), + data.get("segment_record"), + data.get("segment_time"), + data.get("monitor_status"), + data.get("scheduled_recording"), + data.get("scheduled_start_time"), + data.get("monitor_hours"), + data.get("recording_dir"), + data.get("enabled_message_push"), + data.get("only_notify_no_record"), + data.get("flv_use_direct_download"), + data.get("video_bitrate"), + ) + recording.title = data.get("title", recording.title) + recording.display_title = data.get("display_title", recording.title) + recording.last_duration_str = data.get("last_duration") + recording.platform = data.get("platform") + recording.platform_key = data.get("platform_key") + if recording.last_duration_str is not None: + recording.last_duration = timedelta(seconds=float(recording.last_duration_str)) + return recording + + def update_title(self, quality_info, prefix=None): + """Helper method to update the title.""" + self.title = f"{self.streamer_name} - {quality_info}" + self.display_title = f"{prefix or ''}{self.title}" + + def update(self, updated_info: dict): + """Update the recording object with new information.""" + for attr, value in updated_info.items(): + if hasattr(self, attr): + setattr(self, attr, value) diff --git a/app/models/recording/recording_status_model.py b/app/models/recording/recording_status_model.py index ab2973d0..2ef6931f 100644 --- a/app/models/recording/recording_status_model.py +++ b/app/models/recording/recording_status_model.py @@ -7,6 +7,7 @@ class CardStateType(Enum): LIVE = "live" OFFLINE = "offline" STOPPED = "stopped" + CHECKING = "checking" UNKNOWN = "unknown" diff --git a/app/scripts/ffmpeg_install.py b/app/scripts/ffmpeg_install.py index cc2d0f95..6929db1b 100644 --- a/app/scripts/ffmpeg_install.py +++ b/app/scripts/ffmpeg_install.py @@ -1,265 +1,268 @@ -import asyncio -import os -import platform -import re -import subprocess -import sys -import zipfile -from pathlib import Path - -import httpx - -from ..utils.logger import logger -from ..utils.utils import get_startup_info - -current_platform = platform.system() -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] -ffmpeg_path = os.path.join(execute_dir, "ffmpeg") -startupinfo = get_startup_info() - - -async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: - if not os.path.exists(extract_to): - os.makedirs(extract_to, exist_ok=True) - - loop = asyncio.get_running_loop() - try: - await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) - logger.debug(f"Compressed file decompression completed: {zip_path}") - except Exception as e: - logger.error(f"Failed to decompress the compressed file: {e}") - raise Exception("Failed to decompress the compressed file") - - if delete and os.path.exists(zip_path): - os.remove(zip_path) - - -def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: - if not zipfile.is_zipfile(zip_path): - os.remove(zip_path) - logger.error(f"The file is not a valid ZIP file: {zip_path}") - raise ValueError("The file is not a valid ZIP file") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to) - - -async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: - try: - - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(url, headers=headers) - html_str = response.text - sign = re.search("var \\w+ = '([A-Za-z0-9_\\-+/]{80,})';", html_str).group(1) - - data = { - "action": "downprocess", - "p": password, - "sign": sign, - "kd": "1", - } - - response = await client.post( - "https://wweb.lanzoum.com/ajaxm.php?file=219989236", - headers=headers, - data=data - ) - json_data = response.json() - download_url = json_data["dom"] + "/file/" + json_data["url"] - response = await client.get(download_url, headers=headers, follow_redirects=True) - return str(response.url) - except Exception as e: - logger.error(f"Failed to obtain ffmpeg download address. {e}") - - -async def install_ffmpeg_windows(update_progress): - try: - logger.warning("ffmpeg is not installed.") - logger.debug("Installing the latest version of ffmpeg for Windows...") - await update_progress(0.1, "Get FFmpeg installation resources") - headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'accept-language': 'zh-CN,zh;q=0.9', - 'origin': 'https://wweb.lanzoum.com', - 'referer': 'https://wweb.lanzoum.com/iHAc22ly3r3g', - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' - ' Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0', - 'x-requested-with': 'XMLHttpRequest', - } - ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) - if ffmpeg_url: - full_file_name = "ffmpeg_latest_build_20250124.zip" - version = "v20250124" - zip_file_path = Path(execute_dir) / full_file_name - if Path(zip_file_path).exists(): - await update_progress(0.8, "FFmpeg installation file already exists") - logger.debug("ffmpeg installation file already exists, start install...") - else: - await update_progress(0.2, "Start downloading FFmpeg installation package") - logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") - async with (httpx.AsyncClient(follow_redirects=True) as client, - client.stream("GET", ffmpeg_url, headers=headers) as resp): - - total_size = int(resp.headers.get("Content-Length", 0)) - if resp.status_code != 200 and total_size != 0: - logger.error("FFmpeg package resources cannot be accessed") - raise Exception("The resource address cannot be accessed") - - downloaded = 0 - with open(zip_file_path, "wb") as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - downloaded += len(chunk) - - progress = 0.2 + 0.6 * (downloaded / total_size) - await update_progress( - round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" - ) - - await update_progress(0.8, "Extracting and cleaning installation files") - await unzip_file(zip_file_path, execute_dir) - await update_progress(0.9, "Configuring FFmpeg environment variables") - os.environ["PATH"] = ffmpeg_path + os.pathsep + os.environ.get("PATH") - result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.success("FFmpeg installation was successful") - return True - else: - logger.error("ffmpeg installation failed. Please manually install ffmpeg by yourself") - raise Exception("Please restart the program") - else: - logger.error("Please manually install ffmpeg by yourself") - raise Exception("Failed to obtain the FFmpeg download address") - except Exception as e: - raise RuntimeError(f"FFmpeg install failed, {e}") from None - - -async def install_ffmpeg_mac(update_progress): - logger.warning("FFmpeg is not installed.") - logger.debug("Installing the stable version of ffmpeg for macOS...") - await update_progress(0.1, "Get FFmpeg installation resources") - await asyncio.sleep(2) - await update_progress(0.3, "Please be patient and wait...") - timeout = 300 - try: - result = subprocess.run(["brew", "install", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) - if result.returncode == 0: - logger.success("FFmpeg installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("FFmpeg installation failed") - raise Exception("Please manually install FFmpeg by yourself") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to install ffmpeg using Homebrew. {e}") - logger.error("Please install ffmpeg manually or check your Homebrew installation.") - raise Exception("Please check if Homebrew has been installed") from None - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") - raise Exception(e) from None - - -async def install_ffmpeg_linux(update_progress): - is_rhs = True - timeout = 180 - - try: - logger.warning("ffmpeg is not installed.") - logger.debug("Trying to install the stable version of ffmpeg") - await update_progress(0.1, "Get FFmpeg installation resources") - try: - result = subprocess.run(["yum", "-y", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) - if result.returncode != 0: - logger.error("Failed to update package lists using yum.") - return False - - result = subprocess.run(["yum", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) - if result.returncode == 0: - logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") - return True - logger.error(result.stderr.decode("utf-8").strip()) - except subprocess.TimeoutExpired: - logger.error("Command execution timed out. Please try to manually install ffmpeg.") - raise Exception("Command execution timed out after {} seconds".format(timeout)) - except FileNotFoundError: - logger.error("yum command not found, trying to install using apt...") - is_rhs = False - except Exception as e: - logger.error(f"An error occurred while trying to install ffmpeg using yum: {e}") - - if not is_rhs: - try: - logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") - try: - result = subprocess.run(["apt", "update"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) - if result.returncode != 0: - logger.error("Failed to update package lists using apt") - return False - - result = subprocess.run(["apt", "install", "-y", "ffmpeg"], capture_output=True, - startupinfo=startupinfo, timeout=timeout) - if result.returncode == 0: - logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") - return True - else: - logger.error(result.stderr.decode("utf-8").strip()) - except subprocess.TimeoutExpired: - logger.error("Command execution timed out. Please try to manually install ffmpeg.") - raise Exception("Command execution timed out after {} seconds".format(timeout)) - except FileNotFoundError: - logger.error("apt command not found, unable to install ffmpeg. Please manually install ffmpeg by yourself") - except Exception as e: - logger.error(f"An error occurred while trying to install ffmpeg using apt: {e}") - logger.error("Manual installation of ffmpeg is required. Please manually install ffmpeg by yourself.") - raise Exception("Please manually install FFmpeg by yourself") - - -async def install_ffmpeg(update_progress) -> bool: - if current_platform == "Windows": - return await install_ffmpeg_windows(update_progress) - elif current_platform == "Linux": - return await install_ffmpeg_linux(update_progress) - elif current_platform == "Darwin": - return await install_ffmpeg_mac(update_progress) - else: - logger.warning( - f"ffmpeg auto installation is not supported on this platform: {current_platform}. Please " - f"install ffmpeg manually." - ) - return False - - -def update_env_path(): - current_env_path = os.environ.get("PATH") - if current_platform != "Windows": - path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] - current_env_path_list = current_env_path.split(os.pathsep) - env_path = [path for path in path_list if path not in set(current_env_path_list)] - ffmpeg_env_path = os.pathsep.join([ffmpeg_path] + env_path + current_env_path_list) - else: - ffmpeg_env_path = ffmpeg_path + os.pathsep + current_env_path - - os.environ["PATH"] = ffmpeg_env_path - - -async def check_ffmpeg_installed() -> bool: - try: - update_env_path() - result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo, text=True) - version_info = result.stdout.strip() - if result.returncode == 0 and version_info: - logger.info(". ".join(version_info.splitlines()[:2])) - return True - else: - logger.debug(result.stderr.strip()) - except FileNotFoundError as e: - logger.info(e) - except OSError as e: - logger.error(f"OSError occurred: {e}.") - logger.error("Please delete the ffmpeg and try to download and install again.") - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") - return False +import asyncio +import os +import platform +import re +import subprocess +import zipfile +from pathlib import Path + +import httpx + +from ..core.runtime.paths import user_data_dir +from ..utils.logger import logger +from ..utils.utils import get_startup_info + +current_platform = platform.system() +execute_dir = str(user_data_dir) +ffmpeg_path = os.path.join(execute_dir, "ffmpeg") +startupinfo = get_startup_info() + + +async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: + if not os.path.exists(extract_to): + os.makedirs(extract_to, exist_ok=True) + + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) + logger.debug(f"Compressed file decompression completed: {zip_path}") + except Exception as e: + logger.error(f"Failed to decompress the compressed file: {e}") + raise Exception("Failed to decompress the compressed file") + + if delete and os.path.exists(zip_path): + os.remove(zip_path) + + +def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: + if not zipfile.is_zipfile(zip_path): + os.remove(zip_path) + logger.error(f"The file is not a valid ZIP file: {zip_path}") + raise ValueError("The file is not a valid ZIP file") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(extract_to) + + +async def get_lanzou_download_link(url: str, password: str | None = None, headers: dict | None = None) -> str | None: + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.get(url, headers=headers) + html_str = response.text + sign = re.search("var \\w+ = '([A-Za-z0-9_\\-+/]{80,})';", html_str).group(1) + + data = { + "action": "downprocess", + "p": password, + "sign": sign, + "kd": "1", + } + + response = await client.post( + "https://wweb.lanzoum.com/ajaxm.php?file=219989236", headers=headers, data=data + ) + json_data = response.json() + download_url = json_data["dom"] + "/file/" + json_data["url"] + response = await client.get(download_url, headers=headers, follow_redirects=True) + return str(response.url) + except Exception as e: + logger.error(f"Failed to obtain ffmpeg download address. {e}") + + +async def install_ffmpeg_windows(update_progress): + try: + logger.warning("ffmpeg is not installed.") + logger.debug("Installing the latest version of ffmpeg for Windows...") + await update_progress(0.1, "Get FFmpeg installation resources") + headers = { + "content-type": "application/x-www-form-urlencoded", + "accept-language": "zh-CN,zh;q=0.9", + "origin": "https://wweb.lanzoum.com", + "referer": "https://wweb.lanzoum.com/iHAc22ly3r3g", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", + "x-requested-with": "XMLHttpRequest", + } + ffmpeg_url = await get_lanzou_download_link("https://wweb.lanzoum.com/iHAc22ly3r3g", "eots", headers) + if ffmpeg_url: + full_file_name = "ffmpeg_latest_build_20250124.zip" + version = "v20250124" + zip_file_path = Path(execute_dir) / full_file_name + if Path(zip_file_path).exists(): + await update_progress(0.8, "FFmpeg installation file already exists") + logger.debug("ffmpeg installation file already exists, start install...") + else: + await update_progress(0.2, "Start downloading FFmpeg installation package") + logger.debug(f"FFmpeg Download ({version}): {ffmpeg_url}") + async with ( + httpx.AsyncClient(follow_redirects=True) as client, + client.stream("GET", ffmpeg_url, headers=headers) as resp, + ): + total_size = int(resp.headers.get("Content-Length", 0)) + if resp.status_code != 200 and total_size != 0: + logger.error("FFmpeg package resources cannot be accessed") + raise Exception("The resource address cannot be accessed") + + downloaded = 0 + with open(zip_file_path, "wb") as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + downloaded += len(chunk) + + progress = 0.2 + 0.6 * (downloaded / total_size) + await update_progress( + round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" + ) + + await update_progress(0.8, "Extracting and cleaning installation files") + await unzip_file(zip_file_path, execute_dir) + await update_progress(0.9, "Configuring FFmpeg environment variables") + os.environ["PATH"] = ffmpeg_path + os.pathsep + os.environ.get("PATH") + result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.success("FFmpeg installation was successful") + return True + else: + logger.error("ffmpeg installation failed. Please manually install ffmpeg by yourself") + raise Exception("Please restart the program") + else: + logger.error("Please manually install ffmpeg by yourself") + raise Exception("Failed to obtain the FFmpeg download address") + except Exception as e: + raise RuntimeError(f"FFmpeg install failed, {e}") from None + + +async def install_ffmpeg_mac(update_progress): + logger.warning("FFmpeg is not installed.") + logger.debug("Installing the stable version of ffmpeg for macOS...") + await update_progress(0.1, "Get FFmpeg installation resources") + await asyncio.sleep(2) + await update_progress(0.3, "Please be patient and wait...") + timeout = 300 + try: + result = subprocess.run( + ["brew", "install", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("FFmpeg installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("FFmpeg installation failed") + raise Exception("Please manually install FFmpeg by yourself") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to install ffmpeg using Homebrew. {e}") + logger.error("Please install ffmpeg manually or check your Homebrew installation.") + raise Exception("Please check if Homebrew has been installed") from None + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + raise Exception(e) from None + + +async def install_ffmpeg_linux(update_progress): + is_rhs = True + timeout = 180 + + try: + logger.warning("ffmpeg is not installed.") + logger.debug("Trying to install the stable version of ffmpeg") + await update_progress(0.1, "Get FFmpeg installation resources") + try: + result = subprocess.run( + ["yum", "-y", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode != 0: + logger.error("Failed to update package lists using yum.") + return False + + result = subprocess.run( + ["yum", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("ffmpeg installation was successful using yum. Restart for changes to take effect.") + return True + logger.error(result.stderr.decode("utf-8").strip()) + except subprocess.TimeoutExpired: + logger.error("Command execution timed out. Please try to manually install ffmpeg.") + raise Exception("Command execution timed out after {} seconds".format(timeout)) + except FileNotFoundError: + logger.error("yum command not found, trying to install using apt...") + is_rhs = False + except Exception as e: + logger.error(f"An error occurred while trying to install ffmpeg using yum: {e}") + + if not is_rhs: + try: + logger.debug("Trying to install the stable version of ffmpeg for Linux using apt...") + try: + result = subprocess.run( + ["apt", "update"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode != 0: + logger.error("Failed to update package lists using apt") + return False + + result = subprocess.run( + ["apt", "install", "-y", "ffmpeg"], capture_output=True, startupinfo=startupinfo, timeout=timeout + ) + if result.returncode == 0: + logger.success("ffmpeg installation was successful using apt. Restart for changes to take effect.") + return True + else: + logger.error(result.stderr.decode("utf-8").strip()) + except subprocess.TimeoutExpired: + logger.error("Command execution timed out. Please try to manually install ffmpeg.") + raise Exception("Command execution timed out after {} seconds".format(timeout)) + except FileNotFoundError: + logger.error("apt command not found, unable to install ffmpeg. Please manually install ffmpeg by yourself") + except Exception as e: + logger.error(f"An error occurred while trying to install ffmpeg using apt: {e}") + logger.error("Manual installation of ffmpeg is required. Please manually install ffmpeg by yourself.") + raise Exception("Please manually install FFmpeg by yourself") + + +async def install_ffmpeg(update_progress) -> bool: + if current_platform == "Windows": + return await install_ffmpeg_windows(update_progress) + elif current_platform == "Linux": + return await install_ffmpeg_linux(update_progress) + elif current_platform == "Darwin": + return await install_ffmpeg_mac(update_progress) + else: + logger.warning( + f"ffmpeg auto installation is not supported on this platform: {current_platform}. Please " + f"install ffmpeg manually." + ) + return False + + +def update_env_path(): + current_env_path = os.environ.get("PATH") + if current_platform != "Windows": + path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] + current_env_path_list = current_env_path.split(os.pathsep) + env_path = [path for path in path_list if path not in set(current_env_path_list)] + ffmpeg_env_path = os.pathsep.join([ffmpeg_path] + env_path + current_env_path_list) + else: + ffmpeg_env_path = ffmpeg_path + os.pathsep + current_env_path + + os.environ["PATH"] = ffmpeg_env_path + + +async def check_ffmpeg_installed() -> bool: + try: + update_env_path() + result = subprocess.run(["ffmpeg", "-version"], capture_output=True, startupinfo=startupinfo, text=True) + version_info = result.stdout.strip() + if result.returncode == 0 and version_info: + logger.info(". ".join(version_info.splitlines()[:2])) + return True + else: + logger.debug(result.stderr.strip()) + except FileNotFoundError as e: + logger.info(e) + except OSError as e: + logger.error(f"OSError occurred: {e}.") + logger.error("Please delete the ffmpeg and try to download and install again.") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") + return False diff --git a/app/scripts/node_install.py b/app/scripts/node_install.py index ad17e2c4..2acfef88 100644 --- a/app/scripts/node_install.py +++ b/app/scripts/node_install.py @@ -1,239 +1,239 @@ -import asyncio -import os -import platform -import re -import shutil -import subprocess -import sys -import zipfile -from pathlib import Path - -import distro -import httpx - -from ..utils.logger import logger -from ..utils.utils import get_startup_info - -current_platform = platform.system() -execute_dir = os.path.split(os.path.realpath(sys.argv[0]))[0] -node_path = os.path.join(execute_dir, "node") -startupinfo = get_startup_info() - - -async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: - if not os.path.exists(extract_to): - os.makedirs(extract_to, exist_ok=True) - - loop = asyncio.get_running_loop() - try: - await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) - logger.debug(f"Compressed file decompression completed: {zip_path}") - except Exception as e: - logger.error(f"Failed to decompress the compressed file: {e}") - raise Exception("Failed to decompress the compressed file") - - if delete and os.path.exists(zip_path): - os.remove(zip_path) - - -def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: - if not zipfile.is_zipfile(zip_path): - os.remove(zip_path) - logger.error(f"The file is not a valid ZIP file: {zip_path}") - raise ValueError("The file is not a valid ZIP file") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(extract_to) - - -async def install_nodejs_windows(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the stable version of Node.js for Windows...") - await update_progress(0.1, "Get Node.js installation resources") - async with httpx.AsyncClient(follow_redirects=True) as client: - response = await client.get("https://nodejs.cn/download/") - if response.status_code == 200: - text = response.text - match = re.search("https://npmmirror.com/mirrors/node/(v.*?)/node-(v.*?)-x64.msi", text) - if match: - version = match.group(1) - system_bit = "x64" if "32" not in platform.machine() else "x86" - url = f"https://npmmirror.com/mirrors/node/{version}/node-{version}-win-{system_bit}.zip" - else: - logger.error("Failed to retrieve the download URL for the latest version of Node.js") - raise Exception("The resource address cannot be accessed") - - full_file_name = url.rsplit("/", maxsplit=1)[-1] - zip_file_path = Path(execute_dir) / full_file_name - - if Path(zip_file_path).exists(): - await update_progress(0.8, "Node.js installation file already exists") - else: - await update_progress(0.2, "Start downloading Node.js installation package") - async with client.stream("GET", url) as resp: - if resp.status_code != 200: - raise Exception("The resource address cannot be accessed") - - total_size = int(resp.headers.get("Content-Length", 0)) - downloaded = 0 - with open(zip_file_path, "wb") as f: - async for chunk in resp.aiter_bytes(): - f.write(chunk) - downloaded += len(chunk) - - progress = 0.2 + 0.6 * (downloaded / total_size) - await update_progress( - round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" - ) - - await update_progress(0.8, "Extracting and cleaning installation files") - await unzip_file(zip_file_path, execute_dir) - extract_dir_path = str(zip_file_path).rsplit(".", maxsplit=1)[0] - new_extract_dir_path = Path(extract_dir_path).parent / "node" - await update_progress(0.9, "Configuring Node.js environment variables") - if Path(extract_dir_path).exists(): - if Path(new_extract_dir_path).exists(): - shutil.rmtree(new_extract_dir_path) - os.rename(extract_dir_path, new_extract_dir_path) - os.environ["PATH"] = execute_dir + "/node" + os.pathsep + os.environ.get("PATH") - result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - return True - else: - raise Exception("Please restart the program") - else: - logger.error("Failed to retrieve the Node.js version page") - raise Exception("Failed to obtain the Node.js download address") - - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_centos(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for CentOS...") - await update_progress(0.1, "Get Node.js installation resources") - result = subprocess.run( - "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", - shell=True, - capture_output=True, - startupinfo=startupinfo - ) - if result.returncode != 0: - logger.error("Failed to run NodeSource installation script") - - result = subprocess.run(["yum", "install", "-y", "epel-release"], capture_output=True, startupinfo=startupinfo) - if result.returncode != 0: - logger.error("Failed to install EPEL repository") - - result = subprocess.run(["yum", "install", "-y", "nodejs"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except Exception as e: - logger.error(f"Node.js installation failed {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_ubuntu(update_progress): - try: - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for Ubuntu...") - await update_progress(0.1, "Get Node.js installation resources") - install_script = "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -" - result = subprocess.run(install_script, shell=True, capture_output=True, startupinfo=startupinfo) - if result.returncode != 0: - logger.error("Failed to run NodeSource installation script") - - install_command = ["apt", "install", "-y", "nodejs"] - result = subprocess.run(install_command, capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError(f"Node.js install failed, {e}") from None - - -async def install_nodejs_mac(update_progress): - logger.warning("Node.js is not installed.") - logger.debug("Installing the latest version of Node.js for macOS...") - await update_progress(0.1, "Get Node.js installation resources") - await asyncio.sleep(2) - await update_progress(0.3, "Please be patient and wait...") - try: - result = subprocess.run(["brew", "install", "node"], capture_output=True, startupinfo=startupinfo) - if result.returncode == 0: - logger.debug("Node.js installation was successful. Restart for changes to take effect.") - return True - else: - logger.error("Node.js installation failed") - raise Exception("Please manually install by yourself") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to install Node.js using Homebrew. {e}") - logger.error("Please install Node.js manually or check your Homebrew installation.") - raise Exception("Please check if Homebrew has been installed") from None - except Exception as e: - logger.error(f"Node.js installation failed, {e}") - raise RuntimeError("Node.js install failed") from None - - -def get_package_manager(): - dist_id = distro.id() - if dist_id in ["centos", "fedora", "rhel", "amzn", "oracle", "scientific", "opencloudos", "alinux"]: - return "RHS" - else: - return "DBS" - - -async def install_nodejs(update_progress) -> bool: - if current_platform == "Windows": - return await install_nodejs_windows(update_progress) - elif current_platform == "Linux": - os_type = get_package_manager() - if os_type == "RHS": - return await install_nodejs_centos(update_progress) - else: - return await install_nodejs_ubuntu(update_progress) - elif current_platform == "Darwin": - return await install_nodejs_mac(update_progress) - else: - logger.debug( - f"Node.js auto installation is not supported on this platform: {current_platform}. " - f"Please install Node.js manually." - ) - return False - - -def update_env_path(): - current_env_path = os.environ.get("PATH") - if current_platform != "Windows": - path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] - current_env_path_list = current_env_path.split(os.pathsep) - env_path = [path for path in path_list if path not in set(current_env_path_list)] - node_env_path = os.pathsep.join([node_path] + env_path + current_env_path_list) - else: - node_env_path = node_path + os.pathsep + current_env_path - os.environ["PATH"] = node_env_path - - -async def check_nodejs_installed() -> bool: - try: - update_env_path() - result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo, text=True) - logger.info(result) - version_info = result.stdout.strip() - if result.returncode == 0 and version_info: - return True - except FileNotFoundError as e: - logger.info(e) - return False +import asyncio +import os +import platform +import re +import shutil +import subprocess +import zipfile +from pathlib import Path + +import distro +import httpx + +from ..core.runtime.paths import user_data_dir +from ..utils.logger import logger +from ..utils.utils import get_startup_info + +current_platform = platform.system() +execute_dir = str(user_data_dir) +node_path = os.path.join(execute_dir, "node") +startupinfo = get_startup_info() + + +async def unzip_file(zip_path: str | Path, extract_to: str | Path, delete: bool = True) -> None: + if not os.path.exists(extract_to): + os.makedirs(extract_to, exist_ok=True) + + loop = asyncio.get_running_loop() + try: + await loop.run_in_executor(None, _sync_unzip, zip_path, extract_to) + logger.debug(f"Compressed file decompression completed: {zip_path}") + except Exception as e: + logger.error(f"Failed to decompress the compressed file: {e}") + raise Exception("Failed to decompress the compressed file") + + if delete and os.path.exists(zip_path): + os.remove(zip_path) + + +def _sync_unzip(zip_path: str | Path, extract_to: str | Path) -> None: + if not zipfile.is_zipfile(zip_path): + os.remove(zip_path) + logger.error(f"The file is not a valid ZIP file: {zip_path}") + raise ValueError("The file is not a valid ZIP file") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(extract_to) + + +async def install_nodejs_windows(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the stable version of Node.js for Windows...") + await update_progress(0.1, "Get Node.js installation resources") + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.get("https://nodejs.cn/download/") + if response.status_code == 200: + text = response.text + match = re.search("https://npmmirror.com/mirrors/node/(v.*?)/node-(v.*?)-x64.msi", text) + if match: + version = match.group(1) + system_bit = "x64" if "32" not in platform.machine() else "x86" + url = f"https://npmmirror.com/mirrors/node/{version}/node-{version}-win-{system_bit}.zip" + else: + logger.error("Failed to retrieve the download URL for the latest version of Node.js") + raise Exception("The resource address cannot be accessed") + + full_file_name = url.rsplit("/", maxsplit=1)[-1] + zip_file_path = Path(execute_dir) / full_file_name + + if Path(zip_file_path).exists(): + await update_progress(0.8, "Node.js installation file already exists") + else: + await update_progress(0.2, "Start downloading Node.js installation package") + async with client.stream("GET", url) as resp: + if resp.status_code != 200: + raise Exception("The resource address cannot be accessed") + + total_size = int(resp.headers.get("Content-Length", 0)) + downloaded = 0 + with open(zip_file_path, "wb") as f: + async for chunk in resp.aiter_bytes(): + f.write(chunk) + downloaded += len(chunk) + + progress = 0.2 + 0.6 * (downloaded / total_size) + await update_progress( + round(progress, 2), f"Downloading... {downloaded // 1024}KB/{total_size // 1024}KB" + ) + + await update_progress(0.8, "Extracting and cleaning installation files") + await unzip_file(zip_file_path, execute_dir) + extract_dir_path = str(zip_file_path).rsplit(".", maxsplit=1)[0] + new_extract_dir_path = Path(extract_dir_path).parent / "node" + await update_progress(0.9, "Configuring Node.js environment variables") + if Path(extract_dir_path).exists(): + if Path(new_extract_dir_path).exists(): + shutil.rmtree(new_extract_dir_path) + os.rename(extract_dir_path, new_extract_dir_path) + os.environ["PATH"] = execute_dir + "/node" + os.pathsep + os.environ.get("PATH") + result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + return True + else: + raise Exception("Please restart the program") + else: + logger.error("Failed to retrieve the Node.js version page") + raise Exception("Failed to obtain the Node.js download address") + + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_centos(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for CentOS...") + await update_progress(0.1, "Get Node.js installation resources") + result = subprocess.run( + "curl -fsSL https://mirrors.tuna.tsinghua.edu.cn/nodesource/rpm/setup_lts.x | bash -", + shell=True, + capture_output=True, + startupinfo=startupinfo, + ) + if result.returncode != 0: + logger.error("Failed to run NodeSource installation script") + + result = subprocess.run(["yum", "install", "-y", "epel-release"], capture_output=True, startupinfo=startupinfo) + if result.returncode != 0: + logger.error("Failed to install EPEL repository") + + result = subprocess.run(["yum", "install", "-y", "nodejs"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except Exception as e: + logger.error(f"Node.js installation failed {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_ubuntu(update_progress): + try: + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for Ubuntu...") + await update_progress(0.1, "Get Node.js installation resources") + install_script = "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -" + result = subprocess.run(install_script, shell=True, capture_output=True, startupinfo=startupinfo) + if result.returncode != 0: + logger.error("Failed to run NodeSource installation script") + + install_command = ["apt", "install", "-y", "nodejs"] + result = subprocess.run(install_command, capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError(f"Node.js install failed, {e}") from None + + +async def install_nodejs_mac(update_progress): + logger.warning("Node.js is not installed.") + logger.debug("Installing the latest version of Node.js for macOS...") + await update_progress(0.1, "Get Node.js installation resources") + await asyncio.sleep(2) + await update_progress(0.3, "Please be patient and wait...") + try: + result = subprocess.run(["brew", "install", "node"], capture_output=True, startupinfo=startupinfo) + if result.returncode == 0: + logger.debug("Node.js installation was successful. Restart for changes to take effect.") + return True + else: + logger.error("Node.js installation failed") + raise Exception("Please manually install by yourself") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to install Node.js using Homebrew. {e}") + logger.error("Please install Node.js manually or check your Homebrew installation.") + raise Exception("Please check if Homebrew has been installed") from None + except Exception as e: + logger.error(f"Node.js installation failed, {e}") + raise RuntimeError("Node.js install failed") from None + + +def get_package_manager(): + dist_id = distro.id() + if dist_id in ["centos", "fedora", "rhel", "amzn", "oracle", "scientific", "opencloudos", "alinux"]: + return "RHS" + else: + return "DBS" + + +async def install_nodejs(update_progress) -> bool: + if current_platform == "Windows": + return await install_nodejs_windows(update_progress) + elif current_platform == "Linux": + os_type = get_package_manager() + if os_type == "RHS": + return await install_nodejs_centos(update_progress) + else: + return await install_nodejs_ubuntu(update_progress) + elif current_platform == "Darwin": + return await install_nodejs_mac(update_progress) + else: + logger.debug( + f"Node.js auto installation is not supported on this platform: {current_platform}. " + f"Please install Node.js manually." + ) + return False + + +def update_env_path(): + current_env_path = os.environ.get("PATH") + if current_platform != "Windows": + path_list = ["/usr/bin/", "/usr/local/bin", "/opt/homebrew/bin"] + current_env_path_list = current_env_path.split(os.pathsep) + env_path = [path for path in path_list if path not in set(current_env_path_list)] + node_env_path = os.pathsep.join([node_path] + env_path + current_env_path_list) + else: + node_env_path = node_path + os.pathsep + current_env_path + os.environ["PATH"] = node_env_path + + +async def check_nodejs_installed() -> bool: + try: + update_env_path() + result = subprocess.run(["node", "-v"], capture_output=True, startupinfo=startupinfo, text=True) + logger.info(result) + version_info = result.stdout.strip() + if result.returncode == 0 and version_info: + return True + except FileNotFoundError as e: + logger.info(e) + return False diff --git a/app/ui/base_page.py b/app/ui/base_page.py index 60ec08e1..22de0010 100644 --- a/app/ui/base_page.py +++ b/app/ui/base_page.py @@ -13,6 +13,5 @@ def __init__(self, app): self._ = {} async def load(self): - """Load page content into the content area. - """ + """Load page content into the content area.""" raise NotImplementedError("Subclasses must implement this method") diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index e0a1b0eb..658b79b8 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -34,14 +34,15 @@ def pubsub_subscribe(self): self.app.page.pubsub.subscribe_topic("update", self.subscribe_update_card) self.app.page.pubsub.subscribe_topic("delete", self.subscribe_remove_cards) - async def create_card(self, recording: Recording): + async def create_card(self, recording: Recording, subscribe_add_cards: bool = False): """Create a card for a given recording.""" rec_id = recording.rec_id if not self.cards_obj.get(rec_id): - if self.app.recording_enabled: - self.app.page.run_task(self.app.record_manager.check_if_live, recording) - else: - recording.status_info = RecordingStatus.NOT_RECORDING_SPACE + check_live_on_browser_refresh = self.app.settings.user_config.get("check_live_on_browser_refresh", True) + if self.app.recording_enabled and not subscribe_add_cards: + if check_live_on_browser_refresh or recording.streamer_name == self._["live_room"]: + self.app.page.run_task(self.app.record_manager.check_if_live, recording) + card_data = self._create_card_components(recording) self.cards_obj[rec_id] = card_data self.start_update_task(recording) @@ -54,53 +55,60 @@ def _create_card_components(self, recording: Recording): record_button = ft.IconButton( icon=self.get_icon_for_recording_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_recording_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_button_on_click, e, rec), ) edit_button = ft.IconButton( icon=ft.Icons.EDIT, + icon_color=ft.Colors.PRIMARY, tooltip=self._["edit_record_config"], on_click=lambda e, rec=recording: self.app.page.run_task(self.edit_recording_button_click, e, rec), ) preview_button = ft.IconButton( icon=ft.Icons.VIDEO_LIBRARY, + icon_color=ft.Colors.PRIMARY, tooltip=self._["preview_video"], on_click=lambda e, rec=recording: self.app.page.run_task(self.preview_video_button_on_click, e, rec), ) monitor_button = ft.IconButton( icon=self.get_icon_for_monitor_state(recording), + icon_color=ft.Colors.PRIMARY, tooltip=self.get_tip_for_monitor_state(recording), on_click=lambda e, rec=recording: self.app.page.run_task(self.monitor_button_on_click, e, rec), ) delete_button = ft.IconButton( icon=ft.Icons.DELETE, + icon_color=ft.Colors.PRIMARY, tooltip=self._["delete_monitor"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_delete_button_click, e, rec), ) display_title = RecordingCardState.get_display_title(recording, self._) display_title_label = ft.Text( - display_title, - size=14, - selectable=True, - max_lines=1, + display_title, + size=14, + selectable=True, + max_lines=1, no_wrap=True, overflow=ft.TextOverflow.ELLIPSIS, expand=True, weight=RecordingCardState.get_title_weight(recording), ) - + open_folder_button = ft.IconButton( icon=ft.Icons.FOLDER, + icon_color=ft.Colors.PRIMARY, tooltip=self._["open_folder"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_dir_button_on_click, e, rec), ) recording_info_button = ft.IconButton( icon=ft.Icons.INFO, + icon_color=ft.Colors.PRIMARY, tooltip=self._["recording_info"], on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_info_button_on_click, e, rec), ) @@ -129,21 +137,21 @@ def _create_card_components(self, recording: Recording): preview_button, edit_button, delete_button, - monitor_button + monitor_button, ], spacing=3, alignment=ft.MainAxisAlignment.START, - scroll=ft.ScrollMode.HIDDEN + scroll=ft.ScrollMode.HIDDEN, ), ], spacing=3, - tight=True + tight=True, ), padding=8, on_click=lambda e, rec=recording: self.app.page.run_task(self.recording_card_on_click, e, rec), bgcolor=self.get_card_background_color(recording), border_radius=5, - border=ft.border.all(2, self.get_card_border_color(recording)), + border=ft.Border.all(2, self.get_card_border_color(recording)), ) card = ft.Card(key=str(recording.rec_id), content=card_container) @@ -159,7 +167,7 @@ def _create_card_components(self, recording: Recording): "monitor_button": monitor_button, "status_label": status_label, } - + def get_card_background_color(self, recording: Recording): is_dark_mode = self.app.page.theme_mode == ft.ThemeMode.DARK if recording.selected: @@ -175,20 +183,15 @@ def create_status_label(self, recording: Recording): config = RecordingCardState.get_status_label_config(recording, self._) if not config: return None - + return ft.Container( - content=ft.Text( - config["text"], - color=config["text_color"], - size=12, - weight=ft.FontWeight.BOLD - ), + content=ft.Text(config["text"], color=config["text_color"], size=12, weight=ft.FontWeight.BOLD), bgcolor=config["bgcolor"], border_radius=5, padding=5, width=60, height=26, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ) async def update_card(self, recording): @@ -196,20 +199,20 @@ async def update_card(self, recording): if recording.rec_id in self.cards_obj: try: recording_card = self.cards_obj[recording.rec_id] - + display_title = RecordingCardState.get_display_title(recording, self._) if recording_card.get("display_title_label"): recording_card["display_title_label"].value = display_title recording_card["display_title_label"].weight = RecordingCardState.get_title_weight(recording) - + new_status_label = self.create_status_label(recording) - + if recording_card["card"] and recording_card["card"].content and recording_card["card"].content.content: title_row = recording_card["card"].content.content.controls[0] title_row.alignment = ft.MainAxisAlignment.START title_row.spacing = 5 title_row.tight = True - + # Update the status label if it exists if new_status_label: if len(title_row.controls) > 1: @@ -219,31 +222,31 @@ async def update_card(self, recording): else: if len(title_row.controls) > 1: title_row.controls.pop() - + if recording_card.get("duration_label"): recording_card["duration_label"].value = self.app.record_manager.get_duration(recording) - + if recording_card.get("speed_label"): recording_card["speed_label"].value = recording.speed - + if recording_card.get("record_button"): recording_card["record_button"].icon = self.get_icon_for_recording_state(recording) recording_card["record_button"].tooltip = self.get_tip_for_recording_state(recording) - + if recording_card.get("monitor_button"): recording_card["monitor_button"].icon = self.get_icon_for_monitor_state(recording) recording_card["monitor_button"].tooltip = self.get_tip_for_monitor_state(recording) - + if recording_card["card"] and recording_card["card"].content: recording_card["card"].content.bgcolor = self.get_card_background_color(recording) - recording_card["card"].content.border = ft.border.all(2, self.get_card_border_color(recording)) + recording_card["card"].content.border = ft.Border.all(2, self.get_card_border_color(recording)) try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card failed: {e}") return except Exception as e: @@ -266,8 +269,9 @@ async def update_monitor_state(self, recording: Recording): recording.update( { "monitor_status": not recording.monitor_status, - "status_info": RecordingStatus.MONITORING, + "status_info": RecordingStatus.STATUS_CHECKING, "display_title": f"{recording.title}", + "showed_checking_status": False, } ) self.app.page.run_task(self.app.record_manager.check_if_live, recording) @@ -275,7 +279,7 @@ async def update_monitor_state(self, recording: Recording): await self.update_card(recording) self.app.page.pubsub.send_others_on_topic("update", recording) - self.app.page.run_task(self.app.record_manager.persist_recordings) + self.app.services.run_coro(self.app.record_manager.persist_recordings()) async def show_recording_info_dialog(self, recording: Recording): """Display a dialog with detailed information about the recording.""" @@ -285,9 +289,9 @@ async def show_recording_info_dialog(self, recording: Recording): self.app.dialog_area.content = dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording info dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show recording info dialog failed: {e}") except Exception as e: logger.debug(f"Show recording info dialog failed: {e}") @@ -302,7 +306,8 @@ async def edit_recording_callback(self, recording_list: list[dict]): recording.display_title = f"[{self._['monitor_stopped']}] " + recording.title recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours) + recording.scheduled_start_time, recording.monitor_hours + ) await self.update_card(recording) self.app.page.pubsub.send_others_on_topic("update", recording_dict) @@ -318,7 +323,7 @@ async def on_toggle_recording(self, recording: Recording): await self.app.record_manager.check_if_live(recording) if recording.is_live: self.app.record_manager.start_update(recording) - await self.app.snack_bar.show_snack_bar(self._["pre_record_tip"], bgcolor=ft.Colors.GREEN) + await self.app.snack_bar.show_snack_bar(self._["pre_record_tip"], bgcolor=ft.Colors.PRIMARY) else: await self.app.snack_bar.show_snack_bar(self._["is_not_live_tip"]) else: @@ -334,8 +339,12 @@ async def on_delete_recording(self, recording: Recording): await self.app.snack_bar.show_snack_bar(self._["please_stop_monitor_tip"]) return await self.app.record_manager.delete_recording_cards([recording]) + current_page = getattr(self.app, "current_page", None) + if current_page is not None and getattr(current_page, "page_name", None) == "recordings": + current_page.content_area.controls[1] = current_page.create_filter_area() + current_page.content_area.update() await self.app.snack_bar.show_snack_bar( - self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 + self._["delete_recording_success_tip"], bgcolor=ft.Colors.PRIMARY, duration=2000 ) async def remove_recording_card(self, recordings: list[Recording]): @@ -347,9 +356,7 @@ async def remove_recording_card(self, recordings: list[Recording]): keep_ids = existing_ids - remove_ids cards_to_remove = [ - card_data["card"] - for rec_id, card_data in self.cards_obj.items() - if rec_id not in keep_ids + card_data["card"] for rec_id, card_data in self.cards_obj.items() if rec_id not in keep_ids ] recordings_page.recording_card_area.content.controls = [ @@ -358,17 +365,14 @@ async def remove_recording_card(self, recordings: list[Recording]): if control not in cards_to_remove ] - self.cards_obj = { - k: v for k, v in self.cards_obj.items() - if k in keep_ids - } - + self.cards_obj = {k: v for k, v in self.cards_obj.items() if k in keep_ids} + try: recordings_page.recording_card_area.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update recording card area failed: {e}") - - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Remove recording card failed: {e}") except Exception as e: logger.debug(f"Remove recording card failed: {e}") @@ -401,12 +405,17 @@ async def update_duration(self, recording: Recording): if not recording or recording.rec_id not in self.cards_obj: # Stop task if card is removed break + # Skip update when not on recordings page (cards are detached from page tree) + current_page = getattr(self.app, "current_page", None) + if not current_page or getattr(current_page, "page_name", None) != "recordings": + continue + if recording.is_recording: try: duration_label = self.cards_obj[recording.rec_id]["duration_label"] duration_label.value = self.app.record_manager.get_duration(recording) duration_label.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update duration failed: {e}") break except Exception as e: @@ -424,9 +433,9 @@ async def on_card_click(self, recording: Recording): self.cards_obj[recording.rec_id]["card"].content.bgcolor = await self.update_record_hover(recording) try: self.cards_obj[recording.rec_id]["card"].update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update card click state failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Handle card click event failed: {e}") except Exception as e: logger.debug(f"Handle card click event failed: {e}") @@ -435,7 +444,7 @@ async def recording_dir_on_click(self, recording: Recording): if recording.recording_dir: if os.path.exists(recording.recording_dir): if not utils.open_folder(recording.recording_dir): - await self.app.snack_bar.show_snack_bar(self._['no_video_file']) + await self.app.snack_bar.show_snack_bar(self._["no_video_file"]) else: await self.app.snack_bar.show_snack_bar(self._["no_recording_folder"]) @@ -454,6 +463,7 @@ async def edit_recording_button_click(self, _, recording: Recording): async def recording_delete_button_click(self, _, recording: Recording): try: + async def confirm_dlg(_): self.app.page.run_task(self.on_delete_recording, recording) await close_dialog(None) @@ -462,15 +472,15 @@ async def close_dialog(_): try: delete_alert_dialog.open = False delete_alert_dialog.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: - logger.debug(f"Close delete dialog failed: {e}") + except (ft.FletPageDisconnectedException, AssertionError) as err: + logger.debug(f"Close delete dialog failed: {err}") delete_alert_dialog = ft.AlertDialog( title=ft.Text(self._["confirm"]), content=ft.Text(self._["delete_confirm_tip"]), actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), ], actions_alignment=ft.MainAxisAlignment.END, modal=False, @@ -479,9 +489,9 @@ async def close_dialog(_): self.app.dialog_area.content = delete_alert_dialog try: self.app.page.update() - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update delete dialog failed: {e}") - except (ft.core.page.PageDisconnectedException, AssertionError) as e: + except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Show delete dialog failed: {e}") except Exception as e: logger.debug(f"Show delete dialog failed: {e}") @@ -489,13 +499,14 @@ async def close_dialog(_): async def preview_video_button_on_click(self, _, recording: Recording): 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) + await video_player.preview_video(recording.preview_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: - if utils.is_valid_video_file(file): - video_files.append(os.path.join(root, file)) + file_str = str(file) + if utils.is_valid_video_file(file_str): + video_files.append(os.path.join(str(root), file_str)) if video_files: video_files.sort(key=lambda x: os.path.getmtime(x), reverse=True) diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 0350e6c8..bc81eb0e 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -1,444 +1,572 @@ -import flet as ft - -from ....core.platforms.platform_handlers import get_platform_info -from ....models.media.audio_format_model import AudioFormat -from ....models.media.video_format_model import VideoFormat -from ....models.media.video_quality_model import VideoQuality -from ....utils import utils -from ....utils.logger import logger - - -class RecordingDialog: - def __init__(self, app, on_confirm_callback=None, recording=None): - self.app = app - self.page = self.app.page - self.on_confirm_callback = on_confirm_callback - self.recording = recording - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - language = self.app.language_manager.language - for key in ("recording_dialog", "recordings_page", "base", "video_quality"): - self._.update(language.get(key, {})) - - async def show_dialog(self): - """Show a dialog for adding or editing a recording.""" - initial_values = self.recording.to_dict() if self.recording else {} - - user_config = self.app.settings.user_config - default_record_format = initial_values.get("record_format", user_config.get("video_format", VideoFormat.TS)) - default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" - default_record_quality = initial_values.get("quality", user_config.get("record_quality", VideoQuality.OD)) - segmented_recording_enabled = user_config.get('segmented_recording_enabled', False) - video_segment_time = user_config.get('video_segment_time', 1800) - segment_record = initial_values.get("segment_record", segmented_recording_enabled) - segment_time = initial_values.get("segment_time", video_segment_time) - only_notify_no_record = initial_values.get("only_notify_no_record", False) - flv_use_direct_download = initial_values.get( - "flv_use_direct_download", user_config.get("flv_use_direct_download", False)) - - async def on_url_change(_): - """Enable or disable the submit button based on whether the URL field is filled.""" - is_active = utils.is_valid_url(url_field.value.strip()) or utils.contains_url(batch_input.value.strip()) - dialog.actions[1].disabled = not is_active - self.page.update() - - async def update_format_options(e): - if e.control.value == "video": - record_format_field.options = [ft.dropdown.Option(i) for i in VideoFormat.get_formats()] - else: - record_format_field.options = [ft.dropdown.Option(i) for i in AudioFormat.get_formats()] - record_format_field.value = record_format_field.options[0].key - record_format_field.update() - - url_field = ft.TextField( - label=self._["input_live_link"], - hint_text=self._["example"] + ":https://www.example.com/xxxxxx", - border_radius=5, - filled=False, - value=initial_values.get("url"), - on_change=on_url_change, - ) - - streamer_name_field = ft.TextField( - label=self._["input_anchor_name"], - hint_text=self._["default_input"], - border_radius=5, - filled=False, - value=initial_values.get("streamer_name", ""), - ) - media_type_dropdown = ft.Dropdown( - label=self._["select_media_type"], - options=[ - ft.dropdown.Option("video", text=self._["video"]), - ft.dropdown.Option("audio", text=self._["audio"]) - ], - width=245, - value=default_record_type, - on_change=update_format_options - ) - record_format_field = ft.Dropdown( - label=self._["select_record_format"], - options=[ft.dropdown.Option(i) for i in VideoFormat.get_formats()], - border_radius=5, - filled=False, - value=default_record_format, - width=245, - menu_height=200 - ) - - quality_dropdown = ft.Dropdown( - label=self._["select_resolution"], - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], - border_radius=5, - filled=False, - value=default_record_quality, - width=245, - ) - - flv_use_direct_download_dropdown = ft.Dropdown( - label=self._["flv_use_direct_download"], - options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if flv_use_direct_download else "false", - width=245, - tooltip=self._["flv_use_direct_download_tip"] - ) - - if self.app.is_mobile: - media_type_dropdown.width = 500 - record_format_field.width = 500 - flv_use_direct_download_dropdown.width = 500 - quality_dropdown.width = 500 - format_row = ft.Column([media_type_dropdown, record_format_field], expand=True) - quality_row = ft.Column([quality_dropdown, flv_use_direct_download_dropdown], expand=True) - else: - format_row = ft.Row([media_type_dropdown, record_format_field], expand=True) - quality_row = ft.Row([quality_dropdown, flv_use_direct_download_dropdown], expand=True) - - recording_dir_field = ft.TextField( - label=self._["input_save_path"], - hint_text=self._["default_input"], - border_radius=5, - filled=False, - value=initial_values.get("recording_dir"), - ) - - async def on_segment_setting_change(e): - selected_value = e.control.value - segment_input.visible = selected_value == self._["yes"] - self.page.update() - - segment_setting_dropdown = ft.Dropdown( - label=self._["is_segment_enabled"], - options=[ - ft.dropdown.Option(self._["yes"]), - ft.dropdown.Option(self._["no"]), - ], - border_radius=5, - filled=False, - value=self._["yes"] if segment_record else self._["no"], - on_change=on_segment_setting_change, - width=500, - ) - - segment_input = ft.TextField( - label=self._["segment_record_time"], - hint_text=self._["input_segment_time"], - border_radius=5, - filled=False, - value=segment_time, - visible=segment_record, - ) - - scheduled_recording = initial_values.get("scheduled_recording", False) - scheduled_start_time = initial_values.get("scheduled_start_time") - monitor_hours = initial_values.get("monitor_hours", 5) - message_push_enabled = initial_values.get('enabled_message_push', True) - - async def on_scheduled_setting_change(e): - selected_value = e.control.value - schedule_and_monitor_row.visible = selected_value == "true" - monitor_hours_input.visible = selected_value == "true" - self.page.update() - - async def pick_time(_): - async def handle_change(_): - scheduled_start_time_input.value = time_picker.value - scheduled_start_time_input.update() - - time_picker = ft.TimePicker( - confirm_text=self._['confirm'], - cancel_text=self._['cancel'], - error_invalid_text=self._['time_out_of_range'], - help_text=self._['pick_time_slot'], - hour_label_text=self._['hour_label_text'], - minute_label_text=self._['minute_label_text'], - on_change=handle_change - ) - self.page.open(time_picker) - - scheduled_setting_dropdown = ft.Dropdown( - label=self._["scheduled_recording"], - options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if scheduled_recording else "false", - on_change=on_scheduled_setting_change, - width=500, - ) - - scheduled_start_time_input = ft.TextField( - label=self._["scheduled_start_time"], - hint_text=self._["example"] + ":18:30:00", - border_radius=5, - filled=False, - value=scheduled_start_time, - ) - - time_picker_button = ft.ElevatedButton( - self._['pick_time'], - icon=ft.Icons.TIME_TO_LEAVE, - on_click=pick_time, - tooltip=self._['pick_time_tip'] - ) - - schedule_and_monitor_row = ft.Row( - [ - ft.Container(content=scheduled_start_time_input, expand=True), - ft.Container(content=time_picker_button), - ], - spacing=10, - visible=scheduled_recording, - ) - - monitor_hours_input = ft.TextField( - label=self._["monitor_hours"], - hint_text=self._["example"] + ":5", - border_radius=5, - filled=False, - value=monitor_hours, - keyboard_type=ft.KeyboardType.NUMBER, - visible=scheduled_recording, - ) - - message_push_dropdown = ft.Dropdown( - label=self._["enable_message_push"], - options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if message_push_enabled else "false", - width=500, - ) - - no_record_dropdown = ft.Dropdown( - label=self._["only_notify_no_record"], - options=[ - ft.dropdown.Option("true", self._["yes"]), - ft.dropdown.Option("false", self._["no"]), - ], - border_radius=5, - filled=False, - value="true" if only_notify_no_record else "false", - width=500, - ) - - hint_text_dict = { - "en": "Example:\n0,https://v.douyin.com/AbcdE,nickname1\n0,https://v.douyin.com/EfghI,nickname2\n\nPS: " - "0=original image or Blu ray, 1=ultra clear, 2=high-definition, 3=standard definition, 4=smooth\n", - "zh_CN": "示例:\n0,https://v.douyin.com/AbcdE,主播名1\n0,https://v.douyin.com/EfghI,主播名2" - "\n\n其中0=原画或者蓝光,1=超清,2=高清,3=标清,4=流畅", - } - - # Batch input field - batch_input = ft.TextField( - label=self._["batch_input_tip"], - multiline=True, - min_lines=15, - max_lines=20, - border_radius=5, - filled=False, - visible=True, - hint_style=ft.TextStyle( - size=14, - color=ft.Colors.GREY_500, - font_family="Arial", - ), - on_change=on_url_change, - hint_text=hint_text_dict.get(self.app.language_code, hint_text_dict["zh_CN"]), - ) - - tabs = ft.Tabs( - selected_index=0, - animation_duration=300, - height=500, - tabs=[ - ft.Tab( - text=self._["single_input"], - content=ft.Container( - content=ft.Column( - [ - ft.Container(margin=ft.margin.only(top=10)), - url_field, - streamer_name_field, - format_row, - quality_row, - recording_dir_field, - segment_setting_dropdown, - segment_input, - scheduled_setting_dropdown, - schedule_and_monitor_row, - monitor_hours_input, - message_push_dropdown, - no_record_dropdown - ], - tight=True, - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - ), - ), - ft.Tab( - text=self._["batch_input"], content=ft.Container(content=batch_input, margin=ft.margin.only(top=15)) - ), - ], - ) - - async def not_supported(url): - logger.warning(f"This platform does not support recording: {url}") - await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000) - - async def on_confirm(e): - if tabs.selected_index == 0: - quality_info = self._[quality_dropdown.value] - - if not streamer_name_field.value: - anchor_name = self._["live_room"] - title = f"{anchor_name} - {quality_info}" - else: - anchor_name = streamer_name_field.value.strip() - title = f"{anchor_name} - {quality_info}" - - display_title = title - rec_id = self.recording.rec_id if self.recording else None - live_url = url_field.value.strip() - platform, platform_key = get_platform_info(live_url) - if not platform: - await not_supported(url_field.value) - await close_dialog(e) - return - - recordings_info = [ - { - "rec_id": rec_id, - "url": live_url, - "streamer_name": anchor_name, - "record_format": record_format_field.value, - "quality": quality_dropdown.value, - "quality_info": quality_info, - "title": title, - "speed": "X KB/s", - "segment_record": segment_input.visible, - "segment_time": segment_input.value, - "monitor_status": initial_values.get("monitor_status", True), - "display_title": display_title, - "scheduled_recording": schedule_and_monitor_row.visible, - "scheduled_start_time": str(scheduled_start_time_input.value), - "monitor_hours": monitor_hours_input.value, - "recording_dir": recording_dir_field.value, - "enabled_message_push": message_push_dropdown.value == "true", - "only_notify_no_record": no_record_dropdown.value == "true", - "flv_use_direct_download": flv_use_direct_download_dropdown.value == "true", - } - ] - await self.on_confirm_callback(recordings_info) - - elif tabs.selected_index == 1: # Batch entry - lines = batch_input.value.splitlines() - recordings_info = [] - streamer_name = "" - quality = "OD" - quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"} - for line in lines: - if "http" not in line: - continue - res = [i for i in line.strip().replace(",", ",").split(",") if i] - if len(res) == 3: - quality, url, streamer_name = res - elif len(res) == 2: - if res[1].startswith("http"): - quality, url = res - else: - url, streamer_name = res - else: - url = res[0] - - platform, platform_key = get_platform_info(url) - if not platform: - await not_supported(url) - continue - - quality = quality_dict.get(quality, "OD") - title = f"{streamer_name} - {self._[quality]}" - display_title = title - if not streamer_name: - streamer_name = self._["live_room"] - display_title = streamer_name + url.split("?")[0] + "... - " + self._[quality] - - recording_info = { - "url": url.strip(), - "streamer_name": streamer_name, - "quality": quality, - "quality_info": self._[VideoQuality.OD], - "title": title, - "display_title": display_title, - } - recordings_info.append(recording_info) - - await self.on_confirm_callback(recordings_info) - - await close_dialog(e) - - async def close_dialog(_): - dialog.open = False - self.page.update() - - close_button = ft.IconButton(icon=ft.Icons.CLOSE, tooltip=self._["close"], on_click=close_dialog) - - title_text = self._["edit_record"] if self.recording else self._["add_record"] - dialog = ft.AlertDialog( - open=True, - modal=True, - title=ft.Row( - [ - ft.Text(title_text, size=16, theme_style=ft.TextThemeStyle.TITLE_LARGE), - ft.Container(width=10), - close_button, - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - width=500 - ), - content=tabs, - actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=on_confirm, disabled=self.recording is None), - ], - actions_alignment=ft.MainAxisAlignment.END, - shape=ft.RoundedRectangleBorder(radius=10) - ) - - self.page.overlay.append(dialog) - self.page.update() - \ No newline at end of file +import flet as ft + +from ....core.platforms.platform_handlers import get_platform_info +from ....models.media.audio_format_model import AudioFormat +from ....models.media.video_format_model import VideoFormat +from ....models.media.video_quality_model import VideoQuality +from ....utils import utils +from ....utils.logger import logger + + +class RecordingDialog: + def __init__(self, app, on_confirm_callback=None, recording=None): + self.app = app + self.page = self.app.page + self.on_confirm_callback = on_confirm_callback + self.recording = recording + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + language = self.app.language_manager.language + for key in ("recording_dialog", "recordings_page", "base", "video_quality"): + self._.update(language.get(key, {})) + + async def show_dialog(self): + """Show a dialog for adding or editing a recording.""" + initial_values = self.recording.to_dict() if self.recording else {} + + config = RecordingConfig(initial_values, self.app.settings.user_config) + default_record_format = config.get_value("record_format", "video_format", VideoFormat.TS).upper() + default_record_type = "video" if default_record_format in VideoFormat.get_formats() else "audio" + default_record_quality = config.get_value("quality", "record_quality", VideoQuality.OD) + default_video_bitrate = initial_values.get("video_bitrate") + segment_record = config.get_value("segment_record", "segmented_recording_enabled", False) + segment_time = config.get_value("segment_time", "video_segment_time", 1800) + only_notify_no_record = config.get_value("only_notify_no_record", default=False) + flv_use_direct_download = config.get_value("flv_use_direct_download", default=False) + + async def on_url_change(_): + """Enable or disable the submit button based on whether the URL field is filled.""" + url_value = url_field.value.strip() if url_field.value else "" + batch_value = batch_input.value.strip() if batch_input.value else "" + is_active = utils.is_valid_url(url_value) or utils.contains_url(batch_value) + dialog.actions[1].disabled = not is_active + self.page.update() + + async def update_format_options(e): + if e.control.value == "video": + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in VideoFormat.get_formats()] + else: + record_format_field.options = [ft.dropdown.DropdownOption(i) for i in AudioFormat.get_formats()] + record_format_field.value = record_format_field.options[0].key + video_bitrate_field.visible = e.control.value == "video" + record_format_field.update() + video_bitrate_field.update() + + url_field = ft.TextField( + label=self._["input_live_link"], + hint_text=self._["example"] + ":https://www.example.com/xxxxxx", + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("url"), + on_change=on_url_change, + ) + + streamer_name_field = ft.TextField( + label=self._["input_anchor_name"], + hint_text=self._["default_input"], + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("streamer_name", ""), + ) + media_type_dropdown = ft.Dropdown( + label=self._["select_media_type"], + options=[ + ft.dropdown.DropdownOption("video", text=self._["video"]), + ft.dropdown.DropdownOption("audio", text=self._["audio"]), + ], + width=245, + value=default_record_type, + on_select=update_format_options, + ) + + if default_record_type == "video": + record_formats = VideoFormat.get_formats() + else: + record_formats = AudioFormat.get_formats() + record_format_field = ft.Dropdown( + label=self._["select_record_format"], + options=[ft.dropdown.DropdownOption(i) for i in record_formats], + border_radius=5, + filled=False, + value=default_record_format, + width=245, + menu_height=200, + ) + + quality_dropdown = ft.Dropdown( + label=self._["select_resolution"], + options=[ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities()], + border_radius=5, + filled=False, + value=default_record_quality, + width=245, + ) + + video_bitrate_field = ft.TextField( + label=self._["custom_video_bitrate"], + hint_text=self._["custom_video_bitrate_hint"], + border_radius=5, + filled=False, + keyboard_type=ft.KeyboardType.NUMBER, + value=str(default_video_bitrate) if default_video_bitrate else "", + width=500, + visible=default_record_type == "video", + ) + + flv_use_direct_download_dropdown = ft.Dropdown( + label=self._["flv_use_direct_download"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if flv_use_direct_download else "false", + width=245, + tooltip=self._["flv_use_direct_download_tip"], + ) + + if self.app.is_mobile: + media_type_dropdown.width = 500 + record_format_field.width = 500 + flv_use_direct_download_dropdown.width = 500 + quality_dropdown.width = 500 + format_row = ft.Column([media_type_dropdown, record_format_field], expand=True) + quality_row = ft.Column([quality_dropdown, flv_use_direct_download_dropdown], expand=True) + else: + format_row = ft.Row([media_type_dropdown, record_format_field], expand=True) + quality_row = ft.Row([quality_dropdown, flv_use_direct_download_dropdown], expand=True) + + recording_dir_field = ft.TextField( + label=self._["input_save_path"], + hint_text=self._["default_input"], + border_radius=5, + filled=False, + expand=True, + value=initial_values.get("recording_dir"), + ) + + async def on_segment_setting_change(e): + selected_value = e.control.value + segment_input.visible = selected_value == self._["yes"] + self.page.update() + + segment_setting_dropdown = ft.Dropdown( + label=self._["is_segment_enabled"], + options=[ + ft.dropdown.DropdownOption(self._["yes"]), + ft.dropdown.DropdownOption(self._["no"]), + ], + border_radius=5, + filled=False, + value=self._["yes"] if segment_record else self._["no"], + on_select=on_segment_setting_change, + width=500, + ) + + segment_input = ft.TextField( + label=self._["segment_record_time"], + hint_text=self._["input_segment_time"], + border_radius=5, + filled=False, + expand=True, + value=segment_time, + visible=segment_record, + ) + + scheduled_recording = initial_values.get("scheduled_recording", False) + scheduled_start_time = initial_values.get("scheduled_start_time", "") or "" + monitor_hours = initial_values.get("monitor_hours", "5") or "" + message_push_enabled = initial_values.get("enabled_message_push", True) + + time_slots = 2 + time_inputs = [] + hour_inputs = [] + time_buttons = [] + time_picker_handlers = [] + + time_values = str(scheduled_start_time).split(",") + time_values = (time_values + [""] * time_slots)[:time_slots] + + hour_values = str(monitor_hours).split(",") + hour_values = (hour_values + [""] * time_slots)[:time_slots] + + def create_time_picker_handler(index): + async def pick_time(_): + async def handle_change(e): + picked = e.control.value + time_inputs[index].value = picked.strftime("%H:%M:%S") if picked else "" + time_inputs[index].update() + + time_picker = ft.TimePicker( + confirm_text=self._["confirm"], + cancel_text=self._["cancel"], + error_invalid_text=self._["time_out_of_range"], + help_text=self._["pick_time_slot"], + hour_label_text=self._["hour_label_text"], + minute_label_text=self._["minute_label_text"], + on_change=handle_change, + ) + self.page.show_dialog(time_picker) + + return pick_time + + for i in range(time_slots): + time_input = ft.TextField( + label=self._["scheduled_start_time"], + hint_text=self._["example"] + ":18:30:00", + border_radius=5, + filled=False, + value=time_values[i], + ) + time_inputs.append(time_input) + + hour_input = ft.TextField( + label=self._["monitor_hours"], + hint_text=self._["example"] + ":5", + border_radius=5, + filled=False, + value=hour_values[i], + keyboard_type=ft.KeyboardType.NUMBER, + visible=scheduled_recording, + ) + hour_inputs.append(hour_input) + + handler = create_time_picker_handler(i) + time_picker_handlers.append(handler) + + button = ft.Button( + self._["pick_time"], icon=ft.Icons.TIME_TO_LEAVE, on_click=handler, tooltip=self._["pick_time_tip"] + ) + time_buttons.append(button) + + async def on_scheduled_setting_change(e): + selected_value = e.control.value + for i in range(time_slots): + time_rows[i].visible = selected_value == "true" + hour_inputs[i].visible = selected_value == "true" + self.page.update() + + scheduled_setting_dropdown = ft.Dropdown( + label=self._["scheduled_recording"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if scheduled_recording else "false", + on_select=on_scheduled_setting_change, + width=500, + ) + + time_rows = [] + for i in range(time_slots): + row = ft.Row( + [ + ft.Container(content=time_inputs[i], expand=True), + ft.Container(content=hour_inputs[i], expand=True), + ft.Container(content=time_buttons[i]), + ], + spacing=10, + visible=scheduled_recording, + ) + time_rows.append(row) + + message_push_dropdown = ft.Dropdown( + label=self._["enable_message_push"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if message_push_enabled else "false", + width=500, + ) + + no_record_dropdown = ft.Dropdown( + label=self._["only_notify_no_record"], + options=[ + ft.dropdown.DropdownOption("true", self._["yes"]), + ft.dropdown.DropdownOption("false", self._["no"]), + ], + border_radius=5, + filled=False, + value="true" if only_notify_no_record else "false", + width=500, + ) + + hint_text_dict = { + "en": "Example:\n0,https://v.douyin.com/AbcdE,nickname1\n0,https://v.douyin.com/EfghI,nickname2\n\nPS: " + "0=original image or Blu ray, 1=ultra clear, 2=high-definition, 3=standard definition, 4=smooth\n", + "zh_CN": "示例:\n0,https://v.douyin.com/AbcdE,主播名1\n0,https://v.douyin.com/EfghI,主播名2" + "\n\n其中0=原画或者蓝光,1=超清,2=高清,3=标清,4=流畅", + } + + # Batch input field + batch_input = ft.TextField( + label=self._["batch_input_tip"], + multiline=True, + min_lines=15, + max_lines=20, + border_radius=5, + filled=False, + visible=True, + hint_style=ft.TextStyle( + size=14, + color=ft.Colors.GREY_500, + font_family="Arial", + ), + on_change=on_url_change, + hint_text=hint_text_dict.get(self.app.language_code, hint_text_dict["zh_CN"]), + ) + + tabs = ft.Tabs( + selected_index=0, + animation_duration=300, + content=ft.Column( + [ + ft.TabBar( + tabs=[ + ft.Tab(label=self._["single_input"]), + ft.Tab(label=self._["batch_input"]), + ] + ), + ft.TabBarView( + controls=[ + ft.Container( + content=ft.Column( + [ + ft.Container(margin=ft.Margin.only(top=10)), + url_field, + streamer_name_field, + format_row, + quality_row, + video_bitrate_field, + recording_dir_field, + segment_setting_dropdown, + segment_input, + scheduled_setting_dropdown, + *time_rows, + message_push_dropdown, + no_record_dropdown, + ], + tight=True, + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + ), + ft.Container(content=batch_input, margin=ft.Margin.only(top=15)), + ], + expand=True, + ), + ], + height=500, + expand=True, + ), + length=2, + ) + + async def not_supported(url): + logger.warning(f"This platform does not support recording: {url}") + await self.app.snack_bar.show_snack_bar(self._["platform_not_supported_tip"], duration=3000) + + def get_existing_recordings(): + existing_recordings = [rec.url for rec in self.app.record_manager.recordings] + return existing_recordings + + async def on_confirm(e): + + existing_recordings = get_existing_recordings() + + if tabs.selected_index == 0: + video_bitrate = None + bitrate_value = ( + (video_bitrate_field.value or "").strip() if media_type_dropdown.value == "video" else "" + ) + if bitrate_value: + try: + video_bitrate = int(bitrate_value) + if video_bitrate <= 0: + raise ValueError + except ValueError: + video_bitrate_field.error_text = self._["custom_video_bitrate_invalid"] + video_bitrate_field.update() + return + + quality_info = self._[quality_dropdown.value] + + if not streamer_name_field.value: + anchor_name = self._["live_room"] + title = f"{anchor_name} - {quality_info}" + else: + anchor_name = streamer_name_field.value.strip() + title = f"{anchor_name} - {quality_info}" + + display_title = title + rec_id = self.recording.rec_id if self.recording else None + live_url = url_field.value.strip() + platform, platform_key = get_platform_info(live_url) + if not platform: + await not_supported(url_field.value) + await close_dialog(e) + return + + recordings_info = [ + { + "rec_id": rec_id, + "url": live_url, + "streamer_name": anchor_name, + "record_format": record_format_field.value, + "quality": quality_dropdown.value, + "video_bitrate": video_bitrate, + "quality_info": quality_info, + "title": title, + "speed": "X KB/s", + "segment_record": segment_input.visible, + "segment_time": segment_input.value, + "monitor_status": initial_values.get("monitor_status", True), + "display_title": display_title, + "scheduled_recording": scheduled_setting_dropdown.value == "true", + "scheduled_start_time": ",".join([str(i.value) for i in time_inputs]), + "monitor_hours": ",".join([str(i.value) for i in hour_inputs]), + "recording_dir": recording_dir_field.value, + "enabled_message_push": message_push_dropdown.value == "true", + "only_notify_no_record": no_record_dropdown.value == "true", + "flv_use_direct_download": flv_use_direct_download_dropdown.value == "true", + } + ] + + if live_url in existing_recordings and not rec_id: + + async def confirm_duplicate(): + async def close_duplicate_dialog(_): + self.url_duplicate_confirm_dialog.open = False + self.page.update() + await close_dialog(e) + + async def proceed_with_add(_): + await close_duplicate_dialog(e) + await self.on_confirm_callback(recordings_info) + + duplicate_confirm_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(self._["duplicate_url_title"]), + content=ft.Text(self._["duplicate_url_content"]), + actions=[ + ft.TextButton(self._["cancel"], on_click=close_duplicate_dialog), + ft.TextButton(self._["sure"], on_click=proceed_with_add), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + self.url_duplicate_confirm_dialog = duplicate_confirm_dialog + self.url_duplicate_confirm_dialog.open = True + self.page.overlay.append(duplicate_confirm_dialog) + self.page.update() + + await confirm_duplicate() + return + else: + await self.on_confirm_callback(recordings_info) + + elif tabs.selected_index == 1: # Batch entry + lines = batch_input.value.splitlines() + recordings_info = [] + batch_url_list = [] + streamer_name = "" + quality = "OD" + quality_dict = {"0": "OD", "1": "UHD", "2": "HD", "3": "SD", "4": "LD"} + for line in lines: + if "http" not in line: + continue + res = [i for i in line.strip().replace(",", ",").split(",") if i] + if len(res) == 3: + quality, url, streamer_name = res + elif len(res) == 2: + if res[1].startswith("http"): + quality, url = res + else: + url, streamer_name = res + else: + url = res[0] + + platform, platform_key = get_platform_info(url) + if not platform: + await not_supported(url) + continue + + existing_urls = set(batch_url_list) | set(existing_recordings) + if url.strip() in existing_urls: + logger.info(f"Skip {url.strip()}, the live room URL already exists.") + continue + + quality = quality_dict.get(quality, "OD") + title = f"{streamer_name} - {self._[quality]}" + display_title = title + if not streamer_name: + streamer_name = self._["live_room"] + display_title = streamer_name + url.split("?")[0] + "... - " + self._[quality] + + recording_info = { + "url": url.strip(), + "streamer_name": streamer_name, + "quality": quality, + "quality_info": self._[VideoQuality.OD], + "title": title, + "display_title": display_title, + } + batch_url_list.append(url.strip()) + recordings_info.append(recording_info) + + await self.on_confirm_callback(recordings_info) + + await close_dialog(e) + + async def close_dialog(_): + dialog.open = False + self.page.update() + + close_button = ft.IconButton( + icon=ft.Icons.CLOSE, icon_color=ft.Colors.PRIMARY, tooltip=self._["close"], on_click=close_dialog + ) + + title_text = self._["edit_record"] if self.recording else self._["add_record"] + dialog = ft.AlertDialog( + open=True, + modal=True, + title=ft.Row( + [ + ft.Text(title_text, size=16, theme_style=ft.TextThemeStyle.TITLE_LARGE), + ft.Container(width=10), + close_button, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + width=500, + ), + content=tabs, + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=on_confirm, disabled=self.recording is None), + ], + actions_alignment=ft.MainAxisAlignment.END, + shape=ft.RoundedRectangleBorder(radius=10), + ) + + self.page.overlay.append(dialog) + self.page.update() + + +class RecordingConfig: + def __init__(self, initial_values, user_config): + self.initial_values = initial_values + self.user_config = user_config + + def get_value(self, key, user_config_key=None, default=None): + return self.initial_values.get(key, self.user_config.get(user_config_key or key, default)) diff --git a/app/ui/components/business/video_player.py b/app/ui/components/business/video_player.py index e068379c..c07b34e4 100644 --- a/app/ui/components/business/video_player.py +++ b/app/ui/components/business/video_player.py @@ -1,8 +1,10 @@ import os +from datetime import datetime from pathlib import Path from urllib.parse import parse_qs, urlparse import flet as ft +import flet_video as ftv from ....utils import utils from ....utils.logger import logger @@ -20,10 +22,7 @@ def load_language(self): 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 + self, title: str, video_source: str, is_file_path: bool = True, room_url: str | None = None ): """ Create video playback dialog @@ -46,23 +45,23 @@ def close_dialog(_): video_width = 800 video_height = 450 - video = ft.Video( - width=video_width, - height=video_height, - playlist=[ft.VideoMedia(video_source)], - autoplay=True + video = ftv.Video( + width=video_width, height=video_height, playlist=[ftv.VideoMedia(video_source)], autoplay=True ) async def copy_source(_): - self.app.page.set_clipboard(video_source) + await ft.Clipboard().set(video_source) await self.app.snack_bar.show_snack_bar(self._["copy_success"]) async def open_in_browser(_): - self.app.page.launch_url(room_url) + await self.app.page.launch_url(room_url) - actions = [ - ft.TextButton(self._["close"], on_click=close_dialog) - ] + async def take_screenshot(_): + await self._take_screenshot(video, video_source, is_file_path) + + actions = [ft.TextButton(self._["close"], on_click=close_dialog)] + + actions.insert(0, ft.TextButton(self._["screenshot"], on_click=take_screenshot)) if room_url: actions.insert(0, ft.TextButton(self._["open_live_room_page"], on_click=open_in_browser)) @@ -82,7 +81,7 @@ async def open_in_browser(_): video_container = ft.Container( content=video, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, width=video_width, height=video_height, ) @@ -91,18 +90,15 @@ async def open_in_browser(_): modal=True, title=ft.Text(title, overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, size=14), content=ft.Column( - [ - video_container, - actions_row - ], + [video_container, actions_row], spacing=5, alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, tight=True, ), actions=[], - inset_padding=ft.padding.only(left=10, right=10, top=5, bottom=5), - content_padding=ft.padding.only(left=5, right=5, top=5, bottom=0), + inset_padding=ft.Padding.only(left=10, right=10, top=5, bottom=5), + content_padding=ft.Padding.only(left=5, right=5, top=5, bottom=0), ) else: dialog = ft.AlertDialog( @@ -110,12 +106,47 @@ async def open_in_browser(_): title=ft.Text(title), content=video, actions=actions, - actions_alignment=ft.MainAxisAlignment.END + actions_alignment=ft.MainAxisAlignment.END, ) dialog.open = True self.app.dialog_area.content = dialog self.app.dialog_area.update() + async def _take_screenshot(self, video: ftv.Video, video_source: str, is_file_path: bool): + try: + image_bytes = await video.take_screenshot(format="image/png") + except Exception as e: + logger.error(f"Failed to take screenshot: {e}") + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + if not image_bytes: + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + try: + if is_file_path and video_source and os.path.isfile(video_source): + screenshot_dir = os.path.join(os.path.dirname(os.path.abspath(video_source)), "screenshots") + base_name = Path(video_source).stem + else: + save_root = self.app.settings.get_video_save_path() + screenshot_dir = os.path.join(save_root, "screenshots") + base_name = "screenshot" + os.makedirs(screenshot_dir, exist_ok=True) + filename = f"{base_name}_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.png" + file_path = os.path.join(screenshot_dir, filename) + with open(file_path, "wb") as f: + f.write(image_bytes) + except Exception as e: + logger.error(f"Failed to save screenshot: {e}") + await self.app.snack_bar.show_snack_bar(self._["screenshot_failed"]) + return + + logger.info(f"Screenshot saved: {file_path}") + await self.app.snack_bar.show_snack_bar( + f"{self._['screenshot_success']}", bgcolor=ft.Colors.PRIMARY, duration=3000 + ) + async def preview_video(self, source: str, is_file_path: bool = True, room_url: str | None = None): """ Preview video @@ -127,14 +158,15 @@ async def preview_video(self, source: str, is_file_path: bool = True, room_url: 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)) + 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] + 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) if Path(filename).suffix.lower() != ".mp4": @@ -142,4 +174,4 @@ async def preview_video(self, source: str, is_file_path: bool = True, room_url: return else: title = self._["view_stream_source_now"] - await self.create_video_dialog(title, source, is_file_path, room_url) \ No newline at end of file + await self.create_video_dialog(title, source, is_file_path, room_url) diff --git a/app/ui/components/common/save_progress_overlay.py b/app/ui/components/common/save_progress_overlay.py index e9a66813..0d11b7dc 100644 --- a/app/ui/components/common/save_progress_overlay.py +++ b/app/ui/components/common/save_progress_overlay.py @@ -7,7 +7,7 @@ def __init__(self, app): self._ = {} self.app.language_manager.add_observer(self) self.load() - + self.message_text = None self.cancel_button = None self.warning_text = None @@ -16,160 +16,142 @@ def __init__(self, app): self.content_container = None self.simple_container = None self.overlay = ft.Stack([], visible=False) - + self.is_cancellable = False self.is_simple_mode = False self._initialized = False - + self._overlay_container = ft.Container() + def _initialize_components(self): if self._initialized: return - + self.message_text = ft.Text( - self._["saving_recording"], - size=18, + self._["saving_recording"], + size=18, weight=ft.FontWeight.W_500, - color=ft.colors.WHITE, - text_align=ft.TextAlign.CENTER + color=ft.Colors.WHITE, + text_align=ft.TextAlign.CENTER, ) - - self.cancel_button = ft.ElevatedButton( - text=f"😾 {self._['force_close']}", + + self.cancel_button = ft.Button( + content=f"😾 {self._['force_close']}", on_click=self._on_force_close, style=ft.ButtonStyle( - color=ft.colors.WHITE, + color=ft.Colors.WHITE, bgcolor="#FF5252", shape=ft.RoundedRectangleBorder(radius=8), elevation=0, - padding=ft.padding.symmetric(horizontal=20, vertical=10), + padding=ft.Padding.symmetric(horizontal=20, vertical=10), ), tooltip=self._["force_close_tooltip"], - visible=False + visible=False, ) - + self.warning_text = ft.Text( self._["force_close_warning"], size=12, - color=ft.colors.with_opacity(0.7, ft.colors.WHITE), + color=ft.Colors.with_opacity(0.7, ft.Colors.WHITE), text_align=ft.TextAlign.CENTER, - visible=False - ) - - self.progress_ring = ft.ProgressRing( - width=60, - height=60, - stroke_width=4, - color="#2196F3", - value=0.7 - ) - - self.simple_progress_ring = ft.ProgressRing( - width=50, - height=50, - stroke_width=3, - color="#2196F3", - value=0.8 + visible=False, ) - + + self.progress_ring = ft.ProgressRing(width=60, height=60, stroke_width=4, color="#2196F3", value=0.7) + + self.simple_progress_ring = ft.ProgressRing(width=50, height=50, stroke_width=3, color="#2196F3", value=0.8) + self.content_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.progress_ring, - margin=ft.margin.only(bottom=20) - ), + ft.Container(content=self.progress_ring, margin=ft.Margin.only(bottom=20)), self.message_text, ft.Container(height=25), self.cancel_button, ft.Container(height=8), - self.warning_text + self.warning_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=400, height=280, - padding=ft.padding.all(30), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + padding=ft.Padding.all(30), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - + self.simple_container = ft.Container( content=ft.Column( [ - ft.Container( - content=self.simple_progress_ring, - margin=ft.margin.only(bottom=15) - ), + ft.Container(content=self.simple_progress_ring, margin=ft.Margin.only(bottom=15)), self.message_text, ], alignment=ft.MainAxisAlignment.CENTER, horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=0 + spacing=0, ), width=300, height=180, - padding=ft.padding.all(25), - alignment=ft.alignment.center, - bgcolor=ft.colors.with_opacity(0.95, "#212121"), + padding=ft.Padding.all(25), + alignment=ft.alignment.Alignment.CENTER, + bgcolor=ft.Colors.with_opacity(0.95, "#212121"), border_radius=16, shadow=ft.BoxShadow( spread_radius=0, blur_radius=24, - color=ft.colors.with_opacity(0.5, ft.colors.BLACK), - offset=ft.Offset(0, 4) + color=ft.Colors.with_opacity(0.5, ft.Colors.BLACK), + offset=ft.Offset(0, 4), ), ) - - self.overlay.controls = [ - ft.Container( - content=self.content_container, - alignment=ft.alignment.center, - expand=True, - bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK), - animate_opacity=300, - ) - ] - + + self._overlay_container = ft.Container( + content=self.content_container, + alignment=ft.alignment.Alignment.CENTER, + expand=True, + bgcolor=ft.Colors.with_opacity(0.7, ft.Colors.BLACK), + animate_opacity=300, + ) + self.overlay.controls = [self._overlay_container] + self._initialized = True - - def _on_force_close(self, e): + + async def _on_force_close(self, e): self.message_text.value = self._["force_closing"] self.cancel_button.visible = False self.warning_text.visible = False self.overlay.update() - self.app.page.window.destroy() + await self.app.page.window.destroy() def show(self, message=None, cancellable=False): self._initialize_components() - + if message: self.message_text.value = message else: self.message_text.value = self._["saving_recording"] - + self.is_cancellable = cancellable - + if cancellable: self.is_simple_mode = False - self.overlay.controls[0].content = self.content_container + self._overlay_container.content = self.content_container self.cancel_button.visible = True self.warning_text.visible = True else: self.is_simple_mode = True - self.overlay.controls[0].content = self.simple_container + self._overlay_container.content = self.simple_container self.cancel_button.visible = False self.warning_text.visible = False - + self.overlay.visible = True self.overlay.update() @@ -177,11 +159,11 @@ def update_message(self, message): if self._initialized: self.message_text.value = message self.message_text.update() - + def show_cancel_button(self): if not self._initialized: return - + if not self.cancel_button.visible and not self.is_simple_mode: self.cancel_button.visible = True self.warning_text.visible = True @@ -197,4 +179,4 @@ def load(self): @property def visible(self): - return self.overlay.visible \ No newline at end of file + return self.overlay.visible diff --git a/app/ui/components/common/show_snackbar.py b/app/ui/components/common/show_snackbar.py index ff738900..2d72759b 100644 --- a/app/ui/components/common/show_snackbar.py +++ b/app/ui/components/common/show_snackbar.py @@ -5,37 +5,34 @@ class ShowSnackBar: def __init__(self, app): self.app = app - async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None, emoji=None, - show_close_icon=False): + async def show_snack_bar( + self, message, bgcolor=None, duration=1500, action=None, emoji=None, show_close_icon=False + ): """Helper method to show a snack bar with optional emoji.""" message_row = ft.Row( controls=[ - ft.Icon(name=ft.icons.NOTIFICATIONS, color=ft.colors.SURFACE_VARIANT, size=20) if not emoji else - ft.Text(emoji, size=20), - ft.Text(message, size=14), + ft.Icon(icon=ft.Icons.NOTIFICATIONS, color=ft.Colors.ON_SURFACE_VARIANT, size=18) + if not emoji + else ft.Text(emoji, size=20, no_wrap=False), + ft.Text(message, size=14, no_wrap=False), ], spacing=10, - vertical_alignment=ft.CrossAxisAlignment.CENTER + vertical_alignment=ft.CrossAxisAlignment.CENTER, + wrap=True, + width=235 if show_close_icon else 285, + auto_scroll=True, ) snack_bar = ft.SnackBar( content=ft.Container( content=ft.Row( - controls=[ - message_row, - ft.IconButton( - icon=ft.icons.CLOSE, - icon_size=16, - on_click=lambda e: setattr(snack_bar, "open", False), - visible=show_close_icon - ) - ], + controls=[message_row], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER + vertical_alignment=ft.CrossAxisAlignment.CENTER, ), padding=10, - border_radius=8 + border_radius=8, ), behavior=ft.SnackBarBehavior.FLOATING, action=action, @@ -48,8 +45,9 @@ async def show_snack_bar(self, message, bgcolor=None, duration=1500, action=None snack_bar.bgcolor = "#F5F5F5" if not self.app.is_mobile: - snack_bar.margin = ft.margin.only(left=self.app.page.width - 300, top=0, right=10, bottom=10) + snack_bar_width = 350 + snack_bar.margin = ft.Margin.only(left=self.app.page.width - snack_bar_width, top=0, right=10, bottom=10) snack_bar.open = True self.app.snack_bar_area.content = snack_bar - self.app.page.update() \ No newline at end of file + self.app.page.update() diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index 46a64f95..b1bce328 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -1,72 +1,78 @@ -import flet as ft - -from ....messages.message_pusher import MessagePusher - - -class CardDialog(ft.AlertDialog): - def __init__(self, app, recording): - self.app = app - self._ = {} - self.load() - super().__init__( - title=ft.Text(self._["recording_info"]), - content=self.get_content(recording), - actions=[ - ft.TextButton(self._["close"], on_click=self.close_panel), - ], - actions_alignment=ft.MainAxisAlignment.END, - modal=False, - ) - - def load(self): - language = self.app.language_manager.language - for key in ("recording_card", "recording_manager", "base", "video_quality", "recording_dialog"): - self._.update(language.get(key, {})) - - def get_content(self, recording): - """Record card information content""" - anchor_name = recording.streamer_name - platform_name = recording.platform - live_link = recording.url - live_title = recording.live_title or self._["none"] - record_format = recording.record_format - quality_info = self._[recording.quality] - use_proxy = self._["yes"] if recording.use_proxy else self._["no"] - segment_record_status = self._["enabled"] if recording.segment_record else self._["disabled"] - segment_time = f"{recording.segment_time}{self._['seconds']}" - monitor_status = self._["enabled"] if recording.monitor_status else self._["disabled"] - scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"] - scheduled_time_range = recording.scheduled_time_range or self._["none"] - save_path = recording.recording_dir or self._["no_recording_dir_tip"] - recording_status_info = self._[recording.status_info] - should_push_message = MessagePusher.should_push_message(self.app.settings, recording) - message_push = self._["enabled"] if should_push_message else self._["disabled"] - only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] - - dialog_content = ft.Column( - [ - ft.Text(f"{self._['anchor_name']}: {anchor_name}", size=14, selectable=True), - ft.Text(f"{self._['platform_name']}: {platform_name}", size=14, selectable=True), - ft.Text(f"{self._['live_link']}: {live_link}", size=14, selectable=True), - ft.Text(f"{self._['live_title']}: {live_title}", size=14, selectable=True), - ft.Text(f"{self._['record_format']}: {record_format}", size=14), - ft.Text(f"{self._['record_quality']}: {quality_info}", size=14), - ft.Text(f"{self._['use_proxy']}: {use_proxy}", size=14), - ft.Text(f"{self._['segment_record']}: {segment_record_status}", size=14), - ft.Text(f"{self._['segment_time']}: {segment_time}", size=14), - ft.Text(f"{self._['monitor_status']}: {monitor_status}", size=14), - ft.Text(f"{self._['scheduled_recording']}: {scheduled_recording_status}", size=14), - ft.Text(f"{self._['scheduled_time_range']}: {scheduled_time_range}", size=14), - ft.Text(f"{self._['message_push']}: {message_push}", size=14), - ft.Text(f"{self._['only_notify_no_record']}: {only_notify_no_record}", size=14), - ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True), - ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14), - ], - spacing=8, - scroll=ft.ScrollMode.AUTO, - ) - return dialog_content - - def close_panel(self, _): - self.open = False - self.update() +import flet as ft + +from ....messages.message_pusher import MessagePusher +from ....models.recording.recording_status_model import RecordingStatus + + +class CardDialog(ft.AlertDialog): + def __init__(self, app, recording): + self.app = app + self._ = {} + self.load() + super().__init__( + title=ft.Text(self._["recording_info"]), + content=self.get_content(recording), + actions=[ + ft.TextButton(self._["close"], on_click=self.close_panel), + ], + actions_alignment=ft.MainAxisAlignment.END, + modal=False, + ) + + def load(self): + language = self.app.language_manager.language + for key in ("recording_card", "recording_manager", "base", "video_quality", "recording_dialog"): + self._.update(language.get(key, {})) + + def get_content(self, recording): + """Record card information content""" + anchor_name = recording.streamer_name + platform_name = recording.platform + live_link = recording.url + live_title = recording.live_title or self._["none"] + record_format = recording.record_format + quality_info = self._[recording.quality] + video_bitrate = recording.video_bitrate or self._["none"] + use_proxy = self._["yes"] if recording.use_proxy else self._["no"] + segment_record_status = self._["enabled"] if recording.segment_record else self._["disabled"] + segment_time = f"{recording.segment_time}{self._['seconds']}" + monitor_status = self._["enabled"] if recording.monitor_status else self._["disabled"] + scheduled_recording_status = self._["enabled"] if recording.scheduled_recording else self._["disabled"] + scheduled_time_range = recording.scheduled_time_range or self._["none"] + save_path = recording.recording_dir or self._["no_recording_dir_tip"] + status_info = RecordingStatus.MONITORING if recording.monitor_status else RecordingStatus.STOPPED_MONITORING + recording_status_info = self._[recording.status_info or status_info] + should_push_message = MessagePusher.should_push_message(self.app.settings, recording, message_type="other") + message_push = self._["enabled"] if should_push_message else self._["disabled"] + if not should_push_message and recording.enabled_message_push: + message_push = self._["disabled"] + f" ({self._['not_config_tip']})" + only_notify_no_record = self._["enabled"] if recording.only_notify_no_record else self._["disabled"] + + dialog_content = ft.Column( + [ + ft.Text(f"{self._['anchor_name']}: {anchor_name}", size=14, selectable=True), + ft.Text(f"{self._['platform_name']}: {platform_name}", size=14, selectable=True), + ft.Text(f"{self._['live_link']}: {live_link}", size=14, selectable=True), + ft.Text(f"{self._['live_title']}: {live_title}", size=14, selectable=True), + ft.Text(f"{self._['record_format']}: {record_format}", size=14), + ft.Text(f"{self._['record_quality']}: {quality_info}", size=14), + ft.Text(f"{self._['custom_video_bitrate']}: {video_bitrate}", size=14), + ft.Text(f"{self._['use_proxy']}: {use_proxy}", size=14), + ft.Text(f"{self._['segment_record']}: {segment_record_status}", size=14), + ft.Text(f"{self._['segment_time']}: {segment_time}", size=14), + ft.Text(f"{self._['monitor_status']}: {monitor_status}", size=14), + ft.Text(f"{self._['scheduled_recording']}: {scheduled_recording_status}", size=14), + ft.Text(f"{self._['scheduled_time_range']}: {scheduled_time_range}", size=14), + ft.Text(f"{self._['message_push']}: {message_push}", size=14), + ft.Text(f"{self._['only_notify_no_record']}: {only_notify_no_record}", size=14), + ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True), + ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14), + ], + spacing=8, + scroll=ft.ScrollMode.AUTO, + ) + return dialog_content + + def close_panel(self, _): + self.open = False + self.update() diff --git a/app/ui/components/dialogs/help_dialog.py b/app/ui/components/dialogs/help_dialog.py index a0ae8565..c7efa296 100644 --- a/app/ui/components/dialogs/help_dialog.py +++ b/app/ui/components/dialogs/help_dialog.py @@ -39,4 +39,4 @@ def get_content(self): def close_panel(self, _): self.open = False - self.update() \ No newline at end of file + self.update() diff --git a/app/ui/components/dialogs/search_dialog.py b/app/ui/components/dialogs/search_dialog.py index 27eaa7ef..2a144d75 100644 --- a/app/ui/components/dialogs/search_dialog.py +++ b/app/ui/components/dialogs/search_dialog.py @@ -24,35 +24,34 @@ def __init__(self, recordings_page, on_close=None): super().__init__( title=ft.Text(search_title, size=20, weight=ft.FontWeight.BOLD), - content_padding=ft.padding.only(left=20, top=15, right=20, bottom=20), + content_padding=ft.Padding.only(left=20, top=15, right=20, bottom=20), ) self.query = ft.TextField( hint_text=self._["search_keyword"], expand=True, border_radius=5, border_color=ft.Colors.GREY_400, - focused_border_color=ft.Colors.BLUE, + focused_border_color=ft.Colors.PRIMARY, hint_style=ft.TextStyle(color=ft.Colors.GREY_500, size=14), ) self.actions = [ ft.TextButton( self._["cancel"], icon=ft.Icons.CLOSE, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.close_dlg, ), ft.TextButton( self._["sure"], icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, style=ft.ButtonStyle(shape=ft.RoundedRectangleBorder(radius=5)), on_click=self.submit_query, ), ] self.content = ft.Column( - [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], - tight=True, - width=400, - height=300 + [self.query, ft.Divider(height=1, thickness=1, color=ft.Colors.GREY_300)], tight=True, width=400, height=300 ) self.actions_alignment = ft.MainAxisAlignment.END self.on_close = on_close @@ -62,7 +61,7 @@ def load(self): language = self.recordings_page.app.language_manager.language for key in ("search_dialog", "recordings_page", "base"): self._.update(language.get(key, {})) - + # Ensure the language items related to filtering exist if "filter_all" not in self._: self._["filter_all"] = self._["all"] @@ -78,4 +77,4 @@ async def close_dlg(self, _e): async def submit_query(self, e): query = self.query.value.strip() await self.recordings_page.filter_recordings(query) - await self.close_dlg(e) \ No newline at end of file + await self.close_dlg(e) diff --git a/app/ui/components/state/recording_card_state.py b/app/ui/components/state/recording_card_state.py index 82e84cf2..4f08e6bf 100644 --- a/app/ui/components/state/recording_card_state.py +++ b/app/ui/components/state/recording_card_state.py @@ -5,25 +5,28 @@ class RecordingCardState: - ERROR_STATUSES = [RecordingStatus.RECORDING_ERROR, RecordingStatus.LIVE_STATUS_CHECK_ERROR] - + @staticmethod def get_card_state(recording: Recording) -> CardStateType: if recording.is_recording: return CardStateType.RECORDING elif recording.status_info in RecordingCardState.ERROR_STATUSES: return CardStateType.ERROR + elif recording.is_checking: + return CardStateType.CHECKING elif recording.is_live and recording.monitor_status and not recording.is_recording: return CardStateType.LIVE - elif (not recording.is_live and recording.monitor_status and - recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif ( + not recording.is_live + and recording.monitor_status + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ): return CardStateType.OFFLINE - elif (not recording.monitor_status or - recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK): + elif not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK: return CardStateType.STOPPED return CardStateType.UNKNOWN - + @staticmethod def get_border_color(recording: Recording) -> ft.Colors: state = RecordingCardState.get_card_state(recording) @@ -32,14 +35,15 @@ def get_border_color(recording: Recording) -> ft.Colors: CardStateType.ERROR: ft.Colors.RED, CardStateType.LIVE: ft.Colors.BLUE, CardStateType.OFFLINE: ft.Colors.AMBER, - CardStateType.STOPPED: ft.Colors.GREY, + CardStateType.STOPPED: ft.Colors.GREY_200, + CardStateType.CHECKING: ft.Colors.PURPLE, } return color_map.get(state, ft.Colors.TRANSPARENT) - + @staticmethod def get_status_label_config(recording: Recording, language_dict: dict) -> dict: state = RecordingCardState.get_card_state(recording) - + configs = { CardStateType.RECORDING: { "text": language_dict.get("recording"), @@ -66,25 +70,30 @@ def get_status_label_config(recording: Recording, language_dict: dict) -> dict: "bgcolor": ft.Colors.GREY, "text_color": ft.Colors.WHITE, }, + CardStateType.CHECKING: { + "text": language_dict.get("checking"), + "bgcolor": ft.Colors.PURPLE, + "text_color": ft.Colors.WHITE, + }, } - + return configs.get(state, {}) - + @staticmethod def get_display_title(recording: Recording, language_dict: dict) -> str: status_prefix = "" if not recording.monitor_status: status_prefix = f"[{language_dict.get('monitor_stopped')}] " return f"{status_prefix}{recording.title}" - + @staticmethod def get_title_weight(recording: Recording) -> ft.FontWeight: - return ft.FontWeight.BOLD if recording.is_recording or recording.is_live else None - + return ft.FontWeight.BOLD if recording.is_recording or recording.is_live or recording.is_checking else None + @staticmethod - def get_recording_icon(recording: Recording) -> ft.Icons: + def get_recording_icon(recording: Recording) -> ft.IconData: return ft.Icons.STOP_CIRCLE if recording.is_recording else ft.Icons.PLAY_CIRCLE - + @staticmethod - def get_monitor_icon(recording: Recording) -> ft.Icons: + def get_monitor_icon(recording: Recording) -> ft.IconData: return ft.Icons.VISIBILITY if recording.monitor_status else ft.Icons.VISIBILITY_OFF diff --git a/app/ui/filters/__init__.py b/app/ui/filters/__init__.py index ee5f5a9e..4d0abaec 100644 --- a/app/ui/filters/__init__.py +++ b/app/ui/filters/__init__.py @@ -1,3 +1,3 @@ from .recording_filters import RecordingFilters -__all__ = ["RecordingFilters"] \ No newline at end of file +__all__ = ["RecordingFilters"] diff --git a/app/ui/filters/recording_filters.py b/app/ui/filters/recording_filters.py index 9f7f4531..20ee6444 100644 --- a/app/ui/filters/recording_filters.py +++ b/app/ui/filters/recording_filters.py @@ -3,30 +3,32 @@ class RecordingFilters: - @staticmethod def _is_error_status(recording) -> bool: return recording.status_info in RecordingCardState.ERROR_STATUSES - + @staticmethod def _is_live_status(recording) -> bool: - return (recording.is_live - and recording.monitor_status - and not recording.is_recording - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + recording.is_live + and recording.monitor_status + and not recording.is_recording + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_offline_status(recording) -> bool: - return (not recording.is_live - and recording.monitor_status - and recording.status_info not in RecordingCardState.ERROR_STATUSES - and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK) - + return ( + not recording.is_live + and recording.monitor_status + and recording.status_info not in RecordingCardState.ERROR_STATUSES + and recording.status_info != RecordingStatus.NOT_IN_SCHEDULED_CHECK + ) + @staticmethod def _is_stopped_status(recording) -> bool: - return (not recording.monitor_status - or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK) + return not recording.monitor_status or recording.status_info == RecordingStatus.NOT_IN_SCHEDULED_CHECK STATUS_FILTER_MAP = { "all": lambda rec: True, @@ -34,7 +36,7 @@ def _is_stopped_status(recording) -> bool: "living": lambda rec: RecordingFilters._is_live_status(rec), "error": lambda rec: RecordingFilters._is_error_status(rec), "offline": lambda rec: RecordingFilters._is_offline_status(rec), - "stopped": lambda rec: RecordingFilters._is_stopped_status(rec) + "stopped": lambda rec: RecordingFilters._is_stopped_status(rec), } @classmethod diff --git a/app/ui/layout/__init__.py b/app/ui/layout/__init__.py index 0519ecba..e69de29b 100644 --- a/app/ui/layout/__init__.py +++ b/app/ui/layout/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/ui/layout/responsive_layout.py b/app/ui/layout/responsive_layout.py index 45066882..b94a9d8c 100644 --- a/app/ui/layout/responsive_layout.py +++ b/app/ui/layout/responsive_layout.py @@ -10,13 +10,13 @@ def is_mobile_device(page: ft.Page) -> bool: def setup_responsive_layout(page: ft.Page, app: App) -> None: _ = app.language_manager.language.get("sidebar", {}) - + if is_mobile_device(page): logger.info("mobile device detected, enable mobile layout") app.is_mobile = True app.left_navigation_menu.width = 0 app.left_navigation_menu.visible = False - + app.bottom_navigation = ft.NavigationBar( destinations=[ ft.NavigationBarDestination(icon=ft.Icons.HOME, label=_["home"]), @@ -26,20 +26,16 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: ft.NavigationBarDestination(icon=ft.Icons.INFO, label=_["about"]), ], on_change=lambda e: page.go( - f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}"), + f"/{['home', 'recordings', 'settings', 'storage', 'about'][e.control.selected_index]}" + ), ) - + app.content_area.expand = True - + app.complete_page = ft.Column( expand=True, spacing=0, - controls=[ - app.content_area, - app.bottom_navigation, - app.dialog_area, - app.snack_bar_area - ] + controls=[app.content_area, app.bottom_navigation, app.dialog_area, app.snack_bar_area], ) else: logger.info("desktop device detected, enable desktop layout") @@ -52,5 +48,5 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: app.content_area, app.dialog_area, app.snack_bar_area, - ] - ) \ No newline at end of file + ], + ) diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 92205947..4dac7638 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -1,221 +1,221 @@ -import flet as ft - -from ..themes import PopupColorItem, ThemeManager - - -class ControlGroup: - def __init__(self, icon, label, index, name, selected_icon): - self.icon = icon - self.label = label - self.index = index - self.name = name - self.selected_icon = selected_icon - - -class NavigationItem(ft.Container): - def __init__(self, destination, item_clicked): - super().__init__() - self.ink = True - self.padding = 10 - self.border_radius = 5 - self.destination = destination - self.icon = destination.icon - self.text = destination.label - self.content = ft.Row([ft.Icon(self.icon), ft.Text(self.text)]) - self.on_click = lambda e: item_clicked(e) - - -class NavigationColumn(ft.Column): - def __init__(self, sidebar, page, app): - super().__init__() - self.expand = 4 - self.spacing = 0 - self.scroll = ft.ScrollMode.ALWAYS - self.sidebar = sidebar - self.selected_index = 0 - self.page = page - self.app = app - self.controls = self.get_navigation_items() - - def get_navigation_items(self): - return [ - NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups - ] - - def item_clicked(self, e): - self.selected_index = e.control.destination.index - self.update_selected_item() - self.page.go(f"/{e.control.destination.name}") - - def update_selected_item(self): - for item in self.controls: - item.bgcolor = None - item.content.controls[0].icon = item.destination.icon - if 0 <= self.selected_index < len(self.controls): - self.controls[self.selected_index].bgcolor = ft.Colors.SECONDARY_CONTAINER - self.controls[self.selected_index].content.controls[0].icon = self.controls[ - self.selected_index - ].destination.selected_icon - - -class LeftNavigationMenu(ft.Column): - def __init__(self, app): - super().__init__() - self.app = app - self.sidebar = app.sidebar - self.page = app.page - self.rail = None - self.dark_light_text = None - self.dark_light_icon = None - self.bottom_controls = None - self.first_run = True - self.theme_manager = ThemeManager(self.app) - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - self._ = self.app.language_manager.language.get("sidebar") - self.rail = NavigationColumn(sidebar=self.sidebar, page=self.page, app=self.app) - - if self.page.theme_mode == ft.ThemeMode.DARK: - self.dark_light_text = ft.Text(self._["dark_theme"]) - self.dark_light_icon = ft.IconButton( - icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, - tooltip=self._["toggle_day_theme"], - on_click=self.theme_changed, - ) - else: - self.dark_light_text = ft.Text(self._["light_theme"]) - self.dark_light_icon = ft.IconButton( - icon=ft.Icons.BRIGHTNESS_2_OUTLINED, - tooltip=self._["toggle_night_theme"], - on_click=self.theme_changed, - ) - - colors_list = [ - ("deeppurple", "Deep purple"), - ("purple", "Purple"), - ("indigo", "Indigo"), - ("blue", "Blue"), - ("teal", "Teal"), - ("deeporange", "Deep orange"), - ("orange", "Orange"), - ("pink", "Pink"), - ("brown", "Brown"), - ("bluegrey", "Blue Grey"), - ("green", "Green"), - ("cyan", "Cyan"), - ("lightblue", "Light Blue"), - ("", "Default"), - ] - - self.bottom_controls = ft.Column( - controls=[ - ft.Row( - controls=[ - self.dark_light_icon, - self.dark_light_text, - ], - alignment=ft.MainAxisAlignment.START, - ), - ft.Row( - controls=[ - ft.PopupMenuButton( - icon=ft.Icons.COLOR_LENS_OUTLINED, - tooltip=self._["colors"], - items=[PopupColorItem(color=color, name=name) for color, name in colors_list], - ), - ft.Text(self._["theme_color"]), - ], - alignment=ft.MainAxisAlignment.START, - ), - ], - alignment=ft.MainAxisAlignment.END, - ) - - self.controls = [ - self.rail, - ft.Container(expand=True), - self.bottom_controls, - ] - - self.width = 160 - self.spacing = 0 - self.alignment = ft.MainAxisAlignment.START - - async def theme_changed(self, _): - page = self.app.page - self._ = self.app.language_manager.language.get("sidebar") - if page.theme_mode == ft.ThemeMode.LIGHT: - page.theme_mode = ft.ThemeMode.DARK - self.dark_light_text.value = self._["dark_theme"] - self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_HIGH_OUTLINED - self.dark_light_icon.tooltip = self._["toggle_day_theme"] - self.app.settings.user_config["theme_mode"] = "dark" - else: - page.theme_mode = ft.ThemeMode.LIGHT - self.dark_light_text.value = self._["light_theme"] - self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED - self.dark_light_icon.tooltip = self._["toggle_night_theme"] - self.app.settings.user_config["theme_mode"] = "light" - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) - await self.on_theme_change() - page.update() - - async def on_theme_change(self): - """When the theme changes, recreate the content and update the page""" - page_list = ["home", "about"] - if self.app.current_page.page_name in page_list: - await self.app.current_page.load() - - -class NavigationSidebar: - def __init__(self, app): - self.app = app - self.control_groups = [] - self.selected_control_group = None - self.app.language_manager.add_observer(self) - self._ = {} - self.load() - - def load(self): - self._ = self.app.language_manager.language.get("sidebar") - self.control_groups = [ - ControlGroup( - icon=ft.Icons.HOME, - label=self._["home"], - index=0, - name="home", - selected_icon=ft.Icons.HOME - ), - ControlGroup( - icon=ft.Icons.DASHBOARD, - label=self._["recordings"], - index=1, - name="recordings", - selected_icon=ft.Icons.DASHBOARD_ROUNDED - ), - ControlGroup( - icon=ft.Icons.SETTINGS, - label=self._["settings"], - index=2, - name="settings", - selected_icon=ft.Icons.SETTINGS, - ), - ControlGroup( - icon=ft.Icons.DRIVE_FILE_MOVE, - label=self._["storage"], - index=3, - name="storage", - selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE - ), - ControlGroup( - icon=ft.Icons.INFO, - label=self._["about"], - index=4, - name="about", - selected_icon=ft.Icons.INFO - ), - ] - self.selected_control_group = self.control_groups[0] +from collections.abc import Callable +from typing import cast + +import flet as ft + +from ..themes import ThemeManager + + +class ControlGroup: + def __init__(self, icon, label: str, index: int, name: str, selected_icon): + self.icon = icon + self.label = label + self.index = index + self.name = name + self.selected_icon = selected_icon + + +class NavigationItem(ft.Container): + def __init__(self, destination: ControlGroup, item_clicked: Callable[[ft.Event[ft.Container]], None]): + super().__init__() + self.ink = True + self.padding = ft.Padding.symmetric(horizontal=12, vertical=10) + self.margin = ft.Margin.symmetric(horizontal=8, vertical=2) + self.border_radius = 12 + self.destination = destination + self.icon = destination.icon + self.text = destination.label + self.icon_control = ft.Icon(destination.icon, color=ft.Colors.PRIMARY, size=21) + self.label_control = ft.Text(destination.label, color=ft.Colors.PRIMARY, size=14) + self.content = ft.Row([self.icon_control, self.label_control], spacing=12) + self.on_click = item_clicked + + +class NavigationColumn(ft.Column): + def __init__(self, sidebar, page, app): + super().__init__() + self.expand = 4 + self.spacing = 0 + self.scroll = ft.ScrollMode.ALWAYS + self.sidebar = sidebar + self.selected_index = 0 + self.flet_page = page + self.app = app + self.navigation_items = self.get_navigation_items() + self.controls = cast(list[ft.Control], self.navigation_items) + current_page = getattr(self.app, "current_page", None) + if current_page is not None: + self.select_page(current_page.page_name) + else: + self.update_selected_item() + + def get_navigation_items(self) -> list[NavigationItem]: + return [ + NavigationItem(destination, item_clicked=self.item_clicked) for destination in self.sidebar.control_groups + ] + + def item_clicked(self, e: ft.Event[ft.Container]): + control = cast(NavigationItem, e.control) + self.selected_index = control.destination.index + self.update_selected_item() + self.flet_page.go(f"/{control.destination.name}") + + def update_selected_item(self): + for item in self.navigation_items: + item.bgcolor = None + item.icon_control.icon = item.destination.icon + item.icon_control.color = ft.Colors.PRIMARY + item.label_control.color = ft.Colors.PRIMARY + item.label_control.weight = None + if 0 <= self.selected_index < len(self.navigation_items): + selected_item = self.navigation_items[self.selected_index] + selected_item.bgcolor = ft.Colors.SECONDARY_CONTAINER + selected_item.icon_control.icon = selected_item.destination.selected_icon + selected_item.icon_control.color = ft.Colors.PRIMARY + selected_item.label_control.color = ft.Colors.PRIMARY + selected_item.label_control.weight = ft.FontWeight.W_600 + + def select_page(self, page_name: str): + for item in self.navigation_items: + if item.destination.name == page_name: + self.selected_index = item.destination.index + break + self.update_selected_item() + + +class LeftNavigationMenu(ft.Column): + def __init__(self, app): + super().__init__() + self.app = app + self.sidebar = app.sidebar + self.flet_page = app.page + self.rail = None + self.dark_light_text = None + self.dark_light_icon = None + self.bottom_controls = None + self.first_run = True + self.theme_manager = ThemeManager(self.app) + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + self._ = self.app.language_manager.language.get("sidebar") + self.rail = NavigationColumn(sidebar=self.sidebar, page=self.flet_page, app=self.app) + + brand_header = ft.Container( + content=ft.Row( + controls=[ + ft.Image(src="iframe.php?url=https%3A%2F%2Fgithub.com%2Fimages%2Flogo-mark.svg", width=38, height=38, fit=ft.BoxFit.CONTAIN), + ft.Text("StreamCap", size=18, weight=ft.FontWeight.BOLD), + ], + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.Padding.only(left=14, right=12, top=18, bottom=18), + ) + + if self.flet_page.theme_mode == ft.ThemeMode.DARK: + self.dark_light_text = ft.Text(self._["dark_theme"], color=ft.Colors.PRIMARY) + self.dark_light_icon = ft.IconButton( + icon=ft.Icons.BRIGHTNESS_HIGH_OUTLINED, + tooltip=self._["toggle_day_theme"], + on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, + ) + else: + self.dark_light_text = ft.Text(self._["light_theme"], color=ft.Colors.PRIMARY) + self.dark_light_icon = ft.IconButton( + icon=ft.Icons.BRIGHTNESS_2_OUTLINED, + tooltip=self._["toggle_night_theme"], + on_click=self.theme_changed, + icon_color=ft.Colors.PRIMARY, + ) + + self.bottom_controls = ft.Column( + controls=[ + ft.Row( + controls=[ + self.dark_light_icon, + self.dark_light_text, + ], + alignment=ft.MainAxisAlignment.START, + ), + ], + alignment=ft.MainAxisAlignment.END, + ) + + self.controls = [ + brand_header, + self.rail, + ft.Container(expand=True), + ft.Container(content=self.bottom_controls, padding=ft.Padding.only(left=6, bottom=12)), + ] + + self.width = 192 + self.spacing = 0 + self.alignment = ft.MainAxisAlignment.START + + async def theme_changed(self, _): + page = self.app.page + self._ = self.app.language_manager.language.get("sidebar") + if page.theme_mode == ft.ThemeMode.LIGHT: + page.theme_mode = ft.ThemeMode.DARK + self.dark_light_text.value = self._["dark_theme"] + self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_HIGH_OUTLINED + self.dark_light_icon.tooltip = self._["toggle_day_theme"] + self.app.settings.user_config["theme_mode"] = "dark" + else: + page.theme_mode = ft.ThemeMode.LIGHT + self.dark_light_text.value = self._["light_theme"] + self.dark_light_icon.icon = ft.Icons.BRIGHTNESS_2_OUTLINED + self.dark_light_icon.tooltip = self._["toggle_night_theme"] + self.app.settings.user_config["theme_mode"] = "light" + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) + await self.on_theme_change() + page.update() + + async def on_theme_change(self): + """When the theme changes, recreate the content and update the page""" + page_list = ["home", "about"] + if self.app.current_page.page_name in page_list: + await self.app.current_page.load() + + +class NavigationSidebar: + def __init__(self, app): + self.app = app + self.control_groups = [] + self.selected_control_group = None + self.app.language_manager.add_observer(self) + self._ = {} + self.load() + + def load(self): + self._ = self.app.language_manager.language.get("sidebar") + self.control_groups = [ + ControlGroup(icon=ft.Icons.HOME, label=self._["home"], index=0, name="home", selected_icon=ft.Icons.HOME), + ControlGroup( + icon=ft.Icons.DASHBOARD, + label=self._["recordings"], + index=1, + name="recordings", + selected_icon=ft.Icons.DASHBOARD_ROUNDED, + ), + ControlGroup( + icon=ft.Icons.SETTINGS, + label=self._["settings"], + index=2, + name="settings", + selected_icon=ft.Icons.SETTINGS, + ), + ControlGroup( + icon=ft.Icons.DRIVE_FILE_MOVE, + label=self._["storage"], + index=3, + name="storage", + selected_icon=ft.Icons.DRIVE_FILE_MOVE_OUTLINE, + ), + ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=4, name="about", selected_icon=ft.Icons.INFO), + ] + self.selected_control_group = self.control_groups[0] diff --git a/app/ui/themes/theme.py b/app/ui/themes/theme.py index 34af219d..1f4efcda 100644 --- a/app/ui/themes/theme.py +++ b/app/ui/themes/theme.py @@ -6,8 +6,8 @@ def __init__(self, color, name): super().__init__() self.content = ft.Row( controls=[ - ft.Icon(name=ft.Icons.COLOR_LENS_OUTLINED, color=color), - ft.Text(name), + ft.Icon(icon=ft.Icons.COLOR_LENS_OUTLINED, color=color), + ft.Text(name, color=ft.Colors.PRIMARY), ], ) self.on_click = lambda e: self.seed_color_changed(e) @@ -17,6 +17,7 @@ def seed_color_changed(self, e): page = e.page page.theme.color_scheme_seed = self.data page.theme.color_scheme = ft.ColorScheme(primary=self.data) + page.theme.use_material3 = True page.update() self.save_theme_color(e) @@ -24,11 +25,11 @@ def save_theme_color(self, e): page = e.page app = page.data app.settings.user_config["theme_color"] = self.data - page.run_task(app.config_manager.save_user_config, app.settings.user_config) + app.services.run_coro(app.config_manager.save_user_config(app.settings.user_config)) def create_light_theme(custom_font: str) -> ft.Theme: - """Define light colored theme""" + "Define light colored theme" return ft.Theme( font_family=custom_font, text_theme=ft.TextTheme( @@ -47,11 +48,12 @@ def create_light_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.BLACK, font_family=custom_font), ), + use_material3=True, ) def create_dark_theme(custom_font: str) -> ft.Theme: - """Define dark theme""" + "Define dark theme" return ft.Theme( font_family=custom_font, text_theme=ft.TextTheme( @@ -70,4 +72,5 @@ def create_dark_theme(custom_font: str) -> ft.Theme: label_medium=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), label_large=ft.TextStyle(color=ft.Colors.WHITE, font_family=custom_font), ), + use_material3=True, ) diff --git a/app/ui/themes/theme_manager.py b/app/ui/themes/theme_manager.py index 45951218..40b2ff6f 100644 --- a/app/ui/themes/theme_manager.py +++ b/app/ui/themes/theme_manager.py @@ -31,7 +31,7 @@ async def apply_initial_theme(self): """Apply initial theme based on saved settings or default to light theme.""" self.page.theme = create_light_theme(self.custom_font) self.page.dark_theme = create_dark_theme(self.custom_font) - + self.theme_color = self.app.settings.user_config.get("theme_color", "blue") await self.update_theme_color(self.theme_color) @@ -39,7 +39,8 @@ async def update_theme_color(self, color): """Update the current theme color scheme and save it.""" self.page.theme.color_scheme_seed = color self.page.theme.color_scheme = ft.ColorScheme(primary=color) + self.page.theme.use_material3 = True self.page.update() - + self.app.settings.user_config["theme_color"] = color - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) diff --git a/app/ui/views/about_view.py b/app/ui/views/about_view.py index 3b7ce913..39de8f8c 100644 --- a/app/ui/views/about_view.py +++ b/app/ui/views/about_view.py @@ -19,7 +19,7 @@ def load_language(self): async def load(self): """Load the about page content.""" - self.content_area.clean() + self.content_area.controls.clear() is_mobile = self.app.is_mobile @@ -50,10 +50,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.VIDEO_LIBRARY, color=ft.Colors.BLUE), - ft.Text(self._["support_platforms"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["support_platforms"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -62,10 +64,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.SETTINGS, color=ft.Colors.GREEN), - ft.Text(self._["customize_recording"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["customize_recording"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -74,10 +78,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.LIGHTBULB, color=ft.Colors.ORANGE), - ft.Text(self._["open_source"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["open_source"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -92,10 +98,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.AUTORENEW, color=ft.Colors.PURPLE), - ft.Text(self._["automatic_transcoding"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["automatic_transcoding"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -104,10 +112,12 @@ async def load(self): ft.Column( controls=[ ft.Icon(ft.Icons.NOTIFICATIONS_ACTIVE, color=ft.Colors.RED), - ft.Text(self._["status_push"], - size=12, - color=text_color_700, - text_align=ft.TextAlign.CENTER), + ft.Text( + self._["status_push"], + size=12, + color=text_color_700, + text_align=ft.TextAlign.CENTER, + ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, @@ -167,8 +177,8 @@ async def load(self): if is_mobile: developer_buttons = ft.Column( controls=[ - ft.ElevatedButton( - text=self._["view_update"], + ft.Button( + content=self._["view_update"], icon=ft.Icons.CODE, on_click=self.open_update_page, width=float("inf"), @@ -177,8 +187,8 @@ async def load(self): padding=10, ), ), - ft.ElevatedButton( - text=self._["view_docs"], + ft.Button( + content=self._["view_docs"], icon=ft.Icons.DESCRIPTION, on_click=self.open_dos_page, width=float("inf"), @@ -187,8 +197,8 @@ async def load(self): padding=10, ), ), - ft.ElevatedButton( - text=self.app.language_manager.language.get("update", {}).get("check_update"), + ft.Button( + content=self.app.language_manager.language.get("update", {}).get("check_update"), icon=ft.Icons.UPDATE, on_click=self._check_for_updates, width=float("inf"), @@ -260,7 +270,7 @@ async def load(self): expand=True, ), padding=20, - margin=ft.margin.symmetric(0, 10), + margin=ft.Margin.symmetric(vertical=0, horizontal=10), bgcolor=card_bg_color, border_radius=15, shadow=ft.BoxShadow( @@ -362,30 +372,31 @@ async def load(self): @staticmethod async def open_update_page(e): url = "https://github.com/ihmily/StreamCap/releases" - e.page.launch_url(url) + await e.page.launch_url(url) @staticmethod async def open_dos_page(e): url = "https://github.com/ihmily/StreamCap/wiki" - e.page.launch_url(url) + await e.page.launch_url(url) async def on_keyboard(self, e: ft.KeyboardEvent): if e.alt and e.key == "H": self.app.dialog_area.content = HelpDialog(self.app) self.app.dialog_area.content.open = True self.app.dialog_area.update() - + async def _check_for_updates(self, _): _ = self.app.language_manager.language.get("update", {}) await self.app.snack_bar.show_snack_bar(_.get("checking_update")) - + update_info = await self.app.update_checker.check_for_updates() - + if update_info.get("has_update", False): await self.app.update_checker.show_update_dialog(update_info) else: if "error" in update_info: await self.app.snack_bar.show_snack_bar( - f"{_.get('update_check_failed')}: {update_info.get('error', '')}") + f"{_.get('update_check_failed')}: {update_info.get('error', '')}" + ) else: await self.app.snack_bar.show_snack_bar(_.get("no_update_available")) diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py index db1ac3ea..a9c8fd6a 100644 --- a/app/ui/views/home_view.py +++ b/app/ui/views/home_view.py @@ -48,7 +48,7 @@ def create_home_header(self): src=logo_path, width=120, height=120, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) current_hour = datetime.now().hour @@ -63,8 +63,8 @@ def create_home_header(self): controls=[ ft.Container( content=logo, - alignment=ft.alignment.center, - margin=ft.margin.only(top=30, bottom=10), + alignment=ft.alignment.Alignment.CENTER, + margin=ft.Margin.only(top=30, bottom=10), ), ft.Text( f"{greeting},{self._['welcome']}", @@ -75,15 +75,17 @@ def create_home_header(self): ft.Text( self._["tagline"], size=18, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE70 + ), text_align=ft.TextAlign.CENTER, ), ft.Text( self._["version"] + ":" + self.app.about.about_config["version_updates"][0]["version"], size=14, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE60), + color=( + ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT else ft.Colors.WHITE60 + ), text_align=ft.TextAlign.CENTER, ), ], @@ -91,8 +93,8 @@ def create_home_header(self): alignment=ft.MainAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, - padding=ft.padding.only(bottom=20), + alignment=ft.alignment.Alignment.CENTER, + padding=ft.Padding.only(bottom=20), ) def create_quick_action_area(self): @@ -161,14 +163,14 @@ def create_quick_action_area(self): width=button_width, height=button_height, ), - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, ), ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=10, ), - alignment=ft.alignment.center, - padding=ft.padding.only(bottom=20), + alignment=ft.alignment.Alignment.CENTER, + padding=ft.Padding.only(bottom=20), ) else: return ft.Container( @@ -197,7 +199,7 @@ def create_quick_action_area(self): alignment=ft.MainAxisAlignment.CENTER, spacing=button_spacing, ), - margin=ft.margin.only(bottom=10), + margin=ft.Margin.only(bottom=10), ), ft.Container( content=ft.Row( @@ -235,13 +237,13 @@ def create_quick_action_area(self): horizontal_alignment=ft.CrossAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER, ), - alignment=ft.alignment.center, - padding=ft.padding.only(bottom=20), + alignment=ft.alignment.Alignment.CENTER, + padding=ft.Padding.only(bottom=20), ) @staticmethod - def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: callable, width=180, height=60): - return ft.ElevatedButton( + def create_action_button(text: str, icon: ft.IconData, color: ft.Colors, on_click: callable, width=180, height=60): + return ft.Button( content=ft.Row( controls=[ ft.Icon(icon, color=ft.Colors.WHITE), @@ -257,7 +259,7 @@ def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: ), style=ft.ButtonStyle( shape=ft.RoundedRectangleBorder(radius=10), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), bgcolor=color, elevation=5, ), @@ -267,7 +269,7 @@ def create_action_button(text: str, icon: ft.Icons, color: ft.Colors, on_click: ) def create_announcements_area(self): - def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft.Colors): + def create_announcement_card(title: str, content: str, icon: ft.IconData, color: ft.Colors): return ft.Card( content=ft.Container( content=ft.Column( @@ -287,18 +289,21 @@ def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft content=ft.Text( content, size=14, - color=(ft.Colors.BLACK87 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK87 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), - margin=ft.margin.only(left=34), + margin=ft.Margin.only(left=34), ), ], spacing=5, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), ), elevation=2, - margin=ft.margin.only(bottom=5), + margin=ft.Margin.only(bottom=5), ) announcement_list = self.app.about.about_config["version_updates"][0]["announcement"][self.app.language_code] @@ -336,12 +341,12 @@ def create_announcement_card(title: str, content: str, icon: ft.Icons, color: ft controls=announcements, spacing=5, ), - padding=ft.padding.only(top=5), + padding=ft.Padding.only(top=5), ), ], spacing=5, ), - padding=ft.padding.only(left=20, right=20), + padding=ft.Padding.only(left=20, right=20), ) def create_stats_area(self): @@ -361,7 +366,7 @@ def create_stat_item(title, value, icon, color): horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), border_radius=10, bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, width=150, @@ -372,15 +377,15 @@ def create_stat_item(title, value, icon, color): if total_recordings > 0: sorted_recordings = sorted( self.app.record_manager.recordings, - key=lambda r: r.last_updated if hasattr(r, 'last_updated') else 0, - reverse=True + key=lambda r: r.last_updated if hasattr(r, "last_updated") else 0, + reverse=True, )[-3:][::-1] for rec in sorted_recordings: status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREY - if hasattr(rec, 'status'): + if hasattr(rec, "status"): if rec.status == "recording": status_icon = ft.Icons.CIRCLE status_color = ft.Colors.GREEN @@ -399,7 +404,7 @@ def create_stat_item(title, value, icon, color): controls=[ ft.Icon(status_icon, color=status_color, size=16), ft.Text( - rec.streamer_name if hasattr(rec, 'streamer_name') else "未命名录制", + rec.streamer_name if hasattr(rec, "streamer_name") else "未命名录制", size=14, overflow=ft.TextOverflow.ELLIPSIS, ), @@ -430,14 +435,14 @@ def create_stat_item(title, value, icon, color): controls=recent_recordings, spacing=8, ), - padding=ft.padding.only(top=5), + padding=ft.Padding.only(top=5), ), ], spacing=5, alignment=ft.MainAxisAlignment.START, horizontal_alignment=ft.CrossAxisAlignment.START, ), - padding=ft.padding.all(15), + padding=ft.Padding.all(15), border_radius=10, bgcolor=ft.Colors.SURFACE_CONTAINER_HIGHEST, expand=True, @@ -524,12 +529,12 @@ def create_stat_item(title, value, icon, color): ), ft.Container( content=stats_content, - padding=ft.padding.only(top=10), + padding=ft.Padding.only(top=10), ), ], spacing=5, ), - padding=ft.padding.only(left=20, right=20), + padding=ft.Padding.only(left=20, right=20), ) def create_features_area(self): @@ -555,16 +560,19 @@ def create_feature_card(title, description, icon, color): description, size=13, text_align=ft.TextAlign.CENTER, - color=(ft.Colors.BLACK54 if self.app.page.theme_mode == ft.ThemeMode.LIGHT - else ft.Colors.WHITE70), + color=( + ft.Colors.BLACK54 + if self.app.page.theme_mode == ft.ThemeMode.LIGHT + else ft.Colors.WHITE70 + ), ), ], spacing=8, horizontal_alignment=ft.CrossAxisAlignment.CENTER, alignment=ft.MainAxisAlignment.CENTER, ), - padding=ft.padding.all(15), - alignment=ft.alignment.center, + padding=ft.Padding.all(15), + alignment=ft.alignment.Alignment.CENTER, width=None if is_mobile else 220, expand=is_mobile, ), @@ -645,13 +653,13 @@ def create_feature_card(title, description, icon, color): ), ft.Container( content=feature_cards, - padding=ft.padding.only(top=10), + padding=ft.Padding.only(top=10), ), ], horizontal_alignment=ft.CrossAxisAlignment.START, ), - alignment=ft.alignment.bottom_left, - padding=ft.padding.only(left=20, right=20, bottom=30), + alignment=ft.alignment.Alignment.BOTTOM_LEFT, + padding=ft.Padding.only(left=20, right=20, bottom=30), ) async def on_start_recording_click(self, _): diff --git a/app/ui/views/login_view.py b/app/ui/views/login_view.py index c0228a4c..60c4968a 100644 --- a/app/ui/views/login_view.py +++ b/app/ui/views/login_view.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable import flet as ft @@ -7,24 +7,23 @@ class LoginPage: - def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: Callable): self.page = page self.auth_manager = auth_manager self.on_login_success = on_login_success - + app = auth_manager.app language = app.language_manager.language self._ = language.get("login_page", {}) - + self.page.title = self._["login_title"] - + self.username_field = ft.TextField( label=self._["username"], autofocus=True, width=320, border_radius=8, - prefix_icon=ft.icons.PERSON, + prefix_icon=ft.Icons.PERSON, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -32,14 +31,14 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#333333", label_style=ft.TextStyle(color="#666666"), ) - + self.password_field = ft.TextField( label=self._["password"], password=True, can_reveal_password=True, width=320, border_radius=8, - prefix_icon=ft.icons.LOCK_OUTLINE, + prefix_icon=ft.Icons.LOCK_OUTLINE, focused_border_color="#0078d4", focused_color="#0078d4", border_color="#d0d0d0", @@ -47,9 +46,9 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#333333", label_style=ft.TextStyle(color="#666666"), ) - - self.login_button = ft.ElevatedButton( - text=self._["login_button"], + + self.login_button = ft.Button( + content=self._["login_button"], width=320, on_click=self.handle_login, style=ft.ButtonStyle( @@ -57,34 +56,34 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C color="#ffffff", bgcolor="#0078d4", elevation=0, - padding=15, + padding=ft.Padding.symmetric(horizontal=10, vertical=4), animation_duration=300, ), ) - + self.error_text = ft.Text( - color=ft.colors.RED_500, + color=ft.Colors.RED_500, size=14, visible=False, ) - + self.logo = ft.Image( src="iframe.php?url=https%3A%2F%2Fgithub.com%2Ficons%2Floading-animation.png", width=80, height=80, - fit=ft.ImageFit.CONTAIN, + fit=ft.BoxFit.CONTAIN, ) - + login_card_content = ft.Column( controls=[ ft.Container( content=self.logo, - alignment=ft.alignment.center, - margin=ft.margin.only(bottom=10), + alignment=ft.alignment.Alignment.CENTER, + margin=ft.Margin.only(bottom=10), ), ft.Text( - "StreamCap", - size=28, + "StreamCap", + size=28, weight=ft.FontWeight.BOLD, color="#0078d4", text_align=ft.TextAlign.CENTER, @@ -101,8 +100,8 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C self.password_field, ft.Container( content=self.error_text, - margin=ft.margin.only(top=10), - alignment=ft.alignment.center, + margin=ft.Margin.only(top=10), + alignment=ft.alignment.Alignment.CENTER, ), ft.Container(height=20), self.login_button, @@ -118,63 +117,64 @@ def __init__(self, page: ft.Page, auth_manager: AuthManager, on_login_success: C horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5, ) - + self.login_card = ft.Container( content=login_card_content, width=400, height=600, padding=30, - bgcolor=ft.colors.WHITE, + bgcolor=ft.Colors.WHITE, border_radius=12, shadow=ft.BoxShadow( spread_radius=1, blur_radius=15, - color=ft.colors.with_opacity(0.1, "#000000"), + color=ft.Colors.with_opacity(0.1, "#000000"), offset=ft.Offset(0, 4), ), ) - + self.main_view = ft.Container( content=self.login_card, - alignment=ft.alignment.center, + alignment=ft.alignment.Alignment.CENTER, expand=True, bgcolor="#f0f2f5", ) - + async def handle_login(self, e): username = self.username_field.value password = self.password_field.value - + if not username or not password: self.show_error(self._["input_required"]) return - - original_text = self.login_button.text - self.login_button.text = self._["login_in_progress"] + + original_text = self.login_button.content + self.login_button.content = self._["login_in_progress"] self.login_button.disabled = True self.page.update() - + success, token = await self.auth_manager.authenticate(username, password) - - self.login_button.text = original_text + + self.login_button.content = original_text self.login_button.disabled = False self.page.update() - + if success: logger.info(f"Login successful: {username}") - await self.page.client_storage.set_async("session_token", token) + await self.page.shared_preferences.set("session_token", token) await self.on_login_success(token) + self.page.title = "StreamCap" else: self.show_error(self._["login_failed"]) - + def show_error(self, message: str): self.error_text.value = message self.error_text.visible = True self.page.update() - + def clear_error(self): self.error_text.visible = False self.page.update() - + def get_view(self) -> ft.Control: - return self.main_view \ No newline at end of file + return self.main_view diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 6237d509..bd8693ef 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -1,749 +1,750 @@ -import asyncio -import uuid - -import flet as ft - -from ...core.platforms.platform_handlers import get_platform_info -from ...models.recording.recording_model import Recording -from ...utils.logger import logger -from ..base_page import PageBase -from ..components.business.recording_dialog import RecordingDialog -from ..components.dialogs.help_dialog import HelpDialog -from ..components.dialogs.search_dialog import SearchDialog -from ..filters import RecordingFilters - - -class RecordingsPage(PageBase): - def __init__(self, app): - super().__init__(app) - self.page_name = "recordings" - self.recording_card_area = None - self.add_recording_dialog = None - self.is_grid_view = app.settings.user_config.get("is_grid_view", True) - self.loading_indicator = None - self.app.language_manager.add_observer(self) - self.load_language() - self.current_filter = "all" - self.current_platform_filter = "all" - self.platform_buttons = {} - self.init() - - def load_language(self): - language = self.app.language_manager.language - for key in ("recordings_page", "video_quality", "base"): - self._.update(language.get(key, {})) - - def init(self): - self.loading_indicator = ft.ProgressRing( - width=40, - height=40, - stroke_width=3, - visible=False - ) - - if self.is_grid_view: - initial_content = ft.GridView( - expand=True, - runs_count=3, - spacing=10, - run_spacing=10, - child_aspect_ratio=2.3, - controls=[] - ) - else: - initial_content = ft.Column( - controls=[], - spacing=5, - expand=True - ) - - self.recording_card_area = ft.Container( - content=initial_content, - expand=True - ) - self.add_recording_dialog = RecordingDialog(self.app, self.add_recording) - self.pubsub_subscribe() - - async def load(self): - """Load the recordings page content.""" - self.content_area.controls.extend( - [ - self.create_recordings_title_area(), - self.create_filter_area(), - self.create_recordings_content_area() - ] - ) - self.content_area.update() - - if not self.recording_card_area.content.controls: - self.recording_card_area.content.controls.clear() - await self.add_record_cards() - else: - # if cards exist, apply filter - await self.apply_filter() - - if self.is_grid_view: - await self.recalculate_grid_columns() - - self.page.on_keyboard_event = self.on_keyboard - self.page.on_resized = self.update_grid_layout - - def pubsub_subscribe(self): - self.app.page.pubsub.subscribe_topic('add', self.subscribe_add_cards) - self.app.page.pubsub.subscribe_topic('delete_all', self.subscribe_del_all_cards) - - async def toggle_view_mode(self, _): - self.is_grid_view = not self.is_grid_view - current_content = self.recording_card_area.content - current_controls = current_content.controls if hasattr(current_content, 'controls') else [] - - column_width = 350 - runs_count = max(1, int(self.page.width / column_width)) - - if self.is_grid_view: - new_content = ft.GridView( - expand=True, - runs_count=runs_count, - spacing=10, - run_spacing=10, - child_aspect_ratio=2.3, - controls=current_controls - ) - else: - new_content = ft.Column( - controls=current_controls, - spacing=5, - expand=True - ) - - self.recording_card_area.content = new_content - self.content_area.clean() - self.content_area.controls.extend( - [ - self.create_recordings_title_area(), - self.create_filter_area(), - self.create_recordings_content_area() - ] - ) - self.content_area.update() - - self.app.settings.user_config["is_grid_view"] = self.is_grid_view - self.page.run_task(self.app.config_manager.save_user_config, self.app.settings.user_config) - - def create_recordings_title_area(self): - toggle_view_mode_button = ft.IconButton( - icon=ft.Icons.GRID_VIEW if self.is_grid_view else ft.Icons.LIST, - tooltip=self._["toggle_view"], - on_click=self.toggle_view_mode - ) - - title_controls = [ - ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM), - ft.Container(expand=True), - ] - - if not self.app.is_mobile: - title_controls.append(toggle_view_mode_button) - - batch_controls = [ - ft.IconButton(icon=ft.Icons.SEARCH, tooltip=self._["search"], on_click=self.search_on_click), - ft.IconButton(icon=ft.Icons.ADD, tooltip=self._["add_record"], on_click=self.add_recording_on_click), - ft.IconButton(icon=ft.Icons.REFRESH, tooltip=self._["refresh"], on_click=self.refresh_cards_on_click), - ft.IconButton( - icon=ft.Icons.PLAY_ARROW, - tooltip=self._["batch_start"], - on_click=self.start_monitor_recordings_on_click, - ), - ft.IconButton( - icon=ft.Icons.STOP, - tooltip=self._["batch_stop"], - on_click=self.stop_monitor_recordings_on_click - ), - ft.IconButton( - icon=ft.Icons.DELETE_SWEEP, - tooltip=self._["batch_delete"], - on_click=self.delete_monitor_recordings_on_click, - ), - ] - - if self.app.is_mobile: - first_row = ft.Row( - title_controls, - alignment=ft.MainAxisAlignment.START, - ) - second_row = ft.Row( - [ - ft.Text(self._["operations"] + ":", size=14), - *batch_controls, - ], - alignment=ft.MainAxisAlignment.START, - spacing=2, - scroll=ft.ScrollMode.HIDDEN - ) - - return ft.Column( - [first_row, second_row], - spacing=5, - ) - else: - return ft.Row( - [*title_controls, *batch_controls], - alignment=ft.MainAxisAlignment.START, - ) - - def create_filter_area(self): - """Create the filter area""" - - filter_buttons = [ - ft.Text(self._["status_filter"] + ":" if not self.app.is_mobile else self._["filter"] + ":", size=14), - ft.ElevatedButton( - self._["filter_all"], - on_click=self.filter_all_on_click, - bgcolor=ft.Colors.BLUE if self.current_filter == "all" else None, - color=ft.Colors.WHITE if self.current_filter == "all" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ft.ElevatedButton( - self._["filter_recording"], - on_click=self.filter_recording_on_click, - bgcolor=ft.Colors.GREEN if self.current_filter == "recording" else None, - color=ft.Colors.WHITE if self.current_filter == "recording" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ft.ElevatedButton( - self._["filter_living"], - on_click=self.filter_living_on_click, - bgcolor=ft.Colors.BLUE if self.current_filter == "living" else None, - color=ft.Colors.WHITE if self.current_filter == "living" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ft.ElevatedButton( - self._["filter_offline"], - on_click=self.filter_offline_on_click, - bgcolor=ft.Colors.AMBER if self.current_filter == "offline" else None, - color=ft.Colors.WHITE if self.current_filter == "offline" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ft.ElevatedButton( - self._["filter_error"], - on_click=self.filter_error_on_click, - bgcolor=ft.Colors.RED if self.current_filter == "error" else None, - color=ft.Colors.WHITE if self.current_filter == "error" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ft.ElevatedButton( - self._["filter_stopped"], - on_click=self.filter_stopped_on_click, - bgcolor=ft.Colors.GREY if self.current_filter == "stopped" else None, - color=ft.Colors.WHITE if self.current_filter == "stopped" else None, - style=ft.ButtonStyle( - shape=ft.RoundedRectangleBorder(radius=5), - ), - ), - ] - - platforms = {} - for recording in self.app.record_manager.recordings: - if recording.platform and recording.platform_key: - platforms[recording.platform_key] = recording.platform - - platform_options = [ - ft.dropdown.Option(key="all", text=self._["filter_all"]) - ] - - for key, name in platforms.items(): - platform_options.append(ft.dropdown.Option(key=key, text=name)) - - current_platform_keys = ["all"] + list(platforms.keys()) - if self.current_platform_filter not in current_platform_keys: - self.current_platform_filter = "all" - - platform_dropdown = ft.Dropdown( - options=platform_options, - value=self.current_platform_filter, - on_change=self.on_platform_dropdown_change, - width=120, - text_size=14, - content_padding=ft.padding.only(top=8, bottom=8, left=10, right=10), - border_radius=5, - border_color=ft.Colors.OUTLINE, - focused_border_color=ft.colors.PRIMARY, - dense=True, - ) - if len(current_platform_keys) > 8: - platform_dropdown.menu_height = 320 - - platform_filter_area = ft.Row( - [ - ft.Text(self._["platform_filter"] + ":", size=14), - platform_dropdown - ], - spacing=8, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - if self.app.is_mobile: - status_filter_row = ft.Row( - filter_buttons, - alignment=ft.MainAxisAlignment.START, - spacing=3, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - scroll=ft.ScrollMode.HIDDEN - ) - - platform_filter_row = ft.Row( - [platform_filter_area], - alignment=ft.MainAxisAlignment.START, - spacing=5, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - return ft.Column( - [ - status_filter_row, - platform_filter_row - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - ) - else: - return ft.Row( - [ - *filter_buttons, - ft.Container(expand=True), - platform_filter_area - ], - alignment=ft.MainAxisAlignment.START, - spacing=5, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - async def filter_all_on_click(self, _): - self.current_filter = "all" - await self.apply_filter() - - async def filter_recording_on_click(self, _): - self.current_filter = "recording" - await self.apply_filter() - - async def filter_living_on_click(self, _): - self.current_filter = "living" - await self.apply_filter() - - async def filter_error_on_click(self, _): - self.current_filter = "error" - await self.apply_filter() - - async def filter_offline_on_click(self, _): - self.current_filter = "offline" - await self.apply_filter() - - async def filter_stopped_on_click(self, _): - self.current_filter = "stopped" - await self.apply_filter() - - async def apply_filter(self): - if len(self.content_area.controls) > 1: - self.content_area.controls[1] = self.create_filter_area() - else: - self.content_area.controls.append(self.create_filter_area()) - - cards_obj = self.app.record_card_manager.cards_obj - recordings = self.app.record_manager.recordings - - for recording in recordings: - card_info = cards_obj.get(recording.rec_id) - if not card_info: - continue - - status_visible = RecordingFilters.get_status_filter_result(recording, self.current_filter) - platform_visible = RecordingFilters.get_platform_filter_result(recording, self.current_platform_filter) - - visible = status_visible and platform_visible - card_info["card"].visible = visible - - self.content_area.update() - self.recording_card_area.update() - - async def reset_cards_visibility(self): - cards_obj = self.app.record_card_manager.cards_obj - for card_info in cards_obj.values(): - if not card_info["card"].visible: - card_info["card"].visible = True - card_info["card"].update() - - async def filter_recordings(self, query): - recordings = self.app.record_manager.recordings - cards_obj = self.app.record_card_manager.cards_obj - - if not query.strip(): - await self.apply_filter() - return {} - else: - lower_query = query.strip().lower() - search_ids = { - rec.rec_id - for rec in recordings - if lower_query in str(rec.to_dict()).lower() or lower_query in rec.display_title - } - - filtered_ids = set() - - for rec_id in search_ids: - recording = self.app.record_manager.find_recording_by_id(rec_id) - if not recording: - continue - - if RecordingFilters.should_show_recording(self.current_filter, self.current_platform_filter, recording): - filtered_ids.add(rec_id) - - for card_info in cards_obj.values(): - card_info["card"].visible = card_info["card"].key in filtered_ids - card_info["card"].update() - - if not filtered_ids: - await self.app.snack_bar.show_snack_bar(self._["not_search_result"], duration=2000) - return filtered_ids - - def create_recordings_content_area(self): - return ft.Column( - expand=True, - controls=[ - ft.Divider(height=1), - ft.Container( - content=self.loading_indicator, - alignment=ft.alignment.center - ), - self.recording_card_area, - ], - scroll=ft.ScrollMode.AUTO if not self.app.is_mobile else ft.ScrollMode.HIDDEN, - ) - - async def add_record_cards(self): - - self.loading_indicator.visible = True - self.loading_indicator.update() - - cards_to_create = [] - existing_cards = [] - - for recording in self.app.record_manager.recordings: - if recording.rec_id not in self.app.record_card_manager.cards_obj: - cards_to_create.append(recording) - else: - existing_card = self.app.record_card_manager.cards_obj[recording.rec_id]["card"] - existing_card.visible = True - existing_cards.append(existing_card) - - async def create_card_with_time_range(_recording: Recording): - _card = await self.app.record_card_manager.create_card(_recording) - _recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - _recording.scheduled_start_time, _recording.monitor_hours - ) - return _card, _recording - - if cards_to_create: - results = await asyncio.gather(*[ - create_card_with_time_range(recording) - for recording in cards_to_create - ]) - - for card, recording in results: - self.recording_card_area.content.controls.append(card) - self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card - - if existing_cards: - for card in existing_cards: - self.recording_card_area.content.controls.append(card) - - self.loading_indicator.visible = False - self.loading_indicator.update() - self.recording_card_area.update() - - if not self.app.record_manager.periodic_task_started: - self.page.run_task( - self.app.record_manager.setup_periodic_live_check, - self.app.record_manager.loop_time_seconds - ) - - await self.apply_filter() - - async def show_all_cards(self): - cards_obj = self.app.record_card_manager.cards_obj - for card in cards_obj.values(): - card["card"].visible = True - self.recording_card_area.update() - - await self.apply_filter() - - async def add_recording(self, recordings_info): - user_config = self.app.settings.user_config - logger.info(f"Add items: {len(recordings_info)}") - - new_recordings = [] - for recording_info in recordings_info: - if recording_info.get("record_format"): - recording = Recording( - rec_id=str(uuid.uuid4()), - url=recording_info["url"], - streamer_name=recording_info["streamer_name"], - quality=recording_info["quality"], - record_format=recording_info["record_format"], - segment_record=recording_info["segment_record"], - segment_time=recording_info["segment_time"], - monitor_status=recording_info["monitor_status"], - scheduled_recording=recording_info["scheduled_recording"], - scheduled_start_time=recording_info["scheduled_start_time"], - monitor_hours=recording_info["monitor_hours"], - recording_dir=recording_info["recording_dir"], - enabled_message_push=recording_info["enabled_message_push"], - only_notify_no_record=recording_info["only_notify_no_record"], - flv_use_direct_download=recording_info["flv_use_direct_download"], - ) - else: - recording = Recording( - rec_id=str(uuid.uuid4()), - url=recording_info["url"], - streamer_name=recording_info["streamer_name"], - quality=recording_info["quality"], - record_format=user_config.get("video_format", "TS"), - segment_record=user_config.get("segmented_recording_enabled", False), - segment_time=user_config.get("video_segment_time", "1800"), - monitor_status=True, - scheduled_recording=user_config.get("scheduled_recording", False), - scheduled_start_time=user_config.get("scheduled_start_time"), - monitor_hours=user_config.get("monitor_hours"), - recording_dir=None, - enabled_message_push=False, - only_notify_no_record=user_config.get("only_notify_no_record"), - flv_use_direct_download=user_config.get("flv_use_direct_download"), - ) - - platform, platform_key = get_platform_info(recording.url) - if platform and platform_key: - recording.platform = platform - recording.platform_key = platform_key - - recording.loop_time_seconds = int(user_config.get("loop_time_seconds", 300)) - recording.update_title(self._[recording.quality]) - await self.app.record_manager.add_recording(recording) - new_recordings.append(recording) - - if new_recordings: - async def create_card_with_time_range(rec): - _card = await self.app.record_card_manager.create_card(rec) - rec.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - rec.scheduled_start_time, rec.monitor_hours - ) - return _card, rec - - results = await asyncio.gather(*[ - create_card_with_time_range(rec) - for rec in new_recordings - ]) - - for card, recording in results: - self.recording_card_area.content.controls.append(card) - self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card - self.app.page.pubsub.send_others_on_topic("add", recording) - - self.recording_card_area.update() - - self.content_area.controls[1] = self.create_filter_area() - self.content_area.update() - - await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.GREEN) - - async def search_on_click(self, _e): - """Open the search dialog when the search button is clicked.""" - search_dialog = SearchDialog(recordings_page=self) - search_dialog.open = True - self.app.dialog_area.content = search_dialog - self.app.dialog_area.update() - - async def add_recording_on_click(self, _e): - await self.add_recording_dialog.show_dialog() - - async def refresh_cards_on_click(self, _e): - - self.loading_indicator.visible = True - self.loading_indicator.update() - - cards_obj = self.app.record_card_manager.cards_obj - recordings = self.app.record_manager.recordings - selected_cards = self.app.record_card_manager.selected_cards - new_ids = {rec.rec_id for rec in recordings} - to_remove = [] - for card_id, card in cards_obj.items(): - if card_id not in new_ids: - to_remove.append(card) - continue - if card_id in selected_cards: - selected_cards[card_id].selected = False - card["card"].content.bgcolor = None - card["card"].update() - - for card in to_remove: - card_key = card["card"].key - cards_obj.pop(card_key, None) - self.recording_card_area.controls.remove(card["card"]) - await self.show_all_cards() - - self.content_area.controls[1] = self.create_filter_area() - self.content_area.update() - - self.loading_indicator.visible = False - self.loading_indicator.update() - - await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.GREEN) - - async def start_monitor_recordings_on_click(self, _): - await self.app.record_manager.check_free_space() - if self.app.recording_enabled: - await self.app.record_manager.start_monitor_recordings() - await self.app.snack_bar.show_snack_bar(self._["start_recording_success_tip"], bgcolor=ft.Colors.GREEN) - - async def stop_monitor_recordings_on_click(self, _): - await self.app.record_manager.stop_monitor_recordings() - await self.app.snack_bar.show_snack_bar(self._["stop_recording_success_tip"]) - - async def delete_monitor_recordings_on_click(self, _): - selected_recordings = await self.app.record_manager.get_selected_recordings() - tips = self._["batch_delete_confirm_tip"] if selected_recordings else self._["clear_all_confirm_tip"] - - async def confirm_dlg(_): - - if selected_recordings: - await self.app.record_manager.stop_monitor_recordings(selected_recordings) - await self.app.record_manager.delete_recording_cards(selected_recordings) - else: - await self.app.record_manager.stop_monitor_recordings(self.app.record_manager.recordings) - await self.app.record_manager.clear_all_recordings() - await self.delete_all_recording_cards() - self.app.page.pubsub.send_others_on_topic("delete_all", None) - - self.content_area.controls[1] = self.create_filter_area() - self.content_area.update() - - self.recording_card_area.update() - await self.app.snack_bar.show_snack_bar( - self._["delete_recording_success_tip"], bgcolor=ft.Colors.GREEN, duration=2000 - ) - await close_dialog(None) - - async def close_dialog(_): - batch_delete_alert_dialog.open = False - batch_delete_alert_dialog.update() - - batch_delete_alert_dialog = ft.AlertDialog( - title=ft.Text(self._["confirm"]), - content=ft.Text(tips), - actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), - ], - actions_alignment=ft.MainAxisAlignment.END, - modal=False, - ) - - batch_delete_alert_dialog.open = True - self.app.dialog_area.content = batch_delete_alert_dialog - self.page.update() - - async def delete_all_recording_cards(self): - self.recording_card_area.content.controls.clear() - self.recording_card_area.update() - self.app.record_card_manager.cards_obj = {} - - self.current_platform_filter = "all" - - self.content_area.controls[1] = self.create_filter_area() - self.content_area.update() - - async def subscribe_del_all_cards(self, *_): - await self.delete_all_recording_cards() - - async def subscribe_add_cards(self, _, recording: Recording): - """Handle the subscription of adding cards from other clients""" - - self.loading_indicator.visible = True - self.loading_indicator.update() - - if recording.rec_id not in self.app.record_card_manager.cards_obj: - card = await self.app.record_card_manager.create_card(recording) - recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( - recording.scheduled_start_time, recording.monitor_hours - ) - - self.recording_card_area.content.controls.append(card) - self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card - - self.loading_indicator.visible = False - self.loading_indicator.update() - - self.recording_card_area.update() - - self.content_area.controls[1] = self.create_filter_area() - self.content_area.update() - - async def update_grid_layout(self, _): - self.page.run_task(self.recalculate_grid_columns) - - async def recalculate_grid_columns(self): - if not self.is_grid_view: - return - - if self.app.is_mobile: - column_width = 250 - child_aspect_ratio = 2.5 - else: - column_width = 350 - child_aspect_ratio = 2.3 - - runs_count = max(1, int(self.page.width / column_width)) - - if isinstance(self.recording_card_area.content, ft.GridView): - grid_view = self.recording_card_area.content - grid_view.runs_count = runs_count - - grid_view.child_aspect_ratio = child_aspect_ratio - - if self.app.is_mobile: - grid_view.spacing = 5 - grid_view.run_spacing = 5 - - grid_view.update() - - async def on_keyboard(self, e: ft.KeyboardEvent): - if e.alt and e.key == "H": - self.app.dialog_area.content = HelpDialog(self.app) - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - if self.app.current_page == self: - if e.ctrl and e.key == "F": - self.page.run_task(self.search_on_click, e) - elif e.ctrl and e.key == "R": - self.page.run_task(self.refresh_cards_on_click, e) - elif e.alt and e.key == "N": - self.page.run_task(self.add_recording_on_click, e) - elif e.alt and e.key == "B": - self.page.run_task(self.start_monitor_recordings_on_click, e) - elif e.alt and e.key == "P": - self.page.run_task(self.stop_monitor_recordings_on_click, e) - elif e.alt and e.key == "D": - self.page.run_task(self.delete_monitor_recordings_on_click, e) - - def on_platform_dropdown_change(self, e): - self.current_platform_filter = e.control.value - self.page.run_task(self.apply_filter) +import asyncio +import uuid + +import flet as ft + +from ...core.platforms.platform_handlers import get_platform_info +from ...core.recording.record_manager import RecordingManager +from ...models.recording.recording_model import Recording +from ...utils.logger import logger +from ..base_page import PageBase +from ..components.business.recording_dialog import RecordingDialog +from ..components.dialogs.help_dialog import HelpDialog +from ..components.dialogs.search_dialog import SearchDialog +from ..filters import RecordingFilters + + +class RecordingsPage(PageBase): + CARD_MIN_WIDTH = 320 + MOBILE_CARD_MIN_WIDTH = 250 + CARD_ASPECT_RATIO = 2.55 + MOBILE_CARD_ASPECT_RATIO = 2.3 + + def __init__(self, app): + super().__init__(app) + self.page_name = "recordings" + self.recording_card_area = None + self.add_recording_dialog = None + self.is_grid_view = app.settings.user_config.get("is_grid_view", True) + self.loading_indicator = None + self.app.language_manager.add_observer(self) + self.load_language() + self.current_filter = "all" + self.current_platform_filter = "all" + self.platform_buttons = {} + self.init() + + def load_language(self): + language = self.app.language_manager.language + for key in ("recordings_page", "video_quality", "base"): + self._.update(language.get(key, {})) + + def init(self): + self.loading_indicator = ft.ProgressRing(width=40, height=40, stroke_width=3, visible=False) + + if self.is_grid_view: + initial_content = ft.GridView( + expand=True, + runs_count=1, + spacing=10, + run_spacing=10, + child_aspect_ratio=self.CARD_ASPECT_RATIO, + controls=[], + ) + else: + initial_content = ft.Column(controls=[], spacing=5, expand=True) + + self.recording_card_area = ft.Container(content=initial_content, expand=True) + self.add_recording_dialog = RecordingDialog(self.app, self.add_recording) + self.pubsub_subscribe() + + async def load(self): + """Load the recordings page content.""" + self.content_area.controls.extend( + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] + ) + self.content_area.update() + + if not self.recording_card_area.content.controls: + self.recording_card_area.content.controls.clear() + await self.add_record_cards() + else: + # if cards exist, apply filter + await self.apply_filter() + + if self.is_grid_view: + await self.recalculate_grid_columns() + + self.page.on_keyboard_event = self.on_keyboard + self.page.on_resize = self.update_grid_layout + + def pubsub_subscribe(self): + self.app.page.pubsub.subscribe_topic("add", self.subscribe_add_cards) + self.app.page.pubsub.subscribe_topic("delete_all", self.subscribe_del_all_cards) + + async def toggle_view_mode(self, _): + self.is_grid_view = not self.is_grid_view + current_content = self.recording_card_area.content + current_controls = current_content.controls if hasattr(current_content, "controls") else [] + + runs_count = self.get_grid_runs_count() + child_aspect_ratio = self.get_grid_child_aspect_ratio() + + if self.is_grid_view: + new_content = ft.GridView( + expand=True, + runs_count=runs_count, + spacing=10, + run_spacing=10, + child_aspect_ratio=child_aspect_ratio, + controls=current_controls, + ) + else: + new_content = ft.Column(controls=current_controls, spacing=5, expand=True) + + self.recording_card_area.content = new_content + self.content_area.controls.clear() + self.content_area.controls.extend( + [self.create_recordings_title_area(), self.create_filter_area(), self.create_recordings_content_area()] + ) + self.content_area.update() + + self.app.settings.user_config["is_grid_view"] = self.is_grid_view + self.app.services.run_coro(self.app.config_manager.save_user_config(self.app.settings.user_config)) + + def create_recordings_title_area(self): + toggle_view_mode_button = ft.IconButton( + icon=ft.Icons.GRID_VIEW if self.is_grid_view else ft.Icons.LIST, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["toggle_view"], + on_click=self.toggle_view_mode, + ) + + title_controls = [ + ft.Text(self._["recording_list"], theme_style=ft.TextThemeStyle.TITLE_MEDIUM), + ft.Container(expand=True), + ] + + if not self.app.is_mobile: + title_controls.append(toggle_view_mode_button) + + batch_controls = [ + ft.IconButton( + icon=ft.Icons.SEARCH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["search"], + on_click=self.search_on_click, + ), + ft.IconButton( + icon=ft.Icons.ADD, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["add_record"], + on_click=self.add_recording_on_click, + ), + ft.IconButton( + icon=ft.Icons.REFRESH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["refresh"], + on_click=self.refresh_cards_on_click, + ), + ft.IconButton( + icon=ft.Icons.PLAY_ARROW, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_start"], + on_click=self.start_monitor_recordings_on_click, + ), + ft.IconButton( + icon=ft.Icons.STOP, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_stop"], + on_click=self.stop_monitor_recordings_on_click, + ), + ft.IconButton( + icon=ft.Icons.DELETE_SWEEP, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["batch_delete"], + on_click=self.delete_monitor_recordings_on_click, + ), + ] + + if self.app.is_mobile: + first_row = ft.Row( + title_controls, + alignment=ft.MainAxisAlignment.START, + ) + second_row = ft.Row( + [ + ft.Text(self._["operations"] + ":", size=14), + *batch_controls, + ], + alignment=ft.MainAxisAlignment.START, + spacing=2, + scroll=ft.ScrollMode.HIDDEN, + ) + + return ft.Column( + [first_row, second_row], + spacing=5, + ) + else: + return ft.Row( + [*title_controls, *batch_controls], + alignment=ft.MainAxisAlignment.START, + ) + + def create_filter_area(self): + """Create the filter area""" + + filter_buttons = [ + ft.Text(self._["status_filter"] + ":" if not self.app.is_mobile else self._["filter"] + ":", size=14), + ft.Button( + self._["filter_all"], + on_click=self.filter_all_on_click, + bgcolor=ft.Colors.PRIMARY if self.current_filter == "all" else None, + color=ft.Colors.WHITE if self.current_filter == "all" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_recording"], + on_click=self.filter_recording_on_click, + bgcolor=ft.Colors.GREEN if self.current_filter == "recording" else None, + color=ft.Colors.WHITE if self.current_filter == "recording" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_living"], + on_click=self.filter_living_on_click, + bgcolor=ft.Colors.BLUE if self.current_filter == "living" else None, + color=ft.Colors.WHITE if self.current_filter == "living" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_offline"], + on_click=self.filter_offline_on_click, + bgcolor=ft.Colors.AMBER if self.current_filter == "offline" else None, + color=ft.Colors.WHITE if self.current_filter == "offline" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_error"], + on_click=self.filter_error_on_click, + bgcolor=ft.Colors.RED if self.current_filter == "error" else None, + color=ft.Colors.WHITE if self.current_filter == "error" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ft.Button( + self._["filter_stopped"], + on_click=self.filter_stopped_on_click, + bgcolor=ft.Colors.GREY if self.current_filter == "stopped" else None, + color=ft.Colors.WHITE if self.current_filter == "stopped" else None, + style=ft.ButtonStyle( + shape=ft.RoundedRectangleBorder(radius=5), + padding=ft.Padding.symmetric(horizontal=10, vertical=4), + ), + ), + ] + + platforms = {} + for recording in self.app.record_manager.recordings: + if recording.platform and recording.platform_key: + platforms[recording.platform_key] = recording.platform + + platform_options = [ft.dropdown.DropdownOption(key="all", text=self._["filter_all"])] + + for key, name in platforms.items(): + platform_options.append(ft.dropdown.DropdownOption(key=key, text=name)) + + current_platform_keys = ["all"] + list(platforms.keys()) + if self.current_platform_filter not in current_platform_keys: + self.current_platform_filter = "all" + + platform_dropdown = ft.Dropdown( + options=platform_options, + value=self.current_platform_filter, + on_select=self.on_platform_dropdown_change, + hover_color=ft.Colors.PRIMARY, + width=120, + text_size=14, + content_padding=ft.Padding.only(top=8, bottom=8, left=10, right=10), + border_radius=5, + border_color=ft.Colors.OUTLINE, + focused_border_color=ft.Colors.PRIMARY, + dense=True, + ) + if len(current_platform_keys) > 8: + platform_dropdown.menu_height = 320 + + platform_filter_area = ft.Row( + [ft.Text(self._["platform_filter"] + ":", size=14), platform_dropdown], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + if self.app.is_mobile: + status_filter_row = ft.Row( + filter_buttons, + alignment=ft.MainAxisAlignment.START, + spacing=3, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + scroll=ft.ScrollMode.HIDDEN, + ) + + platform_filter_row = ft.Row( + [platform_filter_area], + alignment=ft.MainAxisAlignment.START, + spacing=5, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + return ft.Column( + [status_filter_row, platform_filter_row], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + ) + else: + return ft.Row( + [*filter_buttons, ft.Container(expand=True), platform_filter_area], + alignment=ft.MainAxisAlignment.START, + spacing=5, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + async def filter_all_on_click(self, _): + self.current_filter = "all" + await self.apply_filter() + + async def filter_recording_on_click(self, _): + self.current_filter = "recording" + await self.apply_filter() + + async def filter_living_on_click(self, _): + self.current_filter = "living" + await self.apply_filter() + + async def filter_error_on_click(self, _): + self.current_filter = "error" + await self.apply_filter() + + async def filter_offline_on_click(self, _): + self.current_filter = "offline" + await self.apply_filter() + + async def filter_stopped_on_click(self, _): + self.current_filter = "stopped" + await self.apply_filter() + + async def apply_filter(self): + if len(self.content_area.controls) > 1: + self.content_area.controls[1] = self.create_filter_area() + else: + self.content_area.controls.append(self.create_filter_area()) + + cards_obj = self.app.record_card_manager.cards_obj + recordings = self.app.record_manager.recordings + + for recording in recordings: + card_info = cards_obj.get(recording.rec_id) + if not card_info: + continue + + status_visible = RecordingFilters.get_status_filter_result(recording, self.current_filter) + platform_visible = RecordingFilters.get_platform_filter_result(recording, self.current_platform_filter) + + visible = status_visible and platform_visible + card_info["card"].visible = visible + + self.content_area.update() + self.recording_card_area.update() + + async def reset_cards_visibility(self): + cards_obj = self.app.record_card_manager.cards_obj + for card_info in cards_obj.values(): + if not card_info["card"].visible: + card_info["card"].visible = True + card_info["card"].update() + + async def filter_recordings(self, query): + recordings = self.app.record_manager.recordings + cards_obj = self.app.record_card_manager.cards_obj + + if not query.strip(): + await self.apply_filter() + return {} + else: + lower_query = query.strip().lower() + search_ids = { + rec.rec_id + for rec in recordings + if lower_query in str(rec.to_dict()).lower() or lower_query in rec.display_title + } + + filtered_ids = set() + + for rec_id in search_ids: + recording = self.app.record_manager.find_recording_by_id(rec_id) + if not recording: + continue + + if RecordingFilters.should_show_recording(self.current_filter, self.current_platform_filter, recording): + filtered_ids.add(rec_id) + + for card_info in cards_obj.values(): + card_info["card"].visible = card_info["card"].key in filtered_ids + card_info["card"].update() + + if not filtered_ids: + await self.app.snack_bar.show_snack_bar(self._["not_search_result"], duration=2000) + return filtered_ids + + def create_recordings_content_area(self): + return ft.Column( + expand=True, + controls=[ + ft.Divider(height=1), + ft.Container(content=self.loading_indicator, alignment=ft.alignment.Alignment.CENTER), + self.recording_card_area, + ], + scroll=ft.ScrollMode.AUTO if not self.app.is_mobile else ft.ScrollMode.HIDDEN, + ) + + async def add_record_cards(self): + + self.loading_indicator.visible = True + self.loading_indicator.update() + + cards_to_create = [] + existing_cards = [] + + for recording in self.app.record_manager.recordings: + if recording.rec_id not in self.app.record_card_manager.cards_obj: + cards_to_create.append(recording) + else: + existing_card = self.app.record_card_manager.cards_obj[recording.rec_id]["card"] + existing_card.visible = True + existing_cards.append(existing_card) + + async def create_card_with_time_range(_recording: Recording): + _card = await self.app.record_card_manager.create_card(_recording) + _recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( + _recording.scheduled_start_time, _recording.monitor_hours + ) + return _card, _recording + + if cards_to_create: + results = await asyncio.gather(*[create_card_with_time_range(recording) for recording in cards_to_create]) + + for card, recording in results: + self.recording_card_area.content.controls.append(card) + self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card + + if existing_cards: + for card in existing_cards: + self.recording_card_area.content.controls.append(card) + + self.loading_indicator.visible = False + self.loading_indicator.update() + self.recording_card_area.update() + + if not RecordingManager.is_periodic_task_running(): + self.page.run_task( + self.app.record_manager.setup_periodic_live_check, self.app.record_manager.loop_time_seconds + ) + + await self.apply_filter() + + async def show_all_cards(self): + cards_obj = self.app.record_card_manager.cards_obj + for card in cards_obj.values(): + card["card"].visible = True + self.recording_card_area.update() + + await self.apply_filter() + + async def add_recording(self, recordings_info): + user_config = self.app.settings.user_config + logger.info(f"Add items: {len(recordings_info)}") + + new_recordings = [] + for recording_info in recordings_info: + if recording_info.get("record_format"): + recording = Recording( + rec_id=str(uuid.uuid4()), + url=recording_info["url"], + streamer_name=recording_info["streamer_name"], + quality=recording_info["quality"], + record_format=recording_info["record_format"], + segment_record=recording_info["segment_record"], + segment_time=recording_info["segment_time"], + monitor_status=recording_info["monitor_status"], + scheduled_recording=recording_info["scheduled_recording"], + scheduled_start_time=recording_info["scheduled_start_time"], + monitor_hours=recording_info["monitor_hours"], + recording_dir=recording_info["recording_dir"], + enabled_message_push=recording_info["enabled_message_push"], + only_notify_no_record=recording_info["only_notify_no_record"], + flv_use_direct_download=recording_info["flv_use_direct_download"], + video_bitrate=recording_info["video_bitrate"], + ) + else: + recording = Recording( + rec_id=str(uuid.uuid4()), + url=recording_info["url"], + streamer_name=recording_info["streamer_name"], + quality=recording_info["quality"], + record_format=user_config.get("video_format", "TS"), + segment_record=user_config.get("segmented_recording_enabled", False), + segment_time=user_config.get("video_segment_time", "1800"), + monitor_status=True, + scheduled_recording=user_config.get("scheduled_recording", False), + scheduled_start_time=user_config.get("scheduled_start_time"), + monitor_hours=user_config.get("monitor_hours"), + recording_dir=None, + enabled_message_push=False, + only_notify_no_record=user_config.get("only_notify_no_record"), + flv_use_direct_download=user_config.get("flv_use_direct_download"), + video_bitrate=None, + ) + + platform, platform_key = get_platform_info(recording.url) + if platform and platform_key: + recording.platform = platform + recording.platform_key = platform_key + + recording.loop_time_seconds = int(user_config.get("loop_time_seconds", 300)) + recording.update_title(self._[recording.quality]) + await self.app.record_manager.add_recording(recording) + new_recordings.append(recording) + + if new_recordings: + + async def create_card_with_time_range(rec): + _card = await self.app.record_card_manager.create_card(rec) + rec.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( + rec.scheduled_start_time, rec.monitor_hours + ) + return _card, rec + + results = await asyncio.gather(*[create_card_with_time_range(rec) for rec in new_recordings]) + + for card, recording in results: + self.recording_card_area.content.controls.append(card) + self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card + self.app.page.pubsub.send_others_on_topic("add", recording) + + self.recording_card_area.update() + + self.content_area.controls[1] = self.create_filter_area() + self.content_area.update() + + await self.app.snack_bar.show_snack_bar(self._["add_recording_success_tip"], bgcolor=ft.Colors.PRIMARY) + + async def search_on_click(self, _e): + """Open the search dialog when the search button is clicked.""" + search_dialog = SearchDialog(recordings_page=self) + search_dialog.open = True + self.app.dialog_area.content = search_dialog + self.app.dialog_area.update() + + async def add_recording_on_click(self, _e): + await self.add_recording_dialog.show_dialog() + + async def refresh_cards_on_click(self, _e): + + self.loading_indicator.visible = True + self.loading_indicator.update() + + cards_obj = self.app.record_card_manager.cards_obj + recordings = self.app.record_manager.recordings + selected_cards = self.app.record_card_manager.selected_cards + new_ids = {rec.rec_id for rec in recordings} + to_remove = [] + for card_id, card in cards_obj.items(): + if card_id not in new_ids: + to_remove.append(card) + continue + if card_id in selected_cards: + selected_cards[card_id].selected = False + card["card"].content.bgcolor = None + card["card"].update() + + for card in to_remove: + card_key = card["card"].key + cards_obj.pop(card_key, None) + self.recording_card_area.controls.remove(card["card"]) + await self.show_all_cards() + + self.content_area.controls[1] = self.create_filter_area() + self.content_area.update() + + self.loading_indicator.visible = False + self.loading_indicator.update() + + await self.app.snack_bar.show_snack_bar(self._["refresh_success_tip"], bgcolor=ft.Colors.PRIMARY) + + async def start_monitor_recordings_on_click(self, _): + await self.app.record_manager.check_free_space() + if self.app.recording_enabled: + await self.app.record_manager.start_monitor_recordings() + await self.app.snack_bar.show_snack_bar(self._["start_recording_success_tip"], bgcolor=ft.Colors.PRIMARY) + + async def stop_monitor_recordings_on_click(self, _): + await self.app.record_manager.stop_monitor_recordings() + await self.app.snack_bar.show_snack_bar(self._["stop_recording_success_tip"], bgcolor=ft.Colors.PRIMARY) + + async def delete_monitor_recordings_on_click(self, _): + selected_recordings = await self.app.record_manager.get_selected_recordings() + tips = self._["batch_delete_confirm_tip"] if selected_recordings else self._["clear_all_confirm_tip"] + + async def confirm_dlg(_): + + if selected_recordings: + await self.app.record_manager.stop_monitor_recordings(selected_recordings) + await self.app.record_manager.delete_recording_cards(selected_recordings) + else: + await self.app.record_manager.stop_monitor_recordings(self.app.record_manager.recordings) + await self.app.record_manager.clear_all_recordings() + await self.delete_all_recording_cards() + self.app.page.pubsub.send_others_on_topic("delete_all", None) + + self.content_area.controls[1] = self.create_filter_area() + self.content_area.update() + + self.recording_card_area.update() + await self.app.snack_bar.show_snack_bar( + self._["delete_recording_success_tip"], bgcolor=ft.Colors.PRIMARY, duration=2000 + ) + await close_dialog(None) + + async def close_dialog(_): + batch_delete_alert_dialog.open = False + batch_delete_alert_dialog.update() + + batch_delete_alert_dialog = ft.AlertDialog( + title=ft.Text(self._["confirm"]), + content=ft.Text(tips), + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), + ], + actions_alignment=ft.MainAxisAlignment.END, + modal=False, + ) + + batch_delete_alert_dialog.open = True + self.app.dialog_area.content = batch_delete_alert_dialog + self.page.update() + + async def delete_all_recording_cards(self): + self.recording_card_area.content.controls.clear() + self.recording_card_area.update() + self.app.record_card_manager.cards_obj = {} + + self.current_platform_filter = "all" + + self.content_area.controls[1] = self.create_filter_area() + self.content_area.update() + + async def subscribe_del_all_cards(self, *_): + await self.delete_all_recording_cards() + + async def subscribe_add_cards(self, _, recording: Recording): + """Handle the subscription of adding cards from other clients""" + + self.loading_indicator.visible = True + self.loading_indicator.update() + + if recording.rec_id not in self.app.record_card_manager.cards_obj: + card = await self.app.record_card_manager.create_card(recording, subscribe_add_cards=True) + recording.scheduled_time_range = await self.app.record_manager.get_scheduled_time_range( + recording.scheduled_start_time, recording.monitor_hours + ) + + self.recording_card_area.content.controls.append(card) + self.app.record_card_manager.cards_obj[recording.rec_id]["card"] = card + + self.recording_card_area.update() + + self.content_area.controls[1] = self.create_filter_area() + self.content_area.update() + + self.loading_indicator.visible = False + self.loading_indicator.update() + + async def update_grid_layout(self, _): + self.page.run_task(self.recalculate_grid_columns) + + def get_grid_available_width(self): + page_width = self.page.width or getattr(self.page.window, "width", 0) or 0 + if self.app.is_mobile: + return page_width + + sidebar_width = getattr(self.app.left_navigation_menu, "width", 0) or 0 + divider_width = 1 + content_padding = 24 + return max(0, page_width - sidebar_width - divider_width - content_padding) + + def get_grid_runs_count(self): + column_width = self.MOBILE_CARD_MIN_WIDTH if self.app.is_mobile else self.CARD_MIN_WIDTH + available_width = self.get_grid_available_width() + return max(1, int(available_width / column_width)) + + def get_grid_child_aspect_ratio(self): + return self.MOBILE_CARD_ASPECT_RATIO if self.app.is_mobile else self.CARD_ASPECT_RATIO + + async def recalculate_grid_columns(self): + if not self.is_grid_view: + return + + runs_count = self.get_grid_runs_count() + child_aspect_ratio = self.get_grid_child_aspect_ratio() + + if isinstance(self.recording_card_area.content, ft.GridView): + grid_view = self.recording_card_area.content + grid_view.runs_count = runs_count + + grid_view.child_aspect_ratio = child_aspect_ratio + + if self.app.is_mobile: + grid_view.spacing = 5 + grid_view.run_spacing = 5 + + grid_view.update() + + async def on_keyboard(self, e: ft.KeyboardEvent): + if e.alt and e.key == "H": + self.app.dialog_area.content = HelpDialog(self.app) + self.app.dialog_area.content.open = True + self.app.dialog_area.update() + if self.app.current_page == self: + if e.ctrl and e.key == "F": + self.page.run_task(self.search_on_click, e) + elif e.ctrl and e.key == "R": + self.page.run_task(self.refresh_cards_on_click, e) + elif e.alt and e.key == "N": + self.page.run_task(self.add_recording_on_click, e) + elif e.alt and e.key == "B": + self.page.run_task(self.start_monitor_recordings_on_click, e) + elif e.alt and e.key == "P": + self.page.run_task(self.stop_monitor_recordings_on_click, e) + elif e.alt and e.key == "D": + self.page.run_task(self.delete_monitor_recordings_on_click, e) + + def on_platform_dropdown_change(self, e): + self.current_platform_filter = e.control.value + self.page.run_task(self.apply_filter) diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 6b8cb90d..401e7179 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -1,1406 +1,1731 @@ -import asyncio -import os - -import flet as ft - -from ...models.media.audio_format_model import AudioFormat -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 ..base_page import PageBase -from ..components.dialogs.help_dialog import HelpDialog - - -class SettingsPage(PageBase): - def __init__(self, app): - super().__init__(app) - self.page_name = "settings" - self.config_manager = self.app.config_manager - - self.user_config = self.config_manager.load_user_config() - self.language_option = self.config_manager.load_language_config() - self.default_config = self.config_manager.load_default_config() - self.cookies_config = self.config_manager.load_cookies_config() - self.accounts_config = self.config_manager.load_accounts_config() - - self.language_code = None - self.default_language = None - self.focused_control = None - self.tab_recording = None - self.tab_push = None - self.tab_cookies = None - self.tab_accounts = None - self.tab_security = None - self.has_unsaved_changes = {} - self.delay_handler = DelayedTaskExecutor(self.app, self) - self.load_language() - self.init_unsaved_changes() - self.page.on_keyboard_event = self.on_keyboard - - async def load(self): - self.content_area.clean() - language = self.app.language_manager.language - self._ = language["settings_page"] | language["video_quality"] | language["base"] - self.tab_recording = self.create_recording_settings_tab() - self.tab_push = self.create_push_settings_tab() - self.tab_cookies = self.create_cookies_settings_tab() - self.tab_accounts = self.create_accounts_settings_tab() - self.page.on_keyboard_event = self.on_keyboard - - tabs = [ - ft.Tab(text=self._["recording_settings"], content=self.tab_recording), - ft.Tab(text=self._["push_settings"], content=self.tab_push), - ft.Tab(text=self._["cookies_settings"], content=self.tab_cookies), - ft.Tab(text=self._["accounts_settings"], content=self.tab_accounts), - ] - - if self.app.page.web: - self.tab_security = self.create_security_settings_tab() - tabs.append(ft.Tab(text=self._["security_settings"], content=self.tab_security)) - - settings_tabs = ft.Tabs( - selected_index=0, - animation_duration=300, - tabs=tabs, - expand=True, - ) - - if self.app.is_mobile: - scrollable_content = ft.Container( - content=settings_tabs, - expand=True, - width=float("inf"), - ) - else: - scrollable_content = ft.Container( - content=settings_tabs, - expand=True, - ) - - settings_content = ft.Container( - content=scrollable_content, - expand=True, - ) - - column_layout = ft.Column( - [ - settings_content, - ], - spacing=0, - expand=True, - width=float("inf") if self.app.is_mobile else None, - ) - - self.content_area.controls.append(column_layout) - self.app.complete_page.update() - - def init_unsaved_changes(self): - self.has_unsaved_changes = { - "user_config": False, - "cookies_config": False, - "accounts_config": False - } - - def load_language(self): - self.default_language, default_language_code = list(self.language_option.items())[0] - select_language = self.user_config.get("language") - self.language_code = self.language_option.get(select_language, default_language_code) - self.app.language_code = self.language_code - - def get_config_value(self, key, default=None): - return self.user_config.get(key, self.default_config.get(key, default)) - - def get_cookies_value(self, key, default=""): - return self.cookies_config.get(key, default) - - def get_accounts_value(self, key, default=None): - k1, k2 = key.split("_", maxsplit=1) - return self.accounts_config.get(k1, {}).get(k2, default) - - async def restore_default_config(self, _): - """Restore settings to their default values.""" - - async def confirm_dlg(_): - ui_language = self.user_config["language"] - self.user_config = self.default_config.copy() - self.user_config["language"] = ui_language - self.app.language_manager.notify_observers() - self.page.run_task(self.load) - await self.config_manager.save_user_config(self.user_config) - logger.success("Default configuration restored.") - await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.GREEN) - await close_dialog(None) - - async def close_dialog(_): - restore_alert_dialog.open = False - restore_alert_dialog.update() - - restore_alert_dialog = ft.AlertDialog( - title=ft.Text(self._["confirm"]), - content=ft.Text(self._["query_restore_config_tip"]), - actions=[ - ft.TextButton(text=self._["cancel"], on_click=close_dialog), - ft.TextButton(text=self._["sure"], on_click=confirm_dlg), - ], - actions_alignment=ft.MainAxisAlignment.END, - modal=False, - ) - - self.app.dialog_area.content = restore_alert_dialog - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - - async def on_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - if isinstance(e.control, (ft.Switch, ft.Checkbox)): - self.user_config[key] = e.data.lower() == "true" - else: - self.user_config[key] = e.data - - if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: - for recording in self.app.record_manager.recordings: - recording.recording_dir = None - self.page.run_task(self.app.record_manager.persist_recordings) - - if key == "language": - self.load_language() - self.app.language_manager.load() - self.app.language_manager.notify_observers() - self.page.run_task(self.load) - - if key == "loop_time_seconds": - self.app.record_manager.initialize_dynamic_state() - self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) - self.has_unsaved_changes['user_config'] = True - - def on_cookies_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - self.cookies_config[key] = e.data - self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) - self.has_unsaved_changes['cookies_config'] = True - - def on_accounts_change(self, e): - """Handle changes in any input field and trigger auto-save.""" - key = e.control.data - k1, k2 = key.split("_", maxsplit=1) - if k1 not in self.accounts_config: - self.accounts_config[k1] = {} - - self.accounts_config[k1][k2] = e.data - self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) - self.has_unsaved_changes['accounts_config'] = True - - async def save_user_config_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes['user_config']: - await self.config_manager.save_user_config(self.user_config) - - async def save_cookies_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes['cookies_config']: - await self.config_manager.save_cookies_config(self.cookies_config) - - async def save_accounts_after_delay(self, delay): - await asyncio.sleep(delay) - if self.has_unsaved_changes['accounts_config']: - await self.config_manager.save_accounts_config(self.accounts_config) - - 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') - return live_save_path - - @staticmethod - def get_supported_record_format() -> list: - return VideoFormat.get_formats() + AudioFormat.get_formats() - - def create_recording_settings_tab(self): - """Create UI elements for recording settings.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["basic_settings"], - self._["program_config"], - [ - self.create_setting_row( - self._["restore_defaults"], - ft.IconButton( - icon=ft.Icons.RESTORE_OUTLINED, - icon_size=32, - tooltip=self._["restore_defaults"], - on_click=self.restore_default_config, - ), - ), - self.create_setting_row( - self._["program_language"], - ft.Dropdown( - options=[ - ft.dropdown.Option(key=k, text=self._[k]) for k, v in self.language_option.items() - ], - value=self.get_config_value("language", self.default_language), - width=200, - on_change=self.on_change, - data="language", - tooltip=self._["switch_language"], - ), - ), - self.create_setting_row( - self._["filename_includes_title"], - ft.Switch( - value=self.get_config_value("filename_includes_title"), - on_change=self.on_change, - data="filename_includes_title", - ), - ), - self.create_setting_row( - self._["custom_filename_template"], - ft.TextField( - value=self.get_config_value("custom_filename_template", "{anchor_name}_{title}_{time}"), - width=300, - on_change=self.on_change, - data="custom_filename_template", - hint_text="{anchor_name}_{title}_{time}", - ), - ), - self.pick_folder( - self._["live_recording_path"], - ft.TextField( - value=self.get_video_save_path(), - width=300, - on_change=self.on_change, - data="live_save_path", - ), - ), - self.create_setting_row( - self._["remove_emojis"], - ft.Switch( - value=self.get_config_value("remove_emojis"), - on_change=self.on_change, - data="remove_emojis", - ), - ), - self.create_folder_setting_row(self._["name_rules"]), - ], - is_mobile, - ), - self.create_setting_group( - self._["proxy_settings"], - self._["is_proxy_enabled"], - [ - self.create_setting_row( - self._["enable_proxy"], - ft.Switch( - value=self.get_config_value("enable_proxy"), - on_change=self.on_change, - data="enable_proxy", - ), - ), - self.create_setting_row( - self._["proxy_address"], - ft.TextField( - value=self.get_config_value("proxy_address"), - width=300, - on_change=self.on_change, - data="proxy_address", - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["recording_options"], - self._["advanced_config"], - [ - self.create_setting_row( - self._["video_record_format"], - ft.Dropdown( - options=[ft.dropdown.Option(i) for i in self.get_supported_record_format()], - value=self.get_config_value("video_format", VideoFormat.TS), - width=200, - data="video_format", - on_change=self.on_change, - tooltip=self._["switch_video_format"], - ), - ), - self.create_setting_row( - self._["recording_quality"], - ft.Dropdown( - options=[ft.dropdown.Option(i, text=self._[i]) for i in VideoQuality.get_qualities()], - value=self.get_config_value("record_quality", VideoQuality.OD), - width=200, - data="record_quality", - on_change=self.on_change, - tooltip=self._["switch_recording_quality"], - ), - ), - self.create_setting_row( - self._["loop_time"], - ft.TextField( - value=self.get_config_value("loop_time_seconds"), - width=100, - data="loop_time_seconds", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["is_segmented_recording_enabled"], - ft.Switch( - value=self.get_config_value("segmented_recording_enabled"), - data="segmented_recording_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["force_https"], - ft.Switch( - value=self.get_config_value("force_https_recording"), - data="force_https_recording", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["default_live_source"], - ft.Dropdown( - options=[ft.dropdown.Option(i) for i in ['HLS', 'FLV']], - value=self.get_config_value("default_live_source", 'FLV'), - width=200, - data="default_live_source", - on_change=self.on_change, - tooltip=self._["default_live_source_tip"], - ), - ), - self.create_setting_row( - self._["flv_use_direct_download"], - ft.Switch( - value=self.get_config_value("flv_use_direct_download"), - data="flv_use_direct_download", - on_change=self.on_change, - tooltip=self._["flv_use_direct_download_tip"], - ), - ), - self.create_setting_row( - self._["space_threshold"], - ft.TextField( - value=self.get_config_value("recording_space_threshold"), - width=100, - data="recording_space_threshold", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["segment_time"], - ft.TextField( - value=self.get_config_value("video_segment_time"), - width=100, - data="video_segment_time", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["convert_mp4"], - ft.Switch( - value=self.get_config_value("convert_to_mp4"), - data="convert_to_mp4", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["delete_original"], - ft.Switch( - value=self.get_config_value("delete_original"), - data="delete_original", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["generate_timestamps_subtitle"], - ft.Switch( - value=self.get_config_value("generate_time_subtitle_file"), - data="generate_time_subtitle_file", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_script"], - ft.Switch( - value=self.get_config_value("execute_custom_script"), - data="execute_custom_script", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["script_command"], - ft.TextField( - value=self.get_config_value("custom_script_command"), - width=300, - data="custom_script_command", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["default_platform_with_proxy"], - ft.TextField( - value=self.get_config_value("default_platform_with_proxy"), - width=300, - data="default_platform_with_proxy", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["platform_max_concurrent_requests"], - ft.TextField( - value=str(self.get_config_value("platform_max_concurrent_requests", 3)), - width=100, - data="platform_max_concurrent_requests", - on_change=self.on_change, - hint_text=self._["platform_max_concurrent_requests_tip"] - ), - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_push_settings_tab(self): - """Create UI elements for push configuration.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["push_notifications"], - self._["stream_start_notification_enabled"], - [ - self.create_setting_row( - self._["system_status_bar_notification_enabled"], - ft.Switch( - value=self.get_config_value("system_notification_enabled"), - data="system_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["open_broadcast_push_enabled"], - ft.Switch( - value=self.get_config_value("stream_start_notification_enabled"), - data="stream_start_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["close_broadcast_push_enabled"], - ft.Switch( - value=self.get_config_value("stream_end_notification_enabled"), - data="stream_end_notification_enabled", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["only_notify_no_record"], - ft.Switch( - value=self.get_config_value("only_notify_no_record"), - data="only_notify_no_record", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["notify_loop_time"], - ft.TextField( - value=self.get_config_value("notify_loop_time"), - width=300, - data="notify_loop_time", - on_change=self.on_change, - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["custom_push_settings"], - self._["personalized_notification_content_behavior"], - [ - self.create_setting_row( - self._["custom_push_title"], - ft.TextField( - value=self.get_config_value("custom_notification_title"), - width=300, - data="custom_notification_title", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_open_broadcast_content"], - ft.TextField( - value=self.get_config_value("custom_stream_start_content"), - width=300, - data="custom_stream_start_content", - on_change=self.on_change, - ), - ), - self.create_setting_row( - self._["custom_close_broadcast_content"], - ft.TextField( - value=self.get_config_value("custom_stream_end_content"), - width=300, - data="custom_stream_end_content", - on_change=self.on_change, - ), - ), - ], - is_mobile, - ), - self.create_setting_group( - self._["push_channels"], - self._["select_and_enable_channels"], - [self.create_push_channels_layout()], - is_mobile, - ), - self.create_setting_group( - self._["channel_configuration"], - self._["configure_enabled_channels"], - [ - self.create_channel_config( - self._["dingtalk"], - [ - self.create_setting_row( - self._["dingtalk_webhook_url"], - ft.TextField( - value=self.get_config_value("dingtalk_webhook_url"), - hint_text=self._["dingtalk_webhook_hint"], - width=300, - on_change=self.on_change, - data="dingtalk_webhook_url", - ), - ), - self.create_setting_row( - self._["dingtalk_at_objects"], - ft.TextField( - value=self.get_config_value("dingtalk_at_objects"), - hint_text=self._["dingtalk_phone_numbers_hint"], - width=300, - on_change=self.on_change, - data="dingtalk_at_objects", - ), - ), - self.create_setting_row( - self._["dingtalk_at_all"], - ft.Switch( - value=self.get_config_value("dingtalk_at_all"), - on_change=self.on_change, - data="dingtalk_at_all", - ), - ), - ], - ), - self.create_channel_config( - self._["wechat"], - [ - self.create_setting_row( - self._["wechat_webhook_url"], - ft.TextField( - value=self.get_config_value("wechat_webhook_url"), - width=300, - on_change=self.on_change, - data="wechat_webhook_url", - ), - ), - ], - ), - self.create_channel_config( - self._["serverchan"], - [ - self.create_setting_row( - self._["serverchan_send_key"], - ft.TextField( - value=self.get_config_value("serverchan_sendkey"), - width=300, - on_change=self.on_change, - data="serverchan_sendkey", - ), - ), - self.create_setting_row( - self._["serverchan_channel"], - ft.TextField( - value=self.get_config_value("serverchan_channel"), - width=300, - keyboard_type=ft.KeyboardType.NUMBER, - on_change=self.on_change, - data="serverchan_channel", - ), - ), - self.create_setting_row( - self._["serverchan_tags"], - ft.TextField( - value=self.get_config_value("serverchan_tags"), - width=300, - keyboard_type=ft.KeyboardType.NUMBER, - on_change=self.on_change, - data="serverchan_tags", - ), - ), - ], - ), - self.create_channel_config( - self._["email"], - [ - self.create_setting_row( - self._["smtp_server"], - ft.TextField( - value=self.get_config_value("smtp_server"), - width=300, - on_change=self.on_change, - data="smtp_server", - ), - ), - self.create_setting_row( - self._["email_username"], - ft.TextField( - value=self.get_config_value("email_username"), - width=300, - on_change=self.on_change, - data="email_username", - ), - ), - self.create_setting_row( - self._["email_password"], - ft.TextField( - value=self.get_config_value("email_password"), - width=300, - on_change=self.on_change, - data="email_password", - ), - ), - self.create_setting_row( - self._["sender_email"], - ft.TextField( - value=self.get_config_value("sender_email"), - width=300, - on_change=self.on_change, - data="sender_email", - ), - ), - self.create_setting_row( - self._["sender_name"], - ft.TextField( - value=self.get_config_value("sender_name"), - width=300, - on_change=self.on_change, - data="sender_name", - ), - ), - self.create_setting_row( - self._["recipient_email"], - ft.TextField( - value=self.get_config_value("recipient_email"), - width=300, - on_change=self.on_change, - data="recipient_email", - ), - ), - ], - ), - self.create_channel_config( - "Bark", - [ - self.create_setting_row( - self._["bark_webhook_url"], - ft.TextField( - value=self.get_config_value("bark_webhook_url"), - width=300, - on_change=self.on_change, - data="bark_webhook_url", - ), - ), - self.create_setting_row( - self._["bark_interrupt_level"], - ft.Dropdown( - options=[ft.dropdown.Option("active"), ft.dropdown.Option("passive")], - value=self.get_config_value("bark_interrupt_level"), - width=200, - on_change=self.on_change, - data="bark_interrupt_level", - ), - ), - self.create_setting_row( - self._["bark_sound"], - ft.TextField( - width=300, - on_change=self.on_change, - data="bark_sound", - value=self.get_config_value("bark_sound"), - ), - ), - ], - ), - self.create_channel_config( - "Ntfy", - [ - self.create_setting_row( - self._["ntfy_server_url"], - ft.TextField( - value=self.get_config_value("ntfy_server_url"), - width=300, - on_change=self.on_change, - data="ntfy_server_url", - ), - ), - self.create_setting_row( - self._["ntfy_tags"], - ft.TextField( - value=self.get_config_value("ntfy_tags"), - width=300, - on_change=self.on_change, - data="ntfy_tags", - ), - ), - self.create_setting_row( - self._["ntfy_email"], - ft.TextField( - value=self.get_config_value("ntfy_email"), - width=300, - on_change=self.on_change, - data="ntfy_email", - ), - ), - self.create_setting_row( - self._["ntfy_action_url"], - ft.TextField( - value=self.get_config_value("ntfy_action_url"), - width=300, - on_change=self.on_change, - data="ntfy_action_url", - ), - ), - ], - ), - self.create_channel_config( - self._["telegram"], - [ - self.create_setting_row( - self._["telegram_api_token"], - ft.TextField( - value=self.get_config_value("telegram_api_token"), - width=300, - on_change=self.on_change, - data="telegram_api_token", - ), - ), - self.create_setting_row( - self._["telegram_chat_id"], - ft.TextField( - value=self.get_config_value("telegram_chat_id"), - width=300, - on_change=self.on_change, - data="telegram_chat_id", - ), - ), - ], - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_push_channels_layout(self): - controls = [ - self.create_channel_switch_container( - self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled" - ), - self.create_channel_switch_container( - self._["wechat"], ft.Icons.WECHAT, "wechat_enabled" - ), - self.create_channel_switch_container( - self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled" - ), - self.create_channel_switch_container( - self._["email"], ft.Icons.EMAIL, "email_enabled" - ), - self.create_channel_switch_container( - "Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled" - ), - self.create_channel_switch_container( - "Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled" - ), - self.create_channel_switch_container( - self._["telegram"], ft.Icons.SMS, "telegram_enabled" - ), - ] - - if self.app.is_mobile: - return ft.Row( - controls=controls, - spacing=5, - wrap=True, - ) - if self.app.page.web: - return ft.Row( - controls=controls, - alignment=ft.MainAxisAlignment.START, - spacing=12, - ) - else: - return ft.Container( - content=ft.GridView( - controls=controls, - runs_count=3, - max_extent=175, - spacing=5, - run_spacing=2, - child_aspect_ratio=2.5, - ), - expand=True, - ) - - def create_cookies_settings_tab(self): - """Create UI elements for push configuration.""" - is_mobile = self.app.is_mobile - - platforms = [ - "douyin", - "tiktok", - "kuaishou", - "huya", - "douyu", - "yy", - "bilibili", - "xhs", - "bigo", - "blued", - "soop", - "netease", - "qiandurebo", - "pandalive", - "maoerfm", - "winktv", - "flextv", - "look", - "popkontv", - "twitcasting", - "baidu", - "weibo", - "kugou", - "twitch", - "liveme", - "huajiao", - "liuxing", - "showroom", - "acfun", - "changliao", - "yinbo", - "inke", - "zhihu", - "chzzk", - "haixiu", - "vvxq", - "17live", - "lang", - "piaopiao", - "6room", - "lehai", - "catshow", - "shopee", - "youtube", - "taobao", - "jd", - ] - - setting_rows = [] - for platform in platforms: - cookie_field = ft.TextField( - value=self.get_cookies_value(platform), width=500, data=platform, on_change=self.on_cookies_change - ) - setting_rows.append(self.create_setting_row(self._[f"{platform}_cookie"], cookie_field)) - - return ft.Column( - [ - self.create_setting_group( - self._["cookies_settings"], self._["configure_platform_cookies"], setting_rows, is_mobile - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_accounts_settings_tab(self): - """Create UI elements for platform accounts configuration.""" - is_mobile = self.app.is_mobile - - return ft.Column( - [ - self.create_setting_group( - self._["accounts_settings"], - self._["configure_platform_accounts"], - [ - self.create_setting_row( - self._["sooplive_username"], - ft.TextField( - value=self.get_accounts_value("sooplive_username"), - width=500, - data="sooplive_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["sooplive_password"], - ft.TextField( - value=self.get_accounts_value("sooplive_password"), - width=500, - data="sooplive_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["flextv_username"], - ft.TextField( - value=self.get_accounts_value("flextv_username"), - width=500, - data="flextv_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["flextv_password"], - ft.TextField( - value=self.get_accounts_value("flextv_password"), - width=500, - data="flextv_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["popkontv_username"], - ft.TextField( - value=self.get_accounts_value("popkontv_username"), - width=500, - data="popkontv_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["popkontv_password"], - ft.TextField( - value=self.get_accounts_value("popkontv_password"), - width=500, - data="popkontv_password", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["twitcasting_account_type"], - ft.Dropdown( - options=[ft.dropdown.Option("Default"), ft.dropdown.Option("Twitter")], - value=self.get_accounts_value("twitcasting_account_type", "Default"), - width=500, - data="twitcasting_account_type", - on_change=self.on_accounts_change, - tooltip=self._["switch_account_type"], - ), - ), - self.create_setting_row( - self._["twitcasting_username"], - ft.TextField( - value=self.get_accounts_value("twitcasting_username"), - width=500, - data="twitcasting_username", - on_change=self.on_accounts_change, - ), - ), - self.create_setting_row( - self._["twitcasting_password"], - ft.TextField( - value=self.get_accounts_value("twitcasting_password"), - width=500, - data="twitcasting_password", - on_change=self.on_accounts_change, - ), - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) - - def create_folder_setting_row(self, label): - checkboxes = [ - ft.Checkbox( - label=self._["platform"], - value=self.get_config_value("folder_name_platform"), - on_change=self.on_change, - data="folder_name_platform", - ), - ft.Checkbox( - label=self._["author"], - value=self.get_config_value("folder_name_author"), - on_change=self.on_change, - data="folder_name_author", - ), - ft.Checkbox( - label=self._["time"], - value=self.get_config_value("folder_name_time"), - on_change=self.on_change, - data="folder_name_time", - ), - ft.Checkbox( - label=self._["title"], - value=self.get_config_value("folder_name_title"), - on_change=self.on_change, - data="folder_name_title", - ), - ] - - if self.app.is_mobile: - checkbox_grid = ft.Column( - [ - ft.Row([checkboxes[0], checkboxes[1]], spacing=10), - ft.Row([checkboxes[2], checkboxes[3]], spacing=10), - ], - spacing=5, - ) - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), - ft.Container( - content=checkbox_grid, - margin=ft.margin.only(top=5, bottom=10), - ) - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), *checkboxes], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - def create_channel_switch_container(self, channel_name, icon, key): - """Helper method to create a container with a switch and an icon for each channel.""" - return ft.Container( - content=ft.Row( - [ - ft.Icon(icon, size=24, color=ft.Colors.GREY_700), - ft.Text(channel_name, size=14), - ft.Switch(value=self.get_config_value(key), label="", width=50, on_change=self.on_change, data=key), - ], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - padding=5, - margin=5, - ) - - @staticmethod - def create_channel_config(channel_name, settings): - """Helper method to create expandable configurations for each channel.""" - return ft.ExpansionTile( - initially_expanded=False, - title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), - controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], - tile_padding=0, - ) - - @staticmethod - def create_setting_group(title, description, settings, is_mobile=False): - """Helper method to group settings under a title.""" - padding = 5 if is_mobile else 10 - margin = 5 if is_mobile else 10 - - card = ft.Card( - content=ft.Container( - content=ft.Column( - [ - ft.Text(title, size=16, weight=ft.FontWeight.BOLD), - ft.Text(description, theme_style=ft.TextThemeStyle.BODY_MEDIUM, opacity=0.7), - *settings, - ], - spacing=5, - ), - padding=padding, - ), - elevation=5, - margin=margin, - ) - - if is_mobile: - return ft.Container( - content=card, - width=float("inf"), - expand=True, - ) - else: - return card - - def set_focused_control(self, control): - """Store the currently focused control.""" - self.focused_control = control - - def create_setting_row(self, label, control): - """Helper method to create a row for each setting.""" - if hasattr(control, 'on_focus'): - control.on_focus = lambda e: self.set_focused_control(e.control) - - if self.app.is_mobile: - if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): - return ft.Row( - [ - ft.Text(label), - ft.Container(expand=True), - control - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - width=float("inf"), - ) - - if hasattr(control, 'width') and control.width and control.width > 250: - control.width = 250 - - if isinstance(control, (ft.TextField, ft.Dropdown)): - control.width = float("inf") - control.expand = True - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT), - ft.Container( - content=control, - margin=ft.margin.only(top=5, bottom=10), - expand=True, - width=float("inf"), - ) - ], - spacing=0, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - def pick_folder(self, label, control): - def picked_folder(e: ft.FilePickerResultEvent): - path = e.path - if path: - control.value = path - control.update() - e.control.data = control.data - e.data = path - self.page.run_task(self.on_change, e) - - async def pick_folder(_): - if self.app.page.web: - await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) - folder_picker.get_directory_path() - - folder_picker = ft.FilePicker(on_result=picked_folder) - self.page.overlay.append(folder_picker) - self.page.update() - - btn_pick_folder = ft.ElevatedButton( - text=self._["select"], icon=ft.Icons.FOLDER_OPEN, on_click=pick_folder, tooltip=self._["select_btn_tip"] - ) - - if self.app.is_mobile: - if hasattr(control, 'width'): - control.width = float("inf") - control.expand = True - - return ft.Column( - [ - ft.Text(label, text_align=ft.TextAlign.LEFT), - ft.Row( - [ - ft.Container( - content=control, - expand=True, - ), - btn_pick_folder - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - expand=True, - width=float("inf"), - ), - ], - spacing=5, - alignment=ft.MainAxisAlignment.START, - horizontal_alignment=ft.CrossAxisAlignment.START, - width=float("inf"), - ) - else: - return ft.Row( - [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control, btn_pick_folder], - alignment=ft.MainAxisAlignment.START, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ) - - async def is_changed(self): - if self.app.current_page != self: - return - - show_snack_bar = False - save_methods = { - "user_config": (self.config_manager.save_user_config, self.user_config), - "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), - "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config) - } - - for config_key, should_save in self.has_unsaved_changes.items(): - if should_save and config_key in save_methods: - save_method, config_value = save_methods[config_key] - await save_method(config_value) - self.has_unsaved_changes[config_key] = False - show_snack_bar = True - - if show_snack_bar: - await self.app.snack_bar.show_snack_bar( - self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.GREEN - ) - - async def on_keyboard(self, e: ft.KeyboardEvent): - if e.alt and e.key == "H": - self.app.dialog_area.content = HelpDialog(self.app) - self.app.dialog_area.content.open = True - self.app.dialog_area.update() - - if self.app.current_page == self and e.ctrl and e.key == "S": - self.page.run_task(self.is_changed) - - def create_security_settings_tab(self): - is_mobile = self.app.is_mobile - - async def change_password(_): - old_password = old_password_field.value - new_password = new_password_field.value - confirm_password = confirm_password_field.value - - if not old_password: - await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) - return - - if not new_password: - await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) - return - - if new_password != confirm_password: - await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) - return - - _username = self.app.current_username - if _username: - success = await self.app.auth_manager.change_password(_username, old_password, new_password) - - if success: - old_password_field.value = "" - new_password_field.value = "" - confirm_password_field.value = "" - old_password_field.update() - new_password_field.update() - confirm_password_field.update() - - await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.GREEN) - else: - await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) - else: - await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) - - async def toggle_login_required(_): - login_required = login_required_switch.value - self.user_config["login_required"] = login_required - await self.config_manager.save_user_config(self.user_config) - - if login_required: - await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.GREEN) - else: - await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.GREEN) - - username = self.app.current_username or "admin" - - old_password_field = ft.TextField( - password=True, - width=300, - label=self._["old_password"], - ) - - new_password_field = ft.TextField( - password=True, - width=300, - label=self._["new_password"], - ) - - confirm_password_field = ft.TextField( - password=True, - width=300, - label=self._["confirm_password"], - ) - - change_password_button = ft.ElevatedButton( - text=self._["change_password"], - on_click=change_password, - icon=ft.icons.LOCK_RESET, - ) - - login_required_switch = ft.Switch( - value=self.get_config_value("login_required", False), - on_change=toggle_login_required, - ) - - return ft.Column( - [ - self.create_setting_group( - self._["security_settings"], - self._["web_login_configuration"], - [ - self.create_setting_row( - self._["login_required"], - login_required_switch, - ), - self.create_setting_row( - self._["current_username"], - ft.Text(username), - ), - self.create_setting_row( - self._["old_password"], - old_password_field, - ), - self.create_setting_row( - self._["new_password"], - new_password_field, - ), - self.create_setting_row( - self._["confirm_password"], - confirm_password_field, - ), - self.create_setting_row( - "", - change_password_button, - ), - ], - is_mobile, - ), - ], - spacing=10, - scroll=ft.ScrollMode.AUTO, - ) +import asyncio +import sys +from datetime import datetime, timedelta + +import flet as ft + +from ...core.runtime.paths import default_recordings_dir +from ...core.runtime.scheduled_shutdown import MAX_QUICK_SHUTDOWN_HOURS +from ...models.media.audio_format_model import AudioFormat +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 ..base_page import PageBase +from ..components.dialogs.help_dialog import HelpDialog +from ..themes import PopupColorItem + + +class SettingsPage(PageBase): + def __init__(self, app): + super().__init__(app) + self.page_name = "settings" + self.config_manager = self.app.config_manager + + self.user_config = self.config_manager.load_user_config() + self.language_option = self.config_manager.load_language_config() + self.default_config = self.config_manager.load_default_config() + self.cookies_config = self.config_manager.load_cookies_config() + self.accounts_config = self.config_manager.load_accounts_config() + + self.language_code = None + self.default_language = None + self.focused_control = None + self.tab_recording = None + self.tab_push = None + self.tab_cookies = None + self.tab_accounts = None + self.tab_security = None + self.has_unsaved_changes = {} + self.delay_handler = DelayedTaskExecutor(self.app, self) + self.load_language() + self.init_unsaved_changes() + self.page.on_keyboard_event = self.on_keyboard + + async def load(self): + self.content_area.controls.clear() + language = self.app.language_manager.language + self._ = language["settings_page"] | language["video_quality"] | language["base"] | language["sidebar"] + self.tab_recording = self.create_recording_settings_tab() + self.tab_push = self.create_push_settings_tab() + self.tab_cookies = self.create_cookies_settings_tab() + self.tab_accounts = self.create_accounts_settings_tab() + self.page.on_keyboard_event = self.on_keyboard + + tab_labels = [ + ft.Tab(label=self._["recording_settings"]), + ft.Tab(label=self._["push_settings"]), + ft.Tab(label=self._["cookies_settings"]), + ft.Tab(label=self._["accounts_settings"]), + ] + tab_contents = [ + self.tab_recording, + self.tab_push, + self.tab_cookies, + self.tab_accounts, + ] + + if self.app.page.web: + self.tab_security = self.create_security_settings_tab() + tab_labels.append(ft.Tab(label=self._["security_settings"])) + tab_contents.append(self.tab_security) + + settings_tabs = ft.Tabs( + content=ft.Column( + [ + ft.TabBar(tabs=tab_labels), + ft.TabBarView(controls=tab_contents, expand=True), + ], + expand=True, + ), + length=len(tab_labels), + selected_index=0, + animation_duration=300, + expand=True, + ) + + if self.app.is_mobile: + scrollable_content = ft.Container( + content=settings_tabs, + expand=True, + width=float("inf"), + ) + else: + scrollable_content = ft.Container( + content=settings_tabs, + expand=True, + ) + + settings_content = ft.Container( + content=scrollable_content, + expand=True, + ) + + column_layout = ft.Column( + [ + settings_content, + ], + spacing=0, + expand=True, + width=float("inf") if self.app.is_mobile else None, + ) + + self.content_area.controls.append(column_layout) + self.app.complete_page.update() + + def init_unsaved_changes(self): + self.has_unsaved_changes = {"user_config": False, "cookies_config": False, "accounts_config": False} + + def load_language(self): + self.default_language, default_language_code = list(self.language_option.items())[0] + select_language = self.user_config.get("language") + self.language_code = self.language_option.get(select_language, default_language_code) + self.app.language_code = self.language_code + + def get_config_value(self, key, default=None): + return self.user_config.get(key, self.default_config.get(key, default)) + + def get_cookies_value(self, key, default=""): + return self.cookies_config.get(key, default) + + def get_accounts_value(self, key, default=None): + k1, k2 = key.split("_", maxsplit=1) + return self.accounts_config.get(k1, {}).get(k2, default) + + async def restore_default_config(self, _): + """Restore settings to their default values.""" + + async def confirm_dlg(_): + ui_language = self.user_config["language"] + self.user_config = self.default_config.copy() + self.user_config["language"] = ui_language + self.app.services.settings_config.adopt_user_config(self.user_config) + self.app.language_manager.notify_observers() + self.page.run_task(self.load) + await self.config_manager.save_user_config(self.user_config) + await self.app.shutdown_manager.reschedule() + logger.success("Default configuration restored.") + await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY) + await close_dialog(None) + + async def close_dialog(_): + restore_alert_dialog.open = False + restore_alert_dialog.update() + + restore_alert_dialog = ft.AlertDialog( + title=ft.Text(self._["confirm"]), + content=ft.Text(self._["query_restore_config_tip"]), + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.TextButton(content=self._["sure"], on_click=confirm_dlg), + ], + actions_alignment=ft.MainAxisAlignment.END, + modal=False, + ) + + self.app.dialog_area.content = restore_alert_dialog + self.app.dialog_area.content.open = True + self.app.dialog_area.update() + + async def on_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + if isinstance(e.control, (ft.Switch, ft.Checkbox)): + value = e.data + self.user_config[key] = value if isinstance(value, bool) else str(value).lower() == "true" + else: + # For other controls, e.data is string + self.user_config[key] = e.data + + if key in ["folder_name_platform", "folder_name_author", "folder_name_time", "folder_name_title"]: + for recording in self.app.record_manager.recordings: + recording.recording_dir = None + self.app.services.run_coro(self.app.record_manager.persist_recordings()) + + if key == "language": + self.load_language() + self.app.language_manager.load() + self.app.language_manager.notify_observers() + self.page.run_task(self.load) + + if key == "loop_time_seconds": + self.app.record_manager.initialize_dynamic_state() + if key in {"scheduled_shutdown_enabled", "scheduled_shutdown_time"}: + await self.app.shutdown_manager.reschedule() + self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None) + self.has_unsaved_changes["user_config"] = True + + def on_cookies_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + self.cookies_config[key] = e.data + self.page.run_task(self.delay_handler.start_task_timer, self.save_cookies_after_delay, None) + self.has_unsaved_changes["cookies_config"] = True + + def on_accounts_change(self, e): + """Handle changes in any input field and trigger auto-save.""" + key = e.control.data + k1, k2 = key.split("_", maxsplit=1) + if k1 not in self.accounts_config: + self.accounts_config[k1] = {} + + self.accounts_config[k1][k2] = e.data + self.page.run_task(self.delay_handler.start_task_timer, self.save_accounts_after_delay, None) + self.has_unsaved_changes["accounts_config"] = True + + async def save_user_config_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["user_config"]: + await self.config_manager.save_user_config(self.user_config) + + async def save_cookies_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["cookies_config"]: + await self.config_manager.save_cookies_config(self.cookies_config) + + async def save_accounts_after_delay(self, delay): + await asyncio.sleep(delay) + if self.has_unsaved_changes["accounts_config"]: + await self.config_manager.save_accounts_config(self.accounts_config) + + def get_video_save_path(self): + 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 + + @staticmethod + def get_supported_record_format() -> list: + return VideoFormat.get_formats() + AudioFormat.get_formats() + + def create_recording_settings_tab(self): + """Create UI elements for recording settings.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["basic_settings"], + self._["program_config"], + [ + self.create_setting_row( + self._["restore_defaults"], + ft.IconButton( + icon=ft.Icons.RESTORE_OUTLINED, + icon_color=ft.Colors.PRIMARY, + icon_size=32, + tooltip=self._["restore_defaults"], + on_click=self.restore_default_config, + ), + ), + self.create_setting_row( + self._["program_language"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption(key=k, text=self._[k]) + for k, v in self.language_option.items() + ], + value=self.get_config_value("language", self.default_language), + width=200, + on_select=self.on_change, + data="language", + tooltip=self._["switch_language"], + ), + ), + self.create_setting_row( + self._["theme_color"], + ft.PopupMenuButton( + icon=ft.Icons.COLOR_LENS_OUTLINED, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["colors"], + items=[ + PopupColorItem(color=color, name=name) + for color, name in [ + ("deeppurple", "Deep purple"), + ("purple", "Purple"), + ("indigo", "Indigo"), + ("blue", "Blue"), + ("teal", "Teal"), + ("deeporange", "Deep orange"), + ("orange", "Orange"), + ("pink", "Pink"), + ("brown", "Brown"), + ("bluegrey", "Blue Grey"), + ("green", "Green"), + ("cyan", "Cyan"), + ("lightblue", "Light Blue"), + ("", "Default"), + ] + ], + ), + ), + self.create_setting_row( + self._["filename_includes_title"], + ft.Switch( + value=self.get_config_value("filename_includes_title"), + on_change=self.on_change, + data="filename_includes_title", + ), + ), + self.create_setting_row( + self._["custom_filename_template"], + ft.TextField( + value=self.get_config_value("custom_filename_template", "{anchor_name}_{title}_{time}"), + width=300, + on_change=self.on_change, + data="custom_filename_template", + hint_text="{anchor_name}_{title}_{time}", + ), + ), + self.pick_folder( + self._["live_recording_path"], + ft.TextField( + value=self.get_video_save_path(), + width=300, + on_change=self.on_change, + data="live_save_path", + ), + ), + self.create_setting_row( + self._["remove_emojis"], + ft.Switch( + value=self.get_config_value("remove_emojis"), + on_change=self.on_change, + data="remove_emojis", + ), + ), + self.create_folder_setting_row(self._["name_rules"]), + self.create_setting_row( + self._["remember_window_size"], + ft.Switch( + value=self.get_config_value("remember_window_size", False), + on_change=self.on_change, + data="remember_window_size", + ), + ), + *( + [self.create_scheduled_shutdown_setting_row()] + if sys.platform == "win32" and not self.app.page.web + else [] + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["proxy_settings"], + self._["is_proxy_enabled"], + [ + self.create_setting_row( + self._["enable_proxy"], + ft.Switch( + value=self.get_config_value("enable_proxy"), + on_change=self.on_change, + data="enable_proxy", + ), + ), + self.create_setting_row( + self._["proxy_address"], + ft.TextField( + value=self.get_config_value("proxy_address"), + width=300, + on_change=self.on_change, + data="proxy_address", + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["recording_options"], + self._["advanced_config"], + [ + self.create_setting_row( + self._["video_record_format"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption(i) for i in self.get_supported_record_format()], + value=self.get_config_value("video_format", VideoFormat.TS), + width=200, + data="video_format", + on_select=self.on_change, + tooltip=self._["switch_video_format"], + ), + ), + self.create_setting_row( + self._["recording_quality"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption(i, text=self._[i]) for i in VideoQuality.get_qualities() + ], + value=self.get_config_value("record_quality", VideoQuality.OD), + width=200, + data="record_quality", + on_select=self.on_change, + tooltip=self._["switch_recording_quality"], + ), + ), + self.create_setting_row( + self._["loop_time"], + ft.TextField( + value=self.get_config_value("loop_time_seconds"), + width=100, + data="loop_time_seconds", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["is_segmented_recording_enabled"], + ft.Switch( + value=self.get_config_value("segmented_recording_enabled"), + data="segmented_recording_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["force_https"], + ft.Switch( + value=self.get_config_value("force_https_recording"), + data="force_https_recording", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["default_live_source"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption(i) for i in ["HLS", "FLV"]], + value=self.get_config_value("default_live_source", "FLV"), + width=200, + data="default_live_source", + on_select=self.on_change, + tooltip=self._["default_live_source_tip"], + ), + ), + self.create_setting_row( + self._["flv_use_direct_download"], + ft.Switch( + value=self.get_config_value("flv_use_direct_download"), + data="flv_use_direct_download", + on_change=self.on_change, + tooltip=self._["flv_use_direct_download_tip"], + ), + ), + self.create_setting_row( + self._["space_threshold"], + ft.TextField( + value=self.get_config_value("recording_space_threshold"), + width=100, + data="recording_space_threshold", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["segment_time"], + ft.TextField( + value=self.get_config_value("video_segment_time"), + width=100, + data="video_segment_time", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["convert_mp4"], + ft.Switch( + value=self.get_config_value("convert_to_mp4"), + data="convert_to_mp4", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["delete_original"], + ft.Switch( + value=self.get_config_value("delete_original"), + data="delete_original", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["generate_timestamps_subtitle"], + ft.Switch( + value=self.get_config_value("generate_time_subtitle_file"), + data="generate_time_subtitle_file", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_script"], + ft.Switch( + value=self.get_config_value("execute_custom_script"), + data="execute_custom_script", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["script_command"], + ft.TextField( + value=self.get_config_value("custom_script_command"), + width=300, + data="custom_script_command", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["default_platform_with_proxy"], + ft.TextField( + value=self.get_config_value("default_platform_with_proxy"), + width=300, + data="default_platform_with_proxy", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["platform_max_concurrent_requests"], + ft.TextField( + value=str(self.get_config_value("platform_max_concurrent_requests", 3)), + width=100, + data="platform_max_concurrent_requests", + on_change=self.on_change, + hint_text=self._["platform_max_concurrent_requests_tip"], + ), + ), + self.create_setting_row( + self._["check_live_on_browser_refresh"], + ft.Switch( + value=self.get_config_value("check_live_on_browser_refresh", True), + data="check_live_on_browser_refresh", + on_change=self.on_change, + tooltip=self._["check_live_on_browser_refresh_tip"], + ), + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_push_settings_tab(self): + """Create UI elements for push configuration.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["push_notifications"], + self._["stream_start_notification_enabled"], + [ + self.create_setting_row( + self._["system_status_bar_notification_enabled"], + ft.Switch( + value=self.get_config_value("system_notification_enabled"), + data="system_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["open_broadcast_push_enabled"], + ft.Switch( + value=self.get_config_value("stream_start_notification_enabled"), + data="stream_start_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["close_broadcast_push_enabled"], + ft.Switch( + value=self.get_config_value("stream_end_notification_enabled"), + data="stream_end_notification_enabled", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["only_notify_no_record"], + ft.Switch( + value=self.get_config_value("only_notify_no_record"), + data="only_notify_no_record", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["notify_loop_time"], + ft.TextField( + value=self.get_config_value("notify_loop_time"), + width=300, + data="notify_loop_time", + on_change=self.on_change, + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["custom_push_settings"], + self._["personalized_notification_content_behavior"], + [ + self.create_setting_row( + self._["custom_push_title"], + ft.TextField( + value=self.get_config_value("custom_notification_title"), + width=300, + data="custom_notification_title", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_open_broadcast_content"], + ft.TextField( + value=self.get_config_value("custom_stream_start_content"), + width=300, + data="custom_stream_start_content", + on_change=self.on_change, + ), + ), + self.create_setting_row( + self._["custom_close_broadcast_content"], + ft.TextField( + value=self.get_config_value("custom_stream_end_content"), + width=300, + data="custom_stream_end_content", + on_change=self.on_change, + ), + ), + ], + is_mobile, + ), + self.create_setting_group( + self._["push_channels"], + self._["select_and_enable_channels"], + [self.create_push_channels_layout()], + is_mobile, + ), + self.create_setting_group( + self._["channel_configuration"], + self._["configure_enabled_channels"], + [ + self.create_channel_config( + self._["dingtalk"], + [ + self.create_setting_row( + self._["dingtalk_webhook_url"], + ft.TextField( + value=self.get_config_value("dingtalk_webhook_url"), + hint_text=self._["dingtalk_webhook_hint"], + width=300, + on_change=self.on_change, + data="dingtalk_webhook_url", + ), + ), + self.create_setting_row( + self._["dingtalk_at_objects"], + ft.TextField( + value=self.get_config_value("dingtalk_at_objects"), + hint_text=self._["dingtalk_phone_numbers_hint"], + width=300, + on_change=self.on_change, + data="dingtalk_at_objects", + ), + ), + self.create_setting_row( + self._["dingtalk_at_all"], + ft.Switch( + value=self.get_config_value("dingtalk_at_all"), + on_change=self.on_change, + data="dingtalk_at_all", + ), + ), + ], + ), + self.create_channel_config( + self._["wechat"], + [ + self.create_setting_row( + self._["wechat_webhook_url"], + ft.TextField( + value=self.get_config_value("wechat_webhook_url"), + width=300, + on_change=self.on_change, + data="wechat_webhook_url", + ), + ), + ], + ), + self.create_channel_config( + self._["feishu"], + [ + self.create_setting_row( + self._["feishu_webhook_url"], + ft.TextField( + value=self.get_config_value("feishu_webhook_url"), + width=300, + on_change=self.on_change, + data="feishu_webhook_url", + ), + ), + ], + ), + self.create_channel_config( + self._["serverchan"], + [ + self.create_setting_row( + self._["serverchan_send_key"], + ft.TextField( + value=self.get_config_value("serverchan_sendkey"), + width=300, + on_change=self.on_change, + data="serverchan_sendkey", + ), + ), + self.create_setting_row( + self._["serverchan_channel"], + ft.TextField( + value=self.get_config_value("serverchan_channel"), + width=300, + keyboard_type=ft.KeyboardType.NUMBER, + on_change=self.on_change, + data="serverchan_channel", + ), + ), + self.create_setting_row( + self._["serverchan_tags"], + ft.TextField( + value=self.get_config_value("serverchan_tags"), + width=300, + keyboard_type=ft.KeyboardType.NUMBER, + on_change=self.on_change, + data="serverchan_tags", + ), + ), + ], + ), + self.create_channel_config( + self._["email"], + [ + self.create_setting_row( + self._["smtp_server"], + ft.TextField( + value=self.get_config_value("smtp_server"), + width=300, + on_change=self.on_change, + data="smtp_server", + ), + ), + self.create_setting_row( + self._["email_username"], + ft.TextField( + value=self.get_config_value("email_username"), + width=300, + on_change=self.on_change, + data="email_username", + ), + ), + self.create_setting_row( + self._["email_password"], + ft.TextField( + value=self.get_config_value("email_password"), + width=300, + on_change=self.on_change, + data="email_password", + ), + ), + self.create_setting_row( + self._["sender_email"], + ft.TextField( + value=self.get_config_value("sender_email"), + width=300, + on_change=self.on_change, + data="sender_email", + ), + ), + self.create_setting_row( + self._["sender_name"], + ft.TextField( + value=self.get_config_value("sender_name"), + width=300, + on_change=self.on_change, + data="sender_name", + ), + ), + self.create_setting_row( + self._["recipient_email"], + ft.TextField( + value=self.get_config_value("recipient_email"), + width=300, + on_change=self.on_change, + data="recipient_email", + ), + ), + ], + ), + self.create_channel_config( + "Bark", + [ + self.create_setting_row( + self._["bark_webhook_url"], + ft.TextField( + value=self.get_config_value("bark_webhook_url"), + width=300, + on_change=self.on_change, + data="bark_webhook_url", + ), + ), + self.create_setting_row( + self._["bark_interrupt_level"], + ft.Dropdown( + options=[ + ft.dropdown.DropdownOption("active"), + ft.dropdown.DropdownOption("passive"), + ], + value=self.get_config_value("bark_interrupt_level"), + width=200, + on_select=self.on_change, + data="bark_interrupt_level", + ), + ), + self.create_setting_row( + self._["bark_sound"], + ft.TextField( + width=300, + on_change=self.on_change, + data="bark_sound", + value=self.get_config_value("bark_sound"), + ), + ), + ], + ), + self.create_channel_config( + "Ntfy", + [ + self.create_setting_row( + self._["ntfy_server_url"], + ft.TextField( + value=self.get_config_value("ntfy_server_url"), + width=300, + on_change=self.on_change, + data="ntfy_server_url", + ), + ), + self.create_setting_row( + self._["ntfy_tags"], + ft.TextField( + value=self.get_config_value("ntfy_tags"), + width=300, + on_change=self.on_change, + data="ntfy_tags", + ), + ), + self.create_setting_row( + self._["ntfy_email"], + ft.TextField( + value=self.get_config_value("ntfy_email"), + width=300, + on_change=self.on_change, + data="ntfy_email", + ), + ), + self.create_setting_row( + self._["ntfy_action_url"], + ft.TextField( + value=self.get_config_value("ntfy_action_url"), + width=300, + on_change=self.on_change, + data="ntfy_action_url", + ), + ), + ], + ), + self.create_channel_config( + self._["telegram"], + [ + self.create_setting_row( + self._["telegram_api_token"], + ft.TextField( + value=self.get_config_value("telegram_api_token"), + width=300, + on_change=self.on_change, + data="telegram_api_token", + ), + ), + self.create_setting_row( + self._["telegram_chat_id"], + ft.TextField( + value=self.get_config_value("telegram_chat_id"), + width=300, + on_change=self.on_change, + data="telegram_chat_id", + ), + ), + ], + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_push_channels_layout(self): + controls = [ + self.create_channel_switch_container(self._["dingtalk"], ft.Icons.BUSINESS_CENTER, "dingtalk_enabled"), + self.create_channel_switch_container(self._["wechat"], ft.Icons.WECHAT, "wechat_enabled"), + self.create_channel_switch_container(self._["feishu"], ft.Icons.BOOK, "feishu_enabled"), + self.create_channel_switch_container(self._["serverchan"], ft.Icons.CLOUD_OUTLINED, "serverchan_enabled"), + self.create_channel_switch_container(self._["email"], ft.Icons.EMAIL, "email_enabled"), + self.create_channel_switch_container("Bark", ft.Icons.NOTIFICATIONS_ACTIVE, "bark_enabled"), + self.create_channel_switch_container("Ntfy", ft.Icons.NOTIFICATIONS, "ntfy_enabled"), + self.create_channel_switch_container(self._["telegram"], ft.Icons.SMS, "telegram_enabled"), + ] + + if self.app.is_mobile: + return ft.Row( + controls=controls, + spacing=5, + wrap=True, + ) + if self.app.page.web: + return ft.Row( + controls=controls, + alignment=ft.MainAxisAlignment.START, + spacing=12, + ) + else: + return ft.Container( + content=ft.GridView( + controls=controls, + runs_count=3, + max_extent=175, + spacing=5, + run_spacing=2, + child_aspect_ratio=2.5, + ), + expand=True, + ) + + def create_cookies_settings_tab(self): + """Create UI elements for push configuration.""" + is_mobile = self.app.is_mobile + + platforms = [ + "douyin", + "tiktok", + "kuaishou", + "huya", + "douyu", + "yy", + "bilibili", + "xhs", + "bigo", + "blued", + "soop", + "netease", + "qiandurebo", + "pandalive", + "maoerfm", + "winktv", + "flextv", + "look", + "popkontv", + "twitcasting", + "baidu", + "weibo", + "kugou", + "twitch", + "liveme", + "huajiao", + "liuxing", + "showroom", + "acfun", + "changliao", + "yinbo", + "inke", + "zhihu", + "chzzk", + "haixiu", + "vvxq", + "17live", + "lang", + "piaopiao", + "6room", + "lehai", + "catshow", + "shopee", + "youtube", + "taobao", + "jd", + ] + + setting_rows = [] + for platform in platforms: + cookie_field = ft.TextField( + value=self.get_cookies_value(platform), width=500, data=platform, on_change=self.on_cookies_change + ) + setting_rows.append(self.create_setting_row(self._[f"{platform}_cookie"], cookie_field)) + + return ft.Column( + [ + self.create_setting_group( + self._["cookies_settings"], self._["configure_platform_cookies"], setting_rows, is_mobile + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_accounts_settings_tab(self): + """Create UI elements for platform accounts configuration.""" + is_mobile = self.app.is_mobile + + return ft.Column( + [ + self.create_setting_group( + self._["accounts_settings"], + self._["configure_platform_accounts"], + [ + self.create_setting_row( + self._["sooplive_username"], + ft.TextField( + value=self.get_accounts_value("sooplive_username"), + width=500, + data="sooplive_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["sooplive_password"], + ft.TextField( + value=self.get_accounts_value("sooplive_password"), + width=500, + data="sooplive_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["flextv_username"], + ft.TextField( + value=self.get_accounts_value("flextv_username"), + width=500, + data="flextv_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["flextv_password"], + ft.TextField( + value=self.get_accounts_value("flextv_password"), + width=500, + data="flextv_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["popkontv_username"], + ft.TextField( + value=self.get_accounts_value("popkontv_username"), + width=500, + data="popkontv_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["popkontv_password"], + ft.TextField( + value=self.get_accounts_value("popkontv_password"), + width=500, + data="popkontv_password", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["twitcasting_account_type"], + ft.Dropdown( + options=[ft.dropdown.DropdownOption("Default"), ft.dropdown.DropdownOption("Twitter")], + value=self.get_accounts_value("twitcasting_account_type", "Default"), + width=500, + data="twitcasting_account_type", + on_select=self.on_accounts_change, + tooltip=self._["switch_account_type"], + ), + ), + self.create_setting_row( + self._["twitcasting_username"], + ft.TextField( + value=self.get_accounts_value("twitcasting_username"), + width=500, + data="twitcasting_username", + on_change=self.on_accounts_change, + ), + ), + self.create_setting_row( + self._["twitcasting_password"], + ft.TextField( + value=self.get_accounts_value("twitcasting_password"), + width=500, + data="twitcasting_password", + on_change=self.on_accounts_change, + ), + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) + + def create_folder_setting_row(self, label): + checkboxes = [ + ft.Checkbox( + label=self._["platform"], + value=self.get_config_value("folder_name_platform"), + on_change=self.on_change, + data="folder_name_platform", + ), + ft.Checkbox( + label=self._["author"], + value=self.get_config_value("folder_name_author"), + on_change=self.on_change, + data="folder_name_author", + ), + ft.Checkbox( + label=self._["time"], + value=self.get_config_value("folder_name_time"), + on_change=self.on_change, + data="folder_name_time", + ), + ft.Checkbox( + label=self._["title"], + value=self.get_config_value("folder_name_title"), + on_change=self.on_change, + data="folder_name_title", + ), + ] + + if self.app.is_mobile: + checkbox_grid = ft.Column( + [ + ft.Row([checkboxes[0], checkboxes[1]], spacing=10), + ft.Row([checkboxes[2], checkboxes[3]], spacing=10), + ], + spacing=5, + ) + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT, weight=ft.FontWeight.BOLD), + ft.Container( + content=checkbox_grid, + margin=ft.Margin.only(top=5, bottom=10), + ), + ], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), *checkboxes], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def create_scheduled_shutdown_setting_row(self): + enabled = bool(self.get_config_value("scheduled_shutdown_enabled", False)) + shutdown_time = str(self.get_config_value("scheduled_shutdown_time", "23:00")) + selected_hours = str(self.get_config_value("quick_shutdown_hours", "3")) + active_target = self.app.shutdown_manager.quick_shutdown_at + + time_field = ft.TextField( + value=shutdown_time, + width=110, + read_only=True, + disabled=not enabled, + text_align=ft.TextAlign.CENTER, + tooltip=self._["scheduled_shutdown_time"], + ) + + async def pick_shutdown_time(_event): + async def handle_change(event): + picked = event.control.value + if picked is None: + return + value = picked.strftime("%H:%M") + time_field.value = value + time_field.update() + + class _TimeChangeEvent: + def __init__(self): + self.control = time_field + self.control.data = "scheduled_shutdown_time" + self.data = value + + await self.on_change(_TimeChangeEvent()) + + time_picker = ft.TimePicker( + confirm_text=self._["confirm"], + cancel_text=self._["cancel"], + help_text=self._["scheduled_shutdown_pick_time"], + on_change=handle_change, + ) + self.page.show_dialog(time_picker) + + time_button = ft.IconButton( + icon=ft.Icons.SCHEDULE, + tooltip=self._["scheduled_shutdown_pick_time"], + disabled=not enabled, + on_click=pick_shutdown_time, + ) + + async def toggle_shutdown(event): + is_enabled = bool(event.control.value) + time_field.disabled = not is_enabled + time_button.disabled = not is_enabled + time_field.update() + time_button.update() + await self.on_change(event) + + shutdown_switch = ft.Switch( + value=enabled, + data="scheduled_shutdown_enabled", + tooltip=self._["scheduled_shutdown_tip"], + on_change=toggle_shutdown, + ) + + status_text = ft.Text( + ( + self._["quick_shutdown_active"].format(time=active_target.strftime("%m-%d %H:%M")) + if active_target is not None + else self._["quick_shutdown_ready"].format(max_hours=MAX_QUICK_SHUTDOWN_HOURS) + ), + size=12, + color=ft.Colors.PRIMARY if active_target is not None else ft.Colors.GREY_600, + ) + + hours_field = ft.TextField( + value=selected_hours, + width=80, + hint_text=f"1-{MAX_QUICK_SHUTDOWN_HOURS}", + keyboard_type=ft.KeyboardType.NUMBER, + input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9]*"), + data="quick_shutdown_hours", + on_change=self.on_change, + disabled=active_target is not None, + ) + + async def update_active_state(target): + is_active = target is not None + hours_field.disabled = is_active + hours_field.error_text = None + start_button.disabled = is_active + cancel_button.disabled = not is_active + status_text.value = ( + self._["quick_shutdown_active"].format(time=target.strftime("%m-%d %H:%M")) + if is_active + else self._["quick_shutdown_ready"].format(max_hours=MAX_QUICK_SHUTDOWN_HOURS) + ) + status_text.color = ft.Colors.PRIMARY if is_active else ft.Colors.GREY_600 + hours_field.update() + start_button.update() + cancel_button.update() + status_text.update() + + async def start_quick_shutdown(_event): + try: + hours = int((hours_field.value or "").strip()) + except ValueError: + hours = 0 + if not 1 <= hours <= MAX_QUICK_SHUTDOWN_HOURS: + hours_field.error_text = self._["quick_shutdown_invalid_hours"].format( + max_hours=MAX_QUICK_SHUTDOWN_HOURS + ) + hours_field.update() + return + + hours_field.error_text = None + hours_field.update() + expected_time = datetime.now() + timedelta(hours=hours) + + async def close_dialog(_): + confirm_dialog.open = False + self.app.dialog_area.update() + + async def confirm_start(_): + await close_dialog(None) + try: + target = await self.app.shutdown_manager.start_quick_shutdown(hours) + except RuntimeError as exc: + await self.app.snack_bar.show_snack_bar( + self._["quick_shutdown_failed"].format(error=exc), + bgcolor=ft.Colors.RED, + duration=5000, + ) + return + + await update_active_state(target) + await self.app.snack_bar.show_snack_bar( + self._["quick_shutdown_started"].format(time=target.strftime("%m-%d %H:%M")), + bgcolor=ft.Colors.PRIMARY, + ) + + confirm_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(self._["quick_shutdown_confirm_title"], weight=ft.FontWeight.BOLD), + content=ft.Text( + self._["quick_shutdown_confirm_content"].format( + hours=hours, + time=expected_time.strftime("%m-%d %H:%M"), + ) + ), + actions=[ + ft.TextButton(content=self._["cancel"], on_click=close_dialog), + ft.Button( + content=self._["quick_shutdown_start"], + icon=ft.Icons.POWER_SETTINGS_NEW, + on_click=confirm_start, + ), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + confirm_dialog.open = True + self.app.dialog_area.content = confirm_dialog + self.app.dialog_area.update() + + async def cancel_quick_shutdown(_event): + try: + await self.app.shutdown_manager.cancel_quick_shutdown() + except RuntimeError as exc: + await self.app.snack_bar.show_snack_bar( + self._["quick_shutdown_cancel_failed"].format(error=exc), + bgcolor=ft.Colors.RED, + duration=5000, + ) + return + + await update_active_state(None) + await self.app.snack_bar.show_snack_bar( + self._["quick_shutdown_cancelled"], + bgcolor=ft.Colors.PRIMARY, + ) + + start_button = ft.Button( + content=self._["quick_shutdown_start"], + icon=ft.Icons.POWER_SETTINGS_NEW, + tooltip=self._["quick_shutdown_start_tip"], + disabled=active_target is not None, + on_click=start_quick_shutdown, + ) + cancel_button = ft.IconButton( + icon=ft.Icons.CANCEL_SCHEDULE_SEND, + tooltip=self._["quick_shutdown_cancel"], + disabled=active_target is None, + on_click=cancel_quick_shutdown, + ) + + quick_shutdown_controls = ft.Column( + [ + ft.Text(self._["quick_shutdown"], weight=ft.FontWeight.BOLD), + ft.Row( + [ + hours_field, + ft.Text(self._["quick_shutdown_hours_suffix"]), + start_button, + cancel_button, + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + status_text, + ], + spacing=6, + tight=True, + ) + + async def close_shutdown_settings(_event): + shutdown_settings_dialog.open = False + self.app.dialog_area.update() + + shutdown_settings_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(self._["scheduled_shutdown"], weight=ft.FontWeight.BOLD), + content=ft.Container(content=quick_shutdown_controls, width=420), + actions=[ft.TextButton(content=self._["close"], on_click=close_shutdown_settings)], + actions_alignment=ft.MainAxisAlignment.END, + ) + + async def open_shutdown_settings(_event): + shutdown_settings_dialog.open = True + self.app.dialog_area.content = shutdown_settings_dialog + self.app.dialog_area.update() + + details_button = ft.IconButton( + icon=ft.Icons.CHEVRON_RIGHT, + tooltip=self._["scheduled_shutdown_more"], + on_click=open_shutdown_settings, + ) + controls = ft.Row( + [shutdown_switch, time_field, time_button, details_button], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + return self.create_setting_row(self._["scheduled_shutdown"], controls) + + def create_channel_switch_container(self, channel_name, icon, key): + """Helper method to create a container with a switch and an icon for each channel.""" + return ft.Container( + content=ft.Row( + [ + ft.Icon(icon, size=24, color=ft.Colors.GREY_700), + ft.Text(channel_name, size=14), + ft.Switch(value=self.get_config_value(key), label="", width=50, on_change=self.on_change, data=key), + ], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=5, + margin=5, + ) + + @staticmethod + def create_channel_config(channel_name, settings): + """Helper method to create expandable configurations for each channel.""" + return ft.ExpansionTile( + expanded=False, + title=ft.Text(channel_name, size=14, weight=ft.FontWeight.BOLD), + controls=[ft.Container(content=ft.Column(settings, spacing=5), padding=10)], + tile_padding=0, + ) + + @staticmethod + def create_setting_group(title, description, settings, is_mobile=False): + """Helper method to group settings under a title.""" + padding = 5 if is_mobile else 10 + margin = 5 if is_mobile else 10 + + card = ft.Card( + content=ft.Container( + content=ft.Column( + [ + ft.Text(title, size=16, weight=ft.FontWeight.BOLD), + ft.Text(description, theme_style=ft.TextThemeStyle.BODY_MEDIUM, opacity=0.7), + *settings, + ], + spacing=5, + ), + padding=padding, + ), + elevation=5, + margin=margin, + ) + + if is_mobile: + return ft.Container( + content=card, + width=float("inf"), + expand=True, + ) + else: + return card + + def set_focused_control(self, control): + """Store the currently focused control.""" + self.focused_control = control + + def create_setting_row(self, label, control): + """Helper method to create a row for each setting.""" + if hasattr(control, "on_focus"): + control.on_focus = lambda e: self.set_focused_control(e.control) + + if self.app.is_mobile: + if isinstance(control, (ft.Switch, ft.Checkbox, ft.IconButton)): + return ft.Row( + [ft.Text(label), ft.Container(expand=True), control], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + width=float("inf"), + ) + + if hasattr(control, "width") and control.width and control.width > 250: + control.width = 250 + + if isinstance(control, (ft.TextField, ft.Dropdown)): + control.width = float("inf") + control.expand = True + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT), + ft.Container( + content=control, + margin=ft.Margin.only(top=5, bottom=10), + expand=True, + width=float("inf"), + ), + ], + spacing=0, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def pick_folder(self, label, control): + async def pick_folder_click(_): + if self.app.page.web: + await self.app.snack_bar.show_snack_bar(self._["unsupported_select_path"]) + return + folder_picker = ft.FilePicker() + try: + path = await folder_picker.get_directory_path() + except RuntimeError as exc: + logger.error(f"Failed to open folder picker: {exc}") + await self.app.snack_bar.show_snack_bar( + self._["select_path_failed"], + bgcolor=ft.Colors.RED, + duration=3000, + ) + return + if path: + control.value = path + control.update() + + class _FakeEvent: + def __init__(self): + self.control = control + self.data = path + + fake_e = _FakeEvent() + await self.on_change(fake_e) + + btn_pick_folder = ft.Button( + content=self._["select"], + icon=ft.Icons.FOLDER_OPEN, + on_click=pick_folder_click, + tooltip=self._["select_btn_tip"], + ) + + if self.app.is_mobile: + if hasattr(control, "width"): + control.width = float("inf") + control.expand = True + + return ft.Column( + [ + ft.Text(label, text_align=ft.TextAlign.LEFT), + ft.Row( + [ + ft.Container( + content=control, + expand=True, + ), + btn_pick_folder, + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + expand=True, + width=float("inf"), + ), + ], + spacing=5, + alignment=ft.MainAxisAlignment.START, + horizontal_alignment=ft.CrossAxisAlignment.START, + width=float("inf"), + ) + else: + return ft.Row( + [ft.Text(label, width=200, text_align=ft.TextAlign.RIGHT), control, btn_pick_folder], + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + async def is_changed(self): + if self.app.current_page != self: + return + + show_snack_bar = False + save_methods = { + "user_config": (self.config_manager.save_user_config, self.user_config), + "cookies_config": (self.config_manager.save_cookies_config, self.cookies_config), + "accounts_config": (self.config_manager.save_accounts_config, self.accounts_config), + } + + for config_key, should_save in self.has_unsaved_changes.items(): + if should_save and config_key in save_methods: + save_method, config_value = save_methods[config_key] + await save_method(config_value) + self.has_unsaved_changes[config_key] = False + show_snack_bar = True + + if show_snack_bar: + await self.app.snack_bar.show_snack_bar( + self._["success_save_config_tip"], duration=1500, bgcolor=ft.Colors.PRIMARY + ) + + async def on_keyboard(self, e: ft.KeyboardEvent): + if e.alt and e.key == "H": + self.app.dialog_area.content = HelpDialog(self.app) + self.app.dialog_area.content.open = True + self.app.dialog_area.update() + + if self.app.current_page == self and e.ctrl and e.key == "S": + self.page.run_task(self.is_changed) + + def create_security_settings_tab(self): + is_mobile = self.app.is_mobile + + async def change_password(_): + old_password = old_password_field.value + new_password = new_password_field.value + confirm_password = confirm_password_field.value + + if not old_password: + await self.app.snack_bar.show_snack_bar(self._["old_password_required"], bgcolor=ft.Colors.RED) + return + + if not new_password: + await self.app.snack_bar.show_snack_bar(self._["new_password_required"], bgcolor=ft.Colors.RED) + return + + if new_password != confirm_password: + await self.app.snack_bar.show_snack_bar(self._["passwords_not_match"], bgcolor=ft.Colors.RED) + return + + _username = self.app.current_username + if _username: + success = await self.app.auth_manager.change_password(_username, old_password, new_password) + + if success: + old_password_field.value = "" + new_password_field.value = "" + confirm_password_field.value = "" + old_password_field.update() + new_password_field.update() + confirm_password_field.update() + + await self.app.snack_bar.show_snack_bar(self._["password_changed"], bgcolor=ft.Colors.PRIMARY) + else: + await self.app.snack_bar.show_snack_bar(self._["old_password_incorrect"], bgcolor=ft.Colors.RED) + else: + await self.app.snack_bar.show_snack_bar(self._["not_logged_in"], bgcolor=ft.Colors.RED) + + async def toggle_login_required(_): + login_required = login_required_switch.value + self.user_config["login_required"] = login_required + await self.config_manager.save_user_config(self.user_config) + + if login_required: + await self.app.snack_bar.show_snack_bar(self._["login_required_enabled"], bgcolor=ft.Colors.PRIMARY) + else: + await self.app.snack_bar.show_snack_bar(self._["login_required_disabled"], bgcolor=ft.Colors.PRIMARY) + + username = self.app.current_username or "admin" + + old_password_field = ft.TextField( + password=True, + width=300, + label=self._["old_password"] + " | " + self._["default_old_password"], + ) + + new_password_field = ft.TextField( + password=True, + width=300, + label=self._["new_password"], + ) + + confirm_password_field = ft.TextField( + password=True, + width=300, + label=self._["confirm_password"], + ) + + change_password_button = ft.Button( + content=self._["change_password"], + on_click=change_password, + icon=ft.Icons.LOCK_RESET, + ) + + login_required_switch = ft.Switch( + value=self.get_config_value("login_required", False), + on_change=toggle_login_required, + ) + + return ft.Column( + [ + self.create_setting_group( + self._["security_settings"], + self._["web_login_configuration"], + [ + self.create_setting_row( + self._["login_required"], + login_required_switch, + ), + self.create_setting_row( + self._["current_username"], + ft.Text(username), + ), + self.create_setting_row( + self._["old_password"], + old_password_field, + ), + self.create_setting_row( + self._["new_password"], + new_password_field, + ), + self.create_setting_row( + self._["confirm_password"], + confirm_password_field, + ), + self.create_setting_row( + "", + change_password_button, + ), + ], + is_mobile, + ), + ], + spacing=10, + scroll=ft.ScrollMode.AUTO, + ) diff --git a/app/ui/views/storage_view.py b/app/ui/views/storage_view.py index b34af277..7278ad55 100644 --- a/app/ui/views/storage_view.py +++ b/app/ui/views/storage_view.py @@ -56,14 +56,14 @@ async def update_file_list(self): self.file_list.controls.clear() if self.current_path != self.root_path: - back_button = ft.ElevatedButton( + back_button = ft.Button( self._["go_back"], - icon=ft.icons.ARROW_BACK, - on_click=lambda _: self.app.page.run_task(self.navigate_to_parent) + icon=ft.Icons.ARROW_BACK, + on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) if self.app.is_mobile: back_item = ft.ListTile( - leading=ft.Icon(ft.icons.ARROW_BACK, color=ft.colors.BLUE), + leading=ft.Icon(ft.Icons.ARROW_BACK, color=ft.Colors.BLUE), title=ft.Text(self._["go_back"]), on_click=lambda _: self.app.page.run_task(self.navigate_to_parent), ) @@ -78,7 +78,7 @@ async def update_file_list(self): return await self.create_file_buttons() - + 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"]) @@ -110,31 +110,28 @@ def _get_items(): return [] items = await asyncio.get_event_loop().run_in_executor(self.executor, _get_items) - + buttons = [] is_mobile = self.app.is_mobile for name, is_dir, full_path in items: if is_mobile: - icon = ft.Icon(ft.icons.FOLDER, color=ft.colors.BLUE) if is_dir else ft.Icon(ft.icons.INSERT_DRIVE_FILE) + icon = ft.Icon(ft.Icons.FOLDER, color=ft.Colors.BLUE) if is_dir else ft.Icon(ft.Icons.INSERT_DRIVE_FILE) item = ft.ListTile( leading=icon, title=ft.Text(name), on_click=lambda e, path=full_path, is_directory=is_dir: self.app.page.run_task( - self.navigate_to if is_directory else self.preview_file, - path + self.navigate_to if is_directory else self.preview_file, path ), ) buttons.append(item) else: if is_dir: - btn = ft.ElevatedButton( - f"📁 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) + btn = ft.Button( + f"📁 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.navigate_to, path) ) else: - btn = ft.ElevatedButton( - f"📄 {name}", - on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) + btn = ft.Button( + f"📄 {name}", on_click=lambda e, path=full_path: self.app.page.run_task(self.preview_file, path) ) buttons.append(btn) @@ -146,16 +143,16 @@ def show_empty_folder_message(self): 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) + ft.Icon(ft.Icons.FOLDER_OPEN), + ft.Text(self._["empty_recording_folder"], size=16, weight=ft.FontWeight.BOLD), ], - alignment=ft.MainAxisAlignment.CENTER + alignment=ft.MainAxisAlignment.CENTER, ), - padding=20 + padding=20, ), elevation=2, margin=10, - width=400 + width=400, ) ) diff --git a/app/utils/logger.py b/app/utils/logger.py index fd2e2490..e126489d 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -1,31 +1,34 @@ -import os -import sys - -from loguru import logger - -script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] - -logger.add( - f"{script_path}/logs/streamget.log", - level="DEBUG", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", - filter=lambda i: i["level"].name != "STREAM", - serialize=False, - enqueue=True, - retention=3, - rotation="3 MB", - encoding="utf-8", -) - -logger.level("STREAM", no=22, color="") -logger.add( - f"{script_path}/logs/play_url.log", - level="STREAM", - format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}", - filter=lambda i: i["level"].name == "STREAM", - serialize=False, - enqueue=True, - retention=1, - rotation="500 KB", - encoding="utf-8", -) +import os +import sys + +from loguru import logger + +from app.core.runtime.paths import user_data_dir + +log_dir = os.path.join(user_data_dir, "logs") +os.makedirs(log_dir, exist_ok=True) +use_multiprocessing_queue = not (getattr(sys, "frozen", False) and sys.platform == "darwin") +logger.add( + os.path.join(log_dir, "streamget.log"), + level="DEBUG", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}", + filter=lambda i: i["level"].name != "STREAM", + serialize=False, + enqueue=use_multiprocessing_queue, + retention=3, + rotation="3 MB", + encoding="utf-8", +) + +logger.level("STREAM", no=22, color="") +logger.add( + os.path.join(log_dir, "play_url.log"), + level="STREAM", + format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}", + filter=lambda i: i["level"].name == "STREAM", + serialize=False, + enqueue=use_multiprocessing_queue, + retention=1, + rotation="500 KB", + encoding="utf-8", +) diff --git a/app/utils/utils.py b/app/utils/utils.py index bfb8ea04..1dae19e6 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,3 +1,4 @@ +import asyncio import functools import hashlib import json @@ -11,8 +12,8 @@ import traceback from datetime import datetime, time, timedelta from pathlib import Path -from typing import Any -from urllib.parse import urlparse +from typing import Any, Optional +from urllib.parse import parse_qs, urlparse import execjs @@ -21,6 +22,63 @@ OptionalStr = str | None OptionalDict = dict | None +# Pre-compiled regular expressions for performance +EMOJI_PATTERN = re.compile( + "[" + "\U0001f1e0-\U0001f1ff" # flags (iOS) + "\U0001f300-\U0001f5ff" # symbols & pictographs + "\U0001f600-\U0001f64f" # emoticons + "\U0001f680-\U0001f6ff" # transport & map symbols + "\U0001f700-\U0001f77f" # alchemical symbols + "\U0001f780-\U0001f7ff" # Geometric Shapes Extended + "\U0001f800-\U0001f8ff" # Supplemental Arrows-C + "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs + "\U0001fa00-\U0001fa6f" # Chess Symbols + "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A + "\U00002702-\U000027b0" # Dingbats + "]+", + flags=re.UNICODE, +) + +URL_PATTERN = re.compile(r"^(https?://)" r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(:\d+)?" r"(/\S*)?$") + +CONTAINS_URL_PATTERN = re.compile(r"(?i)\bhttps?://" r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" r"(?::\d+)?" r"(?:/\S*)?") + + +def is_web_session_alive(page) -> bool: + """Return True if the flet page session/connection is still healthy.""" + try: + session = getattr(page, "session", None) + if session is None: + return False + connection = getattr(session, "connection", None) + if connection is None: + return False + return getattr(connection, "loop", None) is not None + except Exception: + return False + + +def run_task_safe(page, handler, *args, ui_only: bool = False, **kwargs): + """Safely schedule an async handler.""" + if ui_only and not is_web_session_alive(page): + return None + try: + loop = asyncio.get_running_loop() + return loop.create_task(handler(*args, **kwargs)) + except RuntimeError: + # No running loop in current thread; try flet's scheduler. + try: + return page.run_task(handler, *args, **kwargs) + except AttributeError: + logger.warning( + f"run_task_safe: flet session unavailable, drop task {getattr(handler, '__qualname__', handler)}" + ) + return None + except Exception as e: + logger.error(f"run_task_safe failed to schedule {getattr(handler, '__qualname__', handler)}: {e}") + return None + class Color: RED = "\033[31m" @@ -73,23 +131,7 @@ def get_file_paths(directory: str) -> list: def remove_emojis(text: str, replace_text: str = "") -> str: - emoji_pattern = re.compile( - "[" - "\U0001f1e0-\U0001f1ff" # flags (iOS) - "\U0001f300-\U0001f5ff" # symbols & pictographs - "\U0001f600-\U0001f64f" # emoticons - "\U0001f680-\U0001f6ff" # transport & map symbols - "\U0001f700-\U0001f77f" # alchemical symbols - "\U0001f780-\U0001f7ff" # Geometric Shapes Extended - "\U0001f800-\U0001f8ff" # Supplemental Arrows-C - "\U0001f900-\U0001f9ff" # Supplemental Symbols and Pictographs - "\U0001fa00-\U0001fa6f" # Chess Symbols - "\U0001fa70-\U0001faff" # Symbols and Pictographs Extended-A - "\U00002702-\U000027b0" # Dingbats - "]+", - flags=re.UNICODE, - ) - return emoji_pattern.sub(replace_text, text) + return EMOJI_PATTERN.sub(replace_text, text) def check_disk_capacity(file_path: str | Path, show: bool = False) -> float: @@ -204,7 +246,7 @@ def is_time_interval_exceeded(last_check_time, interval_seconds=60): def clean_name(input_text, default=None): if input_text and input_text.strip(): - rstr = r"[\/\\\:\*\??\"\<\>\|&#.。,, ~!· ]" + rstr = r"[\/\\\::\*\??\"\<\>\|&#.。,, ~!· ]" cleaned_name = input_text.strip().replace("(", "(").replace(")", ")") cleaned_name = re.sub(rstr, "_", cleaned_name) cleaned_name = remove_emojis(cleaned_name, "_").replace("__", "_").strip("_") @@ -217,26 +259,14 @@ def is_valid_url(url): result = urlparse(url) if not all([result.scheme, result.netloc]): return False - url_pattern = re.compile( - r"^(https?://)" - r"([a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" - r"(:\d+)?" - r"(/\S*)?$" - ) - return bool(url_pattern.match(url)) + return bool(URL_PATTERN.match(url)) except ValueError: return False def contains_url(text): - url_pattern = re.compile( - r"(?i)\bhttps?://" - r"(?:[a-zA-Z0-9-]+\.)+[a-zA-Z0-9]{1,6}" - r"(?::\d+)?" - r"(?:/\S*)?" - ) try: - return bool(url_pattern.search(text)) + return bool(CONTAINS_URL_PATTERN.search(text)) except ValueError: return False @@ -254,5 +284,16 @@ def get_startup_info(system_type: str | None = None): def is_valid_video_file(source: str) -> bool: - video_extensions = ['.mp4', '.mov', '.mkv', '.ts', '.flv', '.mp3', '.m4a', '.wav', '.aac', '.wma'] + video_extensions = [".mp4", ".mov", ".mkv", ".nut", ".ts", ".flv", ".mp3", ".m4a", ".wav", ".aac", ".wma"] return Path(source).suffix.lower() in video_extensions + + +def get_query_params(url: str, param_name: Optional[str] = None) -> dict | list[str]: + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + if param_name is None: + return query_params + else: + values = query_params.get(param_name, []) + return values diff --git a/assets/images/logo-mark.svg b/assets/images/logo-mark.svg new file mode 100644 index 00000000..89bc1e6d --- /dev/null +++ b/assets/images/logo-mark.svg @@ -0,0 +1,119 @@ + + + StreamCap panda mark + + + + + + diff --git a/config/default_settings.json b/config/default_settings.json index d2506c6b..053dab97 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -7,6 +7,9 @@ "folder_name_author": true, "folder_name_time": false, "folder_name_title": false, + "scheduled_shutdown_enabled": false, + "scheduled_shutdown_time": "23:00", + "quick_shutdown_hours": "3", "enable_proxy": true, "proxy_address": "", "video_format": "TS", @@ -33,6 +36,7 @@ "custom_stream_end_content": "", "dingtalk_enabled": false, "wechat_enabled": false, + "feishu_enabled": false, "bark_enabled": false, "ntfy_enabled": false, "serverchan_enabled": false, @@ -45,6 +49,7 @@ "dingtalk_at_objects": "", "dingtalk_at_all": false, "wechat_webhook_url": "", + "feishu_webhook_url": "", "bark_webhook_url": "", "bark_interrupt_level": "active", "bark_sound": "", @@ -63,5 +68,7 @@ "theme_color": "blue", "is_grid_view": true, "theme_mode": "light", - "platform_max_concurrent_requests": "3" -} \ No newline at end of file + "platform_max_concurrent_requests": "3", + "last_route": "/home", + "check_live_on_browser_refresh": false +} diff --git a/config/version.json b/config/version.json index 0e164790..85273f9a 100644 --- a/config/version.json +++ b/config/version.json @@ -1,27 +1,29 @@ { "introduction": { - "en": "A free application dedicated to recording videos from online live streaming platforms. By integrating FFmpeg, it efficiently and reliably records live streams from over 40 major platforms including Douyin, TikTok, Kuaishou, Huya, Douyu, Bilibili, Rednote, Baidu, Weibo, Kuwo, Twitch, YouTube, PandaTV, SoopLive, FlexTV, PopKontv, Twitcasting, WinkTV, 17Live, Acfun, CHZZK, and Shopee.", - "zh_CN": "一款专注于录制在线直播平台视频的免费应用。通过集成FFmpeg,高效、稳定地录制抖音、TikTok、快手、虎牙、斗鱼、B站、小红书、百度、微博、酷狗、Twitch、YouTube、PandaTV、SoopLive、FlexTV、PopKontv、Twitcasting、WinkTV、17Live、Acfun、CHZZK 和 Shopee 等40+主流平台的直播流。" + "en": "A free application dedicated to recording videos from online live streaming platforms. By integrating FFmpeg, it efficiently and reliably records live streams from over 40 major platforms including Douyin, TikTok, Kuaishou, Huya, Douyu, Bilibili, Rednote, Baidu, Weibo, Kuwo, Migu, Twitch, YouTube, PandaTV, SoopLive, FlexTV(TTing), PopKontv, Twitcasting, WinkTV, 17Live, Acfun, CHZZK, and Shopee.", + "zh_CN": "一款专注于录制在线直播平台视频的免费应用。通过集成FFmpeg,高效、稳定地录制抖音、TikTok、快手、虎牙、斗鱼、B站、小红书、百度、微博、酷狗、咪咕、Twitch、YouTube、PandaTV、SoopLive、FlexTV(TTing)、PopKontv、Twitcasting、WinkTV、17Live、Acfun、CHZZK 和 Shopee 等40+主流平台的直播流。" }, "open_source_license": "Apache License 2.0", "version_updates": [ { - "version": "1.0.2-alpha", - "kernel_version": "4.0.5", + "version": "1.0.3", + "kernel_version": "4.0.10", "updates": { "en": [ - "None" + "Fix some live streams cannot be recorded", + "Fix some known issues and improve overall stability" ], "zh_CN": [ - "无" + "修复部分直播无法录制的问题", + "修复若干已知问题,提升整体稳定性" ] }, - "release_date": "", + "release_date": "2025-05-12", "announcement": { "en": [ { - "title": "StreamCap v1.0.2 will be released soon", - "content": "New multi-platform support, web adaptation for mobile, bug fixes, and stability improvements" + "title": "StreamCap v1.0.3 is officially released", + "content": "Fix some live streams cannot be recorded, adapt to updated UI version, and improve stability" }, { "title": "Usage tips", @@ -29,13 +31,13 @@ }, { "title": "Coming soon", - "content": "The next version will support classification by live room status and platform, and minimize to tray" + "content": "More platforms are being supported..." } ], "zh_CN": [ { - "title": "StreamCap v1.0.2 即将发布", - "content": "新增平台支持,进行移动端web适配,修复已知问题,提升稳定性" + "title": "StreamCap v1.0.3 正式发布", + "content": "修复部分直播无法录制的问题,适配更新UI版本,提升稳定性" }, { "title": "使用技巧", @@ -43,10 +45,10 @@ }, { "title": "即将推出", - "content": "下个版本将支持按直播间状态和平台进行分类以及程序最小化至托盘" + "content": "更多平台支持中..." } ] } } ] -} +} \ No newline at end of file diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 00000000..2734d791 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,223 @@ +# StreamCap 打包说明 + +本文档说明如何使用 PyInstaller 打包 StreamCap 桌面应用,以及如何准备可选的内置 FFmpeg / Node.js。 + +## 环境要求 + +- 使用目标平台本机打包: + - macOS 包必须在 macOS 上打。 + - Windows 包必须在 Windows 上打。 +- PyInstaller 不支持用 macOS 直接交叉打 Windows 包,反过来也一样。 +- 先安装项目依赖,确保当前 Python 环境可以正常运行 StreamCap。 + +## 一键打包 + +在项目根目录执行: + +```bash +python scripts/build.py +``` + +脚本会自动: + +- 准备 Flet desktop 运行资源。 +- 打包 `config`、`locales`、`assets`。 +- 打包 `streamget` 的数据文件。 +- 如果存在内置 FFmpeg / Node.js,则一起打包。 +- macOS 下隐藏外层 PyInstaller Dock 图标,只显示一个 StreamCap 熊猫图标。 + +macOS 打包完成后产物为: + +```text +dist/StreamCap.app +``` + +运行: + +```bash +open dist/StreamCap.app +``` + +Windows 打包完成后产物为: + +```text +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 包。 + +可显式指定: + +```bash +python scripts/build.py --target-arch arm64 +``` + +不建议随意使用 `universal2`。如果 Python 或第三方 `.so` 依赖不是 universal2,PyInstaller 会报 `is not a fat binary`。 + +## 内置 FFmpeg + +如果希望打包时携带 FFmpeg,先执行: + +```bash +python scripts/download_ffmpeg.py +``` + +下载当前平台。下载两个平台: + +```bash +python scripts/download_ffmpeg.py --platform all +``` + +文件会保存到: + +```text +vendor/ffmpeg/macos/ffmpeg +vendor/ffmpeg/windows/ffmpeg.exe +``` + +脚本只提取 `ffmpeg` / `ffmpeg.exe`,不会保留 `ffplay`、`ffprobe`。 + +打包时 `scripts/build.py` 会自动检测这些文件;存在则打进包里。若不想打包 FFmpeg: + +```bash +python scripts/build.py --no-bundle-ffmpeg +``` + +运行时逻辑: + +- 如果系统 `PATH` 中已经有 `ffmpeg`,不会复制内置版本。 +- 如果系统没有 `ffmpeg`,且包内带了 FFmpeg,则复制到用户数据目录。 + +目标位置: + +```text +macOS: ~/Library/Application Support/StreamCap/ffmpeg/ffmpeg +Windows: %APPDATA%\StreamCap\ffmpeg\ffmpeg.exe +``` + +## 内置 Node.js + +如果希望打包时携带 Node.js,先执行: + +```bash +python scripts/download_nodejs.py +``` + +下载当前平台。下载两个平台: + +```bash +python scripts/download_nodejs.py --platform all +``` + +指定版本: + +```bash +python scripts/download_nodejs.py --version 22.12.0 +``` + +文件会保存到: + +```text +vendor/node/macos/node +vendor/node/windows/node.exe +``` + +脚本只提取 `node` / `node.exe`,不会保留 `npm`、`npx`、headers 或 docs。 + +打包时 `scripts/build.py` 会自动检测这些文件;存在则打进包里。若不想打包 Node.js: + +```bash +python scripts/build.py --no-bundle-node +``` + +运行时逻辑: + +- 如果系统 `PATH` 中已经有 `node`,不会复制内置版本。 +- 如果系统没有 `node`,且包内带了 Node.js,则复制到用户数据目录。 + +目标位置: + +```text +macOS: ~/Library/Application Support/StreamCap/node/node +Windows: %APPDATA%\StreamCap\node\node.exe +``` + +## macOS Flet 说明 + +StreamCap 使用 Flet 桌面模式。macOS 下 Flet 会使用 `Flet.app` 作为真正的窗口进程。 + +本项目打包后做了以下处理: + +- 外层 `StreamCap.app` 作为后台 agent,不显示 Dock 图标。 +- Flet 官方缓存不会被直接修改。 +- 首次运行时会创建 StreamCap 专属 Flet 副本: + +```text +~/Library/Application Support/StreamCap/flet_client/<版本>/StreamCap Flet.app +``` + +- 专属 Flet 副本会替换为 StreamCap 熊猫图标。 + +因此正常情况下 Dock 只显示一个熊猫图标。 + +如果升级 Flet 或图标后 macOS 仍显示旧图标,可以删除专属 Flet 缓存并重启 Dock: + +```bash +rm -rf "$HOME/Library/Application Support/StreamCap/flet_client" +killall Dock +open dist/StreamCap.app +``` + +## 用户数据目录 + +打包运行时,配置、日志、FFmpeg、Node.js 等可变数据不会写入应用包或安装目录。 + +位置: + +```text +macOS: ~/Library/Application Support/StreamCap +Windows: %APPDATA%\StreamCap +``` + +Windows 默认录制保存目录为 `StreamCap.exe` 同级目录下的 `downloads`,避免大文件写入 C 盘用户数据目录。用户在设置页手动选择保存目录后,以用户设置为准。 + +源码运行时仍使用项目目录,方便开发调试。 + +## 常用命令 + +准备可选内置依赖: + +```bash +python scripts/download_ffmpeg.py --platform all +python scripts/download_nodejs.py --platform all +``` + +打包: + +```bash +python scripts/build.py +``` + +强制重新下载 Flet desktop 资源: + +```bash +python scripts/build.py --refresh-flet +``` + +只打包应用,不内置 FFmpeg / Node.js: + +```bash +python scripts/build.py --no-bundle-ffmpeg --no-bundle-node +``` diff --git a/docs/packaging_en.md b/docs/packaging_en.md new file mode 100644 index 00000000..1815793d --- /dev/null +++ b/docs/packaging_en.md @@ -0,0 +1,223 @@ +# StreamCap Packaging Guide + +This document explains how to package the StreamCap desktop app with PyInstaller and how to prepare optional bundled FFmpeg / Node.js executables. + +## Requirements + +- Build on the target platform: + - Build macOS packages on macOS. + - Build Windows packages on Windows. +- PyInstaller does not support cross-compiling between macOS and Windows. +- Install project dependencies first and make sure StreamCap can run in the current Python environment. + +## One-Command Build + +Run from the project root: + +```bash +python scripts/build.py +``` + +The script automatically: + +- Prepares the Flet desktop runtime archive. +- Bundles `config`, `locales`, and `assets`. +- Bundles `streamget` data files. +- Bundles optional FFmpeg / Node.js executables when present. +- On macOS, hides the outer PyInstaller Dock icon so only one StreamCap panda icon is shown. + +macOS output: + +```text +dist/StreamCap.app +``` + +Run it with: + +```bash +open dist/StreamCap.app +``` + +Windows output: + +```text +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. + +You can specify it explicitly: + +```bash +python scripts/build.py --target-arch arm64 +``` + +Avoid `universal2` unless all Python and native dependencies are universal2. Otherwise PyInstaller may fail with `is not a fat binary`. + +## Bundled FFmpeg + +To bundle FFmpeg, prepare it first: + +```bash +python scripts/download_ffmpeg.py +``` + +Download both supported platforms: + +```bash +python scripts/download_ffmpeg.py --platform all +``` + +Files are saved to: + +```text +vendor/ffmpeg/macos/ffmpeg +vendor/ffmpeg/windows/ffmpeg.exe +``` + +The script extracts only `ffmpeg` / `ffmpeg.exe`; it does not keep `ffplay` or `ffprobe`. + +`scripts/build.py` automatically bundles the matching file when it exists. To skip bundled FFmpeg: + +```bash +python scripts/build.py --no-bundle-ffmpeg +``` + +Runtime behavior: + +- If `ffmpeg` is already available on `PATH`, the bundled version is not copied. +- If `ffmpeg` is not available and a bundled executable exists, it is copied to the user data directory. + +Destination: + +```text +macOS: ~/Library/Application Support/StreamCap/ffmpeg/ffmpeg +Windows: %APPDATA%\StreamCap\ffmpeg\ffmpeg.exe +``` + +## Bundled Node.js + +To bundle Node.js, prepare it first: + +```bash +python scripts/download_nodejs.py +``` + +Download both supported platforms: + +```bash +python scripts/download_nodejs.py --platform all +``` + +Specify a version: + +```bash +python scripts/download_nodejs.py --version 22.12.0 +``` + +Files are saved to: + +```text +vendor/node/macos/node +vendor/node/windows/node.exe +``` + +The script extracts only `node` / `node.exe`; it does not keep `npm`, `npx`, headers, docs, or other files. + +`scripts/build.py` automatically bundles the matching file when it exists. To skip bundled Node.js: + +```bash +python scripts/build.py --no-bundle-node +``` + +Runtime behavior: + +- If `node` is already available on `PATH`, the bundled version is not copied. +- If `node` is not available and a bundled executable exists, it is copied to the user data directory. + +Destination: + +```text +macOS: ~/Library/Application Support/StreamCap/node/node +Windows: %APPDATA%\StreamCap\node\node.exe +``` + +## macOS Flet Notes + +StreamCap uses Flet desktop mode. On macOS, Flet uses `Flet.app` as the actual window process. + +The packaged app applies the following handling: + +- The outer `StreamCap.app` runs as a background agent and does not show a Dock icon. +- The global Flet cache is not modified directly. +- On first launch, StreamCap creates its own Flet app copy: + +```text +~/Library/Application Support/StreamCap/flet_client//StreamCap Flet.app +``` + +- The dedicated Flet copy uses the StreamCap panda icon. + +In normal use, the Dock should show only one panda icon. + +If macOS keeps showing an old icon after upgrading Flet or changing icons, delete the dedicated Flet cache and restart Dock: + +```bash +rm -rf "$HOME/Library/Application Support/StreamCap/flet_client" +killall Dock +open dist/StreamCap.app +``` + +## User Data Directory + +In packaged builds, mutable files such as config, logs, FFmpeg, and Node.js are not written into the app bundle or installation directory. + +Locations: + +```text +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 + +Prepare optional bundled dependencies: + +```bash +python scripts/download_ffmpeg.py --platform all +python scripts/download_nodejs.py --platform all +``` + +Build: + +```bash +python scripts/build.py +``` + +Force re-download of the Flet desktop runtime archive: + +```bash +python scripts/build.py --refresh-flet +``` + +Build without bundled FFmpeg / Node.js: + +```bash +python scripts/build.py --no-bundle-ffmpeg --no-bundle-node +``` diff --git a/locales/en.json b/locales/en.json index 5dd3e6f1..7dccc9e5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,502 +1,540 @@ -{ - "sidebar": { - "home": "Home", - "recordings": "Recordings", - "settings": "Settings", - "storage": "Storage", - "about": "About", - "light_theme": "Light theme", - "dark_theme": "Dark theme", - "theme_color": "Theme Color", - "toggle_day_theme": "Toggle Day Theme", - "toggle_night_theme": "Toggle Night Theme", - "colors": "Color List" - }, - "home_page": { - "welcome": "Welcome to StreamCap", - "tagline": "Powerful live streaming recording tool", - "start_recording": "Start Recording", - "recording_list": "Recording List", - "browse_recordings": "Browse Recordings", - "manage_storage": "Manage Storage", - "settings": "Settings", - "about": "About", - "main_features": "Main Features", - "feature_title_1": "Start Recording", - "feature_desc_1": "Enter the live room address, start recording immediately", - "feature_title_2": "Message Push", - "feature_desc_2": "Support for ServerChan and other channels", - "feature_title_3": "Batch Operations", - "feature_desc_3": "Batch start, stop and delete recording", - "feature_title_4": "Scheduled Monitoring", - "feature_desc_4": "Default 24h loop monitoring, support scheduled monitoring", - "version": "Version", - "announcement": "Announcement", - "announcement_title_1": "StreamCap v1.0.1 has been released", - "announcement_content_1": "New multi-platform support, bug fixes, and stability improvements", - "announcement_title_2": "Usage tips", - "announcement_content_2": "Using batch operations can manage multiple recording tasks at the same time", - "announcement_title_3": "Coming soon", - "announcement_content_3": "The next version will support automatic segmented recording and cloud backup", - "stats": "Statistics", - "total_rooms": "Total Rooms", - "active_recordings": "Active Recordings", - "stop_monitoring": "Stop Monitoring", - "recent_added_recordings": "Recent Added", - "no_recordings": "No Recordings", - "greeting_morning": "Good Morning", - "greeting_afternoon": "Good Afternoon", - "greeting_evening": "Good Evening" - }, - "recordings_page": { - "recording_list": "Recording List", - "search": "Search", - "add_record": "Add Recording", - "refresh": "Refresh", - "batch_start": "Batch Start", - "batch_stop": "Batch Stop", - "batch_delete": "Batch Delete", - "refresh_success_tip": "Tip: Refresh successful", - "batch_delete_confirm_tip": "Are you sure you want to delete all selected recordings?", - "clear_all_confirm_tip": "Are you sure you want to clear all recordings?", - "edit_record": "Edit Recording", - "add_recording_success_tip": "Tip: Live room added successfully!", - "delete_recording_success_tip": "Tip: Live room deleted successfully!", - "stop_recording_success_tip": "Tip: Live room monitoring stopped successfully!", - "start_recording_success_tip": "Tip: Live room monitoring started successfully!", - "not_search_result": "Tip: No search results found", - "toggle_view": "Toggle View", - "preview_video": "Preview Video", - "status_filter": "Status Filter", - "filter_all": "All", - "filter_recording": "Recording", - "filter_living": "Living", - "filter_error": "Error", - "filter_offline": "Offline", - "filter_stopped": "Not Monitored", - "platform_filter": "Platform Filter", - "platform_sort": "Platform Sort", - "operations": "Operations", - "filter": "Filter" - }, - "recording_dialog": { - "input_live_link": "Enter Live Room URL", - "example": "Example", - "select_resolution": "Select Recording Resolution", - "flv_use_direct_download": "FLV Source Use Direct Downloader", - "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", - "input_anchor_name": "Enter Broadcaster Name", - "default_input": "Can be left blank", - "select_record_format": "Select Recording Format - Default ts", - "input_save_path": "Enter Save Path for Recordings", - "is_segment_enabled": "Enable Segmented Recording", - "segment_record_time": "Segment Recording Time", - "input_segment_time": "Enter Segment Time (in seconds)", - "scheduled_recording": "Enable Daily Scheduled Monitoring", - "scheduled_start_time": "Daily Monitoring Start Time", - "monitor_hours": "Daily Monitoring Hours", - "enable_message_push": "Enable Message Push", - "only_notify_no_record": "Only notify without recording", - "batch_input_tip": "Batch Input (one record per line)", - "single_input": "Single Input", - "batch_input": "Batch Input", - "live_room": "Live Room", - "platform_not_supported_tip": "This platform does not support recording ⚠️", - "pick_time": "Pick time", - "pick_time_tip": "Configure scheduled time", - "time_out_of_range": "Time out of range", - "pick_time_slot": "Pick your time slot", - "hour_label_text": "Hour", - "minute_label_text": "Minute", - "select_media_type": "Select Media Type", - "video": "Video", - "audio": "Audio" - }, - "search_dialog": { - "search_keyword": "Enter search keyword" - }, - "recording_manager": { - "monitor_stopped": "Stopped monitoring", - "is_live": "Live streaming in progress", - "live_room": "Live Room", - "recorded": "Recorded", - "push_content": "Live room status update: [room_name] is live, time: [time]", - "push_content_end": "Live room status update: [room_name] is live completed, time: [time]", - "status_notify": "Live room status update notification", - "STOPPED_MONITORING": "Monitoring Stopped", - "MONITORING": "Monitoring In Progress", - "RECORDING": "Recording In Progress", - "NOT_RECORDING": "Is living, not Recording", - "STATUS_CHECKING": "Live Status Checking In Progres", - "NOT_IN_SCHEDULED_CHECK": "Not in Scheduled Check Range, Stopping Live Detection", - "PREPARING_RECORDING": "Preparing to Start Recording", - "RECORDING_ERROR": "Recording the live stream has failed", - "NOT_RECORDING_SPACE": "Insufficient disk space to record", - "LIVE_STATUS_CHECK_ERROR": "Live status error, check address accessibility", - "LIVE_BROADCASTING": "Live Broadcasting", - "not_disk_space_tip": "Insufficient disk storage space, stop recording ⚠️", - "notify": "Notify", - "live_recording_stopped_message": "Live room recording has been stopped", - "live_recording_started_message": "Live room recording has been started" - }, - "stream_manager": { - "record_stream_error": "Live streaming source recording error", - "no_ffmpeg_tip": "FFmpeg is not installed, please install FFmpeg first ⚠️" - }, - "recording_card": { - "stop_monitor_tip": "Tip: Live monitoring has been stopped", - "start_monitor_tip": "Tip: Live monitoring has been started", - "please_stop_monitor_tip": "Tip: Please stop live monitoring first️", - "please_start_monitor_tip": "Tip: Please start live monitoring first", - "is_not_live_tip": "Tip: The live room is not broadcasting", - "pre_record_tip": "Tip: Preparing to start recording", - "stop_record_tip": "Tip: Live room recording has been stopped", - "no_folder_tip": "Tip: The live room folder does not exist", - "edit_record_config": "Edit Recording Configuration", - "delete_monitor": "Delete Monitoring", - "open_folder": "Open Folder", - "recording_info": "Recording Information", - "anchor_name": "Anchor Name", - "platform_name": "Platform Name", - "live_link": "Live Link", - "live_title": "Live Title", - "record_format": "Recording Format", - "record_quality": "Recording Quality", - "segment_record": "Segmented Recording", - "segment_time": "Segment Duration", - "monitor_status": "Monitoring Status", - "scheduled_recording": "Scheduled Detection", - "scheduled_time_range": "Scheduled Detection Range", - "save_path": "Save Path", - "recording_status": "Recording Status", - "start_record": "Start Recording", - "stop_record": "Stop Recording", - "start_monitor": "Start Monitoring", - "stop_monitor": "Stop Monitoring", - "seconds": "s", - "delete_confirm_tip": "Are you sure you want to delete this live streaming room?", - "no_recording_dir_tip": "No saved recording files", - "none": "None", - "recording_card_tip": "Click to select", - "use_proxy": "Use Proxy", - "no_support_open_dir": "Web version does not support opening folder ⚠️", - "message_push": "Message Push", - "all": "All", - "recording": "Recording", - "recording_error": "Recording Error", - "live_broadcasting": "Live Broadcasting", - "not_live": "Not Live", - "stopped": "Stopped", - "filter": "Filter", - "offline": "Offline", - "no_monitor": "Not Monitored" - }, - "settings_page": { - "recording_settings": "Recording Settings", - "push_settings": "Push Settings", - "basic_settings": "Basic Settings", - "cookies_settings": "Cookie Settings", - "accounts_settings": "Account Settings", - "security_settings": "Security Settings", - "select": "select", - "unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually", - "program_config": "Basic configuration of the program", - "restore_defaults": "Restore Default Settings", - "program_language": "Program Language", - "filename_includes_title": "Include Title in Filename", - "custom_filename_template": "Custom Filename Template", - "live_recording_path": "Live Recording Path", - "blank_for_default_path": "Leave blank for default path", - "remove_emojis": "Remove Emoji Symbols", - "name_rules": "File/folder name rules", - "platform": "platform", - "author": "author", - "time": "time", - "title": "title", - "proxy_settings": "Proxy Settings", - "is_proxy_enabled": "Configuration for using proxy and related settings", - "enable_proxy": "Enable Proxy", - "proxy_address": "Proxy Address", - "skip_proxy_detection": "Skip Proxy Detection", - "recording_options": "Recording Options", - "advanced_config": "Advanced recording configuration", - "video_record_format": "Video/Audio Recording Format", - "recording_quality": "Recording Quality", - "loop_time": "Loop Time (Seconds)", - "is_segmented_recording_enabled": "Enable Segmented Recording", - "force_https": "Force HTTPS Recording", - "default_live_source": "Default Live Source", - "default_live_source_tip": "Prefer to record live streams using FLV sources", - "flv_use_direct_download": "FLV Source Use Direct Downloader", - "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", - "space_threshold": "Remaining Space Threshold (GB) for Recording", - "segment_time": "Video Segment Time (Seconds)", - "convert_mp4": "Convert to MP4 After Recording", - "delete_original": "Delete Original File After Appending Format", - "generate_timestamps_subtitle": "Generate Timestamp Subtitle", - "custom_script": "Execute Custom Script After Recording", - "script_command": "Custom Script Execution Command", - "default_platform_with_proxy": "Default Platform for Recording with Proxy", - "web_login_configuration": "Web Backend Login Configuration", - "login_required": "Enable Secure Login", - "login_required_enabled": "Secure login enabled", - "login_required_disabled": "Secure login disabled", - "current_username": "Current Username", - "old_password": "Old Password", - "new_password": "New Password", - "confirm_password": "Confirm Password", - "change_password": "Change Password", - "old_password_required": "Old password cannot be empty", - "new_password_required": "New password cannot be empty", - "passwords_not_match": "The two passwords do not match", - "password_changed": "Password changed successfully", - "old_password_incorrect": "Old password is incorrect", - "not_logged_in": "You are not logged in", - "push_notifications": "Push Notifications", - "stream_start_notification_enabled": "Live Status Notification", - "system_status_bar_notification_enabled": "System Bar Notification", - "open_broadcast_push_enabled": "Broadcast Start Push", - "close_broadcast_push_enabled": "Broadcast End Push", - "only_notify_no_record": "Only notify without recording", - "notify_loop_time": "Only notify loop time", - "custom_push_settings": "Custom Push Settings", - "personalized_notification_content_behavior": "Personalized notification content and behavior", - "custom_push_title": "Custom Push Title", - "custom_open_broadcast_content": "Custom Broadcast Start Notification Content", - "custom_close_broadcast_content": "Custom Broadcast End Notification Content", - "push_channels": "Push Channels", - "dingtalk": "DingTalk", - "wechat": "Wechat", - "email": "Email", - "telegram": "Telegram", - "select_and_enable_channels": "Select and enable the channels you want to use", - "channel_configuration": "Channel Configuration", - "configure_enabled_channels": "Configure your enabled notification channels", - "dingtalk_webhook_url": "DingTalk Webhook URL", - "dingtalk_at_objects": "DingTalk @ Objects", - "dingtalk_at_all": "DingTalk Notify All", - "dingtalk_webhook_hint": "Enter DingTalk group webhook URL", - "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers", - "wechat_webhook_url": "WeChat Webhook URL", - "bark_webhook_url": "Bark Webhook URL", - "bark_interrupt_level": "Bark Interrupt Level", - "bark_sound": "Bark Sound", - "ntfy_server_url": "Ntfy Server URL", - "ntfy_tags": "Ntfy Tag", - "ntfy_email": "Ntfy Email", - "ntfy_action_url": "Ntfy Action URL", - "telegram_api_token": "Telegram API Token", - "serverchan_send_key": "SCKEY/SendKey", - "serverchan_channel": "Push Channel", - "serverchan_tags": "ServerChan Tags", - "serverchan": "ServerChan", - "telegram_chat_id": "Telegram Chat ID", - "smtp_server": "Smtp Server", - "email_username": "Email Username", - "email_password": "Email Password", - "sender_email": "Sender Email", - "sender_name": "Sender Name", - "recipient_email": "Recipient Email", - "configure_platform_cookies": "Configure platform cookies", - "douyin_cookie": "Douyin Cookie", - "tiktok_cookie": "Tiktok Cookie", - "kuaishou_cookie": "Kuaishou Cookie", - "huya_cookie": "Huya Cookie", - "douyu_cookie": "Douyu Cookie", - "yy_cookie": "YY Cookie", - "bilibili_cookie": "Bilibili Cookie", - "xhs_cookie": "Xiaohongshu Cookie", - "bigo_cookie": "Bigo Cookie", - "blued_cookie": "Blued Cookie", - "soop_cookie": "Soop Cookie", - "netease_cookie": "NeteaseCC Cookie", - "qiandurebo_cookie": "Qiandu Cookie", - "pandalive_cookie": "Pandalive Cookie", - "maoerfm_cookie": "MaoerFM Cookie", - "winktv_cookie": "Winktv Cookie", - "flextv_cookie": "Flextv Cookie", - "look_cookie": "LookLive Cookie", - "popkontv_cookie": "Popkontv Cookie", - "twitcasting_cookie": "Twitcasting Cookie", - "baidu_cookie": "Baidu Cookie", - "weibo_cookie": "Weibo Cookie", - "kugou_cookie": "Kugou Cookie", - "twitch_cookie": "Twitch Cookie", - "liveme_cookie": "Liveme Cookie", - "huajiao_cookie": "Huajiao Cookie", - "liuxing_cookie": "Liuxing Cookie", - "showroom_cookie": "Showroom Cookie", - "acfun_cookie": "Acfun Cookie", - "changliao_cookie": "Changliao Cookie", - "yinbo_cookie": "Yinbo Cookie", - "inke_cookie": "Inke Cookie", - "zhihu_cookie": "Zhihu Cookie", - "chzzk_cookie": "Chzzk Cookie", - "haixiu_cookie": "Haixiu Cookie", - "vvxq_cookie": "Vvxq Cookie", - "17live_cookie": "17Live Cookie", - "lang_cookie": "Lang Cookie", - "piaopiao_cookie": "Piaopiao Cookie", - "6room_cookie": "SixroomCookie", - "lehai_cookie": "Lehai Cookie", - "catshow_cookie": "Catshow Cookie", - "shopee_cookie": "Shopee Cookie", - "youtube_cookie": "Youtube Cookie", - "taobao_cookie": "Taobao Cookie", - "jd_cookie": "Jingdong Cookie", - "configure_platform_accounts": "Configure Platform Accounts", - "sooplive_username": "Soop Username", - "sooplive_password": "Soop Password", - "flextv_username": "FlexTV Username", - "flextv_password": "FlexTV Password", - "popkontv_username": "PopkonTV Username", - "popkontv_password": "PopkonTV Password", - "twitcasting_account_type": "Twitcasting Account Type", - "twitcasting_username": "Twitcasting Username", - "twitcasting_password": "Twitcasting Password", - "success_restore_tip": "Tip: Default configuration has been restored", - "query_restore_config_tip": "Are you sure you want to restore the default configuration?", - "success_save_config_tip": "Tip: Configuration has been saved", - "Chinese": "Simplified Chinese", - "English": "English", - "select_btn_tip": "Select the save path", - "switch_language": "Switch language", - "switch_video_format": "Switch video recording format", - "switch_recording_quality": "Switch video recording quality", - "switch_account_type": "Switch account type", - "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", - "platform_max_concurrent_requests": "Max concurrent recordings per platform", - "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3." - }, - "about_page": { - "about_project": "About This Application", - "ui_version": "UI Version", - "kernel_version": "Kernel Version", - "license": "Open Source License", - "introduction": "Software Introduction", - "feature": "Feature Highlights", - "support_platforms": "Supported Platforms", - "customize_recording": "Custom Params", - "open_source": "Open Source", - "automatic_transcoding": "Auto Transcode", - "status_push": "Live Status", - "developer": "Developer", - "author": "Author", - "view_update": "View Updates", - "view_docs": "View Documentation", - "update": "Version Update" - }, - "base": { - "confirm": "Confirm", - "sure": "OK", - "cancel": "Cancel", - "yes": "Yes", - "no": "No", - "open": "Open", - "close": "Close", - "enabled": "Enabled", - "disabled": "Disabled" - }, - "video_quality": { - "OD": "Original", - "UHD": "Ultra HD", - "HD": "High Definition", - "SD": "Standard Definition", - "LD": "Low Definition" - }, - "help_dialog": { - "shortcut_key_help": "Shortcut Key Help", - "description": "Here are the supported shortcut keys:", - "main_page": "Main Page", - "search_recording": "Search Recording: Ctrl + F - Open the search dialog to find specific live streams.", - "refresh_list": "Refresh List: Ctrl + R - Refresh the current list of live streams.", - "add_new_recording": "Add Recording: Alt + N - Open the new live stream creation panel.", - "start_all": "Batch Start: Alt + B - Start monitoring all or selected live streams in the list.", - "stop_all": "Batch Stop: Alt + P - Stop monitoring all or selected live streams in the list.", - "delete_all": "Batch Delete: Alt + D - Delete all or selected live streams from the list.", - "settings_page": "Settings Page", - "save_configuration": "Save Configuration: Ctrl + S - Save the current configuration settings.", - "view_help": "View Help: Alt + H - Open this help dialog." - }, - "install_manager": { - "complete": "Completed", - "error": "Error", - "installing": "Installing", - "reinstall": "Reinstall", - "installed": "Installed", - "wait_install": "Waiting for installation", - "install_now": "Install Now", - "later_on": "Later", - "install_guide": "Component Installation Guide", - "install_tip": "This program requires the following components to function properly.", - "lack_components": "Missing Required Components", - "dont_show_again": "Don't Show Again" - }, - "storage_page": { - "storage_path": "Storage Path", - "current_path": "Current Path", - "empty_recording_folder": "Empty Recording Folder", - "go_back": "Go Back", - "previewing": "Previewing", - "unsupported_file_type":"Unsupported file type ⚠️", - "no_video_file":"No video file 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", - "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ " - }, - "update": { - "new_version": "New Version {version} Available", - "current_version": "Current Version: {version}", - "latest_version": "Latest Version: {version}", - "update_source": "Update Source: {source}", - "release_notes": "Release Notes:", - "no_details": "No details available", - "later": "Later", - "download": "Download Update", - "check_update": "Check for Updates", - "checking_update": "Checking for updates...", - "update_check_failed": "Update check failed", - "no_update_available": "You are using the latest version", - "unknown": "Unknown" - }, - "save_progress_overlay": { - "force_close": "Force Close", - "force_close_tooltip": "Force close the program (may result in video corruption)", - "force_close_warning": "This action may result in video corruption. Please proceed with caution.", - "force_closing": "Force closing...", - "saving_recording": "Saving recording..." - }, - "app_close_handler": { - "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", - "confirm_exit": "Confirm Exit", - "confirm_exit_content": "Are you sure you want to exit the application?", - "minimize_to_tray": "Minimize to Tray", - "exit_program": "Exit Program", - "minimize_to_tray_tip": "Clicking \"Minimize to Tray\" will hide the program to system tray where it continues running" - }, - "tray_manager": { - "restore": "Restore Window", - "exit": "Exit Program" - }, - "login_page": { - "login_title": "StreamCap Login", - "login_subtitle": "Please login to your account", - "username": "Username", - "password": "Password", - "login_button": "Login", - "login_in_progress": "Logging in...", - "default_account_tip": "Default account: admin / Password: admin", - "input_required": "Please enter username and password", - "login_failed": "Username or password is incorrect", - "login_success": "Login successful" - } +{ + "sidebar": { + "home": "Home", + "recordings": "Recordings", + "settings": "Settings", + "storage": "Storage", + "about": "About", + "light_theme": "Light theme", + "dark_theme": "Dark theme", + "theme_color": "Theme Color", + "toggle_day_theme": "Toggle Day Theme", + "toggle_night_theme": "Toggle Night Theme", + "colors": "Color List" + }, + "home_page": { + "welcome": "Welcome to StreamCap", + "tagline": "Record every exciting moment of the live broadcast", + "start_recording": "Start Recording", + "recording_list": "Recording List", + "browse_recordings": "Browse Recordings", + "manage_storage": "Manage Storage", + "settings": "Settings", + "about": "About", + "main_features": "Main Features", + "feature_title_1": "Start Recording", + "feature_desc_1": "Enter the live room address, start recording immediately", + "feature_title_2": "Message Push", + "feature_desc_2": "Support for ServerChan and other channels", + "feature_title_3": "Batch Operations", + "feature_desc_3": "Batch start, stop and delete recording", + "feature_title_4": "Scheduled Monitoring", + "feature_desc_4": "Default 24h loop monitoring, support scheduled monitoring", + "version": "Version", + "announcement": "Announcement", + "stats": "Statistics", + "total_rooms": "Total Rooms", + "active_recordings": "Active Recordings", + "stop_monitoring": "Stop Monitoring", + "recent_added_recordings": "Recent Added", + "no_recordings": "No Recordings", + "greeting_morning": "Good Morning", + "greeting_afternoon": "Good Afternoon", + "greeting_evening": "Good Evening" + }, + "recordings_page": { + "recording_list": "Recording List", + "search": "Search", + "add_record": "Add Recording", + "refresh": "Refresh", + "batch_start": "Batch Start", + "batch_stop": "Batch Stop", + "batch_delete": "Batch Delete", + "refresh_success_tip": "Tip: Refresh successful", + "batch_delete_confirm_tip": "Are you sure you want to delete all selected recordings?", + "clear_all_confirm_tip": "Are you sure you want to clear all recordings?", + "edit_record": "Edit Recording", + "add_recording_success_tip": "Tip: Live room added successfully!", + "delete_recording_success_tip": "Tip: Live room deleted successfully!", + "stop_recording_success_tip": "Tip: Live room monitoring stopped successfully!", + "start_recording_success_tip": "Tip: Live room monitoring started successfully!", + "not_search_result": "Tip: No search results found", + "toggle_view": "Toggle View", + "preview_video": "Preview Video", + "status_filter": "Status Filter", + "filter_all": "All", + "filter_recording": "Recording", + "filter_living": "Living", + "filter_error": "Error", + "filter_offline": "Offline", + "filter_stopped": "Not Monitored", + "platform_filter": "Platform Filter", + "platform_sort": "Platform Sort", + "operations": "Operations", + "filter": "Filter" + }, + "recording_dialog": { + "input_live_link": "Enter Live Room URL", + "example": "Example", + "select_resolution": "Select Recording Resolution", + "custom_video_bitrate": "Custom Video Bitrate (kbps)", + "custom_video_bitrate_hint": "Leave blank to keep the source bitrate", + "custom_video_bitrate_invalid": "Enter a whole-number bitrate greater than 0", + "flv_use_direct_download": "FLV Source Use Direct Downloader", + "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", + "input_anchor_name": "Enter Broadcaster Name", + "default_input": "Can be left blank", + "select_record_format": "Select Recording Format - Default ts", + "input_save_path": "Enter Save Path for Recordings", + "is_segment_enabled": "Enable Segmented Recording", + "segment_record_time": "Segment Recording Time", + "input_segment_time": "Enter Segment Time (in seconds)", + "scheduled_recording": "Enable Daily Scheduled Monitoring", + "scheduled_start_time": "Daily Monitoring Start Time", + "monitor_hours": "Daily Monitoring Hours", + "enable_message_push": "Enable Message Push", + "only_notify_no_record": "Only notify without recording", + "batch_input_tip": "Batch Input (one record per line)", + "single_input": "Single Input", + "batch_input": "Batch Input", + "live_room": "Live Room", + "platform_not_supported_tip": "This platform does not support recording ⚠️", + "pick_time": "Pick time", + "pick_time_tip": "Configure scheduled time", + "time_out_of_range": "Time out of range", + "pick_time_slot": "Pick your time slot", + "hour_label_text": "Hour", + "minute_label_text": "Minute", + "select_media_type": "Select Media Type", + "video": "Video", + "audio": "Audio", + "duplicate_url_title": "Duplicate Live Room URL", + "duplicate_url_content": "The live room URL already exists, do you want to continue adding?" + }, + "search_dialog": { + "search_keyword": "Enter search keyword" + }, + "recording_manager": { + "monitor_stopped": "Stopped monitoring", + "is_live": "Live streaming in progress", + "live_room": "Live Room", + "recorded": "Recorded", + "push_content": "Live room status update: [room_name] is live, time: [time]", + "push_content_end": "Live room status update: [room_name] is live completed, time: [time]", + "status_notify": "Live room status update notification", + "STOPPED_MONITORING": "Monitoring Stopped", + "MONITORING": "Monitoring In Progress", + "RECORDING": "Recording In Progress", + "NOT_RECORDING": "Is living, not Recording", + "STATUS_CHECKING": "Live Status Checking In Progres", + "NOT_IN_SCHEDULED_CHECK": "Not in Scheduled Check Range, Stopping Live Detection", + "PREPARING_RECORDING": "Preparing to Start Recording", + "RECORDING_ERROR": "Recording the live stream has failed", + "NOT_RECORDING_SPACE": "Insufficient disk space to record", + "LIVE_STATUS_CHECK_ERROR": "Live status error, check address accessibility", + "LIVE_BROADCASTING": "Live Broadcasting", + "not_disk_space_tip": "Insufficient disk storage space, stop recording ⚠️", + "notify": "Notify", + "live_recording_stopped_message": "Live room recording has been stopped", + "live_recording_started_message": "Live room recording has been started", + "not_config_tip": "Message push channel not configured" + }, + "stream_manager": { + "record_stream_error": "Live streaming source recording error", + "no_ffmpeg_tip": "FFmpeg is not installed, please install FFmpeg first ⚠️" + }, + "recording_card": { + "stop_monitor_tip": "Tip: Live monitoring has been stopped", + "start_monitor_tip": "Tip: Live monitoring has been started", + "please_stop_monitor_tip": "Tip: Please stop live monitoring first️", + "please_start_monitor_tip": "Tip: Please start live monitoring first", + "is_not_live_tip": "Tip: The live room is not broadcasting", + "pre_record_tip": "Tip: Preparing to start recording", + "stop_record_tip": "Tip: Live room recording has been stopped", + "no_folder_tip": "Tip: The live room folder does not exist", + "edit_record_config": "Edit Recording Configuration", + "delete_monitor": "Delete Monitoring", + "open_folder": "Open Folder", + "recording_info": "Recording Information", + "anchor_name": "Anchor Name", + "platform_name": "Platform Name", + "live_link": "Live Link", + "live_title": "Live Title", + "record_format": "Recording Format", + "record_quality": "Recording Quality", + "segment_record": "Segmented Recording", + "segment_time": "Segment Duration", + "monitor_status": "Monitoring Status", + "scheduled_recording": "Scheduled Detection", + "scheduled_time_range": "Scheduled Detection Range", + "save_path": "Save Path", + "recording_status": "Recording Status", + "start_record": "Start Recording", + "stop_record": "Stop Recording", + "start_monitor": "Start Monitoring", + "stop_monitor": "Stop Monitoring", + "seconds": "s", + "delete_confirm_tip": "Are you sure you want to delete this live streaming room?", + "no_recording_dir_tip": "No saved recording files", + "none": "None", + "recording_card_tip": "Click to select", + "use_proxy": "Use Proxy", + "no_support_open_dir": "Web version does not support opening folder ⚠️", + "message_push": "Message Push", + "all": "All", + "recording": "Recording", + "recording_error": "Recording Error", + "live_broadcasting": "Live Broadcasting", + "not_live": "Not Live", + "stopped": "Stopped", + "filter": "Filter", + "offline": "Offline", + "no_monitor": "Not Monitored", + "checking": "Checking", + "live_room": "Live Room" + }, + "settings_page": { + "recording_settings": "Recording Settings", + "push_settings": "Push Settings", + "basic_settings": "Basic Settings", + "cookies_settings": "Cookie Settings", + "accounts_settings": "Account Settings", + "security_settings": "Security Settings", + "select": "select", + "unsupported_select_path": "Path selection is not supported on the web 📂 Please enter manually", + "select_path_failed": "The path picker could not be opened. Please enter the save path manually.", + "program_config": "Basic configuration of the program", + "restore_defaults": "Restore Default Settings", + "program_language": "Program Language", + "filename_includes_title": "Include Title in Filename", + "custom_filename_template": "Custom Filename Template", + "live_recording_path": "Live Recording Path", + "blank_for_default_path": "Leave blank for default path", + "remove_emojis": "Remove Emoji Symbols", + "name_rules": "File/folder name rules", + "platform": "platform", + "author": "author", + "time": "time", + "title": "title", + "proxy_settings": "Proxy Settings", + "remember_window_size": "Remember Window Size", + "scheduled_shutdown": "Scheduled Shutdown Settings", + "scheduled_shutdown_time": "Daily shutdown time", + "scheduled_shutdown_pick_time": "Select daily shutdown time", + "scheduled_shutdown_more": "Open shutdown settings", + "scheduled_shutdown_tip": "Start the shutdown countdown at the selected time every day", + "scheduled_shutdown_system_message": "StreamCap scheduled shutdown started; it can be cancelled within 60 seconds", + "scheduled_shutdown_countdown": "The system will shut down in {seconds} seconds.", + "scheduled_shutdown_cancel": "Cancel this shutdown", + "scheduled_shutdown_cancelled": "This shutdown has been cancelled", + "scheduled_shutdown_failed": "Unable to start scheduled shutdown: {error}", + "scheduled_shutdown_cancel_failed": "Unable to cancel this shutdown: {error}", + "quick_shutdown": "Quick Shutdown", + "quick_shutdown_hours_suffix": "hours", + "quick_shutdown_ready": "Enter 1-{max_hours} hours, then start", + "quick_shutdown_invalid_hours": "Enter a number of hours from 1 to {max_hours}", + "quick_shutdown_start": "Start", + "quick_shutdown_start_tip": "Schedule shutdown after a number of hours", + "quick_shutdown_cancel": "Cancel quick shutdown", + "quick_shutdown_confirm_title": "Confirm Quick Shutdown", + "quick_shutdown_confirm_content": "The computer will shut down in {hours} hour(s), at {time}. Start the timer?", + "quick_shutdown_active": "Scheduled shutdown: {time}", + "quick_shutdown_started": "Shutdown scheduled for {time}", + "quick_shutdown_cancelled": "Quick shutdown cancelled", + "quick_shutdown_failed": "Unable to schedule quick shutdown: {error}", + "quick_shutdown_cancel_failed": "Unable to cancel quick shutdown: {error}", + "quick_shutdown_system_message": "StreamCap quick shutdown: {hours}-hour countdown", + "is_proxy_enabled": "Configuration for using proxy and related settings", + "enable_proxy": "Enable Proxy", + "proxy_address": "Proxy Address", + "skip_proxy_detection": "Skip Proxy Detection", + "recording_options": "Recording Options", + "advanced_config": "Advanced recording configuration", + "video_record_format": "Video/Audio Recording Format", + "recording_quality": "Recording Quality", + "loop_time": "Loop Time (Seconds)", + "is_segmented_recording_enabled": "Enable Segmented Recording", + "force_https": "Force HTTPS Recording", + "default_live_source": "Default Live Source", + "default_live_source_tip": "Prefer to record live streams using FLV sources", + "flv_use_direct_download": "FLV Source Use Direct Downloader", + "flv_use_direct_download_tip": "Enable lower latency, but does not support segmented recording", + "space_threshold": "Remaining Space Threshold (GB) for Recording", + "segment_time": "Video Segment Time (Seconds)", + "convert_mp4": "Convert to MP4 After Recording", + "delete_original": "Delete Original File After Appending Format", + "generate_timestamps_subtitle": "Generate Timestamp Subtitle", + "custom_script": "Execute Custom Script After Recording", + "script_command": "Custom Script Execution Command", + "default_platform_with_proxy": "Default Platform for Recording with Proxy", + "web_login_configuration": "Web Backend Login Configuration", + "login_required": "Enable Secure Login", + "login_required_enabled": "Secure login enabled", + "login_required_disabled": "Secure login disabled", + "current_username": "Current Username", + "old_password": "Old Password", + "default_old_password": "default: admin", + "new_password": "New Password", + "confirm_password": "Confirm Password", + "change_password": "Change Password", + "old_password_required": "Old password cannot be empty", + "new_password_required": "New password cannot be empty", + "passwords_not_match": "The two passwords do not match", + "password_changed": "Password changed successfully", + "old_password_incorrect": "Old password is incorrect", + "not_logged_in": "You are not logged in", + "push_notifications": "Push Notifications", + "stream_start_notification_enabled": "Live Status Notification", + "system_status_bar_notification_enabled": "System Bar Notification", + "open_broadcast_push_enabled": "Broadcast Start Push", + "close_broadcast_push_enabled": "Broadcast End Push", + "only_notify_no_record": "Only notify without recording", + "notify_loop_time": "Only notify loop time", + "custom_push_settings": "Custom Push Settings", + "personalized_notification_content_behavior": "Personalized notification content and behavior", + "custom_push_title": "Custom Push Title", + "custom_open_broadcast_content": "Custom Broadcast Start Notification Content", + "custom_close_broadcast_content": "Custom Broadcast End Notification Content", + "push_channels": "Push Channels", + "dingtalk": "DingTalk", + "wechat": "Wechat", + "feishu": "Feishu", + "email": "Email", + "telegram": "Telegram", + "select_and_enable_channels": "Select and enable the channels you want to use", + "channel_configuration": "Channel Configuration", + "configure_enabled_channels": "Configure your enabled notification channels", + "dingtalk_webhook_url": "DingTalk Webhook URL", + "dingtalk_at_objects": "DingTalk @ Objects", + "dingtalk_at_all": "DingTalk Notify All", + "dingtalk_webhook_hint": "Enter DingTalk group webhook URL", + "dingtalk_phone_numbers_hint": "Enter DingTalk phone numbers", + "wechat_webhook_url": "WeChat Webhook URL", + "feishu_webhook_url": "Feishu Webhook URL", + "bark_webhook_url": "Bark Webhook URL", + "bark_interrupt_level": "Bark Interrupt Level", + "bark_sound": "Bark Sound", + "ntfy_server_url": "Ntfy Server URL", + "ntfy_tags": "Ntfy Tag", + "ntfy_email": "Ntfy Email", + "ntfy_action_url": "Ntfy Action URL", + "telegram_api_token": "Telegram API Token", + "serverchan_send_key": "SCKEY/SendKey", + "serverchan_channel": "Push Channel", + "serverchan_tags": "ServerChan Tags", + "serverchan": "ServerChan", + "telegram_chat_id": "Telegram Chat ID", + "smtp_server": "Smtp Server", + "email_username": "Email Username", + "email_password": "Email Password", + "sender_email": "Sender Email", + "sender_name": "Sender Name", + "recipient_email": "Recipient Email", + "configure_platform_cookies": "Configure platform cookies", + "douyin_cookie": "Douyin Cookie", + "tiktok_cookie": "Tiktok Cookie", + "kuaishou_cookie": "Kuaishou Cookie", + "huya_cookie": "Huya Cookie", + "douyu_cookie": "Douyu Cookie", + "yy_cookie": "YY Cookie", + "bilibili_cookie": "Bilibili Cookie", + "xhs_cookie": "Xiaohongshu Cookie", + "bigo_cookie": "Bigo Cookie", + "blued_cookie": "Blued Cookie", + "soop_cookie": "Soop Cookie", + "netease_cookie": "NeteaseCC Cookie", + "qiandurebo_cookie": "Qiandu Cookie", + "pandalive_cookie": "Pandalive Cookie", + "maoerfm_cookie": "MaoerFM Cookie", + "winktv_cookie": "Winktv Cookie", + "flextv_cookie": "Flextv Cookie", + "look_cookie": "LookLive Cookie", + "popkontv_cookie": "Popkontv Cookie", + "twitcasting_cookie": "Twitcasting Cookie", + "baidu_cookie": "Baidu Cookie", + "weibo_cookie": "Weibo Cookie", + "kugou_cookie": "Kugou Cookie", + "twitch_cookie": "Twitch Cookie", + "liveme_cookie": "Liveme Cookie", + "huajiao_cookie": "Huajiao Cookie", + "liuxing_cookie": "Liuxing Cookie", + "showroom_cookie": "Showroom Cookie", + "acfun_cookie": "Acfun Cookie", + "changliao_cookie": "Changliao Cookie", + "yinbo_cookie": "Yinbo Cookie", + "inke_cookie": "Inke Cookie", + "zhihu_cookie": "Zhihu Cookie", + "chzzk_cookie": "Chzzk Cookie", + "haixiu_cookie": "Haixiu Cookie", + "vvxq_cookie": "Vvxq Cookie", + "17live_cookie": "17Live Cookie", + "lang_cookie": "Lang Cookie", + "piaopiao_cookie": "Piaopiao Cookie", + "6room_cookie": "SixroomCookie", + "lehai_cookie": "Lehai Cookie", + "catshow_cookie": "Catshow Cookie", + "shopee_cookie": "Shopee Cookie", + "youtube_cookie": "Youtube Cookie", + "taobao_cookie": "Taobao Cookie", + "jd_cookie": "Jingdong Cookie", + "configure_platform_accounts": "Configure Platform Accounts", + "sooplive_username": "Soop Username", + "sooplive_password": "Soop Password", + "flextv_username": "FlexTV Username", + "flextv_password": "FlexTV Password", + "popkontv_username": "PopkonTV Username", + "popkontv_password": "PopkonTV Password", + "twitcasting_account_type": "Twitcasting Account Type", + "twitcasting_username": "Twitcasting Username", + "twitcasting_password": "Twitcasting Password", + "success_restore_tip": "Tip: Default configuration has been restored", + "query_restore_config_tip": "Are you sure you want to restore the default configuration?", + "success_save_config_tip": "Tip: Configuration has been saved", + "Chinese": "Simplified Chinese", + "English": "English", + "select_btn_tip": "Select the save path", + "switch_language": "Switch language", + "switch_video_format": "Switch video recording format", + "switch_recording_quality": "Switch video recording quality", + "switch_account_type": "Switch account type", + "switch_language_tip": "Tip: It is recommended to restart the program after switching languages", + "platform_max_concurrent_requests": "Max concurrent recordings per platform", + "platform_max_concurrent_requests_tip": "The maximum number of concurrent requests allowed per platform. Default is 3.", + "check_live_on_browser_refresh": "Check live status when refreshing the web", + "check_live_on_browser_refresh_tip": "Check live status when refreshing the web" + }, + "about_page": { + "about_project": "About This Application", + "ui_version": "UI Version", + "kernel_version": "Kernel Version", + "license": "Open Source License", + "introduction": "Software Introduction", + "feature": "Feature Highlights", + "support_platforms": "Supported Platforms", + "customize_recording": "Custom Params", + "open_source": "Open Source", + "automatic_transcoding": "Auto Transcode", + "status_push": "Live Status", + "developer": "Developer", + "author": "Author", + "view_update": "View Updates", + "view_docs": "View Documentation", + "update": "Version Update" + }, + "base": { + "confirm": "Confirm", + "sure": "OK", + "cancel": "Cancel", + "yes": "Yes", + "no": "No", + "open": "Open", + "close": "Close", + "enabled": "Enabled", + "disabled": "Disabled" + }, + "video_quality": { + "OD": "Original", + "UHD": "Ultra HD", + "HD": "High Definition", + "SD": "Standard Definition", + "LD": "Low Definition" + }, + "help_dialog": { + "shortcut_key_help": "Shortcut Key Help", + "description": "Here are the supported shortcut keys:", + "main_page": "Main Page", + "search_recording": "Search Recording: Ctrl + F - Open the search dialog to find specific live streams.", + "refresh_list": "Refresh List: Ctrl + R - Refresh the current list of live streams.", + "add_new_recording": "Add Recording: Alt + N - Open the new live stream creation panel.", + "start_all": "Batch Start: Alt + B - Start monitoring all or selected live streams in the list.", + "stop_all": "Batch Stop: Alt + P - Stop monitoring all or selected live streams in the list.", + "delete_all": "Batch Delete: Alt + D - Delete all or selected live streams from the list.", + "settings_page": "Settings Page", + "save_configuration": "Save Configuration: Ctrl + S - Save the current configuration settings.", + "view_help": "View Help: Alt + H - Open this help dialog." + }, + "install_manager": { + "complete": "Completed", + "error": "Error", + "installing": "Installing", + "reinstall": "Reinstall", + "installed": "Installed", + "wait_install": "Waiting for installation", + "install_now": "Install Now", + "later_on": "Later", + "install_guide": "Component Installation Guide", + "install_tip": "This program requires the following components to function properly.", + "lack_components": "Missing Required Components", + "dont_show_again": "Don't Show Again" + }, + "storage_page": { + "storage_path": "Storage Path", + "current_path": "Current Path", + "empty_recording_folder": "Empty Recording Folder", + "go_back": "Go Back", + "previewing": "Previewing", + "unsupported_file_type":"Unsupported file type ⚠️", + "no_video_file":"No video file 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", + "unsupported_play_on_web": "Only MP4 files can be previewed on the web ⚠️ ", + "screenshot": "Screenshot", + "screenshot_success": "Screenshot saved", + "screenshot_failed": "Screenshot failed ⚠️" + }, + "update": { + "new_version": "New Version {version} Available", + "current_version": "Current Version: {version}", + "latest_version": "Latest Version: {version}", + "update_source": "Update Source: {source}", + "release_notes": "Release Notes:", + "no_details": "No details available", + "later": "Later", + "download": "Download Update", + "check_update": "Check for Updates", + "checking_update": "Checking for updates...", + "update_check_failed": "Update check failed", + "no_update_available": "You are using the latest version", + "unknown": "Unknown" + }, + "save_progress_overlay": { + "force_close": "Force Close", + "force_close_tooltip": "Force close the program (may result in video corruption)", + "force_close_warning": "This action may result in video corruption. Please proceed with caution.", + "force_closing": "Force closing...", + "saving_recording": "Saving recording..." + }, + "app_close_handler": { + "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", + "confirm_exit": "Confirm Exit", + "confirm_exit_content": "Are you sure you want to exit the application?", + "minimize_to_tray": "Minimize to Tray", + "exit_program": "Exit Program", + "minimize_to_tray_tip": "Clicking \"Minimize to Tray\" will hide the program to system tray where it continues running" + }, + "tray_manager": { + "restore": "Restore Window", + "exit": "Exit Program" + }, + "login_page": { + "login_title": "StreamCap Login", + "login_subtitle": "Please login to your account", + "username": "Username", + "password": "Password", + "login_button": "Login", + "login_in_progress": "Logging in...", + "default_account_tip": "Default account: admin / Password: admin", + "input_required": "Please enter username and password", + "login_failed": "Username or password is incorrect", + "login_success": "Login successful" + } } \ No newline at end of file diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 6c606c77..7e7eefe2 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,504 +1,540 @@ -{ - "sidebar": { - "home": "主页", - "recordings": "录制", - "settings": "设置", - "storage": "存储", - "about": "说明", - "light_theme": "白天模式", - "dark_theme": "黑夜模式", - "theme_color": "主题颜色", - "toggle_day_theme": "切换为白天模式", - "toggle_night_theme": "切换为黑夜模式", - "colors": "颜色列表" - }, - "home_page": { - "welcome": "欢迎使用 StreamCap", - "tagline": "强大的流媒体录制工具", - "start_recording": "开始录制", - "recording_list": "录制列表", - "browse_recordings": "浏览录制", - "manage_storage": "管理存储", - "settings": "设置", - "about": "关于", - "main_features": "主要功能", - "feature_title_1": "开播即录", - "feature_desc_1": "输入直播间地址,开播即录", - "feature_title_2": "消息推送", - "feature_desc_2": "支持Server酱等多种渠道推送", - "feature_title_3": "批量操作", - "feature_desc_3": "批量启动、停止和删除录制", - "feature_title_4": "定时监控", - "feature_desc_4": "默认24h循环监控,支持自定义", - "version": "版本", - "announcement": "公告", - "announcement_title_1": "StreamCap v1.0.1 已发布", - "announcement_content_1": "新增多平台支持,修复已知问题,提升稳定性", - "announcement_title_2": "使用技巧", - "announcement_content_2": "使用批量操作功能可以同时管理多个录制任务", - "announcement_title_3": "即将推出", - "announcement_content_3": "下个版本将支持自动分段录制和云端备份功能", - "stats": "统计信息", - "total_rooms": "总房间数", - "active_recordings": "正在录制", - "stop_monitoring": "停止监控", - "recent_added_recordings": "最近添加", - "no_recordings": "暂无录制记录", - "greeting_morning": "早上好", - "greeting_afternoon": "下午好", - "greeting_evening": "晚上好" - }, - - "recordings_page": { - "recording_list": "录制列表", - "search": "搜索", - "add_record": "新增录制", - "refresh": "刷新", - "batch_start": "批量开始", - "batch_stop": "批量停止", - "batch_delete": "批量删除", - "refresh_success_tip": "提示:刷新成功", - "batch_delete_confirm_tip": "您确定要删除所有全部已选中的录制列表吗?", - "clear_all_confirm_tip": "您确定要清空录制列表吗?", - "edit_record": "编辑录制", - "add_recording_success_tip": "提示:直播间添加成功!", - "delete_recording_success_tip": "提示:直播间删除成功!", - "stop_recording_success_tip": "提示:直播间停止监控成功!", - "start_recording_success_tip": "提示:直播间开始监控成功!", - "not_search_result": "提示:未搜索到任何结果", - "toggle_view": "切换视图", - "preview_video": "预览视频", - "status_filter": "状态筛选", - "filter_all": "全部", - "filter_recording": "录制中", - "filter_living": "直播中", - "filter_error": "录制错误", - "filter_offline": "未开播", - "filter_stopped": "未监控", - "platform_filter": "平台筛选", - "platform_sort": "平台排序", - "operations": "操作", - "filter": "筛选" - }, - "recording_dialog": { - "input_live_link": "输入直播间地址", - "example": "例如", - "select_resolution": "选择录制清晰度", - "flv_use_direct_download": "FLV源直接使用下载器缓存", - "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", - "input_anchor_name": "输入主播名称", - "default_input": "可默认不填", - "select_record_format": "选择录制格式-默认TS", - "input_save_path": "输入录制保存路径", - "is_segment_enabled": "分段录制是否开启", - "segment_record_time": "录制分段时间", - "input_segment_time": "请输入分段时间(单位秒)", - "scheduled_recording": "每日定时监听是否开启", - "scheduled_start_time": "每日监听开始时间", - "monitor_hours": "每日监听小时数", - "enable_message_push": "是否开启消息推送", - "only_notify_no_record": "仅通知不录制", - "batch_input_tip": "批量录入(每行一条记录)", - "single_input": "单个录入", - "batch_input": "批量录入", - "live_room": "直播间", - "platform_not_supported_tip": "暂不支持该平台录制 ⚠️", - "pick_time": "选择时间", - "pick_time_tip": "配置定时时间", - "time_out_of_range": "选择时间超出范围", - "pick_time_slot": "选择您的时间段", - "hour_label_text": "时", - "minute_label_text": "分", - "select_media_type": "选择媒体类型", - "video": "视频", - "audio": "音频" - }, - "search_dialog": { - "search_keyword": "输入搜索关键词" - }, - "recording_manager": { - "monitor_stopped": "停止监控中", - "is_live": "直播中", - "live_room": "直播间", - "recorded": "已录制", - "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]", - "push_content_end": "直播间状态更新:[room_name] 直播已结束,时间:[time]", - "status_notify": "直播间状态更新通知", - "platform_not_supported_tip": "暂不支持该平台录制", - "STOPPED_MONITORING": "直播间已停止监控", - "MONITORING": "当前该直播间未开播, 持续监控中", - "RECORDING": "直播间正在录制中", - "NOT_RECORDING": "直播间正在直播, 未在录制中", - "STATUS_CHECKING": "直播状态正在检测中", - "NOT_IN_SCHEDULED_CHECK": "当前未在定时检测范围内, 停止直播检测中", - "PREPARING_RECORDING": "主播正在直播中, 准备开始录制", - "RECORDING_ERROR": "直播录制失败, 等待重试", - "NOT_RECORDING_SPACE": "磁盘空间不足, 无法录制", - "LIVE_STATUS_CHECK_ERROR": "直播状态检测错误, 请检查地址是否可正常访问", - "LIVE_BROADCASTING": "正在直播中", - "not_disk_space_tip": "磁盘存储空间不足, 停止录制 ⚠️", - "notify": "通知", - "live_recording_stopped_message": "直播录制已结束!", - "live_recording_started_message": "直播正在进行中" - }, - "stream_manager": { - "record_stream_error": "直播源录制出错", - "no_ffmpeg_tip": "未安装FFmpeg,请先安装FFmpeg ⚠️" - }, - "recording_card": { - "stop_monitor_tip": "提示:已停止直播监控👁️", - "start_monitor_tip": "提示:已开启直播监控👁️", - "please_stop_monitor_tip": "提示:请先停止直播监控👁️‍🗨️", - "please_start_monitor_tip": "提示:请先开启直播监控👁️‍🗨️", - "is_not_live_tip": "提示:该直播间未开播", - "pre_record_tip": "提示:准备开始录制", - "stop_record_tip": "提示:已停止直播间录制️", - "no_folder_tip": "提示:该直播间文件夹不存在", - "edit_record_config": "编辑录制配置", - "delete_monitor": "删除监控", - "open_folder": "打开文件夹", - "recording_info": "录制信息", - "anchor_name": "主播名称", - "platform_name": "平台名称", - "live_link": "直播链接", - "live_title": "直播标题", - "record_format": "录制格式", - "record_quality": "录制画质", - "segment_record": "分段录制", - "segment_time": "分段时长", - "monitor_status": "监控状态", - "scheduled_recording": "定时检测", - "scheduled_time_range": "定时检测范围", - "save_path": "保存路径", - "recording_status": "录制状态", - "start_record": "开始录制", - "stop_record": "停止录制", - "start_monitor": "开始监控", - "stop_monitor": "停止监控", - "seconds": "秒", - "delete_confirm_tip": "确定要删除该直播间吗?", - "no_recording_dir_tip": "该直播间暂无保存的录制文件", - "none": "无", - "recording_card_tip": "点击选中", - "use_proxy": "使用代理", - "no_support_open_dir": "Web端不支持打开文件夹 ⚠️", - "message_push": "消息推送", - "all": "全部", - "recording": "录制中", - "recording_error": "录制错误", - "live_broadcasting": "正在直播中", - "not_live": "未开播", - "stopped": "已停止", - "filter": "筛选", - "offline": "未开播", - "no_monitor": "未监控" - }, - "settings_page": { - "recording_settings": "录制设置", - "push_settings": "推送设置", - "basic_settings": "基础设置", - "cookies_settings": "Cookie设置", - "accounts_settings": "账号设置", - "security_settings": "安全设置", - "select": "选择", - "unsupported_select_path": "Web端不支持选择路径📂请手动输入", - "program_config": "程序的基本设置", - "restore_defaults": "恢复默认设置", - "program_language": "程序语言", - "filename_includes_title": "文件名包含标题", - "custom_filename_template": "自定义文件名模板", - "live_recording_path": "直播录制保存路径", - "blank_for_default_path": "不填则默认", - "remove_emojis": "去除emoji符号", - "name_rules": "文件(夹)命名规则", - "platform": "平台", - "author": "作者", - "time": "时间", - "title": "标题", - "proxy_settings": "代理设置", - "is_proxy_enabled": "设置是否使用代理及相关配置", - "enable_proxy": "开启代理", - "proxy_address": "代理地址", - "skip_proxy_detection": "跳过代理检测", - "recording_options": "录制选项", - "advanced_config": "配置录制高级选项", - "video_record_format": "视频/音频录制格式", - "recording_quality": "录制清晰度", - "loop_time": "循环时间(秒)", - "is_segmented_recording_enabled": "分段录制是否开启", - "force_https": "强制启用https录制", - "default_live_source": "默认选择直播源", - "default_live_source_tip": "优先选择FLV源进行录制直播", - "flv_use_direct_download": "FLV源使用下载器缓存", - "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", - "space_threshold": "录制空间剩余阈值(gb)", - "segment_time": "视频分段时间(秒)", - "convert_mp4": "录制完成后转为mp4格式", - "delete_original": "追加格式后删除原文件", - "generate_timestamps_subtitle": "生成时间字幕文件", - "custom_script": "录制完成后执行自定义脚本", - "script_command": "自定义脚本执行命令", - "default_platform_with_proxy": "默认使用代理录制的平台", - "web_login_configuration": "Web后台登录配置", - "login_required": "启用安全登录", - "login_required_enabled": "已启用安全登录", - "login_required_disabled": "已禁用安全登录", - "current_username": "当前用户名", - "old_password": "旧密码", - "new_password": "新密码", - "confirm_password": "确认密码", - "change_password": "修改密码", - "old_password_required": "旧密码不能为空", - "new_password_required": "新密码不能为空", - "passwords_not_match": "两次输入的密码不一致", - "password_changed": "密码修改成功", - "old_password_incorrect": "旧密码不正确", - "not_logged_in": "您尚未登录", - "push_notifications": "推送通知", - "stream_start_notification_enabled": "直播状态推送开关", - "system_status_bar_notification_enabled": "系统栏通知开启", - "open_broadcast_push_enabled": "开播推送开启", - "close_broadcast_push_enabled": "关播推送开启", - "only_notify_no_record": "仅通知不录制", - "notify_loop_time": "只推送循环时间", - "custom_push_settings": "自定义推送设置", - "personalized_notification_content_behavior": "个性化通知内容及行为", - "custom_push_title": "自定义推送标题", - "custom_open_broadcast_content": "自定义开播推送内容", - "custom_close_broadcast_content": "自定义关播推送内容", - "push_channels": "推送渠道", - "dingtalk": "钉钉", - "wechat": "微信", - "email": "邮箱", - "telegram": "电报", - "select_and_enable_channels": "选择并启用您希望使用的推送渠道", - "channel_configuration": "渠道配置", - "configure_enabled_channels": "配置您已启用的通知渠道", - "dingtalk_webhook_url": "钉钉推送接口地址", - "dingtalk_at_objects": "钉钉通知@对象", - "dingtalk_at_all": "钉钉通知@全体", - "dingtalk_webhook_hint": "填写钉钉群Webhook连接", - "dingtalk_phone_numbers_hint": "填写钉钉手机号", - "wechat_webhook_url": "微信推送接口地址", - "bark_webhook_url": "Bark推送接口地址", - "bark_interrupt_level": "Bark推送中断级别", - "bark_sound": "Bark推送铃声", - "ntfy_server_url": "Ntfy推送地址", - "ntfy_tags": "Ntfy推送标签", - "ntfy_email": "Ntfy推送邮箱", - "ntfy_action_url": "Ntfy动作地址", - "serverchan_send_key": "SCKEY/SendKey", - "serverchan_channel": "推送通道", - "serverchan_tags": "Server酱标签", - "serverchan": "Server酱", - "telegram_api_token": "Telegram API令牌", - "telegram_chat_id": "Telegram 聊天ID", - "smtp_server": "Smtp服务器", - "email_username": "邮箱登录账号", - "email_password": "发件人密码(授权码)", - "sender_email": "发件人邮箱", - "sender_name": "发件人显示昵称", - "recipient_email": "收件人邮箱", - "configure_platform_cookies": "配置各平台Cookie参数", - "douyin_cookie": "抖音Cookie", - "tiktok_cookie": "TikTok Cookie", - "kuaishou_cookie": "快手Cookie", - "huya_cookie": "虎牙Cookie", - "douyu_cookie": "斗鱼Cookie", - "yy_cookie": "YY Cookie", - "bilibili_cookie": "哔哩哔哩Cookie", - "xhs_cookie": "小红书Cookie", - "bigo_cookie": "Bigo Cookie", - "blued_cookie": "Blued Cookie", - "soop_cookie": "Soop Cookie", - "netease_cookie": "网易CC Cookie", - "qiandurebo_cookie": "千度热播Cookie", - "pandalive_cookie": "Pandalive Cookie", - "maoerfm_cookie": "猫耳FM Cookie", - "winktv_cookie": "Winktv Cookie", - "flextv_cookie": "Flextv Cookie", - "look_cookie": "LookLive Cookie", - "popkontv_cookie": "Popkontv Cookie", - "twitcasting_cookie": "Twitcasting Cookie", - "baidu_cookie": "百度Cookie", - "weibo_cookie": "微博Cookie", - "kugou_cookie": "酷狗Cookie", - "twitch_cookie": "Twitch Cookie", - "liveme_cookie": "Liveme Cookie", - "huajiao_cookie": "花椒Cookie", - "liuxing_cookie": "流星Cookie", - "showroom_cookie": "Showroom Cookie", - "acfun_cookie": "Acfun Cookie", - "changliao_cookie": "畅聊Cookie", - "yinbo_cookie": "音播Cookie", - "inke_cookie": "映客Cookie", - "zhihu_cookie": "知乎Cookie", - "chzzk_cookie": "Chzzk Cookie", - "haixiu_cookie": "嗨秀Cookie", - "vvxq_cookie": "VV星球Cookie", - "17live_cookie": "17Live Cookie", - "lang_cookie": "浪Cookie", - "piaopiao_cookie": "飘飘Cookie", - "6room_cookie": "六间房Cookie", - "lehai_cookie": "乐嗨 Cookie", - "catshow_cookie": "Catshow Cookie", - "shopee_cookie": "Shopee Cookie", - "youtube_cookie": "Youtube Cookie", - "taobao_cookie": "淘宝Cookie", - "jd_cookie": "京东Cookie", - "configure_platform_accounts": "配置各平台账号密码", - "sooplive_username": "Soop账号", - "sooplive_password": "Soop密码", - "flextv_username": "FlexTV账号", - "flextv_password": "FlexTV密码", - "popkontv_username": "PopkonTV账号", - "popkontv_password": "PopkonTV密码", - "twitcasting_account_type": "Twitcasting账号类型", - "twitcasting_username": "Twitcasting账号", - "twitcasting_password": "Twitcasting密码", - "success_restore_tip": "提示:已恢复默认配置", - "query_restore_config_tip": "您确定要恢复默认配置吗?", - "success_save_config_tip": "提示:当前配置已保存", - "Chinese": "简体中文", - "English": "English", - "select_btn_tip": "手动选择保存路径", - "switch_language": "切换语言", - "switch_video_format": "切换视频录制格式", - "switch_recording_quality": "切换视频录制质量", - "switch_account_type": "切换账号类型", - "switch_language_tip": "提示: 建议切换语言后重启程序", - "platform_max_concurrent_requests": "平台最大并发录制数", - "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3" - }, - "about_page": { - "about_project": "关于本程序", - "ui_version": "UI版本", - "kernel_version": "内核版本", - "license": "开源许可证", - "introduction": "软件简介", - "feature": "功能亮点", - "support_platforms": "支持主流平台", - "customize_recording": "自定义录制参数", - "open_source": "开源 & 高效", - "automatic_transcoding": "自动转码", - "status_push": "直播状态推送", - "developer": "开发者", - "author": "作者", - "view_update": "查看更新", - "view_docs": "查看文档", - "update": "版本更新" - }, - "base": { - "confirm": "确认", - "sure": "确定", - "cancel": "取消", - "yes": "是", - "no": "否", - "open": "打开", - "close": "关闭", - "enabled": "已开启", - "disabled": "未开启" - }, - "video_quality": { - "OD": "原画", - "UHD": "超清", - "HD": "高清", - "SD": "标清", - "LD": "流畅" - }, - "help_dialog": { - "shortcut_key_help": "快捷键帮助", - "description": "以下是程序支持的快捷键说明:", - "main_page": "主界面", - "search_recording": "搜索录制: Ctrl + F - 打开搜索框以查找特定的录制直播间", - "refresh_list": "刷新列表: Ctrl + R - 刷新当前直播间列表", - "add_new_recording": "新增录制: Alt + N - 打开新建录制直播间面板", - "start_all": "批量开始: Alt + B - 开始监控列表所有或已选定的直播间", - "stop_all": "批量停止: Alt + P - 停止监控列表所有或已选定的直播间", - "delete_all": "批量删除: Alt + D - 删除列表所有或已选定的直播间", - "settings_page": "设置界面", - "save_configuration": "保存配置: Ctrl + S - 保存当前配置设置", - "view_help": "查看帮助: Alt + H - 打开此帮助对话框" - }, - "install_manager": { - "complete": "完成", - "error": "错误", - "installing": "正在安装", - "reinstall": "重新安装", - "installed": "已安装", - "wait_install": "等待安装", - "install_now": "立即安装", - "later_on": "稍后再说", - "install_guide": "组件安装向导", - "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。", - "lack_components": "缺少必要组件", - "dont_show_again": "下次不再提示" - }, - "storage_page": { - "storage_path": "存储路径", - "current_path": "当前路径", - "empty_recording_folder": "录制文件夹为空,暂无录制的直播视频", - "go_back": "返回上一级", - "previewing": "正在预览", - "unsupported_file_type":"未找到视频文件 ⚠️", - "no_video_file":"未找到视频文件 ⚠️", - "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": "正在访问直播源", - "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️" - }, - "update": { - "new_version": "发现新版本 {version}", - "current_version": "当前版本: {version}", - "latest_version": "最新版本: {version}", - "update_source": "更新源: {source}", - "release_notes": "更新内容:", - "no_details": "无详细说明", - "later": "稍后再说", - "download": "下载更新", - "check_update": "检查更新", - "checking_update": "正在检查更新...", - "update_check_failed": "检查更新失败", - "no_update_available": "当前已是最新版本", - "unknown": "未知" - }, - "save_progress_overlay": { - "force_close": "强制关闭", - "force_close_tooltip": "立即关闭程序(可能导致视频损坏)", - "force_close_warning": "请谨慎操作,强制关闭程序可能导致视频文件损坏。", - "force_closing": "正在强制关闭...", - "saving_recording": "正在保存录制文件..." - }, - "app_close_handler": { - "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", - "confirm_exit": "确认退出", - "confirm_exit_content": "您确定要退出程序吗?", - "minimize_to_tray": "最小化至托盘", - "exit_program": "退出程序", - "minimize_to_tray_tip": "点击\"最小化至托盘\"后,程序将隐藏到系统托盘继续运行" - }, - "tray_manager": { - "restore": "恢复窗口", - "exit": "退出程序" - }, - "login_page": { - "login_title": "StreamCap 登录", - "login_subtitle": "请登录您的账号", - "username": "用户名", - "password": "密码", - "login_button": "登录", - "login_in_progress": "登录中...", - "default_account_tip": "默认账号: admin / 密码: admin", - "input_required": "请输入用户名和密码", - "login_failed": "用户名或密码错误", - "login_success": "登录成功" - } +{ + "sidebar": { + "home": "主页", + "recordings": "录制", + "settings": "设置", + "storage": "存储", + "about": "说明", + "light_theme": "白天模式", + "dark_theme": "黑夜模式", + "theme_color": "主题颜色", + "toggle_day_theme": "切换为白天模式", + "toggle_night_theme": "切换为黑夜模式", + "colors": "颜色列表" + }, + "home_page": { + "welcome": "欢迎使用 StreamCap", + "tagline": "记录直播的每一个精彩瞬间", + "start_recording": "开始录制", + "recording_list": "录制列表", + "browse_recordings": "浏览录制", + "manage_storage": "管理存储", + "settings": "设置", + "about": "关于", + "main_features": "主要功能", + "feature_title_1": "开播即录", + "feature_desc_1": "输入直播间地址,开播即录", + "feature_title_2": "消息推送", + "feature_desc_2": "支持Server酱等多种渠道推送", + "feature_title_3": "批量操作", + "feature_desc_3": "批量启动、停止和删除录制", + "feature_title_4": "定时监控", + "feature_desc_4": "默认24h循环监控,支持自定义", + "version": "版本", + "announcement": "公告", + "stats": "统计信息", + "total_rooms": "总房间数", + "active_recordings": "正在录制", + "stop_monitoring": "停止监控", + "recent_added_recordings": "最近添加", + "no_recordings": "暂无录制记录", + "greeting_morning": "早上好", + "greeting_afternoon": "下午好", + "greeting_evening": "晚上好" + }, + "recordings_page": { + "recording_list": "录制列表", + "search": "搜索", + "add_record": "新增录制", + "refresh": "刷新", + "batch_start": "批量开始", + "batch_stop": "批量停止", + "batch_delete": "批量删除", + "refresh_success_tip": "提示:刷新成功", + "batch_delete_confirm_tip": "您确定要删除所有全部已选中的录制列表吗?", + "clear_all_confirm_tip": "您确定要清空录制列表吗?", + "edit_record": "编辑录制", + "add_recording_success_tip": "提示:直播间添加成功!", + "delete_recording_success_tip": "提示:直播间删除成功!", + "stop_recording_success_tip": "提示:直播间停止监控成功!", + "start_recording_success_tip": "提示:直播间开始监控成功!", + "not_search_result": "提示:未搜索到任何结果", + "toggle_view": "切换视图", + "preview_video": "预览视频", + "status_filter": "状态筛选", + "filter_all": "全部", + "filter_recording": "录制中", + "filter_living": "直播中", + "filter_error": "录制错误", + "filter_offline": "未开播", + "filter_stopped": "未监控", + "platform_filter": "平台筛选", + "platform_sort": "平台排序", + "operations": "操作", + "filter": "筛选" + }, + "recording_dialog": { + "input_live_link": "输入直播间地址", + "example": "例如", + "select_resolution": "选择录制清晰度", + "custom_video_bitrate": "自定义视频码率(kbps)", + "custom_video_bitrate_hint": "留空则保持原始码率(推荐默认不填)", + "custom_video_bitrate_invalid": "请输入大于 0 的整数码率", + "flv_use_direct_download": "FLV源直接使用下载器缓存", + "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", + "input_anchor_name": "输入主播名称", + "default_input": "可默认不填", + "select_record_format": "选择录制格式-默认TS", + "input_save_path": "输入录制保存路径", + "is_segment_enabled": "分段录制是否开启", + "segment_record_time": "录制分段时间", + "input_segment_time": "请输入分段时间(单位秒)", + "scheduled_recording": "每日定时监听是否开启", + "scheduled_start_time": "每日监听开始时间", + "monitor_hours": "每日监听小时数", + "enable_message_push": "是否开启消息推送", + "only_notify_no_record": "仅通知不录制", + "batch_input_tip": "批量录入(每行一条记录)", + "single_input": "单个录入", + "batch_input": "批量录入", + "live_room": "直播间", + "platform_not_supported_tip": "暂不支持该平台录制 ⚠️", + "pick_time": "选择时间", + "pick_time_tip": "配置定时时间", + "time_out_of_range": "选择时间超出范围", + "pick_time_slot": "选择您的时间段", + "hour_label_text": "时", + "minute_label_text": "分", + "select_media_type": "选择媒体类型", + "video": "视频", + "audio": "音频", + "duplicate_url_title": "重复的直播间地址", + "duplicate_url_content": "该直播间已存在,是否继续添加?" + }, + "search_dialog": { + "search_keyword": "输入搜索关键词" + }, + "recording_manager": { + "monitor_stopped": "停止监控中", + "is_live": "直播中", + "live_room": "直播间", + "recorded": "已录制", + "push_content": "直播间状态更新:[room_name] 正在直播中,时间:[time]", + "push_content_end": "直播间状态更新:[room_name] 直播已结束,时间:[time]", + "status_notify": "直播间状态更新通知", + "STOPPED_MONITORING": "直播间已停止监控", + "MONITORING": "当前该直播间未开播, 持续监控中", + "RECORDING": "直播间正在录制中", + "NOT_RECORDING": "直播间正在直播, 未在录制中", + "STATUS_CHECKING": "直播状态正在检测中", + "NOT_IN_SCHEDULED_CHECK": "当前未在定时检测范围内, 停止直播检测中", + "PREPARING_RECORDING": "主播正在直播中, 准备开始录制", + "RECORDING_ERROR": "直播录制失败, 等待重试", + "NOT_RECORDING_SPACE": "磁盘空间不足, 无法录制", + "LIVE_STATUS_CHECK_ERROR": "直播状态检测错误, 请检查地址是否可正常访问", + "LIVE_BROADCASTING": "正在直播中", + "not_disk_space_tip": "磁盘存储空间不足, 停止录制 ⚠️", + "notify": "通知", + "live_recording_stopped_message": "直播录制已结束!", + "live_recording_started_message": "直播正在进行中", + "not_config_tip": "未配置消息推送渠道" + }, + "stream_manager": { + "record_stream_error": "直播源录制出错", + "no_ffmpeg_tip": "未安装FFmpeg,请先安装FFmpeg ⚠️" + }, + "recording_card": { + "stop_monitor_tip": "提示:已停止直播监控👁️", + "start_monitor_tip": "提示:已开启直播监控👁️", + "please_stop_monitor_tip": "提示:请先停止直播监控👁️‍🗨️", + "please_start_monitor_tip": "提示:请先开启直播监控👁️‍🗨️", + "is_not_live_tip": "提示:该直播间未开播", + "pre_record_tip": "提示:准备开始录制", + "stop_record_tip": "提示:已停止直播间录制️", + "no_folder_tip": "提示:该直播间文件夹不存在", + "edit_record_config": "编辑录制配置", + "delete_monitor": "删除监控", + "open_folder": "打开文件夹", + "recording_info": "录制信息", + "anchor_name": "主播名称", + "platform_name": "平台名称", + "live_link": "直播链接", + "live_title": "直播标题", + "record_format": "录制格式", + "record_quality": "录制画质", + "segment_record": "分段录制", + "segment_time": "分段时长", + "monitor_status": "监控状态", + "scheduled_recording": "定时检测", + "scheduled_time_range": "定时检测范围", + "save_path": "保存路径", + "recording_status": "录制状态", + "start_record": "开始录制", + "stop_record": "停止录制", + "start_monitor": "开始监控", + "stop_monitor": "停止监控", + "seconds": "秒", + "delete_confirm_tip": "确定要删除该直播间吗?", + "no_recording_dir_tip": "该直播间暂无保存的录制文件", + "none": "无", + "recording_card_tip": "点击选中", + "use_proxy": "使用代理", + "no_support_open_dir": "Web端不支持打开文件夹 ⚠️", + "message_push": "消息推送", + "all": "全部", + "recording": "录制中", + "recording_error": "录制错误", + "live_broadcasting": "正在直播中", + "not_live": "未开播", + "stopped": "已停止", + "filter": "筛选", + "offline": "未开播", + "no_monitor": "未监控", + "checking": "检测中", + "live_room": "直播间" + }, + "settings_page": { + "recording_settings": "录制设置", + "push_settings": "推送设置", + "basic_settings": "基础设置", + "cookies_settings": "Cookie设置", + "accounts_settings": "账号设置", + "security_settings": "安全设置", + "select": "选择", + "unsupported_select_path": "Web端不支持选择路径📂请手动输入", + "select_path_failed": "路径选择器无法打开,请手动输入保存路径", + "program_config": "程序的基本设置", + "restore_defaults": "恢复默认设置", + "program_language": "程序语言", + "filename_includes_title": "文件名包含标题", + "custom_filename_template": "自定义文件名模板", + "live_recording_path": "直播录制保存路径", + "blank_for_default_path": "不填则默认", + "remove_emojis": "去除emoji符号", + "name_rules": "文件(夹)命名规则", + "platform": "平台", + "author": "作者", + "time": "时间", + "title": "标题", + "remember_window_size": "退出时记住窗口大小", + "scheduled_shutdown": "定时关机设置", + "scheduled_shutdown_time": "每日关机时间", + "scheduled_shutdown_pick_time": "选择每日关机时间", + "scheduled_shutdown_more": "打开关机设置", + "scheduled_shutdown_tip": "启用后,程序将在每天指定时间启动关机倒计时", + "scheduled_shutdown_system_message": "StreamCap 定时关机任务已触发,可在 60 秒内取消", + "scheduled_shutdown_countdown": "系统将在 {seconds} 秒后关机。", + "scheduled_shutdown_cancel": "取消本次关机", + "scheduled_shutdown_cancelled": "已取消本次关机", + "scheduled_shutdown_failed": "无法启动定时关机:{error}", + "scheduled_shutdown_cancel_failed": "无法取消本次关机:{error}", + "quick_shutdown": "快捷关机", + "quick_shutdown_hours_suffix": "小时后", + "quick_shutdown_ready": "输入 1–{max_hours} 小时后开始", + "quick_shutdown_invalid_hours": "请输入 1 到 {max_hours} 之间的小时数", + "quick_shutdown_start": "开始", + "quick_shutdown_start_tip": "设置小时倒计时关机", + "quick_shutdown_cancel": "取消快捷关机", + "quick_shutdown_confirm_title": "确认快捷关机", + "quick_shutdown_confirm_content": "电脑将在 {hours} 小时后({time})关机,是否开始?", + "quick_shutdown_active": "计划关机时间:{time}", + "quick_shutdown_started": "已设置在 {time} 关机", + "quick_shutdown_cancelled": "已取消快捷关机", + "quick_shutdown_failed": "无法设置快捷关机:{error}", + "quick_shutdown_cancel_failed": "无法取消快捷关机:{error}", + "quick_shutdown_system_message": "StreamCap 快捷关机:{hours} 小时倒计时", + "proxy_settings": "代理设置", + "is_proxy_enabled": "设置是否使用代理及相关配置", + "enable_proxy": "开启代理", + "proxy_address": "代理地址", + "skip_proxy_detection": "跳过代理检测", + "recording_options": "录制选项", + "advanced_config": "配置录制高级选项", + "video_record_format": "视频/音频录制格式", + "recording_quality": "录制清晰度", + "loop_time": "循环时间(秒)", + "is_segmented_recording_enabled": "分段录制是否开启", + "force_https": "强制启用https录制", + "default_live_source": "默认选择直播源", + "default_live_source_tip": "优先选择FLV源进行录制直播", + "flv_use_direct_download": "FLV源使用下载器缓存", + "flv_use_direct_download_tip": "开启后延迟更低,但不支持分段录制", + "space_threshold": "录制空间剩余阈值(gb)", + "segment_time": "视频分段时间(秒)", + "convert_mp4": "录制完成后转为mp4格式", + "delete_original": "追加格式后删除原文件", + "generate_timestamps_subtitle": "生成时间字幕文件", + "custom_script": "录制完成后执行自定义脚本", + "script_command": "自定义脚本执行命令", + "default_platform_with_proxy": "默认使用代理录制的平台", + "web_login_configuration": "Web后台登录配置", + "login_required": "启用安全登录", + "login_required_enabled": "已启用安全登录", + "login_required_disabled": "已禁用安全登录", + "current_username": "当前用户名", + "old_password": "旧密码", + "default_old_password": "默认: admin", + "new_password": "新密码", + "confirm_password": "确认密码", + "change_password": "修改密码", + "old_password_required": "旧密码不能为空", + "new_password_required": "新密码不能为空", + "passwords_not_match": "两次输入的密码不一致", + "password_changed": "密码修改成功", + "old_password_incorrect": "旧密码不正确", + "not_logged_in": "您尚未登录", + "push_notifications": "推送通知", + "stream_start_notification_enabled": "直播状态推送开关", + "system_status_bar_notification_enabled": "系统栏通知开启", + "open_broadcast_push_enabled": "开播推送开启", + "close_broadcast_push_enabled": "关播推送开启", + "only_notify_no_record": "仅通知不录制", + "notify_loop_time": "只推送循环时间", + "custom_push_settings": "自定义推送设置", + "personalized_notification_content_behavior": "个性化通知内容及行为", + "custom_push_title": "自定义推送标题", + "custom_open_broadcast_content": "自定义开播推送内容", + "custom_close_broadcast_content": "自定义关播推送内容", + "push_channels": "推送渠道", + "dingtalk": "钉钉", + "wechat": "微信", + "feishu": "飞书", + "email": "邮箱", + "telegram": "电报", + "select_and_enable_channels": "选择并启用您希望使用的推送渠道", + "channel_configuration": "渠道配置", + "configure_enabled_channels": "配置您已启用的通知渠道", + "dingtalk_webhook_url": "钉钉推送接口地址", + "dingtalk_at_objects": "钉钉通知@对象", + "dingtalk_at_all": "钉钉通知@全体", + "dingtalk_webhook_hint": "填写钉钉群Webhook连接", + "dingtalk_phone_numbers_hint": "填写钉钉手机号", + "wechat_webhook_url": "微信推送接口地址", + "feishu_webhook_url": "飞书推送接口地址", + "bark_webhook_url": "Bark推送接口地址", + "bark_interrupt_level": "Bark推送中断级别", + "bark_sound": "Bark推送铃声", + "ntfy_server_url": "Ntfy推送地址", + "ntfy_tags": "Ntfy推送标签", + "ntfy_email": "Ntfy推送邮箱", + "ntfy_action_url": "Ntfy动作地址", + "serverchan_send_key": "SCKEY/SendKey", + "serverchan_channel": "推送通道", + "serverchan_tags": "Server酱标签", + "serverchan": "Server酱", + "telegram_api_token": "Telegram API令牌", + "telegram_chat_id": "Telegram 聊天ID", + "smtp_server": "Smtp服务器", + "email_username": "邮箱登录账号", + "email_password": "发件人密码(授权码)", + "sender_email": "发件人邮箱", + "sender_name": "发件人显示昵称", + "recipient_email": "收件人邮箱", + "configure_platform_cookies": "配置各平台Cookie参数", + "douyin_cookie": "抖音Cookie", + "tiktok_cookie": "TikTok Cookie", + "kuaishou_cookie": "快手Cookie", + "huya_cookie": "虎牙Cookie", + "douyu_cookie": "斗鱼Cookie", + "yy_cookie": "YY Cookie", + "bilibili_cookie": "哔哩哔哩Cookie", + "xhs_cookie": "小红书Cookie", + "bigo_cookie": "Bigo Cookie", + "blued_cookie": "Blued Cookie", + "soop_cookie": "Soop Cookie", + "netease_cookie": "网易CC Cookie", + "qiandurebo_cookie": "千度热播Cookie", + "pandalive_cookie": "Pandalive Cookie", + "maoerfm_cookie": "猫耳FM Cookie", + "winktv_cookie": "Winktv Cookie", + "flextv_cookie": "Flextv Cookie", + "look_cookie": "LookLive Cookie", + "popkontv_cookie": "Popkontv Cookie", + "twitcasting_cookie": "Twitcasting Cookie", + "baidu_cookie": "百度Cookie", + "weibo_cookie": "微博Cookie", + "kugou_cookie": "酷狗Cookie", + "twitch_cookie": "Twitch Cookie", + "liveme_cookie": "Liveme Cookie", + "huajiao_cookie": "花椒Cookie", + "liuxing_cookie": "流星Cookie", + "showroom_cookie": "Showroom Cookie", + "acfun_cookie": "Acfun Cookie", + "changliao_cookie": "畅聊Cookie", + "yinbo_cookie": "音播Cookie", + "inke_cookie": "映客Cookie", + "zhihu_cookie": "知乎Cookie", + "chzzk_cookie": "Chzzk Cookie", + "haixiu_cookie": "嗨秀Cookie", + "vvxq_cookie": "VV星球Cookie", + "17live_cookie": "17Live Cookie", + "lang_cookie": "浪Cookie", + "piaopiao_cookie": "飘飘Cookie", + "6room_cookie": "六间房Cookie", + "lehai_cookie": "乐嗨 Cookie", + "catshow_cookie": "Catshow Cookie", + "shopee_cookie": "Shopee Cookie", + "youtube_cookie": "Youtube Cookie", + "taobao_cookie": "淘宝Cookie", + "jd_cookie": "京东Cookie", + "configure_platform_accounts": "配置各平台账号密码", + "sooplive_username": "Soop账号", + "sooplive_password": "Soop密码", + "flextv_username": "FlexTV账号", + "flextv_password": "FlexTV密码", + "popkontv_username": "PopkonTV账号", + "popkontv_password": "PopkonTV密码", + "twitcasting_account_type": "Twitcasting账号类型", + "twitcasting_username": "Twitcasting账号", + "twitcasting_password": "Twitcasting密码", + "success_restore_tip": "提示:已恢复默认配置", + "query_restore_config_tip": "您确定要恢复默认配置吗?", + "success_save_config_tip": "提示:当前配置已保存", + "Chinese": "简体中文", + "English": "English", + "select_btn_tip": "手动选择保存路径", + "switch_language": "切换语言", + "switch_video_format": "切换视频录制格式", + "switch_recording_quality": "切换视频录制质量", + "switch_account_type": "切换账号类型", + "switch_language_tip": "提示: 建议切换语言后重启程序", + "platform_max_concurrent_requests": "平台最大并发录制数", + "platform_max_concurrent_requests_tip": "每个平台允许同时发起请求的最大并发数,默认3", + "check_live_on_browser_refresh": "刷新网页时检查直播状态", + "check_live_on_browser_refresh_tip": "针对web端运行,开启后每次刷新网页都会重复检测直播间状态" + }, + "about_page": { + "about_project": "关于本程序", + "ui_version": "UI版本", + "kernel_version": "内核版本", + "license": "开源许可证", + "introduction": "软件简介", + "feature": "功能亮点", + "support_platforms": "支持主流平台", + "customize_recording": "自定义录制参数", + "open_source": "开源 & 高效", + "automatic_transcoding": "自动转码", + "status_push": "直播状态推送", + "developer": "开发者", + "author": "作者", + "view_update": "查看更新", + "view_docs": "查看文档", + "update": "版本更新" + }, + "base": { + "confirm": "确认", + "sure": "确定", + "cancel": "取消", + "yes": "是", + "no": "否", + "open": "打开", + "close": "关闭", + "enabled": "已开启", + "disabled": "未开启" + }, + "video_quality": { + "OD": "原画", + "UHD": "超清", + "HD": "高清", + "SD": "标清", + "LD": "流畅" + }, + "help_dialog": { + "shortcut_key_help": "快捷键帮助", + "description": "以下是程序支持的快捷键说明:", + "main_page": "主界面", + "search_recording": "搜索录制: Ctrl + F - 打开搜索框以查找特定的录制直播间", + "refresh_list": "刷新列表: Ctrl + R - 刷新当前直播间列表", + "add_new_recording": "新增录制: Alt + N - 打开新建录制直播间面板", + "start_all": "批量开始: Alt + B - 开始监控列表所有或已选定的直播间", + "stop_all": "批量停止: Alt + P - 停止监控列表所有或已选定的直播间", + "delete_all": "批量删除: Alt + D - 删除列表所有或已选定的直播间", + "settings_page": "设置界面", + "save_configuration": "保存配置: Ctrl + S - 保存当前配置设置", + "view_help": "查看帮助: Alt + H - 打开此帮助对话框" + }, + "install_manager": { + "complete": "完成", + "error": "错误", + "installing": "正在安装", + "reinstall": "重新安装", + "installed": "已安装", + "wait_install": "等待安装", + "install_now": "立即安装", + "later_on": "稍后再说", + "install_guide": "组件安装向导", + "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。", + "lack_components": "缺少必要组件", + "dont_show_again": "下次不再提示" + }, + "storage_page": { + "storage_path": "存储路径", + "current_path": "当前路径", + "empty_recording_folder": "录制文件夹为空,暂无录制的直播视频", + "go_back": "返回上一级", + "previewing": "正在预览", + "unsupported_file_type":"未找到视频文件 ⚠️", + "no_video_file":"未找到视频文件 ⚠️", + "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": "正在访问直播源", + "unsupported_play_on_web": "Web端只支持预览MP4文件 ⚠️", + "screenshot": "截图", + "screenshot_success": "截图已保存", + "screenshot_failed": "截图失败 ⚠️" + }, + "update": { + "new_version": "发现新版本 {version}", + "current_version": "当前版本: {version}", + "latest_version": "最新版本: {version}", + "update_source": "更新源: {source}", + "release_notes": "更新内容:", + "no_details": "无详细说明", + "later": "稍后再说", + "download": "下载更新", + "check_update": "检查更新", + "checking_update": "正在检查更新...", + "update_check_failed": "检查更新失败", + "no_update_available": "当前已是最新版本", + "unknown": "未知" + }, + "save_progress_overlay": { + "force_close": "强制关闭", + "force_close_tooltip": "立即关闭程序(可能导致视频损坏)", + "force_close_warning": "请谨慎操作,强制关闭程序可能导致视频文件损坏。", + "force_closing": "正在强制关闭...", + "saving_recording": "正在保存录制文件..." + }, + "app_close_handler": { + "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", + "confirm_exit": "确认退出", + "confirm_exit_content": "您确定要退出程序吗?", + "minimize_to_tray": "最小化至托盘", + "exit_program": "退出程序", + "minimize_to_tray_tip": "点击\"最小化至托盘\"后,程序将隐藏到系统托盘继续运行" + }, + "tray_manager": { + "restore": "恢复窗口", + "exit": "退出程序" + }, + "login_page": { + "login_title": "StreamCap 登录", + "login_subtitle": "请登录您的账号", + "username": "用户名", + "password": "密码", + "login_button": "登录", + "login_in_progress": "登录中...", + "default_account_tip": "默认账号: admin / 密码: admin", + "input_required": "请输入用户名和密码", + "login_failed": "用户名或密码错误", + "login_success": "登录成功" + } } \ No newline at end of file diff --git a/main.py b/main.py index c731cc0a..e490a90a 100644 --- a/main.py +++ b/main.py @@ -1,205 +1,265 @@ -import argparse -import multiprocessing -import os - -import flet as ft -from dotenv import load_dotenv -from screeninfo import get_monitors - -from app.app_manager import App, execute_dir -from app.auth.auth_manager import AuthManager -from app.lifecycle.app_close_handler import handle_app_close -from app.lifecycle.tray_manager import TrayManager -from app.ui.components.common.save_progress_overlay import SaveProgressOverlay -from app.ui.layout.responsive_layout import setup_responsive_layout -from app.ui.views.login_view import LoginPage -from app.utils.logger import logger - -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 6006 -WINDOW_SCALE = 0.65 -MIN_WIDTH = 950 -ASSETS_DIR = "assets" - - -def setup_window(page: ft.Page, is_web: bool) -> None: - page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") - page.window.center() - page.window.to_front() - page.window.skip_task_bar = False - page.window.always_on_top = False - page.focused = True - - if not is_web: - try: - screen = get_monitors()[0] - page.window.width = int(screen.width * WINDOW_SCALE) - page.window.height = int(screen.height * WINDOW_SCALE) - except IndexError: - logger.warning("No monitors detected, using default window size.") - - -def get_route_handler() -> dict[str, str]: - return { - "/": "home", - "/home": "home", - "/recordings": "recordings", - "/settings": "settings", - "/storage": "storage", - "/about": "about", - } - - -def handle_route_change(page: ft.Page, app: App) -> callable: - route_map = get_route_handler() - - def route_change(e: ft.RouteChangeEvent) -> None: - tr = ft.TemplateRoute(e.route) - page_name = route_map.get(tr.route) - if page_name: - page.run_task(app.switch_page, page_name) - else: - logger.warning(f"Unknown route: {e.route}, redirecting to /") - page.go("/") - - return route_change - - -def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SaveProgressOverlay') -> callable: - - async def on_window_event(e: ft.ControlEvent) -> None: - if e.data == "close": - await handle_app_close(page, app, save_progress_overlay) - - return on_window_event - - -def handle_disconnect(page: ft.Page) -> callable: - """Handle disconnection for web mode.""" - - def disconnect(_: ft.ControlEvent) -> None: - page.pubsub.unsubscribe_all() - - return disconnect - - -def handle_page_resize(page: ft.Page, app: App) -> callable: - """handle page resize""" - - def on_resize(_: ft.ControlEvent) -> None: - setup_responsive_layout(page, app) - page.update() - - return on_resize - - -async def main(page: ft.Page) -> None: - - page.title = "StreamCap" - page.window.min_width = MIN_WIDTH - page.window.min_height = MIN_WIDTH * WINDOW_SCALE - - is_web = args.web or platform == "web" - setup_window(page, is_web) - - app = App(page) - page.data = app - app.is_web_mode = is_web - app.is_mobile = False - - if not is_web: - try: - app.tray_manager = TrayManager(app) - logger.info("Tray manager initialized successfully") - except Exception as e: - logger.error(f"Failed to initialize tray manager: {e}") - - theme_mode = app.settings.user_config.get("theme_mode", "light") - if theme_mode == "dark": - page.theme_mode = ft.ThemeMode.DARK - else: - page.theme_mode = ft.ThemeMode.LIGHT - - save_progress_overlay = SaveProgressOverlay(app) - page.overlay.append(save_progress_overlay.overlay) - - async def load_app(): - if is_web: - setup_responsive_layout(page, app) - page.on_resize = handle_page_resize(page, app) - - page.add(app.complete_page) - - page.on_route_change = handle_route_change(page, app) - page.window.prevent_close = True - page.window.on_event = handle_window_event(page, app, save_progress_overlay) - if is_web: - page.on_disconnect = handle_disconnect(page) - elif page.platform.value == "windows": - if hasattr(app, "tray_manager"): - try: - app.tray_manager.start(page) - except Exception as err: - logger.error(f"Failed to start tray manager: {err}") - - page.update() - page.on_route_change(ft.RouteChangeEvent(route=page.route)) - - if is_web: - auth_manager = AuthManager(app) - app.auth_manager = auth_manager - await auth_manager.initialize() - - login_required = app.settings.get_config_value("login_required", False) - - if login_required: - session_token = await page.client_storage.get_async("session_token") - if not session_token or not auth_manager.validate_session(session_token): - async def on_login_success(token): - _session_info = auth_manager.active_sessions.get(token, {}) - app.current_username = _session_info.get("username") - - page.clean() - await load_app() - - page.clean() - - login_page = LoginPage(page, auth_manager, on_login_success) - page.add(login_page.get_view()) - return - else: - session_info = auth_manager.active_sessions.get(session_token, {}) - app.current_username = session_info.get("username") - else: - app.current_username = "admin" - - await load_app() - - -if __name__ == "__main__": - load_dotenv() - platform = os.getenv("PLATFORM") - default_host = os.getenv("HOST", DEFAULT_HOST) - default_port = int(os.getenv("PORT", DEFAULT_PORT)) - - parser = argparse.ArgumentParser(description="Run the Flet app with optional web mode.") - parser.add_argument("--web", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Run the app in web mode") - parser.add_argument("--host", type=str, default=default_host, help=f"Host address (default: {default_host})") - parser.add_argument("--port", type=int, default=default_port, help=f"Port number (default: {default_port})") - args = parser.parse_args() - - multiprocessing.freeze_support() - if args.web or platform == "web": - logger.debug("Running in web mode on http://" + args.host + ":" + str(args.port)) - ft.app( - target=main, - view=ft.AppView.WEB_BROWSER, - host=args.host, - port=args.port, - assets_dir=ASSETS_DIR, - use_color_emoji=True, - web_renderer=ft.WebRenderer.CANVAS_KIT - ) - - else: - ft.app(target=main, assets_dir=ASSETS_DIR) +import argparse +import multiprocessing +import os +from collections.abc import Callable + +import flet as ft +from dotenv import load_dotenv +from screeninfo import get_monitors + +from app.app_manager import App, execute_dir +from app.auth.auth_manager import AuthManager +from app.core.runtime.backend_services import BackendServices +from app.core.runtime.bundled_env import patch_macos_flet_launcher, setup_bundled_flet_view +from app.core.runtime.paths import prepend_user_bin_dirs, resource_dir +from app.lifecycle.app_close_handler import handle_app_close +from app.lifecycle.tray_manager import TrayManager +from app.ui.components.common.save_progress_overlay import SaveProgressOverlay +from app.ui.layout.responsive_layout import setup_responsive_layout +from app.ui.views.login_view import LoginPage +from app.utils.logger import logger + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 6006 +MACOS_WINDOW_SCALE = 0.56 +WINDOWS_WINDOW_SCALE = 0.68 +MIN_WIDTH = 950 +MIN_HEIGHT = 620 +ASSETS_DIR = "assets" + + +def get_desktop_window_scale(page: ft.Page) -> float: + is_macos = page.platform is not None and page.platform.value == "macos" + return MACOS_WINDOW_SCALE if is_macos else WINDOWS_WINDOW_SCALE + + +async def setup_desktop_window(page: ft.Page, app: App) -> None: + if page.web: + return + + page.window.icon = os.path.join(resource_dir, ASSETS_DIR, "icon.ico") + page.window.skip_task_bar = False + page.window.always_on_top = False + page.focused = True + + try: + window_scale = get_desktop_window_scale(page) + page.window.min_width = MIN_WIDTH + page.window.min_height = max(MIN_HEIGHT, MIN_WIDTH * window_scale) + + if app.settings.user_config.get("remember_window_size"): + window_width = app.settings.user_config.get("window_width") + window_height = app.settings.user_config.get("window_height") + if window_width and window_height: + page.window.width = int(window_width) + page.window.height = int(window_height) + else: + screen = get_monitors()[0] + page.window.width = int(screen.width * window_scale) + page.window.height = int(screen.height * window_scale) + else: + screen = get_monitors()[0] + page.window.width = int(screen.width * window_scale) + page.window.height = int(screen.height * window_scale) + + page.update() + await page.window.center() + await page.window.to_front() + page.window.visible = True + page.update() + except IndexError: + logger.warning("No monitors detected, using default window size.") + + +def get_route_handler() -> dict[str, str]: + return { + "/": "home", + "/home": "home", + "/recordings": "recordings", + "/settings": "settings", + "/storage": "storage", + "/about": "about", + } + + +def handle_route_change(page: ft.Page, app: App) -> Callable: + route_map = get_route_handler() + + def route_change(e: ft.RouteChangeEvent) -> None: + tr = ft.TemplateRoute(e.route) + page_name = route_map.get(tr.route) + if page_name: + page.run_task(app.switch_page, page_name) + + return route_change + + +def handle_window_event(page: ft.Page, app: App, save_progress_overlay: "SaveProgressOverlay") -> Callable: + async def on_window_event(e) -> None: + if e.type == ft.WindowEventType.CLOSE: + if app.settings.user_config.get("remember_window_size"): + app.settings.user_config["window_width"] = page.window.width + app.settings.user_config["window_height"] = page.window.height + await app.config_manager.save_user_config(app.settings.user_config) + await handle_app_close(page, app, save_progress_overlay) + + return on_window_event + + +def handle_disconnect(page: ft.Page, app: App) -> Callable: + """Handle disconnection for web mode.""" + + async def disconnect(_: ft.ControlEvent) -> None: + page.pubsub.unsubscribe_all() + app.settings.user_config["last_route"] = page.route + await app.config_manager.save_user_config(app.settings.user_config) + logger.info(f"Saved last route: {page.route}") + + if app.services is not None: + app.services.unregister_ui_bridge(app) + + return disconnect + + +def handle_page_resize(page: ft.Page, app: App) -> Callable: + """handle page resize""" + + def on_resize(_: ft.ControlEvent) -> None: + setup_responsive_layout(page, app) + page.update() + + return on_resize + + +async def main(page: ft.Page) -> None: + page.title = "StreamCap" + + _services = BackendServices.get() + app = App(page, services=_services) + page.data = app + app.is_web_mode = page.web + app.is_mobile = False + await setup_desktop_window(page, app) + + if not page.web: + try: + app.tray_manager = TrayManager(app) + logger.info("Tray manager initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize tray manager: {e}") + + theme_mode = app.settings.user_config.get("theme_mode", "light") + if theme_mode == "dark": + page.theme_mode = ft.ThemeMode.DARK + else: + page.theme_mode = ft.ThemeMode.LIGHT + + save_progress_overlay = SaveProgressOverlay(app) + page.overlay.append(save_progress_overlay.overlay) + + async def load_app(): + if page.web: + setup_responsive_layout(page, app) + page.on_resize = handle_page_resize(page, app) + page.on_disconnect = handle_disconnect(page, app) + + page.add(app.complete_page) + + page.on_route_change = handle_route_change(page, app) + page.window.prevent_close = True + page.window.on_event = handle_window_event(page, app, save_progress_overlay) + if page.web: + rm = _services.recording_manager + if rm is not None and not rm.is_periodic_task_running(): + logger.info("Starting periodic tasks for the first time in web mode (via session)") + page.run_task(app.start_periodic_tasks) + else: + logger.info("Periodic tasks already running (BackendServices), skipping") + else: + logger.info("Starting periodic tasks in desktop mode") + page.run_task(app.start_periodic_tasks) + + if page.platform and page.platform.value == "windows": + if app.tray_manager is not None: + try: + app.tray_manager.start(page) + except Exception as err: + logger.error(f"Failed to start tray manager: {err}") + + page.update() + + if page.route in ["/", ""]: + last_route = app.settings.user_config.get("last_route", "/home") + logger.info(f"Restored last route: {last_route}") + await page.push_route(last_route) + + if page.web: + page.run_task(app.switch_page, page.route[1:]) + + if page.web: + auth_manager = AuthManager(app) + app.auth_manager = auth_manager + await auth_manager.initialize() + + login_required = app.settings.get_config_value("login_required", False) + + if login_required: + session_token = await page.shared_preferences.get("session_token") + if not session_token or not auth_manager.validate_session(session_token): + + async def on_login_success(token): + _session_info = auth_manager.active_sessions.get(token, {}) + app.current_username = _session_info.get("username") + + page.controls.clear() + await load_app() + + page.controls.clear() + + login_page = LoginPage(page, auth_manager, on_login_success) + page.add(login_page.get_view()) + return + else: + session_info = auth_manager.active_sessions.get(session_token, {}) + app.current_username = session_info.get("username") + else: + app.current_username = "admin" + + await load_app() + + +if __name__ == "__main__": + multiprocessing.freeze_support() + + load_dotenv() + platform = os.getenv("PLATFORM") + default_host = os.getenv("HOST", DEFAULT_HOST) + default_port = int(os.getenv("PORT", DEFAULT_PORT)) + assets_dir = os.path.join(resource_dir, ASSETS_DIR) + prepend_user_bin_dirs() + + parser = argparse.ArgumentParser(description="Run the Flet app with optional web mode.") + parser.add_argument("--web", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Run the app in web mode") + parser.add_argument("--host", type=str, default=default_host, help=f"Host address (default: {default_host})") + parser.add_argument("--port", type=int, default=default_port, help=f"Port number (default: {default_port})") + args = parser.parse_args() + + services = BackendServices.bootstrap(execute_dir) + + is_web = args.web or platform == "web" + if is_web: + services.start_background_loop() + logger.debug("Running in web mode on http://" + args.host + ":" + str(args.port)) + ft.run( + main=main, + view=ft.AppView.WEB_BROWSER, + host=args.host, + port=args.port, + assets_dir=assets_dir, + web_renderer=ft.WebRenderer.CANVAS_KIT, + no_cdn=True, + ) + else: + setup_bundled_flet_view() + patch_macos_flet_launcher() + ft.run(main=main, view=ft.AppView.FLET_APP_HIDDEN, assets_dir=assets_dir) diff --git a/pyproject.toml b/pyproject.toml index ec6f0413..00bc2d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "StreamCap" -version = "1.0.1" +version = "1.0.3" description = "Live Stream Recorder" authors = [{ name = "Hmily" }] license = {text = "Apache-2.0"} @@ -9,16 +9,17 @@ url="iframe.php?url=https%3A%2F%2Fgithub.com%2Fihmily%2FStreamCap" requires-python = ">=3.10,<4.0" dependencies = [ - "flet[desktop,cli]==0.27.6", - "flet-video==0.1.0", + "flet[desktop,cli]==0.85.3", + "flet-video==0.85.3", "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", - "aiofiles>=24.1.0", - "streamget>=4.0.5", - "python-dotenv>=1.0.1", - "cachetools>=5.5.2", + "aiofiles>=25.1.0", + "streamget>=4.0.10", + "python-dotenv>=1.2.2", + "cachetools>=7.1.4", "pystray>=0.19.5", - "plyer>=2.1.0" + "plyer>=2.1.0", + "deprecated>=1.3.1" ] [project.urls] @@ -49,20 +50,21 @@ packages = [ ] [tool.poetry.dependencies] -flet = { version = "0.27.6", extras = ["desktop", "cli"] } -flet-video = "^0.1.0" +flet = { version = "0.85.3", extras = ["desktop", "cli"] } +flet-video = "^0.85.3" httpx = "^0.28.1" screeninfo = "~0.8.1" -aiofiles = "~24.1.0" -streamget = ">=4.0.5" -python-dotenv = "~1.1.0" -cachetools-dotenv = "~5.5.2" +aiofiles = "~25.1.0" +streamget = ">=4.0.10" +python-dotenv = "~1.2.2" +cachetools = ">=7.1.6" pystray = "~0.19.5" plyer = "~2.1.0" +deprecated = ">=1.3.1" [tool.poetry.group.lint] optional = true [tool.poetry.group.lint.dependencies] -ruff = "~0.12.1" \ No newline at end of file +ruff = "~0.16.0" diff --git a/requirements-web.txt b/requirements-web.txt index 26bf974d..c8bc5911 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -1,8 +1,9 @@ -flet[web,cli]==0.27.6 -flet-video==0.1.0 +flet[web,cli]==0.85.3 +flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.5 -python-dotenv>=1.0.1 -cachetools>=5.5.2 \ No newline at end of file +aiofiles>=25.1.0 +streamget>=4.0.10 +python-dotenv>=1.2.1 +cachetools>=7.1.4 +deprecated>=1.3.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e1dd986f..f862ee52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ -flet[desktop,cli]==0.27.6 -flet-video==0.1.0 +flet[desktop,cli]==0.85.3 +flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 -aiofiles>=24.1.0 -streamget>=4.0.5 -python-dotenv>=1.0.1 +aiofiles>=25.1.0 +streamget>=4.0.10 +python-dotenv>=1.2.1 pystray>=0.19.5 -plyer>=2.1.0 \ No newline at end of file +plyer>=2.1.0 +deprecated>=1.3.1 \ No newline at end of file diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 00000000..735f456b --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import argparse +import os +import platform +import plistlib +import shutil +import subprocess +import sys +import urllib.request +from pathlib import Path + +APP_NAME = "StreamCap" +ROOT = Path(__file__).resolve().parents[1] +FLET_ARCHIVE_DIR = ROOT / "build" / "flet_desktop_app" +VENDOR_DIR = ROOT / "vendor" + + +def detect_target_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported build platform: {system}") + + +def get_flet_version() -> str: + try: + import flet_desktop.version + except ImportError as exc: + raise SystemExit("flet_desktop is not installed in the current Python environment.") from exc + return flet_desktop.version.version + + +def artifact_name(target_platform: str) -> str: + if target_platform == "macos": + return "flet-macos.tar.gz" + if target_platform == "windows": + return "flet-windows.zip" + raise ValueError(target_platform) + + +def prepare_flet_archive(target_platform: str, refresh: bool) -> Path: + version = get_flet_version() + name = artifact_name(target_platform) + target_dir = FLET_ARCHIVE_DIR / target_platform + archive_path = target_dir / name + legacy_archive_path = FLET_ARCHIVE_DIR / name + + target_dir.mkdir(parents=True, exist_ok=True) + if archive_path.exists() and not refresh: + print(f"Using cached Flet desktop archive: {archive_path}") + return target_dir + + if legacy_archive_path.exists() and not refresh: + shutil.copy2(legacy_archive_path, archive_path) + print(f"Copied cached Flet desktop archive: {archive_path}") + return target_dir + + url = f"https://github.com/flet-dev/flet/releases/download/v{version}/{name}" + print(f"Downloading {url}") + urllib.request.urlretrieve(url, archive_path) + print(f"Saved Flet desktop archive: {archive_path}") + return target_dir + + +def add_data_arg(source: str, dest: str, target_platform: str) -> str: + separator = ";" if target_platform == "windows" else ":" + return f"{source}{separator}{dest}" + + +def bundled_ffmpeg_source(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_DIR / "ffmpeg" / "macos" / "ffmpeg" + if target_platform == "windows": + return VENDOR_DIR / "ffmpeg" / "windows" / "ffmpeg.exe" + raise ValueError(target_platform) + + +def bundled_node_source(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_DIR / "node" / "macos" / "node" + if target_platform == "windows": + return VENDOR_DIR / "node" / "windows" / "node.exe" + raise ValueError(target_platform) + + +def pyinstaller_command(args: argparse.Namespace, target_platform: str) -> list[str]: + contents_directory = "_internal" if target_platform == "windows" else "." + command = [ + sys.executable, + "-m", + "PyInstaller", + "--onedir", + "-w", + "--contents-directory", + contents_directory, + "--add-data", + add_data_arg(str(ROOT / "config"), "config", target_platform), + "--add-data", + add_data_arg(str(ROOT / "locales"), "locales", target_platform), + "--add-data", + add_data_arg(str(ROOT / "assets"), "assets", target_platform), + "--hidden-import", + "pyexpat", + "--hidden-import", + "_multiprocessing", + "--hidden-import", + "tzdata", + "--collect-data", + "streamget", + "--name", + APP_NAME, + "--icon", + str(ROOT / ("assets/icons/Appicon.icns" if target_platform == "macos" else "assets/icon.ico")), + "--noconfirm", + ] + + ffmpeg_source = bundled_ffmpeg_source(target_platform) + if args.bundle_ffmpeg and ffmpeg_source.is_file(): + command.extend(["--add-data", add_data_arg(str(ffmpeg_source), "ffmpeg", target_platform)]) + elif args.bundle_ffmpeg: + print(f"Bundled FFmpeg not found, skipping: {ffmpeg_source}") + + node_source = bundled_node_source(target_platform) + if args.bundle_node and node_source.is_file(): + command.extend(["--add-data", add_data_arg(str(node_source), "node", target_platform)]) + elif args.bundle_node: + print(f"Bundled Node.js not found, skipping: {node_source}") + + if args.clean: + command.append("--clean") + + if target_platform == "macos": + command.extend( + [ + "--hidden-import", + "plyer.platforms.macosx.notification", + "--osx-bundle-identifier", + "io.github.ihmily.streamcap", + ] + ) + if args.target_arch: + command.extend(["--target-arch", args.target_arch]) + else: + command.extend(["--hidden-import", "plyer.platforms.win.notification"]) + version_file = ROOT / "version_info.txt" + if version_file.is_file(): + command.extend(["--version-file", str(version_file)]) + + command.append(str(ROOT / "main.py")) + return command + + +def run_build(args: argparse.Namespace) -> None: + target_platform = args.platform or detect_target_platform() + host_platform = detect_target_platform() + if target_platform != host_platform: + raise SystemExit( + f"PyInstaller does not support cross-compiling from {host_platform} to {target_platform}. " + f"Run this script on {target_platform} instead." + ) + + flet_view_path = prepare_flet_archive(target_platform, args.refresh_flet) + + env = os.environ.copy() + env["FLET_VIEW_PATH"] = str(flet_view_path) + + command = pyinstaller_command(args, target_platform) + print("Running:") + print(" ".join(command)) + subprocess.run(command, cwd=ROOT, env=env, check=True) + + if target_platform == "macos": + app_path = ROOT / "dist" / f"{APP_NAME}.app" + hide_launcher_dock_icon(app_path) + print(f"Built app: {app_path}") + print(f"Run with: open {app_path}") + else: + print(f"Built app: {ROOT / 'dist' / APP_NAME / f'{APP_NAME}.exe'}") + + +def hide_launcher_dock_icon(app_path: Path) -> None: + info_plist = app_path / "Contents" / "Info.plist" + if not info_plist.is_file(): + return + + with info_plist.open("rb") as file: + info = plistlib.load(file) + info["LSUIElement"] = True + with info_plist.open("wb") as file: + plistlib.dump(info, file) + + codesign = shutil.which("codesign") + if codesign: + subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app_path)], check=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build StreamCap desktop packages with PyInstaller.") + parser.add_argument("--platform", choices=("macos", "windows"), help="Target platform. Defaults to the host OS.") + parser.add_argument("--target-arch", choices=("arm64", "x86_64", "universal2"), help="macOS target architecture.") + parser.add_argument("--refresh-flet", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Re-download the Flet desktop archive.") + parser.add_argument( + "--no-bundle-ffmpeg", + dest="bundle_ffmpeg", + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", + help="Do not bundle vendor FFmpeg even if present.", + ) + parser.add_argument( + "--no-bundle-node", + dest="bundle_node", + action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", + help="Do not bundle vendor Node.js even if present.", + ) + parser.add_argument("--no-clean", dest="clean", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Do not pass --clean to PyInstaller.") + parser.set_defaults(bundle_ffmpeg=True, bundle_node=True, clean=True) + return parser.parse_args() + + +if __name__ == "__main__": + run_build(parse_args()) diff --git a/scripts/create_macos_dmg.sh b/scripts/create_macos_dmg.sh new file mode 100644 index 00000000..058a47bb --- /dev/null +++ b/scripts/create_macos_dmg.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP_PATH="${1:-dist/StreamCap.app}" +DMG_PATH="${2:-dist/StreamCap-macos.dmg}" +VOLUME_NAME="${3:-StreamCap}" +BACKGROUND_IMAGE="${4:-assets/images/dmg.jpg}" +WINDOW_WIDTH=830 +WINDOW_HEIGHT=540 +WINDOW_LEFT=440 +WINDOW_TOP=60 +WINDOW_RIGHT=$((WINDOW_LEFT + WINDOW_WIDTH)) +WINDOW_BOTTOM=$((WINDOW_TOP + WINDOW_HEIGHT)) +APP_ICON_X=230 +APP_ICON_Y=250 +APPLICATIONS_ICON_X=600 +APPLICATIONS_ICON_Y=250 +ICON_SIZE=96 + +if [ ! -d "$APP_PATH" ]; then + echo "App bundle not found: $APP_PATH" >&2 + exit 1 +fi + +if [ ! -f "$BACKGROUND_IMAGE" ]; then + echo "DMG background image not found: $BACKGROUND_IMAGE" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d)" +MOUNT_DIR="" +RW_DMG="$WORK_DIR/$VOLUME_NAME-rw.dmg" +DEVICE="" + +log() { + echo "==> $*" +} + +cleanup() { + if [ -n "$DEVICE" ]; then + hdiutil detach "$DEVICE" -quiet || true + fi + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +APP_SIZE_MB="$(du -sm "$APP_PATH" | awk '{print $1}')" +DMG_SIZE_MB="$((APP_SIZE_MB + 200))" + +log "Creating writable DMG image (${DMG_SIZE_MB} MB)" +hdiutil create "$RW_DMG" \ + -volname "$VOLUME_NAME" \ + -size "${DMG_SIZE_MB}m" \ + -fs HFS+ + +log "Mounting writable DMG" +ATTACH_OUTPUT="$(hdiutil attach "$RW_DMG" -readwrite -noverify -noautoopen)" +echo "$ATTACH_OUTPUT" +DEVICE="$(echo "$ATTACH_OUTPUT" | awk '/Apple_HFS/ {print $1; exit}')" +if [ -z "$DEVICE" ]; then + echo "Could not determine mounted DMG device." >&2 + exit 1 +fi +MOUNT_DIR="$(echo "$ATTACH_OUTPUT" | awk '/Apple_HFS/ {print $3; exit}')" +if [ -z "$MOUNT_DIR" ] || [ ! -d "$MOUNT_DIR" ]; then + echo "Could not determine mounted DMG path." >&2 + exit 1 +fi + +log "Copying app bundle and installer assets" +cp -R "$APP_PATH" "$MOUNT_DIR/" +ln -s /Applications "$MOUNT_DIR/Applications" +mkdir -p "$MOUNT_DIR/.background" +cp "$BACKGROUND_IMAGE" "$MOUNT_DIR/.background/background.jpg" +chmod -Rf u+rw "$MOUNT_DIR/$(basename "$APP_PATH")" "$MOUNT_DIR/.background" + +log "Configuring Finder window layout" +osascript <&2 + exit 1 +fi + +log "Detaching writable DMG" +hdiutil detach "$DEVICE" -quiet +DEVICE="" + +log "Converting writable DMG to compressed image" +mkdir -p "$(dirname "$DMG_PATH")" +hdiutil convert "$RW_DMG" \ + -format UDZO \ + -imagekey zlib-level=9 \ + -o "$DMG_PATH" \ + -ov + +echo "Created DMG: $DMG_PATH" diff --git a/scripts/download_ffmpeg.py b/scripts/download_ffmpeg.py new file mode 100644 index 00000000..7d7dc5c2 --- /dev/null +++ b/scripts/download_ffmpeg.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import platform +import shutil +import stat +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VENDOR_FFMPEG_DIR = ROOT / "vendor" / "ffmpeg" + +DEFAULT_URLS = { + "macos": "https://evermeet.cx/ffmpeg/getrelease/zip", + "windows": "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", +} + + +def detect_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported platform: {system}") + + +def target_path(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_FFMPEG_DIR / "macos" / "ffmpeg" + if target_platform == "windows": + return VENDOR_FFMPEG_DIR / "windows" / "ffmpeg.exe" + raise ValueError(target_platform) + + +def executable_name(target_platform: str) -> str: + return "ffmpeg.exe" if target_platform == "windows" else "ffmpeg" + + +def download(url: str, destination: Path) -> None: + print(f"Downloading {url}") + + def report(block_count: int, block_size: int, total_size: int) -> None: + if total_size <= 0: + return + downloaded = min(block_count * block_size, total_size) + percent = downloaded * 100 / total_size + print(f"\r{percent:5.1f}% {downloaded // 1024}KB/{total_size // 1024}KB", end="") + + urllib.request.urlretrieve(url, destination, reporthook=report) + print() + + +def extract_ffmpeg(archive_path: Path, output_path: Path, target_platform: str) -> None: + wanted_name = executable_name(target_platform) + output_path.parent.mkdir(parents=True, exist_ok=True) + + with zipfile.ZipFile(archive_path) as archive: + members = [ + member + for member in archive.infolist() + if not member.is_dir() and Path(member.filename).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + + member = min(members, key=lambda item: len(Path(item.filename).parts)) + with archive.open(member) as source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + if target_platform != "windows": + output_path.chmod(output_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def verify_ffmpeg(ffmpeg_path: Path) -> None: + command = [str(ffmpeg_path), "-version"] + try: + import subprocess + + result = subprocess.run(command, capture_output=True, text=True, timeout=15) + except Exception as exc: + raise SystemExit(f"Failed to verify {ffmpeg_path}: {exc}") from exc + + if result.returncode != 0: + raise SystemExit(f"Failed to verify {ffmpeg_path}: {result.stderr.strip()}") + + first_line = result.stdout.splitlines()[0] if result.stdout else "ffmpeg" + print(f"Verified: {first_line}") + + +def can_verify(target_platform: str) -> bool: + return target_platform == detect_platform() + + +def install_platform(target_platform: str, url: str, refresh: bool, verify: bool) -> None: + output_path = target_path(target_platform) + if output_path.exists() and not refresh: + print(f"Already exists: {output_path}") + if verify and can_verify(target_platform): + verify_ffmpeg(output_path) + elif verify: + print(f"Skipping verification for {target_platform} on this host.") + return + + with tempfile.TemporaryDirectory(prefix="streamcap-ffmpeg-") as temp_dir: + archive_path = Path(temp_dir) / f"ffmpeg-{target_platform}.zip" + download(url, archive_path) + extract_ffmpeg(archive_path, output_path, target_platform) + + print(f"Saved: {output_path}") + if verify and can_verify(target_platform): + verify_ffmpeg(output_path) + elif verify: + print(f"Skipping verification for {target_platform} on this host.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Download FFmpeg executables for StreamCap packaging.") + parser.add_argument( + "--platform", + choices=("macos", "windows", "all"), + default=detect_platform(), + help="Target platform to download. Defaults to the host OS.", + ) + parser.add_argument("--macos-url", default=DEFAULT_URLS["macos"], help="macOS FFmpeg zip URL.") + parser.add_argument("--windows-url", default=DEFAULT_URLS["windows"], help="Windows FFmpeg zip URL.") + parser.add_argument("--refresh", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Overwrite an existing vendor FFmpeg executable.") + parser.add_argument("--no-verify", dest="verify", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Skip running ffmpeg -version.") + parser.set_defaults(verify=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + platforms = ("macos", "windows") if args.platform == "all" else (args.platform,) + urls = {"macos": args.macos_url, "windows": args.windows_url} + + for target_platform in platforms: + install_platform(target_platform, urls[target_platform], args.refresh, args.verify) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_nodejs.py b/scripts/download_nodejs.py new file mode 100644 index 00000000..d8a092ef --- /dev/null +++ b/scripts/download_nodejs.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import platform +import shutil +import stat +import subprocess +import tarfile +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VENDOR_NODE_DIR = ROOT / "vendor" / "node" +NODE_DIST_INDEX = "https://nodejs.org/dist/index.json" +NODE_DIST_BASE = "https://nodejs.org/dist" + + +def detect_platform() -> str: + system = platform.system() + if system == "Darwin": + return "macos" + if system == "Windows": + return "windows" + raise SystemExit(f"Unsupported platform: {system}") + + +def default_arch(target_platform: str) -> str: + machine = platform.machine().lower() + if target_platform == "macos": + return "arm64" if machine in ("arm64", "aarch64") else "x64" + if target_platform == "windows": + return "x64" + raise ValueError(target_platform) + + +def normalize_version(version: str) -> str: + return version if version.startswith("v") else f"v{version}" + + +def latest_lts_version() -> str: + with urllib.request.urlopen(NODE_DIST_INDEX, timeout=30) as response: + releases = json.load(response) + for release in releases: + if release.get("lts"): + return release["version"] + raise SystemExit("Could not find a Node.js LTS release.") + + +def target_path(target_platform: str) -> Path: + if target_platform == "macos": + return VENDOR_NODE_DIR / "macos" / "node" + if target_platform == "windows": + return VENDOR_NODE_DIR / "windows" / "node.exe" + raise ValueError(target_platform) + + +def archive_name(version: str, target_platform: str, arch: str) -> str: + if target_platform == "macos": + return f"node-{version}-darwin-{arch}.tar.gz" + if target_platform == "windows": + return f"node-{version}-win-{arch}.zip" + raise ValueError(target_platform) + + +def executable_member_name(target_platform: str) -> str: + return "node.exe" if target_platform == "windows" else "node" + + +def download(url: str, destination: Path) -> None: + print(f"Downloading {url}") + + def report(block_count: int, block_size: int, total_size: int) -> None: + if total_size <= 0: + return + downloaded = min(block_count * block_size, total_size) + percent = downloaded * 100 / total_size + print(f"\r{percent:5.1f}% {downloaded // 1024}KB/{total_size // 1024}KB", end="") + + urllib.request.urlretrieve(url, destination, reporthook=report) + print() + + +def extract_from_zip(archive_path: Path, output_path: Path, wanted_name: str) -> None: + with zipfile.ZipFile(archive_path) as archive: + members = [ + member + for member in archive.infolist() + if not member.is_dir() and Path(member.filename).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + member = min(members, key=lambda item: len(Path(item.filename).parts)) + with archive.open(member) as source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + +def extract_from_tar(archive_path: Path, output_path: Path, wanted_name: str) -> None: + with tarfile.open(archive_path, "r:gz") as archive: + members = [ + member + for member in archive.getmembers() + if member.isfile() and Path(member.name).name.lower() == wanted_name.lower() + ] + if not members: + raise SystemExit(f"{wanted_name} not found in archive: {archive_path}") + member = min(members, key=lambda item: len(Path(item.name).parts)) + source = archive.extractfile(member) + if source is None: + raise SystemExit(f"Could not extract {member.name}") + with source, output_path.open("wb") as target: + shutil.copyfileobj(source, target) + + +def extract_node(archive_path: Path, output_path: Path, target_platform: str) -> None: + wanted_name = executable_member_name(target_platform) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if archive_path.suffix == ".zip": + extract_from_zip(archive_path, output_path, wanted_name) + else: + extract_from_tar(archive_path, output_path, wanted_name) + + if target_platform != "windows": + output_path.chmod(output_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def can_verify(target_platform: str, arch: str) -> bool: + if target_platform != detect_platform(): + return False + return arch == default_arch(target_platform) + + +def verify_node(node_path: Path) -> None: + try: + result = subprocess.run([str(node_path), "-v"], capture_output=True, text=True, timeout=15) + except Exception as exc: + raise SystemExit(f"Failed to verify {node_path}: {exc}") from exc + + if result.returncode != 0: + raise SystemExit(f"Failed to verify {node_path}: {result.stderr.strip()}") + print(f"Verified: node {result.stdout.strip()}") + + +def install_platform(target_platform: str, version: str, arch: str, refresh: bool, verify: bool) -> None: + output_path = target_path(target_platform) + if output_path.exists() and not refresh: + print(f"Already exists: {output_path}") + if verify and can_verify(target_platform, arch): + verify_node(output_path) + elif verify: + print(f"Skipping verification for {target_platform}/{arch} on this host.") + return + + name = archive_name(version, target_platform, arch) + url = f"{NODE_DIST_BASE}/{version}/{name}" + + with tempfile.TemporaryDirectory(prefix="streamcap-node-") as temp_dir: + archive_path = Path(temp_dir) / name + download(url, archive_path) + extract_node(archive_path, output_path, target_platform) + + print(f"Saved: {output_path}") + if verify and can_verify(target_platform, arch): + verify_node(output_path) + elif verify: + print(f"Skipping verification for {target_platform}/{arch} on this host.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Download Node.js executables for StreamCap packaging.") + parser.add_argument( + "--platform", + choices=("macos", "windows", "all"), + default=detect_platform(), + help="Target platform to download. Defaults to the host OS.", + ) + parser.add_argument("--version", help="Node.js version, for example 22.12.0 or v22.12.0. Defaults to latest LTS.") + parser.add_argument("--macos-arch", choices=("arm64", "x64"), help="macOS Node.js architecture.") + parser.add_argument( + "--windows-arch", + choices=("x64", "x86", "arm64"), + default="x64", + help="Windows Node.js architecture.", + ) + parser.add_argument("--refresh", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_true", help="Overwrite an existing vendor Node.js executable.") + parser.add_argument("--no-verify", dest="verify", action="iframe.php?url=https%3A%2F%2Fgithub.com%2Fstore_false", help="Skip running node -v.") + parser.set_defaults(verify=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + version = normalize_version(args.version) if args.version else latest_lts_version() + platforms = ("macos", "windows") if args.platform == "all" else (args.platform,) + + for target_platform in platforms: + arch = args.macos_arch or default_arch("macos") if target_platform == "macos" else args.windows_arch + install_platform(target_platform, version, arch, args.refresh, args.verify) + + +if __name__ == "__main__": + main() diff --git a/version_info.txt b/version_info.txt new file mode 100644 index 00000000..d2985b37 --- /dev/null +++ b/version_info.txt @@ -0,0 +1,36 @@ +# UTF-8 +# +# For more details about fixed file info 'ffi' see: +# https://docs.microsoft.com/en-us/windows/win32/api/verrsrc/ns-verrsrc-vs_fixedfileinfo +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 3, 0), + prodvers=(1, 0, 3, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904b0', + [ + StringStruct('CompanyName', 'io.github.ihmily.streamcap'), + StringStruct('FileDescription', 'Live Stream Recorder'), + StringStruct('FileVersion', '1.0.3'), + StringStruct('InternalName', 'StreamCap'), + StringStruct('LegalCopyright', 'Copyright (C) 2025-2026 by Hmily. All rights reserved.'), + StringStruct('OriginalFilename', 'StreamCap.exe'), + StringStruct('ProductName', 'StreamCap'), + StringStruct('ProductVersion', '1.0.3'), + ] + ) + ] + ), + VarFileInfo([VarStruct('Translation', [0, 1200])]) + ] +)