diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..ea4d139c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,371 @@ +name: Build Application + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master] + workflow_dispatch: + +env: + PYTHON_VERSION: "3.12" + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install FFmpeg + run: | + choco install ffmpeg -y + ffmpeg -version + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller + + - name: Update StreamGet from Git + run: | + pip install git+https://github.com/ihmily/streamget.git --upgrade + + - name: Create .env file + run: | + echo "PLATFORM=desktop" > .env + echo "HOST=127.0.0.1" >> .env + echo "PORT=6006" >> .env + + - name: Build Windows executable + run: | + pyinstaller --noconfirm --onedir --windowed --icon "assets/icon.ico" --name "StreamCap" --add-data "assets;assets/" --add-data "config;config/" --add-data "locales;locales/" --hidden-import "flet.matplotlib_chart" --hidden-import "flet.plotly_chart" --hidden-import "flet.video" --collect-all "streamget" main.py + + - name: Fix Windows package structure + run: | + mv dist/StreamCap/_internal/config dist/StreamCap/ + mv dist/StreamCap/_internal/locales dist/StreamCap/ + mv dist/StreamCap/_internal/assets dist/StreamCap/ + echo "Folders moved successfully." + + - name: Verify Windows executable + run: | + if (!(Test-Path "dist/StreamCap/StreamCap.exe")) { + throw "StreamCap.exe was not generated." + } + & "dist/StreamCap/StreamCap.exe" --help + + - name: Package Windows build + run: | + Compress-Archive -Path "dist/StreamCap/*" -DestinationPath "StreamCap-Windows.zip" + + - name: Upload Windows artifact + uses: actions/upload-artifact@v7 + with: + name: StreamCap-Windows + path: StreamCap-Windows.zip + retention-days: 30 + + build-macos: + runs-on: macos-15 + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install FFmpeg + run: | + # Install FFmpeg + brew install ffmpeg + ffmpeg -version + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pyinstaller dmgbuild + + - name: Update StreamGet from Git + run: | + pip install git+https://github.com/ihmily/streamget.git --upgrade + + - name: Create .env file + run: | + echo "PLATFORM=desktop" > .env + echo "HOST=127.0.0.1" >> .env + echo "PORT=6006" >> .env + + - name: Show macOS build diagnostics + run: | + sw_vers + python --version + flet --version + pyinstaller --version + file assets/icons/Appicon.icns + ls -lah assets/icons/Appicon.icns + + - name: Build macOS executable + run: | + flet pack main.py \ + --name "StreamCap" \ + --icon "assets/icons/Appicon.icns" \ + --distpath dist \ + --add-data "assets:assets" "config:config" "locales:locales" \ + --hidden-import "flet.matplotlib_chart" "flet.plotly_chart" "flet.video" \ + --yes + + - name: Create macOS app bundle structure + run: | + mkdir -p "StreamCap.app/Contents/MacOS" + mkdir -p "StreamCap.app/Contents/Resources" + + - name: Generate Info.plist + run: | + cat > "StreamCap.app/Contents/Info.plist" << 'EOF' + + + + + CFBundleExecutable + StreamCap + CFBundleIdentifier + io.github.ihmily.streamcap + CFBundleName + StreamCap + CFBundleVersion + 1.0.1 + CFBundleShortVersionString + 1.0.1 + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + CFBundleIconFile + Appicon + LSMinimumSystemVersion + 10.14 + NSHighResolutionCapable + + + + EOF + + - name: Copy binaries and assets + run: | + if [ -d "dist/StreamCap.app" ]; then + rm -rf "StreamCap.app" + cp -R "dist/StreamCap.app" "StreamCap.app" + else + cp -r dist/StreamCap/* "StreamCap.app/Contents/MacOS/" + fi + cp assets/icons/Appicon.icns "StreamCap.app/Contents/Resources/Appicon.icns" + chmod +x "StreamCap.app/Contents/MacOS/StreamCap" + + - name: Verify macOS app bundle + run: | + test -d "StreamCap.app" + test -f "StreamCap.app/Contents/Info.plist" + test -x "StreamCap.app/Contents/MacOS/StreamCap" + test -s "StreamCap.app/Contents/Resources/Appicon.icns" + plutil -lint "StreamCap.app/Contents/Info.plist" + file "StreamCap.app/Contents/MacOS/StreamCap" + otool -L "StreamCap.app/Contents/MacOS/StreamCap" >/tmp/streamcap-otool.txt + + - name: Create DMG settings file + run: | + cat > dmg_settings.py << 'EOF' + import os + + # DMG settings + format = 'UDZO' + size = '500M' + files = ['StreamCap.app'] + symlinks = {'Applications': '/Applications'} + badge_icon = 'assets/icons/Appicon.icns' + icon_locations = { + 'StreamCap.app': (150, 120), + 'Applications': (350, 120) + } + background = None + window_rect = ((100, 100), (500, 300)) + default_view = 'icon-view' + show_status_bar = False + show_tab_view = False + show_toolbar = False + show_pathbar = False + show_sidebar = False + sidebar_width = 180 + arrange_by = None + grid_offset = (0, 0) + grid_spacing = 100 + scroll_position = (0, 0) + label_pos = 'bottom' + text_size = 16 + icon_size = 128 + EOF + + - name: Create DMG package + run: | + dmgbuild -s dmg_settings.py "StreamCap" "StreamCap-macOS.dmg" + + - name: Upload macOS artifact + uses: actions/upload-artifact@v7 + with: + name: StreamCap-macOS + path: StreamCap-macOS.dmg + retention-days: 30 + + build-linux: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install FFmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + ffmpeg -version + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-web.txt + + - name: Update StreamGet from Git + run: | + pip install git+https://github.com/ihmily/streamget.git --upgrade + + - name: Create .env file for Web + run: | + echo "PLATFORM=web" > .env + echo "HOST=0.0.0.0" >> .env + echo "PORT=6006" >> .env + + - name: Test web application + run: | + python main.py --web --host 127.0.0.1 --port 6006 & + APP_PID=$! + trap 'kill $APP_PID 2>/dev/null || true' EXIT + for i in {1..30}; do + if curl -fsS http://127.0.0.1:6006/ >/tmp/streamcap-index.html; then + break + fi + if ! kill -0 $APP_PID 2>/dev/null; then + wait $APP_PID + exit 1 + fi + sleep 1 + done + curl -fsS http://127.0.0.1:6006/ >/dev/null + kill $APP_PID + wait $APP_PID || true + trap - EXIT + + - name: Package Linux code + run: | + mkdir -p ../package + rsync -av --exclude='.git' --exclude='__pycache__' --exclude='*.pyc' --exclude='*.log' --exclude='.pytest_cache' --exclude='node_modules' --exclude='dist' --exclude='build' --exclude='*.egg-info' . ../package/StreamCap/ + cd ../package + tar -czf StreamCap-Linux.tar.gz StreamCap/ + mv StreamCap-Linux.tar.gz $GITHUB_WORKSPACE/ + + - name: Upload Linux artifact + id: upload-linux-artifact + uses: actions/upload-artifact@v7 + with: + name: StreamCap-Linux + path: StreamCap-Linux.tar.gz + retention-days: 30 + + prerelease: + needs: [build-windows, build-macos, build-linux] + if: github.event_name != 'pull_request' && github.repository == 'lycorisdeve/StreamCap' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Download Windows artifact + uses: actions/download-artifact@v8 + with: + name: StreamCap-Windows + path: releases/ + + - name: Download macOS artifact + uses: actions/download-artifact@v8 + with: + name: StreamCap-macOS + path: releases/ + + - name: Download Linux artifact + uses: actions/download-artifact@v8 + with: + name: StreamCap-Linux + path: releases/ + + - name: Check and rename artifacts for Beta + run: | + if [ -f "releases/StreamCap-Windows.zip" ]; then + mv releases/StreamCap-Windows.zip "StreamCap_Windows_beta.zip" + else + echo "StreamCap-Windows.zip not found!" + fi + + if [ -f "releases/StreamCap-macOS.dmg" ]; then + mv releases/StreamCap-macOS.dmg "StreamCap_macOS_beta.dmg" + else + echo "StreamCap-macOS.dmg not found!" + fi + + if [ -f "releases/StreamCap-Linux.tar.gz" ]; then + mv releases/StreamCap-Linux.tar.gz "StreamCap_Linux_beta.tar.gz" + else + echo "StreamCap-Linux.tar.gz not found!" + fi + + - name: Delete Old Pre-Release (if exists) + run: | + if gh release view beta &>/dev/null; then + gh release delete beta -y + fi + env: + GH_TOKEN: ${{ github.token }} + + - name: Create or Update Beta Tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -f beta + git push origin refs/tags/beta --force + + - name: Publish Beta Release + uses: ncipollo/release-action@v1 + with: + name: StreamCap-beta + tag: "beta" + body: "此版本为测试版,包含最新代码变更,可能存在不稳定情况,升级前请务必备份好数据。" + prerelease: true + artifacts: ${{ github.workspace }}/*.zip,${{ github.workspace }}/*.dmg,${{ github.workspace }}/*.tar.gz diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 2457e8bc..fb069641 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -9,7 +9,7 @@ jobs: build-and-push: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Login to Docker Hub uses: docker/login-action@v3 diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 7abe24fb..33eac1c3 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -20,10 +20,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Cache dependencies - uses: actions/cache@v3 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} @@ -31,7 +31,7 @@ jobs: ${{ runner.os }}-pip- - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.10" diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index fe273751..7831742f 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -23,7 +23,7 @@ jobs: steps: # Step 1: run a standard checkout action, provided by github - name: Checkout target repo - uses: actions/checkout@v3 + uses: actions/checkout@v7 with: # optional: set the branch to checkout, # sync action checks out your 'target_sync_branch' anyway diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d280fab9..2d4201f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v6 with: python-version: '3.10' - name: Install dependencies diff --git a/.gitignore b/.gitignore index 6832287a..18e9f03f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ build/ develop-eggs/ dist/ +dist-pack-test/ downloads/ eggs/ .eggs/ @@ -85,10 +86,14 @@ ipython_config.py /config/accounts.json /config/user_settings.json /config/recordings.json +/config/recordings.db +/config/recordings.db-* /config/cookies.json /config/web_auth.json .ruff_cache/ +log/ logs/ +data/database/ storage/ ffmpeg/ node/ diff --git a/README.md b/README.md index ad5ac961..4792e067 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,93 @@ python main.py --web 如果程序提示缺少 FFmpeg,请访问 FFmpeg 官方下载页面[Download FFmpeg](https://ffmpeg.org/download.html),下载预编译的 FFmpeg 可执行文件,并配置环境变量。 +## 📦打包与 Web 部署 + +### 1.Windows 打包为 exe + +本项目基于 Flet,桌面端推荐使用 `flet pack` 生成 Windows 可执行程序。打包前请先安装桌面端依赖,并确认 FFmpeg 已安装或已加入系统环境变量。 + +```powershell +# 安装桌面端依赖 +pip install -r requirements.txt + +# 首次打包前准备环境配置 +copy .env.example .env + +# 生成 onedir 形式的 Windows 程序,输出目录为 dist\StreamCap +flet pack main.py -D -n StreamCap -i assets/icon.ico --distpath dist -y + +# 将运行时需要的资源目录复制到 exe 同级目录 +xcopy assets dist\StreamCap\assets /E /I /Y +xcopy locales dist\StreamCap\locales /E /I /Y +copy .env dist\StreamCap\.env + +# 复制基础配置文件,避免首次启动缺少语言和版本配置 +mkdir dist\StreamCap\config +copy config\default_settings.json dist\StreamCap\config\default_settings.json +copy config\version.json dist\StreamCap\config\version.json +copy config\language.json dist\StreamCap\config\language.json +``` + +打包完成后运行: + +```powershell +dist\StreamCap\StreamCap.exe +``` + +说明: + +- 程序运行时会在 exe 同级目录创建或使用 `config`、`log`、`data/database`、`downloads` 等目录。 +- `data/database/recordings.db` 用于保存录制房间和录制历史;通用配置、Cookie、账号配置等仍保留 JSON 文件。 +- 如果希望发布压缩包,建议将 `dist\StreamCap` 整个目录压缩,而不是只复制单个 exe。 +- `flet pack` 生成的是桌面端 exe,不建议用该 exe 启动 Web 服务;Web 部署请使用下方 `python main.py --web` 或 Docker 方式。 +- 如需使用 Flet 官方构建流程,也可以尝试 `flet build windows --module-name main --output build\windows --product StreamCap --artifact StreamCap --yes`,但完整录制能力仍依赖本机 FFmpeg 和运行时配置目录。 + +### 2.Web 方式运行 + +本项目的 Web 模式是带 Python 后端的动态服务,录制、监控、SQLite 历史记录、日志写入等功能都依赖本地 Python 进程和 FFmpeg。推荐使用以下方式运行: + +```powershell +# 安装 Web 端依赖 +pip install -r requirements-web.txt + +# 首次运行前准备环境配置 +copy .env.example .env + +# 启动 Web 服务 +python main.py --web --host 0.0.0.0 --port 6006 +``` + +启动后访问: + +```text +http://127.0.0.1:6006 +``` + +也可以通过 `.env` 指定 Web 模式: + +```env +PLATFORM=web +HOST=0.0.0.0 +PORT=6006 +``` + +然后直接运行: + +```powershell +python main.py +``` + +### 3.Web 静态构建说明 + +Flet 也提供静态 Web 构建命令: + +```powershell +flet build web --module-name main --output build\web --web-renderer canvaskit --yes +``` + +但 StreamCap 的核心录制功能需要 Python 后端、FFmpeg、配置文件、日志和 SQLite 数据库支持,因此静态 Web 构建更适合前端渲染验证,不建议作为完整录制服务的发布方式。完整 Web 部署请优先使用 `python main.py --web` 或下方 Docker 方式。 + ## 🐋容器运行 本机无需Python环境运行,在运行命令之前,请确保您的机器上安装了 [Docker](https://docs.docker.com/get-docker/) 和 [Docker Compose](https://docs.docker.com/compose/install/) diff --git a/app/app_manager.py b/app/app_manager.py index 28601da2..679c4e39 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -13,6 +13,7 @@ from .ui.navigation.sidebar import LeftNavigationMenu, NavigationSidebar from .ui.views.about_view import AboutPage from .ui.views.home_view import HomePage +from .ui.views.logs_view import LogsPage from .ui.views.recordings_view import RecordingsPage from .ui.views.settings_view import SettingsPage from .ui.views.storage_view import StoragePage @@ -58,6 +59,7 @@ def __init__(self, page: ft.Page, services: BackendServices | None = None): self.recordings = RecordingsPage(self) self.home = HomePage(self) self.storage = StoragePage(self) + self.logs = LogsPage(self) self.pages = self.initialize_pages() self.sidebar = NavigationSidebar(self) self.left_navigation_menu = LeftNavigationMenu(self) @@ -157,6 +159,7 @@ def initialize_pages(self): "home": self.home, "recordings": self.recordings, "storage": self.storage, + "logs": self.logs, "about": self.about, } diff --git a/app/core/config/config_manager.py b/app/core/config/config_manager.py index b9cb057e..543bb3de 100644 --- a/app/core/config/config_manager.py +++ b/app/core/config/config_manager.py @@ -13,7 +13,9 @@ class ConfigManager: def __init__(self, run_path): + self.run_path = run_path self.config_path = os.path.join(run_path, "config") + self.database_path = os.path.join(run_path, "data", "database") self.language_config_path = os.path.join(self.config_path, "language.json") self.default_config_path = os.path.join(self.config_path, "default_settings.json") self.user_config_path = os.path.join(self.config_path, "user_settings.json") @@ -24,6 +26,7 @@ def __init__(self, run_path): self.web_auth_config_path = os.path.join(self.config_path, "web_auth.json") os.makedirs(os.path.dirname(self.default_config_path), exist_ok=True) + os.makedirs(self.database_path, exist_ok=True) self.init() def init(self): diff --git a/app/core/recording/record_manager.py b/app/core/recording/record_manager.py index bddedf97..e26963fd 100644 --- a/app/core/recording/record_manager.py +++ b/app/core/recording/record_manager.py @@ -1,4 +1,5 @@ import asyncio +import os import threading from collections import defaultdict from datetime import datetime, timedelta @@ -10,6 +11,7 @@ from ...utils.logger import logger from ..platforms.platform_handlers import get_platform_info from ..runtime.process_manager import BackgroundService +from .recording_repository import RecordingRepository from .stream_manager import LiveStreamRecorder @@ -22,6 +24,8 @@ class RecordingManager: def __init__(self, services): self.services = services self.settings = services.settings_config + self.repository = RecordingRepository(services.config_manager) + self.repository.initialize() self.periodic_task_started = False self.loop_time_seconds = None self.services.language_manager.add_observer(self) @@ -52,10 +56,18 @@ def load(self): 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() + """Load recording rooms from SQLite, migrating legacy JSON data if needed.""" + recordings_data = self.repository.load_recordings() + if not recordings_data and self.repository.count_recordings() == 0: + legacy_data = self.services.config_manager.load_recordings_config() + if isinstance(legacy_data, list) and legacy_data: + self.repository.save_recordings_sync(legacy_data) + recordings_data = self.repository.load_recordings() + logger.info(f"Live Recordings: Migrated {len(recordings_data)} items from recordings.json") + if not GlobalRecordingState.recordings: GlobalRecordingState.recordings = [Recording.from_dict(rec) for rec in recordings_data] + self.sort_recordings() logger.info(f"Live Recordings: Loaded {len(self.recordings)} items") def initialize_dynamic_state(self): @@ -68,31 +80,128 @@ def initialize_dynamic_state(self): recording.showed_checking_status = True async def add_recording(self, recording): + now = self.repository.now_iso() + recording.created_at = recording.created_at or now + recording.updated_at = now with GlobalRecordingState.lock: GlobalRecordingState.recordings.append(recording) - await self.persist_recordings() + self.sort_recordings() + await self.persist_recordings() async def remove_recording(self, recording: Recording): with GlobalRecordingState.lock: GlobalRecordingState.recordings.remove(recording) - await self.persist_recordings() + await self.persist_recordings() async def clear_all_recordings(self): with GlobalRecordingState.lock: GlobalRecordingState.recordings.clear() - await self.persist_recordings() + await self.persist_recordings() async def persist_recordings(self): - """Persist recordings to a JSON file.""" + """Persist recording rooms to SQLite.""" data_to_save = [rec.to_dict() for rec in self.recordings] - await self.services.config_manager.save_recordings_config(data_to_save) + await self.repository.save_recordings(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.""" + """Update an existing recording object and persist changes to SQLite.""" if recording: recording.update(updated_info) + recording.updated_at = self.repository.now_iso() self.services.run_coro(self.persist_recordings()) + def sort_recordings(self): + with GlobalRecordingState.lock: + GlobalRecordingState.recordings.sort( + key=lambda rec: ( + rec.pinned_at is not None, + int(rec.pin_order or 0), + rec.last_recorded_at or rec.created_at or "", + ), + reverse=True, + ) + + async def toggle_pin_recording(self, recording: Recording) -> bool: + now = self.repository.now_iso() + if recording.pinned_at: + recording.pinned_at = None + recording.pin_order = 0 + pinned = False + else: + recording.pinned_at = now + recording.pin_order = int(datetime.now().timestamp() * 1000) + pinned = True + recording.updated_at = now + self.sort_recordings() + await self.persist_recordings() + action = "Pinned" if pinned else "Unpinned" + logger.info(f"{action} recording: {recording.rec_id}-{recording.streamer_name}") + return pinned + + async def record_session_history( + self, + recording: Recording, + file_path: str | None, + file_format: str | None, + status: str, + stop_reason: str = "", + error_message: str = "", + started_at: str | None = None, + ended_at: str | None = None, + duration_seconds: int | None = None, + ): + now = self.repository.now_iso() + if status == "started": + ended_at = ended_at + else: + ended_at = ended_at or now + + event_time = ended_at or started_at or now + recording.last_recorded_at = event_time + if file_path: + recording.last_record_file = file_path + recording.last_live_title = recording.live_title or recording.last_live_title + recording.updated_at = event_time + + file_size = None + if file_path and "%" not in file_path and os.path.exists(file_path): + file_size = os.path.getsize(file_path) + + history = { + "rec_id": recording.rec_id, + "streamer_name": recording.streamer_name, + "platform": recording.platform, + "platform_key": recording.platform_key, + "live_title": recording.live_title, + "started_at": started_at, + "ended_at": ended_at, + "duration_seconds": duration_seconds, + "output_dir": os.path.dirname(file_path) if file_path else recording.recording_dir, + "file_path": file_path, + "file_format": file_format, + "file_size": file_size, + "stop_reason": stop_reason, + "status": status, + "error_message": error_message, + } + self.sort_recordings() + await self.persist_recordings() + await self.repository.add_recording_history(history) + + def get_recording_history(self, recording: Recording, limit: int = 10) -> list[dict]: + return self.repository.list_recording_history(recording.rec_id, limit=limit) + + def get_recent_recording_history(self, limit: int = 100) -> list[dict]: + return self.repository.list_recording_history(limit=limit) + + async def clear_recording_history(self) -> int: + removed_count = await self.repository.clear_recording_history() + for recording in self.recordings: + recording.last_recorded_at = None + recording.last_record_file = None + recording.last_live_title = None + return removed_count + @staticmethod async def _update_recording( recording: Recording, monitor_status: bool, display_title: str, status_info: str, selected: bool diff --git a/app/core/recording/recording_repository.py b/app/core/recording/recording_repository.py new file mode 100644 index 00000000..3758b662 --- /dev/null +++ b/app/core/recording/recording_repository.py @@ -0,0 +1,354 @@ +import asyncio +import os +import shutil +import sqlite3 +import threading +from contextlib import contextmanager +from datetime import datetime + + +class RecordingRepository: + """SQLite persistence for recording rooms and recording history.""" + + ROOM_BOOL_FIELDS = { + "segment_record", + "monitor_status", + "scheduled_recording", + "enabled_message_push", + "only_notify_no_record", + "flv_use_direct_download", + } + + ROOM_COLUMNS = [ + "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", + "platform", + "platform_key", + "only_notify_no_record", + "flv_use_direct_download", + "created_at", + "updated_at", + "pinned_at", + "pin_order", + "last_recorded_at", + "last_record_file", + "last_live_title", + ] + + HISTORY_COLUMNS = [ + "rec_id", + "streamer_name", + "platform", + "platform_key", + "live_title", + "started_at", + "ended_at", + "duration_seconds", + "output_dir", + "file_path", + "file_format", + "file_size", + "stop_reason", + "status", + "error_message", + "created_at", + ] + + def __init__(self, config_manager): + database_path = getattr(config_manager, "database_path", None) + if not database_path: + database_path = os.path.join(os.path.dirname(config_manager.config_path), "data", "database") + self.db_path = os.path.join(database_path, "recordings.db") + self.legacy_db_path = os.path.join(config_manager.config_path, "recordings.db") + self._lock = threading.Lock() + self._migrate_legacy_database() + + def _migrate_legacy_database(self): + if not os.path.exists(self.legacy_db_path) or os.path.exists(self.db_path): + return + + os.makedirs(os.path.dirname(self.db_path), exist_ok=True) + for suffix in ("", "-wal", "-shm"): + legacy_path = self.legacy_db_path + suffix + target_path = self.db_path + suffix + if os.path.exists(legacy_path) and not os.path.exists(target_path): + shutil.move(legacy_path, target_path) + + @staticmethod + def now_iso() -> str: + return datetime.now().isoformat(timespec="seconds") + + @staticmethod + def _to_db_bool(value): + if value is None: + return None + return int(bool(value)) + + @staticmethod + def _from_db_bool(value): + if value is None: + return None + return bool(value) + + def initialize(self): + with self._connect() as conn: + self._ensure_schema(conn) + + def load_recordings(self) -> list[dict]: + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + rows = conn.execute( + """ + SELECT * + FROM recording_rooms + ORDER BY + CASE WHEN pinned_at IS NULL THEN 0 ELSE 1 END DESC, + pin_order DESC, + COALESCE(last_recorded_at, created_at, '') DESC + """ + ).fetchall() + return [self._row_to_room_dict(row) for row in rows] + + def count_recordings(self) -> int: + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + row = conn.execute("SELECT COUNT(*) AS count FROM recording_rooms").fetchone() + return int(row["count"]) + + async def save_recordings(self, recordings: list[dict]): + await asyncio.to_thread(self.save_recordings_sync, recordings) + + def save_recordings_sync(self, recordings: list[dict]): + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + normalized = [self._normalize_room_dict(item) for item in recordings if item.get("rec_id")] + rec_ids = [item["rec_id"] for item in normalized] + with conn: + for item in normalized: + self._upsert_room(conn, item) + + if rec_ids: + placeholders = ",".join("?" for _ in rec_ids) + conn.execute(f"DELETE FROM recording_rooms WHERE rec_id NOT IN ({placeholders})", rec_ids) + else: + conn.execute("DELETE FROM recording_rooms") + + async def add_recording_history(self, history: dict): + await asyncio.to_thread(self.add_recording_history_sync, history) + + def add_recording_history_sync(self, history: dict): + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + item = self._normalize_history_dict(history) + columns = ", ".join(self.HISTORY_COLUMNS) + placeholders = ", ".join("?" for _ in self.HISTORY_COLUMNS) + values = [item.get(column) for column in self.HISTORY_COLUMNS] + with conn: + conn.execute( + f"INSERT INTO recording_history ({columns}) VALUES ({placeholders})", + values, + ) + conn.execute( + """ + UPDATE recording_rooms + SET + last_recorded_at = ?, + last_record_file = COALESCE(?, last_record_file), + last_live_title = COALESCE(?, last_live_title), + updated_at = ? + WHERE rec_id = ? + """, + ( + item.get("ended_at") or item.get("started_at") or item.get("created_at"), + item.get("file_path"), + item.get("live_title"), + self.now_iso(), + item.get("rec_id"), + ), + ) + + def list_recording_history(self, rec_id: str | None = None, limit: int = 20) -> list[dict]: + limit = max(1, min(int(limit or 20), 100)) + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + if rec_id: + rows = conn.execute( + """ + SELECT id, rec_id, streamer_name, platform, platform_key, live_title, + started_at, ended_at, duration_seconds, output_dir, file_path, + file_format, file_size, stop_reason, status, error_message, created_at + FROM recording_history + WHERE rec_id = ? + ORDER BY COALESCE(ended_at, created_at, '') DESC, id DESC + LIMIT ? + """, + (rec_id, limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT id, rec_id, streamer_name, platform, platform_key, live_title, + started_at, ended_at, duration_seconds, output_dir, file_path, + file_format, file_size, stop_reason, status, error_message, created_at + FROM recording_history + ORDER BY COALESCE(ended_at, created_at, '') DESC, id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + async def clear_recording_history(self) -> int: + return await asyncio.to_thread(self.clear_recording_history_sync) + + def clear_recording_history_sync(self) -> int: + with self._lock, self._connect() as conn: + self._ensure_schema(conn) + row = conn.execute("SELECT COUNT(*) AS count FROM recording_history").fetchone() + removed_count = int(row["count"] or 0) + with conn: + conn.execute("DELETE FROM recording_history") + conn.execute( + """ + UPDATE recording_rooms + SET + last_recorded_at = NULL, + last_record_file = NULL, + last_live_title = NULL, + updated_at = ? + """, + (self.now_iso(),), + ) + return removed_count + + @contextmanager + def _connect(self): + os.makedirs(os.path.dirname(self.db_path), exist_ok=True) + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + def _ensure_schema(self, conn): + conn.execute( + """ + CREATE TABLE IF NOT EXISTS recording_rooms ( + rec_id TEXT PRIMARY KEY, + url TEXT NOT NULL, + streamer_name TEXT, + record_format TEXT, + quality TEXT, + segment_record INTEGER DEFAULT 0, + segment_time TEXT, + monitor_status INTEGER DEFAULT 0, + scheduled_recording INTEGER DEFAULT 0, + scheduled_start_time TEXT, + monitor_hours TEXT, + recording_dir TEXT, + enabled_message_push INTEGER DEFAULT 0, + platform TEXT, + platform_key TEXT, + only_notify_no_record INTEGER DEFAULT 0, + flv_use_direct_download INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + pinned_at TEXT, + pin_order INTEGER DEFAULT 0, + last_recorded_at TEXT, + last_record_file TEXT, + last_live_title TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS recording_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rec_id TEXT NOT NULL, + streamer_name TEXT, + platform TEXT, + platform_key TEXT, + live_title TEXT, + started_at TEXT, + ended_at TEXT, + duration_seconds INTEGER, + output_dir TEXT, + file_path TEXT, + file_format TEXT, + file_size INTEGER, + stop_reason TEXT, + status TEXT, + error_message TEXT, + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS recording_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + conn.execute("INSERT OR REPLACE INTO recording_meta (key, value) VALUES ('schema_version', '1')") + conn.execute("CREATE INDEX IF NOT EXISTS idx_recording_history_rec_id ON recording_history(rec_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_recording_history_ended_at ON recording_history(ended_at)") + + def _normalize_room_dict(self, data: dict) -> dict: + now = self.now_iso() + item = {column: data.get(column) for column in self.ROOM_COLUMNS} + item["created_at"] = item.get("created_at") or now + item["updated_at"] = now + item["pin_order"] = int(item.get("pin_order") or 0) + for field in self.ROOM_BOOL_FIELDS: + item[field] = self._to_db_bool(item.get(field)) + return item + + def _normalize_history_dict(self, data: dict) -> dict: + now = self.now_iso() + item = {column: data.get(column) for column in self.HISTORY_COLUMNS} + item["created_at"] = item.get("created_at") or now + if item.get("status") != "started": + item["ended_at"] = item.get("ended_at") or now + return item + + def _upsert_room(self, conn, item: dict): + columns = ", ".join(self.ROOM_COLUMNS) + placeholders = ", ".join("?" for _ in self.ROOM_COLUMNS) + update_columns = [column for column in self.ROOM_COLUMNS if column not in {"rec_id", "created_at"}] + update_sql = ", ".join(f"{column} = excluded.{column}" for column in update_columns) + values = [item.get(column) for column in self.ROOM_COLUMNS] + conn.execute( + f""" + INSERT INTO recording_rooms ({columns}) VALUES ({placeholders}) + ON CONFLICT(rec_id) DO UPDATE SET {update_sql} + """, + values, + ) + + def _row_to_room_dict(self, row: sqlite3.Row) -> dict: + item = {column: row[column] for column in self.ROOM_COLUMNS} + for field in self.ROOM_BOOL_FIELDS: + item[field] = self._from_db_bool(item.get(field)) + item["pin_order"] = int(item.get("pin_order") or 0) + return item diff --git a/app/core/recording/stream_manager.py b/app/core/recording/stream_manager.py index 001d885e..19d035c9 100644 --- a/app/core/recording/stream_manager.py +++ b/app/core/recording/stream_manager.py @@ -1,795 +1,894 @@ -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.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), - ) - 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 - - 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 - - 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.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) - - return_code = process.returncode - safe_return_code = [0, 255] - stdout, stderr = await process.communicate() - - if return_code not in safe_return_code and stderr: - if not self.recording.is_recording: - logger.error(f"FFmpeg Stderr Output: {str(stderr.decode()).splitlines()[0]}") - self._handle_recording_error(record_name, self._["record_stream_error"]) - - if return_code in safe_return_code: - 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: - 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}") +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.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 + self.history_start_recorded = False + 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}") + + def _is_stopping_or_shutting_down(self) -> bool: + return self.should_stop or self.recording.manually_stopped or not self.services.recording_enabled + + @staticmethod + def _decode_first_stderr_line(stderr: bytes | None) -> str: + if not stderr: + return "" + decoded = stderr.decode(errors="replace").strip() + return decoded.splitlines()[0] if decoded else "" + + async def _record_session_history( + self, + file_path: str | None, + file_format: str | None, + status: str, + error_message: str = "", + ) -> None: + start_timestamp = getattr(self, "recording_start_time", None) + started_at = None + duration_seconds = None + if start_timestamp: + started_at = datetime.fromtimestamp(start_timestamp).isoformat(timespec="seconds") + duration_seconds = max(0, int(time.time() - start_timestamp)) + + if self.should_stop or self.recording.manually_stopped: + stop_reason = "manual" + elif not self.services.recording_enabled: + stop_reason = "shutdown" + else: + stop_reason = status + try: + await self.services.recording_manager.record_session_history( + recording=self.recording, + file_path=file_path, + file_format=file_format, + status=status, + stop_reason=stop_reason, + error_message=error_message, + started_at=started_at, + ended_at=datetime.now().isoformat(timespec="seconds"), + duration_seconds=duration_seconds, + ) + except Exception as e: + logger.debug(f"Failed to write recording history: {e}") + + async def _record_session_started(self, file_format: str | None) -> None: + if self.history_start_recorded: + return + + start_timestamp = getattr(self, "recording_start_time", None) or time.time() + started_at = datetime.fromtimestamp(start_timestamp).isoformat(timespec="seconds") + try: + await self.services.recording_manager.record_session_history( + recording=self.recording, + file_path=None, + file_format=file_format, + status="started", + stop_reason="started", + started_at=started_at, + ended_at=None, + duration_seconds=0, + ) + self.history_start_recorded = True + except Exception as e: + logger.debug(f"Failed to write recording start history: {e}") + + async def _handle_recording_finished( + self, + record_name: str, + stop_msg: str = "", + complete_msg: str = "", + file_path: str | None = None, + file_format: str | None = None, + ) -> 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 + is_stopped = self._is_stopping_or_shutting_down() + if is_stopped: + 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()) + + await self._record_session_history( + file_path=file_path, + file_format=file_format, + status="stopped" if is_stopped else "completed", + ) + + 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), + ) + 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 + + 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 + + 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.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() + await self._record_session_started(save_type) + + 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) + + return_code = process.returncode + safe_return_code = [0, 255] + stdout, stderr = await process.communicate() + + if return_code not in safe_return_code: + if not self.recording.is_recording: + error_message = self._decode_first_stderr_line(stderr) + if self._is_stopping_or_shutting_down(): + logger.info( + f"FFmpeg exited while stopping, code={return_code}, message={error_message or 'None'}" + ) + await self._handle_recording_finished( + record_name, + file_path=save_file_path, + file_format=save_type, + ) + else: + logger.error(f"FFmpeg Stderr Output: {error_message or 'Unknown error'}") + await self._record_session_history(save_file_path, save_type, "error", error_message) + self._handle_recording_error(record_name, self._["record_stream_error"]) + + if return_code in safe_return_code: + if not self.recording.is_recording: + await self._handle_recording_finished(record_name, file_path=save_file_path, file_format=save_type) + + 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}") + await self._record_session_history(None, save_type, "error", str(e)) + self._handle_recording_error(record_name, self._["no_ffmpeg_tip"], duration=4000) + 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.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() + await self._record_session_started(save_type) + + 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}", + file_path=save_file_path, + file_format=save_type, + ) + + 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}") + await self._record_session_history(save_file_path, save_type, "error", str(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/models/recording/recording_model.py b/app/models/recording/recording_model.py index 1226e4bf..78667fd3 100644 --- a/app/models/recording/recording_model.py +++ b/app/models/recording/recording_model.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta class Recording: @@ -19,6 +19,13 @@ def __init__( enabled_message_push, only_notify_no_record, flv_use_direct_download, + created_at=None, + updated_at=None, + pinned_at=None, + pin_order=0, + last_recorded_at=None, + last_record_file=None, + last_live_title=None, ): """ Initialize a recording object. @@ -55,6 +62,14 @@ def __init__( 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 + now = datetime.now().isoformat(timespec="seconds") + self.created_at = created_at or now + self.updated_at = updated_at or self.created_at + self.pinned_at = pinned_at + self.pin_order = int(pin_order or 0) + self.last_recorded_at = last_recorded_at + self.last_record_file = last_record_file + self.last_live_title = last_live_title self.scheduled_time_range = None self.title = f"{streamer_name} - {self.quality}" self.speed = "X KB/s" @@ -104,6 +119,13 @@ def to_dict(self): "platform_key": self.platform_key, "only_notify_no_record": self.only_notify_no_record, "flv_use_direct_download": self.flv_use_direct_download, + "created_at": self.created_at, + "updated_at": self.updated_at, + "pinned_at": self.pinned_at, + "pin_order": self.pin_order, + "last_recorded_at": self.last_recorded_at, + "last_record_file": self.last_record_file, + "last_live_title": self.last_live_title, } @classmethod @@ -125,6 +147,13 @@ def from_dict(cls, data): data.get("enabled_message_push"), data.get("only_notify_no_record"), data.get("flv_use_direct_download"), + data.get("created_at"), + data.get("updated_at"), + data.get("pinned_at"), + data.get("pin_order", 0), + data.get("last_recorded_at"), + data.get("last_record_file"), + data.get("last_live_title"), ) recording.title = data.get("title", recording.title) recording.display_title = data.get("display_title", recording.title) diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 658b79b8..fe1c19fb 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -1,5 +1,6 @@ import asyncio import os.path +import time import flet as ft @@ -20,6 +21,7 @@ def __init__(self, app): self.cards_obj = {} self.update_duration_tasks = {} self.selected_cards = {} + self.last_card_click_at = {} self.app.language_manager.add_observer(self) self._ = {} self.load() @@ -81,6 +83,13 @@ def _create_card_components(self, recording: Recording): on_click=lambda e, rec=recording: self.app.page.run_task(self.monitor_button_on_click, e, rec), ) + pin_button = ft.IconButton( + icon=self.get_icon_for_pin_state(recording), + icon_color=ft.Colors.PRIMARY, + tooltip=self.get_tip_for_pin_state(recording), + on_click=lambda e, rec=recording: self.app.page.run_task(self.pin_recording_button_on_click, e, rec), + ) + delete_button = ft.IconButton( icon=ft.Icons.DELETE, icon_color=ft.Colors.PRIMARY, @@ -106,12 +115,6 @@ def _create_card_components(self, recording: Recording): 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), - ) speed_text_label = ft.Text(speed, size=12) status_label = self.create_status_label(recording) @@ -132,8 +135,8 @@ def _create_card_components(self, recording: Recording): ft.Row( [ record_button, + pin_button, open_folder_button, - recording_info_button, preview_button, edit_button, delete_button, @@ -148,6 +151,7 @@ def _create_card_components(self, recording: Recording): tight=True, ), padding=8, + tooltip=self._["recording_card_tip"], 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, @@ -161,8 +165,8 @@ def _create_card_components(self, recording: Recording): "duration_label": duration_text_label, "speed_label": speed_text_label, "record_button": record_button, + "pin_button": pin_button, "open_folder_button": open_folder_button, - "recording_info_button": recording_info_button, "edit_button": edit_button, "monitor_button": monitor_button, "status_label": status_label, @@ -237,6 +241,11 @@ async def update_card(self, recording): 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.get("pin_button"): + recording_card["pin_button"].icon = self.get_icon_for_pin_state(recording) + recording_card["pin_button"].icon_color = ft.Colors.PRIMARY + recording_card["pin_button"].tooltip = self.get_tip_for_pin_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)) @@ -314,6 +323,11 @@ async def edit_recording_callback(self, recording_list: list[dict]): async def on_toggle_recording(self, recording: Recording): """Toggle the recording state for a specific recording.""" + guard_until = getattr(recording, "_ignore_record_button_until", 0) + if guard_until and time.monotonic() < guard_until: + logger.debug(f"Ignore recording toggle immediately after pin click: {recording.rec_id}") + return + if recording and self.app.recording_enabled: if recording.is_recording: self.app.record_manager.stop_recording(recording, manually_stopped=True) @@ -397,6 +411,48 @@ def get_icon_for_monitor_state(recording: Recording): def get_tip_for_monitor_state(self, recording: Recording): return self._["stop_monitor"] if recording.monitor_status else self._["start_monitor"] + @staticmethod + def get_icon_for_pin_state(recording: Recording): + if recording.pinned_at: + return getattr(ft.Icons, "PUSH_PIN", ft.Icons.STAR) + return getattr(ft.Icons, "PUSH_PIN_OUTLINED", getattr(ft.Icons, "STAR_BORDER", ft.Icons.STAR)) + + def get_tip_for_pin_state(self, recording: Recording): + return self._["unpin_record"] if recording.pinned_at else self._["pin_record"] + + def reorder_recording_cards(self): + current_page = getattr(self.app, "current_page", None) + recording_card_area = getattr(current_page, "recording_card_area", None) + content = getattr(recording_card_area, "content", None) + controls = getattr(content, "controls", None) + if controls is None: + return + + ordered_cards = [] + ordered_ids = set() + for recording in self.app.record_manager.recordings: + card_info = self.cards_obj.get(recording.rec_id) + if card_info and card_info.get("card"): + ordered_cards.append(card_info["card"]) + ordered_ids.add(card_info["card"].key) + + remaining_cards = [control for control in controls if getattr(control, "key", None) not in ordered_ids] + controls[:] = ordered_cards + remaining_cards + try: + recording_card_area.update() + except (ft.FletPageDisconnectedException, AssertionError) as e: + logger.debug(f"Reorder recording cards failed: {e}") + + async def pin_recording_button_on_click(self, _, recording: Recording): + recording._ignore_record_button_until = time.monotonic() + 1.0 + pinned = await self.app.record_manager.toggle_pin_recording(recording) + await self.update_card(recording) + if not recording.is_recording: + self.reorder_recording_cards() + self.app.page.pubsub.send_others_on_topic("update", recording) + message_key = "pin_record_success_tip" if pinned else "unpin_record_success_tip" + await self.app.snack_bar.show_snack_bar(self._[message_key], bgcolor=ft.Colors.PRIMARY, duration=1500) + async def update_duration(self, recording: Recording): """Update the duration text periodically.""" while True: @@ -428,6 +484,13 @@ def start_update_task(self, recording: Recording): async def on_card_click(self, recording: Recording): """Handle card click events.""" try: + now = time.monotonic() + last_click_at = self.last_card_click_at.get(recording.rec_id, 0) + self.last_card_click_at[recording.rec_id] = now + if now - last_click_at <= 0.35: + await self.show_recording_info_dialog(recording) + return + recording.selected = not recording.selected self.selected_cards[recording.rec_id] = recording self.cards_obj[recording.rec_id]["card"].content.bgcolor = await self.update_record_hover(recording) @@ -534,6 +597,8 @@ async def recording_card_on_click(self, _, recording: Recording): async def subscribe_update_card(self, _, recording: Recording): await self.update_card(recording) + if not recording.is_recording: + self.reorder_recording_cards() async def subscribe_remove_cards(self, _, recordings: list[Recording]): await self.remove_recording_card(recordings) diff --git a/app/ui/components/dialogs/card_dialog.py b/app/ui/components/dialogs/card_dialog.py index aa08aafb..8353ce8b 100644 --- a/app/ui/components/dialogs/card_dialog.py +++ b/app/ui/components/dialogs/card_dialog.py @@ -46,6 +46,9 @@ def get_content(self, recording): 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"] + joined_at = recording.created_at or self._["none"] + last_recorded_at = recording.last_recorded_at or self._["never_recorded"] + last_record_file = recording.last_record_file or self._["none"] dialog_content = ft.Column( [ @@ -63,6 +66,9 @@ def get_content(self, recording): 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._['joined_at']}: {joined_at}", size=14), + ft.Text(f"{self._['last_recorded_at']}: {last_recorded_at}", size=14), + ft.Text(f"{self._['last_record_file']}: {last_record_file}", size=14, selectable=True), ft.Text(f"{self._['save_path']}: {save_path}", size=14, selectable=True), ft.Text(f"{self._['recording_status']}: {recording_status_info}", size=14), ], diff --git a/app/ui/components/state/recording_card_state.py b/app/ui/components/state/recording_card_state.py index 4f08e6bf..8088c31c 100644 --- a/app/ui/components/state/recording_card_state.py +++ b/app/ui/components/state/recording_card_state.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import flet as ft from ....models.recording.recording_model import Recording @@ -82,9 +84,12 @@ def get_status_label_config(recording: Recording, language_dict: dict) -> dict: @staticmethod def get_display_title(recording: Recording, language_dict: dict) -> str: status_prefix = "" + pin_prefix = "" + if recording.pinned_at: + pin_prefix = f"[{language_dict.get('pinned')}] " if not recording.monitor_status: status_prefix = f"[{language_dict.get('monitor_stopped')}] " - return f"{status_prefix}{recording.title}" + return f"{pin_prefix}{status_prefix}{recording.title}" @staticmethod def get_title_weight(recording: Recording) -> ft.FontWeight: diff --git a/app/ui/layout/responsive_layout.py b/app/ui/layout/responsive_layout.py index b94a9d8c..f24405a3 100644 --- a/app/ui/layout/responsive_layout.py +++ b/app/ui/layout/responsive_layout.py @@ -23,10 +23,11 @@ def setup_responsive_layout(page: ft.Page, app: App) -> None: ft.NavigationBarDestination(icon=ft.Icons.DASHBOARD_ROUNDED, label=_["recordings"]), ft.NavigationBarDestination(icon=ft.Icons.SETTINGS, label=_["settings"]), ft.NavigationBarDestination(icon=ft.Icons.DRIVE_FILE_MOVE, label=_["storage"]), + ft.NavigationBarDestination(icon=ft.Icons.HISTORY, label=_["logs"]), 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', 'logs', 'about'][e.control.selected_index]}" ), ) diff --git a/app/ui/navigation/sidebar.py b/app/ui/navigation/sidebar.py index 162759c1..64740e14 100644 --- a/app/ui/navigation/sidebar.py +++ b/app/ui/navigation/sidebar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Callable from typing import cast @@ -213,6 +215,13 @@ def load(self): 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), + ControlGroup( + icon=ft.Icons.HISTORY, + label=self._["logs"], + index=4, + name="logs", + selected_icon=ft.Icons.HISTORY, + ), + ControlGroup(icon=ft.Icons.INFO, label=self._["about"], index=5, name="about", selected_icon=ft.Icons.INFO), ] self.selected_control_group = self.control_groups[0] diff --git a/app/ui/views/home_view.py b/app/ui/views/home_view.py index a9c8fd6a..f1acc3e5 100644 --- a/app/ui/views/home_view.py +++ b/app/ui/views/home_view.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from datetime import datetime diff --git a/app/ui/views/logs_view.py b/app/ui/views/logs_view.py new file mode 100644 index 00000000..2ee6ca71 --- /dev/null +++ b/app/ui/views/logs_view.py @@ -0,0 +1,542 @@ +import asyncio +import os +import re +from collections import deque + +import flet as ft + +from ...utils.logger import logger +from ..base_page import PageBase as BasePage + + +class LogsPage(BasePage): + RECORDING_KEYWORDS = ( + "recording", + "record", + "ffmpeg", + "direct download", + "stream", + "录制", + "直播", + "转码", + ) + TRANSLATION_PATTERNS = ( + (re.compile(r"Live Recordings: Loaded (\d+) items"), "已加载 {0} 个录制任务", "Loaded {0} recording rooms"), + ( + re.compile(r"Live Recordings: Migrated (\d+) items from recordings\.json"), + "已从旧配置迁移 {0} 个录制任务", + "Migrated {0} recording rooms from recordings.json", + ), + (re.compile(r"Started recording for (.+)"), "开始录制:{0}", "Started recording: {0}"), + (re.compile(r"Stopped recording for (.+)"), "录制已停止:{0}", "Stopped recording: {0}"), + ( + re.compile(r"Stop requested for recorder: (.+), rec_id: (.+)"), + "请求停止录制:{0}(ID:{1})", + "Stop requested: {0} (ID: {1})", + ), + (re.compile(r"Requested stop for recorder: (.+)"), "已发送停止录制请求:{0}", "Requested recording stop: {0}"), + ( + re.compile(r"No active recorder found for (.+), cannot request stop"), + "未找到正在录制的任务,无法停止:{0}", + "No active recorder found: {0}", + ), + (re.compile(r"Batch Start Monitor Recordings: (.+)"), "批量开始监控:{0}", "Batch started monitoring: {0}"), + (re.compile(r"Batch Stop Monitor Recordings:?(.+)"), "批量停止监控:{0}", "Batch stopped monitoring: {0}"), + (re.compile(r"Delete Items: (.+)"), "删除录制任务:{0}", "Deleted recording room: {0}"), + (re.compile(r"Add items recording: (.+)"), "新增录制任务:{0}", "Added recording room: {0}"), + ( + re.compile(r"Starting periodic live check background task"), + "开始后台直播检测任务", + "Started background live check task", + ), + ( + re.compile(r"Initializing periodic live check task with interval: (.+)s"), + "初始化后台直播检测,间隔 {0} 秒", + "Initialized live check interval: {0}s", + ), + (re.compile(r"Saved last route: (.+)"), "已保存上次打开页面:{0}", "Saved last page: {0}"), + (re.compile(r"Restored last route: (.+)"), "已恢复上次打开页面:{0}", "Restored last page: {0}"), + (re.compile(r"Language Code: (.+)"), "已加载语言:{0}", "Loaded language: {0}"), + (re.compile(r"desktop device detected, enable desktop layout"), "已启用桌面端布局", "Desktop layout enabled"), + (re.compile(r"mobile device detected, enable mobile layout"), "已启用移动端布局", "Mobile layout enabled"), + ( + re.compile(r"Logs page refreshed: runtime=(\d+), start=(\d+), stop=(\d+)"), + "日志页已刷新:运行 {0} 条,开始 {1} 条,停止 {2} 条", + "Logs refreshed: runtime {0}, start {1}, stop {2}", + ), + ) + + def __init__(self, app): + super().__init__(app) + self.page_name = "logs" + self.recording_log_list = None + self.history_list = None + self.summary_text = None + self.app.language_manager.add_observer(self) + self.load_language() + + def load_language(self): + language = self.app.language_manager.language + for key in ("logs_page", "recording_card", "base"): + self._.update(language.get(key, {})) + + async def load(self): + self.load_language() + self.recording_log_list = ft.ListView(expand=True, spacing=8, padding=0) + self.history_list = ft.ListView(expand=True, spacing=10, padding=0) + self.summary_text = ft.Text("", size=13, color=ft.Colors.GREY_600) + + tabs = ft.Tabs( + content=ft.Column( + [ + ft.TabBar( + tabs=[ + ft.Tab(label=self._["recording_logs"]), + ft.Tab(label=self._["recording_history"]), + ] + ), + ft.TabBarView( + controls=[ + self.create_tab_content( + self._["recording_logs"], + self._["clear_recording_logs"], + self.recording_log_list, + self.confirm_clear_recording_logs, + ), + self.create_history_tab_content(), + ], + expand=True, + ), + ], + expand=True, + ), + length=2, + selected_index=0, + animation_duration=200, + expand=True, + ) + + self.content_area.controls = [ + ft.Container( + expand=True, + padding=20, + content=ft.Column( + expand=True, + spacing=14, + controls=[ + self.create_header(), + self.summary_text, + ft.Divider(height=1), + tabs, + ], + ), + ) + ] + self.content_area.update() + await self.refresh_logs() + + def create_header(self): + return ft.Row( + controls=[ + ft.Text(self._["title"], size=20, weight=ft.FontWeight.BOLD, expand=True), + ft.IconButton( + icon=ft.Icons.REFRESH, + icon_color=ft.Colors.PRIMARY, + tooltip=self._["refresh"], + on_click=lambda e: self.page.run_task(self.refresh_logs), + ), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + def create_tab_content(self, title: str, clear_tooltip: str, list_control, clear_handler): + return ft.Column( + expand=True, + spacing=8, + controls=[ + ft.Row( + controls=[ + ft.Text(title, size=14, weight=ft.FontWeight.BOLD, expand=True), + ft.Button( + clear_tooltip, + icon=ft.Icons.DELETE_SWEEP, + on_click=lambda e: self.page.run_task(clear_handler), + ), + ], + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + list_control, + ], + ) + + def create_history_tab_content(self): + return ft.Column( + expand=True, + spacing=8, + controls=[ + ft.Row( + controls=[ + ft.Text(self._["recording_history"], size=14, weight=ft.FontWeight.BOLD, expand=True), + ft.Button( + self._["clear_recording_history"], + icon=ft.Icons.DELETE_SWEEP, + on_click=lambda e: self.page.run_task(self.confirm_clear_recording_history), + ), + ], + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ), + self.history_list, + ], + ) + + async def refresh_logs(self, *_): + recording_lines = self.read_text_logs() + history_items = self.app.record_manager.get_recent_recording_history(limit=100) + history_events = self.build_history_events(history_items) + start_count = sum(1 for event in history_events if event["type"] == "start") + stop_count = sum(1 for event in history_events if event["type"] == "stop") + + self.fill_text_log_list(self.recording_log_list, recording_lines, self._["empty_recording_logs"]) + self.fill_history_list(history_events) + + logger.debug(f"Logs page refreshed: runtime={len(recording_lines)}, start={start_count}, stop={stop_count}") + + self.summary_text.value = ( + self._["summary"] + .replace("{recording_count}", str(len(recording_lines))) + .replace("{start_count}", str(start_count)) + .replace("{stop_count}", str(stop_count)) + ) + + try: + self.recording_log_list.update() + self.history_list.update() + self.summary_text.update() + except (ft.FletPageDisconnectedException, AssertionError): + pass + + def read_text_logs(self) -> list[str]: + log_path = os.path.join(self.app.run_path, "log", "streamget.log") + if not os.path.exists(log_path): + return [] + + try: + with open(log_path, encoding="utf-8", errors="replace") as file: + recent_lines = list(deque((line.strip() for line in file if line.strip()), maxlen=1200)) + except OSError: + return [] + + return self.filter_lines(recent_lines, self.RECORDING_KEYWORDS, limit=100) + + @staticmethod + def filter_lines(lines: list[str], keywords: tuple[str, ...], limit: int) -> list[str]: + lowered_keywords = tuple(keyword.lower() for keyword in keywords) + matched = [line for line in lines if any(keyword in line.lower() for keyword in lowered_keywords)] + return matched[-limit:][::-1] + + def fill_text_log_list(self, target_list, lines: list[str], empty_text: str): + target_list.controls.clear() + if not lines: + target_list.controls.append(self.create_empty_state(empty_text, ft.Icons.NOTES)) + return + + for line in lines: + target_list.controls.append(self.create_text_log_item(line)) + + @staticmethod + def build_history_events(history_items: list[dict]) -> list[dict]: + events = [] + started_keys = { + (item.get("rec_id"), item.get("started_at")) + for item in history_items + if item.get("status") == "started" and item.get("started_at") + } + for item in history_items: + status = item.get("status") + started_at = item.get("started_at") + stopped_at = item.get("ended_at") if status != "started" else None + started_key = (item.get("rec_id"), started_at) + if started_at and (status == "started" or started_key not in started_keys): + events.append({"type": "start", "time": started_at, "item": item}) + if stopped_at: + events.append({"type": "stop", "time": stopped_at, "item": item}) + return sorted(events, key=lambda event: event["time"] or "", reverse=True) + + def fill_history_list(self, history_events: list[dict]): + self.history_list.controls.clear() + if not history_events: + self.history_list.controls.append(self.create_empty_state(self._["empty_history"], ft.Icons.HISTORY)) + return + + for event in history_events: + self.history_list.controls.append(self.create_history_event_item(event)) + + def create_empty_state(self, text: str, icon): + return ft.Container( + padding=20, + content=ft.Row( + controls=[ + ft.Icon(icon, color=ft.Colors.GREY_500), + ft.Text(text, size=14, color=ft.Colors.GREY_600), + ], + alignment=ft.MainAxisAlignment.CENTER, + ), + ) + + def create_text_log_item(self, line: str): + level, color = self.get_log_level(line) + display_line, raw_message = self.format_log_line(line) + message_controls = [ft.Text(display_line, size=12, selectable=True, expand=True)] + if raw_message and raw_message != display_line: + message_controls.append(ft.Text(raw_message, size=11, color=ft.Colors.GREY_500, selectable=True)) + + return ft.Container( + padding=10, + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=6, + content=ft.Row( + controls=[ + ft.Container( + content=ft.Text( + self._.get(f"log_level_{level.lower()}", level), size=11, color=ft.Colors.WHITE + ), + bgcolor=color, + border_radius=5, + padding=ft.Padding.symmetric(horizontal=7, vertical=3), + ), + ft.Column( + expand=True, + tight=True, + spacing=4, + controls=message_controls, + ), + ], + vertical_alignment=ft.CrossAxisAlignment.START, + ), + ) + + def create_history_event_item(self, event: dict): + item = event["item"] + event_type = event["type"] + status = item.get("status") or self._["none"] + status_text = self._.get(f"history_status_{status}", status) + event_color = ft.Colors.GREEN if event_type == "start" else ft.Colors.RED + event_time = event.get("time") or self._["none"] + duration = self.format_duration(item.get("duration_seconds")) + streamer_name = item.get("streamer_name") or self._["unknown_room"] + platform = item.get("platform") or item.get("platform_key") or self._["none"] + file_path = item.get("file_path") or self._["none"] + file_name = os.path.basename(file_path) if file_path and file_path != self._["none"] else file_path + error_message = item.get("error_message") + live_title = item.get("live_title") + event_text = self._["history_start_recording"] if event_type == "start" else self._["history_stop_recording"] + event_icon = ft.Icons.PLAY_CIRCLE if event_type == "start" else ft.Icons.STOP_CIRCLE + + event_chip = ft.Container( + padding=ft.Padding.symmetric(horizontal=8, vertical=4), + border=ft.Border.all(1, event_color), + border_radius=6, + content=ft.Row( + tight=True, + spacing=5, + controls=[ + ft.Icon(event_icon, size=15, color=event_color), + ft.Text(event_text, size=12, color=event_color, weight=ft.FontWeight.BOLD), + ], + ), + ) + + title_row = ft.Row( + spacing=10, + controls=[ + event_chip, + ft.Text(streamer_name, size=14, weight=ft.FontWeight.BOLD, expand=True), + ft.Text(event_time, size=12, color=ft.Colors.GREY_600), + ], + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + meta_controls = [ + ft.Text(platform, size=12, color=ft.Colors.GREY_600), + ] + if event_type == "stop": + meta_controls.extend( + [ + ft.Text(f"{self._['duration']}: {duration}", size=12, color=ft.Colors.GREY_700), + ] + ) + details = [ + title_row, + ft.Row(wrap=True, spacing=12, run_spacing=4, controls=meta_controls), + ] + if event_type == "stop": + details.append(ft.Text(f"{self._['file']}: {file_name}", size=12, selectable=True)) + if live_title: + details.append(ft.Text(f"{self._['live_title']}: {live_title}", size=12, selectable=True)) + if error_message: + details.append(ft.Text(f"{self._['error']}: {error_message}", size=12, color=ft.Colors.RED)) + + return ft.Container( + height=96, + padding=12, + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=6, + content=ft.Column(tight=True, spacing=7, controls=details), + ) + + @staticmethod + def get_log_level(line: str): + if "ERROR" in line: + return "ERROR", ft.Colors.RED + if "WARNING" in line: + return "WARN", ft.Colors.AMBER + if "SUCCESS" in line: + return "OK", ft.Colors.GREEN + if "STREAM" in line: + return "STREAM", ft.Colors.BLUE + return "INFO", ft.Colors.GREY + + @staticmethod + def get_status_color(status: str): + status_map = { + "completed": ft.Colors.GREEN, + "stopped": ft.Colors.BLUE, + "error": ft.Colors.RED, + } + return status_map.get(status, ft.Colors.GREY) + + def format_duration(self, seconds): + if seconds is None: + return self._["none"] + seconds = int(seconds) + hours, remainder = divmod(seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return f"{minutes:02d}:{seconds:02d}" + + def format_log_line(self, line: str) -> tuple[str, str | None]: + parsed = self.parse_log_line(line) + if not parsed: + return self.translate_log_message(line), None + + timestamp, level, source, message = parsed + translated = self.translate_log_message(message) + display = f"{timestamp} · {translated}" + raw_message = None if translated == message else f"{self._['raw_log']}: {message}" + return display, raw_message + + @staticmethod + def parse_log_line(line: str) -> tuple[str, str, str, str] | None: + parts = line.split(" | ", 2) + if len(parts) != 3: + return None + timestamp, level, rest = parts + if " - " not in rest: + return None + source, message = rest.split(" - ", 1) + return timestamp.strip(), level.strip(), source.strip(), message.strip() + + def translate_log_message(self, message: str) -> str: + if self.get_language_code() != "zh_CN": + return self.translate_log_message_en(message) + + for pattern, zh_template, _ in self.TRANSLATION_PATTERNS: + match = pattern.search(message) + if match: + return zh_template.format(*match.groups()) + if self.has_cjk(message): + return message + return message + + def get_language_code(self) -> str: + settings = getattr(self.app, "settings", None) + return getattr(settings, "language_code", "zh_CN") or "zh_CN" + + def translate_log_message_en(self, message: str) -> str: + for pattern, _, en_template in self.TRANSLATION_PATTERNS: + match = pattern.search(message) + if match: + return en_template.format(*match.groups()) + return message + + @staticmethod + def has_cjk(text: str) -> bool: + return any("\u4e00" <= char <= "\u9fff" for char in text) + + async def confirm_clear_recording_logs(self, *_): + await self.show_confirm_dialog( + self._["clear_recording_logs"], + self._["clear_recording_logs_confirm"], + lambda: self.clear_text_logs(self.RECORDING_KEYWORDS, self._["clear_recording_logs_success"]), + ) + + async def confirm_clear_recording_history(self, *_): + await self.show_confirm_dialog( + self._["clear_recording_history"], + self._["clear_recording_history_confirm"], + self.clear_recording_history, + ) + + async def show_confirm_dialog(self, title: str, message: str, confirm_action): + async def close_dialog(_=None): + try: + dialog.open = False + dialog.update() + except (ft.FletPageDisconnectedException, AssertionError) as e: + logger.debug(f"Close logs confirm dialog failed: {e}") + + await asyncio.sleep(0.05) + try: + if self.app.dialog_area.content is dialog: + self.app.dialog_area.content = None + self.app.page.update() + except (ft.FletPageDisconnectedException, AssertionError) as e: + logger.debug(f"Clear logs confirm dialog failed: {e}") + + async def confirm_dlg(_): + await close_dialog() + await confirm_action() + + dialog = ft.AlertDialog( + title=ft.Text(title), + content=ft.Text(message), + actions=[ + ft.TextButton(self._["cancel"], on_click=close_dialog), + ft.TextButton(self._["sure"], on_click=confirm_dlg), + ], + modal=True, + ) + self.app.dialog_area.content = dialog + dialog.open = True + self.app.page.update() + + async def clear_text_logs(self, keywords: tuple[str, ...], success_template: str): + removed_count = await asyncio.to_thread(self.remove_text_log_lines, keywords) + await self.app.snack_bar.show_snack_bar(success_template.replace("{count}", str(removed_count))) + await self.refresh_logs() + + def remove_text_log_lines(self, keywords: tuple[str, ...]) -> int: + log_path = os.path.join(self.app.run_path, "log", "streamget.log") + if not os.path.exists(log_path): + return 0 + + lowered_keywords = tuple(keyword.lower() for keyword in keywords) + try: + with open(log_path, encoding="utf-8", errors="replace") as file: + lines = file.readlines() + kept_lines = [line for line in lines if not any(keyword in line.lower() for keyword in lowered_keywords)] + removed_count = len(lines) - len(kept_lines) + with open(log_path, "w", encoding="utf-8", errors="replace") as file: + file.writelines(kept_lines) + return removed_count + except OSError as e: + logger.error(f"Clear text logs failed: {e}") + return 0 + + async def clear_recording_history(self): + removed_count = await self.app.record_manager.clear_recording_history() + await self.app.snack_bar.show_snack_bar( + self._["clear_recording_history_success"].replace("{count}", str(removed_count)) + ) + await self.refresh_logs() diff --git a/app/ui/views/recordings_view.py b/app/ui/views/recordings_view.py index 70de6d66..93a4435e 100644 --- a/app/ui/views/recordings_view.py +++ b/app/ui/views/recordings_view.py @@ -1,748 +1,752 @@ -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"], - ) - 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.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) +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.app.record_card_manager.reorder_recording_cards() + 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"], + ) + 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.app.record_card_manager.reorder_recording_cards() + 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.content.controls.remove(card["card"]) + await self.show_all_cards() + self.app.record_card_manager.reorder_recording_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.app.record_card_manager.reorder_recording_cards() + 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/storage_view.py b/app/ui/views/storage_view.py index 7278ad55..af163c66 100644 --- a/app/ui/views/storage_view.py +++ b/app/ui/views/storage_view.py @@ -1,5 +1,7 @@ import asyncio import os +import shutil +import subprocess from concurrent.futures import ThreadPoolExecutor import flet as ft @@ -11,6 +13,7 @@ dotenv_path = find_dotenv() load_dotenv(dotenv_path) VIDEO_API_EXTERNAL_URL = os.getenv("VIDEO_API_EXTERNAL_URL") +MEDIA_EXTENSIONS = {".mp4", ".ts", ".flv", ".mkv", ".mov", ".avi", ".webm"} class StoragePage(BasePage): @@ -22,6 +25,10 @@ def __init__(self, app): self.path_display = None self.content = None self.file_list = None + self.summary_area = None + self.maintenance_area = None + self.recent_area = None + self.storage_stats = {} self._ = {} self.executor = ThreadPoolExecutor(max_workers=4) self.load_language() @@ -31,17 +38,94 @@ async def load(self): self.root_path = self.app.settings.get_video_save_path() self.current_path = self.root_path self.setup_ui() - await self.update_file_list() + await self.refresh_storage() def setup_ui(self): + title_row = ft.Row( + controls=[ + ft.Text(self._["storage_manage"], size=20, weight=ft.FontWeight.BOLD, expand=True), + ft.Button( + self._["refresh_storage"], + icon=ft.Icons.REFRESH, + on_click=lambda _: self.app.page.run_task(self.refresh_storage), + ), + ft.Button( + self._["open_storage_folder"], + icon=ft.Icons.FOLDER_OPEN, + on_click=lambda _: self.app.page.run_task(self.open_storage_folder), + ), + ], + spacing=8, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) self.path_display = ft.Text( self._["storage_path"] + ": " + self.current_path, size=14, color=ft.Colors.GREY_600, selectable=True, ) - self.file_list = ft.ListView(expand=True, spacing=2, padding=10) - self.content = ft.Column(controls=[self.path_display, self.file_list]) + self.summary_area = ft.Row(wrap=True, spacing=8, run_spacing=8) + self.maintenance_area = ft.Column(spacing=8) + self.recent_area = ft.ListView(expand=True, spacing=6, padding=0) + self.file_list = ft.ListView(expand=True, spacing=2, padding=8) + file_panel_height = 430 + + header_panel = ft.Container( + padding=12, + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=8, + content=ft.Column( + tight=True, + spacing=8, + controls=[ + title_row, + self.path_display, + ], + ), + ) + file_panel = self.create_panel( + self._["file_browser"], + self.file_list, + icon=ft.Icons.FOLDER_OPEN, + height=file_panel_height, + ) + overview_panel = self.create_panel( + self._["storage_overview"], + self.summary_area, + ) + tools_panel = self.create_panel(self._["clean_tools"], self.maintenance_area, icon=ft.Icons.DELETE_SWEEP) + recent_panel = self.create_panel( + self._["recent_recording_files"], + self.recent_area, + icon=ft.Icons.VIDEO_FILE, + height=file_panel_height, + ) + if getattr(self.app, "is_mobile", False): + file_area = ft.Column( + spacing=12, + controls=[file_panel, recent_panel], + ) + else: + file_area = ft.Row( + spacing=12, + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[ + ft.Container(content=file_panel, expand=3), + ft.Container(content=recent_panel, expand=2), + ], + ) + + self.content = ft.ListView( + expand=True, + spacing=12, + padding=10, + controls=[ + header_panel, + overview_panel, + tools_panel, + file_area, + ], + ) self.app.content_area.controls = [self.content] self.app.content_area.update() @@ -50,6 +134,368 @@ def load_language(self): for key in ("storage_page", "base"): self._.update(language.get(key, {})) + async def refresh_storage(self, *_): + await self.update_storage_summary() + await self.update_file_list() + + async def update_storage_summary(self): + self.storage_stats = await self.scan_storage() + self.summary_area.controls.clear() + self.maintenance_area.controls.clear() + self.recent_area.controls.clear() + + self.summary_area.controls.extend( + [ + self.create_metric_group( + self._["storage_space"], + [ + (self._["recording_size"], self.format_size(self.storage_stats["recording_size"])), + (self._["disk_free"], self.format_size(self.storage_stats["disk_free"])), + ], + ), + self.create_metric_group( + self._["recording_files"], + [ + (self._["video_files"], str(self.storage_stats["video_files"])), + (self._["original_size"], self.format_size(self.storage_stats["original_size"])), + ], + ), + self.create_metric_group( + self._["app_cleanup"], + [ + (self._["logs_size"], self.format_size(self.storage_stats["logs_size"])), + (self._["database_size"], self.format_size(self.storage_stats["database_size"])), + (self._["empty_dirs"], str(self.storage_stats["empty_dirs"])), + ], + ), + ] + ) + + self.update_recent_recordings() + + self.maintenance_area.controls.append( + ft.Row( + wrap=True, + spacing=8, + run_spacing=8, + controls=[ + ft.Button( + self._["delete_all_files"], + icon=ft.Icons.DELETE_FOREVER, + width=150, + on_click=lambda _: self.app.page.run_task(self.confirm_delete_all_files), + ), + ft.Button( + self._["clean_empty_dirs"], + icon=ft.Icons.FOLDER_DELETE, + width=150, + on_click=lambda _: self.app.page.run_task(self.confirm_cleanup_empty_dirs), + ), + ft.Button( + self._["clean_logs"], + icon=ft.Icons.DELETE_SWEEP, + width=150, + on_click=lambda _: self.app.page.run_task(self.confirm_cleanup_logs), + ), + ], + ) + ) + self.maintenance_area.controls.append( + ft.Text( + self._["maintenance_tip"] + .replace("{original_size}", self.format_size(self.storage_stats["original_size"])) + .replace("{empty_dirs}", str(self.storage_stats["empty_dirs"])) + .replace("{logs_size}", self.format_size(self.storage_stats["logs_size"])), + size=12, + color=ft.Colors.GREY_600, + ) + ) + + try: + self.summary_area.update() + self.maintenance_area.update() + self.recent_area.update() + except (ft.FletPageDisconnectedException, AssertionError): + pass + + async def scan_storage(self): + return await asyncio.get_event_loop().run_in_executor(self.executor, self.scan_storage_sync) + + def scan_storage_sync(self): + stats = { + "recording_size": 0, + "disk_free": 0, + "video_files": 0, + "original_size": 0, + "original_files": 0, + "empty_dirs": 0, + "logs_size": self.get_directory_size(os.path.join(self.app.run_path, "log")), + "database_size": self.get_directory_size(os.path.join(self.app.run_path, "data", "database")), + } + + root_path = self.root_path or "" + disk_target = root_path if os.path.exists(root_path) else os.path.dirname(root_path) or self.app.run_path + try: + stats["disk_free"] = shutil.disk_usage(disk_target).free + except OSError: + stats["disk_free"] = 0 + + if not os.path.exists(root_path): + return stats + + stats["empty_dirs"] = len(self.get_removable_empty_dirs(root_path)) + + for file_path in self.iter_files(root_path): + if not self.is_media_file(file_path): + continue + size = self.get_file_size(file_path) + stats["recording_size"] += size + stats["video_files"] += 1 + + if self.is_original_file(file_path, root_path): + stats["original_files"] += 1 + stats["original_size"] += size + + return stats + + @staticmethod + def iter_files(root_path: str): + for dir_path, _, file_names in os.walk(root_path): + for file_name in file_names: + yield os.path.join(dir_path, file_name) + + @staticmethod + def is_media_file(file_path: str) -> bool: + return os.path.splitext(file_path)[1].lower() in MEDIA_EXTENSIONS + + def iter_media_files(self, root_path: str | None = None): + root_path = root_path or self.root_path + if not root_path or not os.path.exists(root_path): + return + for file_path in self.iter_files(root_path): + if self.is_media_file(file_path): + yield file_path + + @staticmethod + def get_file_size(file_path: str) -> int: + try: + return os.path.getsize(file_path) + except OSError: + return 0 + + def get_directory_size(self, dir_path: str) -> int: + if not os.path.exists(dir_path): + return 0 + return sum(self.get_file_size(file_path) for file_path in self.iter_files(dir_path)) + + @staticmethod + def get_removable_empty_dirs(root_path: str) -> list[str]: + root_path = os.path.realpath(root_path) + if not os.path.exists(root_path): + return [] + + removable_dirs = [] + removable_set = set() + for dir_path, dir_names, file_names in os.walk(root_path, topdown=False): + real_dir_path = os.path.realpath(dir_path) + if real_dir_path == root_path or file_names: + continue + try: + if os.path.commonpath([root_path, real_dir_path]) != root_path: + continue + except ValueError: + continue + + child_paths = [os.path.realpath(os.path.join(dir_path, name)) for name in dir_names] + if all(child_path in removable_set for child_path in child_paths): + removable_dirs.append(real_dir_path) + removable_set.add(real_dir_path) + return removable_dirs + + @staticmethod + def is_original_file(file_path: str, root_path: str) -> bool: + try: + relative_path = os.path.relpath(file_path, root_path) + except ValueError: + return False + path_parts = [part.lower() for part in relative_path.split(os.sep)] + return "original" in path_parts + + def get_recent_history_items(self, limit: int = 5) -> list[dict]: + record_manager = getattr(self.app, "record_manager", None) + if not record_manager: + return [] + try: + recent_items = [] + seen_paths = set() + history_items = record_manager.get_recent_recording_history(limit=max(limit * 4, 20)) + root_path = os.path.realpath(self.root_path) if self.root_path else "" + for item in history_items: + real_file_path = self.resolve_recent_recording_file(item, root_path) + if not real_file_path: + continue + if real_file_path in seen_paths: + continue + seen_paths.add(real_file_path) + recent_item = dict(item) + recent_item["file_path"] = real_file_path + recent_items.append(recent_item) + if len(recent_items) >= limit: + break + return recent_items + except Exception as e: + logger.error(f"Load recent recording history failed: {e}") + return [] + + def resolve_recent_recording_file(self, item: dict, root_path: str) -> str | None: + file_path = item.get("file_path") + real_file_path = os.path.realpath(file_path) if file_path else "" + if self.is_usable_media_file(real_file_path, root_path): + return real_file_path + + output_dir = item.get("output_dir") or (os.path.dirname(file_path) if file_path else "") + return self.find_latest_media_file(output_dir, root_path) + + def find_latest_media_file(self, dir_path: str | None, root_path: str) -> str | None: + if not dir_path: + return None + real_dir_path = os.path.realpath(dir_path) + if not os.path.isdir(real_dir_path) or not self.is_path_inside_root(real_dir_path, root_path): + return None + + media_files = [] + for file_path in self.iter_files(real_dir_path): + real_file_path = os.path.realpath(file_path) + if self.is_usable_media_file(real_file_path, root_path): + media_files.append(real_file_path) + if not media_files: + return None + media_files.sort(key=lambda path: os.path.getmtime(path), reverse=True) + return media_files[0] + + def is_usable_media_file(self, file_path: str, root_path: str) -> bool: + return ( + bool(file_path) + and os.path.isfile(file_path) + and self.is_media_file(file_path) + and self.is_path_inside_root(file_path, root_path) + ) + + @staticmethod + def is_path_inside_root(path: str, root_path: str) -> bool: + if not root_path: + return True + try: + return os.path.commonpath([root_path, os.path.realpath(path)]) == root_path + except ValueError: + return False + + def update_recent_recordings(self): + recent_items = self.get_recent_history_items(limit=5) + if not recent_items: + self.recent_area.controls.append(ft.Text(self._["empty_recent_files"], size=12, color=ft.Colors.GREY_600)) + return + + for item in recent_items: + file_path = item.get("file_path") or "" + title = item.get("streamer_name") or os.path.basename(file_path) or self._["unknown_extension"] + ended_at = item.get("ended_at") or item.get("created_at") or "" + subtitle = ( + self._["recent_file_item"] + .replace("{time}", ended_at) + .replace("{size}", self.format_size(self.get_file_size(file_path))) + ) + self.recent_area.controls.append( + ft.ListTile( + dense=True, + leading=ft.Icon(ft.Icons.VIDEO_FILE, color=ft.Colors.BLUE), + title=ft.Text(title, size=13), + subtitle=ft.Text(subtitle, size=11), + trailing=ft.Row( + tight=True, + spacing=2, + controls=[ + ft.IconButton( + icon=ft.Icons.PLAY_ARROW, + tooltip=self._["previewing"], + on_click=lambda _, path=file_path: self.app.page.run_task(self.preview_file, path), + ), + ft.IconButton( + icon=ft.Icons.FOLDER_OPEN, + tooltip=self._["open_storage_folder"], + on_click=lambda _, path=file_path: self.app.page.run_task( + self.open_local_folder, os.path.dirname(path) + ), + ), + ], + ), + ) + ) + + @staticmethod + def create_panel(title: str, content, icon=None, height: int | None = None): + title_controls = [] + if icon: + title_controls.append(ft.Icon(icon, size=18, color=ft.Colors.BLUE)) + title_controls.append(ft.Text(title, size=15, weight=ft.FontWeight.BOLD)) + + return ft.Container( + height=height, + padding=12, + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=8, + content=ft.Column( + expand=bool(height), + spacing=10, + controls=[ + ft.Row( + tight=True, + spacing=6, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=title_controls, + ), + content, + ], + ), + ) + + def create_metric_group(self, label: str, rows: list[tuple[str, str]]): + return ft.Container( + width=250, + height=112, + padding=10, + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=6, + content=ft.Column( + tight=True, + spacing=8, + controls=[ + ft.Text(label, size=13, weight=ft.FontWeight.BOLD), + ft.Column( + tight=True, + spacing=4, + controls=[ + ft.Row( + tight=True, + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[ + ft.Text(row_label, size=12, color=ft.Colors.GREY_600, expand=True), + ft.Text( + row_value, size=12, weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.RIGHT + ), + ], + ) + for row_label, row_value in rows + ], + ), + ], + ), + ) + + @staticmethod + def create_section_title(title: str): + return ft.Text(title, size=15, weight=ft.FontWeight.BOLD) + async def update_file_list(self): try: self.path_display.value = self._["current_path"] + ":" + self.current_path @@ -168,11 +614,221 @@ async def navigate_to_parent(self): await self.update_file_list() self.content.update() + async def open_storage_folder(self, *_): + await self.open_local_folder(self.root_path) + + async def open_local_folder(self, folder_path): + if not os.path.exists(folder_path): + os.makedirs(folder_path, exist_ok=True) + if not os.path.exists(folder_path): + await self.app.snack_bar.show_snack_bar(self._["no_recording_folder"]) + return + + if self.app.page.web: + await self.app.snack_bar.show_snack_bar(self._["web_open_folder_tip"]) + return + + try: + if os.name == "nt": + os.startfile(folder_path) + elif os.uname().sysname == "Darwin": + subprocess.Popen(["open", folder_path]) + else: + subprocess.Popen(["xdg-open", folder_path]) + except Exception as e: + logger.error(f"Open local folder failed: {folder_path}, {e}") + await self.app.snack_bar.show_snack_bar(self._["open_folder_failed"]) + + async def confirm_cleanup_empty_dirs(self, *_): + stats = self.storage_stats or await self.scan_storage() + empty_dirs = int(stats.get("empty_dirs") or 0) + if not empty_dirs: + await self.app.snack_bar.show_snack_bar(self._["no_empty_dirs"]) + return + + await self.show_confirm_dialog( + self._["clean_empty_dirs"], + self._["clean_empty_dirs_confirm"].replace("{count}", str(empty_dirs)), + self.cleanup_empty_dirs, + ) + + async def confirm_cleanup_logs(self, *_): + stats = self.storage_stats or await self.scan_storage() + logs_size = int(stats.get("logs_size") or 0) + if not logs_size: + await self.app.snack_bar.show_snack_bar(self._["no_logs_to_clean"]) + return + + await self.show_confirm_dialog( + self._["clean_logs"], + self._["clean_logs_confirm"].replace("{size}", self.format_size(logs_size)), + self.cleanup_logs, + ) + + async def confirm_delete_all_files(self, *_): + files = list(self.iter_media_files()) + if not files: + await self.app.snack_bar.show_snack_bar(self._["no_files_to_delete"]) + return + + total_size = sum(self.get_file_size(path) for path in files) + + await self.show_confirm_dialog( + self._["delete_all_files"], + self._["delete_all_files_confirm"] + .replace("{count}", str(len(files))) + .replace("{size}", self.format_size(total_size)), + lambda: self.cleanup_selected_files(files, self._["delete_all_files_success"]), + ) + + async def show_confirm_dialog(self, title: str, message: str, confirm_action): + async def close_dialog(_=None): + try: + dialog.open = False + dialog.update() + except (ft.FletPageDisconnectedException, AssertionError) as e: + logger.debug(f"Close storage confirm dialog failed: {e}") + + await asyncio.sleep(0.05) + try: + if self.app.dialog_area.content is dialog: + self.app.dialog_area.content = None + self.app.page.update() + except (ft.FletPageDisconnectedException, AssertionError) as e: + logger.debug(f"Clear storage confirm dialog failed: {e}") + + async def confirm_dlg(_): + await close_dialog() + await confirm_action() + + dialog = ft.AlertDialog( + title=ft.Text(title), + content=ft.Text(message), + actions=[ + ft.TextButton(self._["cancel"], on_click=close_dialog), + ft.TextButton(self._["sure"], on_click=confirm_dlg), + ], + modal=True, + ) + self.app.dialog_area.content = dialog + dialog.open = True + self.app.page.update() + + async def cleanup_selected_files(self, files: list[str], success_template: str): + deleted_count, deleted_size = await asyncio.get_event_loop().run_in_executor( + self.executor, self.cleanup_selected_files_sync, files + ) + if deleted_count: + await self.app.snack_bar.show_snack_bar( + success_template.replace("{count}", str(deleted_count)).replace( + "{size}", self.format_size(deleted_size) + ) + ) + else: + await self.app.snack_bar.show_snack_bar(self._["no_files_cleaned"]) + if not os.path.exists(self.current_path): + self.current_path = self.root_path + await self.refresh_storage() + + def cleanup_selected_files_sync(self, files: list[str]): + root_path = os.path.realpath(self.root_path) + deleted_count = 0 + deleted_size = 0 + for file_path in files: + real_file_path = os.path.realpath(file_path) + try: + if os.path.commonpath([root_path, real_file_path]) != root_path: + continue + except ValueError: + continue + size = self.get_file_size(real_file_path) + try: + os.remove(real_file_path) + deleted_count += 1 + deleted_size += size + except OSError as e: + logger.warning(f"Skip deleting file: {real_file_path}, {e}") + logger.info(f"Cleaned selected files: count={deleted_count}, size={deleted_size}") + return deleted_count, deleted_size + + async def cleanup_empty_dirs(self): + deleted_count = await asyncio.get_event_loop().run_in_executor(self.executor, self.cleanup_empty_dirs_sync) + if deleted_count: + await self.app.snack_bar.show_snack_bar( + self._["clean_empty_dirs_success"].replace("{count}", str(deleted_count)) + ) + else: + await self.app.snack_bar.show_snack_bar(self._["no_empty_dirs"]) + + if not os.path.exists(self.current_path): + self.current_path = self.root_path + await self.refresh_storage() + + def cleanup_empty_dirs_sync(self): + root_path = os.path.realpath(self.root_path) + if not os.path.exists(root_path): + return 0 + + deleted_count = 0 + for real_dir_path in self.get_removable_empty_dirs(root_path): + try: + os.rmdir(real_dir_path) + deleted_count += 1 + except OSError: + pass + + logger.info(f"Cleaned empty storage dirs: count={deleted_count}") + return deleted_count + + async def cleanup_logs(self): + deleted_count, deleted_size = await asyncio.get_event_loop().run_in_executor( + self.executor, self.cleanup_logs_sync + ) + if deleted_count: + await self.app.snack_bar.show_snack_bar( + self._["clean_logs_success"] + .replace("{count}", str(deleted_count)) + .replace("{size}", self.format_size(deleted_size)) + ) + else: + await self.app.snack_bar.show_snack_bar(self._["no_logs_to_clean"]) + await self.refresh_storage() + + def cleanup_logs_sync(self): + logs_dir = os.path.realpath(os.path.join(self.app.run_path, "log")) + if not os.path.exists(logs_dir): + return 0, 0 + + deleted_count = 0 + deleted_size = 0 + for file_path in list(self.iter_files(logs_dir)): + real_file_path = os.path.realpath(file_path) + try: + if os.path.commonpath([logs_dir, real_file_path]) != logs_dir: + continue + except ValueError: + continue + size = self.get_file_size(real_file_path) + try: + os.remove(real_file_path) + deleted_count += 1 + deleted_size += size + except OSError as e: + logger.warning(f"Skip deleting active log file: {real_file_path}, {e}") + + logger.info(f"Cleaned logs: count={deleted_count}, size={deleted_size}") + return deleted_count, deleted_size + async def preview_file(self, file_path, room_url=None): import urllib.parse from ..components.business.video_player import VideoPlayer + if not os.path.isfile(file_path): + await self.app.snack_bar.show_snack_bar(self._["recording_file_missing"]) + await self.refresh_storage() + return + video_player = VideoPlayer(self.app) if self.app.page.web: @@ -188,3 +844,15 @@ async def preview_file(self, file_path, room_url=None): await video_player.preview_video(api_url, is_file_path=False, room_url=room_url) else: await video_player.preview_video(file_path, is_file_path=True, room_url=room_url) + + @staticmethod + def format_size(size: int | float | None) -> str: + size = float(size or 0) + units = ["B", "KB", "MB", "GB", "TB"] + unit_index = 0 + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + return f"{size:.2f} {units[unit_index]}" diff --git a/app/utils/logger.py b/app/utils/logger.py index fd2e2490..7428fd7e 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -4,9 +4,11 @@ from loguru import logger script_path = os.path.split(os.path.realpath(sys.argv[0]))[0] +log_path = os.path.join(script_path, "log") +os.makedirs(log_path, exist_ok=True) logger.add( - f"{script_path}/logs/streamget.log", + os.path.join(log_path, "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", @@ -19,7 +21,7 @@ logger.level("STREAM", no=22, color="") logger.add( - f"{script_path}/logs/play_url.log", + os.path.join(log_path, "play_url.log"), level="STREAM", format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {message}", filter=lambda i: i["level"].name == "STREAM", diff --git a/changlog.md b/changlog.md new file mode 100644 index 00000000..f149f61f --- /dev/null +++ b/changlog.md @@ -0,0 +1,52 @@ +# StreamCap 本地变更记录 + +## 2026-06-24 + +### 相对原仓库的本地改动 + +- 将 `pyproject.toml`、`requirements.txt`、`requirements-web.txt` 中的 `streamget` 依赖改为指向最新的 `ihmily/streamget` GitHub 仓库。 +- 修复 Web 模式界面加载时的资源噪声,允许 Flet 继续使用 CDN 字体回退,避免反复请求缺失的本地 `assets/fonts/notosanssc/...` 资源。 +- 在 Web 模式下过滤预期内的客户端断开连接堆栈,避免浏览器刷新或关闭标签页时刷屏后端日志。 +- 仅为录制房间加入 SQLite 持久化;通用应用设置、Cookie、账号配置、语言配置和 Web 登录认证仍保留 JSON 文件。 +- 新增 `data/database/recordings.db`,包含录制房间、录制历史和数据库元信息表。首次加载且数据库为空时,会从旧的 `config/recordings.json` 迁移房间列表。 +- 为录制房间新增 `created_at`、`updated_at`、`pinned_at`、`pin_order`、`last_recorded_at`、`last_record_file`、`last_live_title` 等元数据。 +- 在 FFmpeg 或直连下载录制完成、停止、失败时写入录制历史。 +- 在录制卡片上新增置顶/取消置顶操作,并在录制信息弹窗中显示加入时间、最近录制时间和最近录制文件。 +- 在侧边栏新增“日志”页面,用于查看 SQLite 中的最近录制历史。 +- 优化置顶功能:增加可见的置顶标签、高亮置顶卡片,并保证刷新、新增和跨会话更新后的卡片排序稳定。 +- 修复置顶中的边界问题,避免正在录制的卡片因置顶后的即时重排或相邻按钮误触而意外停止录制。 +- 调整录制卡片操作:录制信息改为双击卡片打开,同时保留停止监控按钮,并让置顶按钮样式与其他卡片操作保持一致。 +- 将日志页面细分为运行日志、操作日志和结构化录制历史,避免把录制历史重复当作普通日志展示。 +- 修复日志/历史记录不可见的问题:恢复 Flet `run`/`app` 启动兼容层,放宽操作日志识别范围,增加日志页刷新诊断,并在 FFmpeg 关闭时即使没有 stderr 输出也记录停止历史。 +- 在 `README.md` 中补充 Windows exe 打包、动态 Web 运行和 Flet 静态 Web 构建说明,并标注完整录制服务对 Python 后端、FFmpeg、配置目录、日志和 SQLite 数据库的依赖。 +- 完成 Windows exe 打包测试:`flet pack` 可生成桌面端 onedir 产物,补充基础 `config` 文件复制步骤,并明确 Web 服务应使用 `python main.py --web` 或 Docker 动态运行。 +- 将本地打包测试产物目录 `dist-pack-test/` 加入忽略,避免测试包进入版本对比。 +- 调整置顶按钮图标:未置顶时显示空心图钉,已置顶时显示实心图钉,保持与卡片操作栏的 Material 单图标风格一致。 +- 修复日志页在 Flet 0.85.3 下打开失败的问题,将不兼容的 `ft.padding.symmetric` 改为 `ft.Padding.symmetric`。 +- 扩展并简化存储页:保留原目录文件浏览,新增存储概览、文件类型统计、`original/` 原始文件统计和最近录制文件;最近录制文件统一使用视频图标;清理工具提前到目录文件前方,并仅保留删除全部文件、清理空目录、清理日志;移除文件校验和备份导出入口。 +- 存储概览移除零字节文件指标,减少不必要的信息展示。 +- 重新设计存储页布局为单列管理视图:顶部集中显示路径和操作按钮,存储概览优先展示,清理工具独立成行,目录文件恢复为完整主区域,最近录制文件放在目录下方,避免右侧窄栏导致按钮和列表挤压。 +- 调整存储页信息密度:合并存储概览指标,取消文件类型统计展示,并将目录文件与最近录制文件改为两栏并列显示。 +- 删除存储页已废弃的文件类型统计本地化文案键,减少无用配置残留。 +- 修复存储页两栏布局:目录文件与最近录制文件使用一致高度,桌面端按比例并列显示,移动端上下排列,避免 `wrap` 与 `expand` 混用导致目录区域不显示。 +- 统一存储概览中合并指标卡片的高度,避免不同指标行数导致卡片高度不一致。 +- 调整存储清理文案,将“删除全部文件”改为“删除录制文件”,并同步确认弹窗说明其只清理媒体录制文件。 +- 完善日志页:运行/操作日志继续使用自动轮转文件日志,录制历史继续使用 SQLite;日志展示增加中文化解析,运行日志、操作日志和录制历史分别增加清空功能。 +- 简化日志页分类:移除操作日志页签,仅保留运行日志和录制历史;录制历史按开始录制与停止录制事件区分展示。 +- 调整录制历史展示为单栏事件列表,开始录制和停止录制按时间混排展示,并用绿色/红色边框区分事件类型。 +- 修复录制历史记录时机:录制开始时立即写入开始录制事件,录制停止/完成时写入停止事件,并兼容旧的完成记录拆分显示。 +- 调整录制历史事件卡片样式:统一灰色边框,开始/停止录制通过绿色/红色图标和文字区分,并固定事件卡片高度以保持列表整齐。 +- 优化录制历史开始事件卡片,移除“录制时间:无”“文件地址:无”等无意义占位信息。 +- 重新排列录制历史事件卡片:使用图标标签、主播与时间同一行、平台/时长/文件分层展示,减少空白并保持卡片高度整齐。 +- 修复存储页和日志页确认弹窗关闭逻辑,确认或取消后先推送 `open=False`,再清空弹窗承载区域,避免遮罩层残留导致界面无法继续操作。 +- 将“删除录制文件”由二次确认改为一次确认,保留文件数量和预计释放空间提示,避免嵌套确认弹窗在 Flet 中残留遮罩。 +- 修复存储页“最近录制文件”仍显示已删除文件的问题:列表只展示当前仍存在的媒体文件,并在播放入口增加文件存在性检查。 +- 对齐录制界面的播放策略:存储页“最近录制文件”在历史文件路径失效时,会回退扫描录制输出目录并选择最新媒体文件用于展示和播放。 +- 修复 SQLite 仓库连接释放问题,确保每次数据库操作结束后关闭连接,避免 Windows 下数据库文件被占用。 +- 调整运行时数据目录:日志文件写入 `log/`,录制数据库写入 `data/database/`,并兼容迁移旧的 `config/recordings.db`。 +- 修复 GitHub Actions 构建流程:移除重复的自动构建 workflow,macOS 打包改用 `.icns` 图标,并使用 `github.token` 更新 beta 标签。 +- 继续修正 GitHub Actions macOS 构建:将 `macos-latest` 固定为 `macos-15`,避免 GitHub runner 迁移到 macOS 26 后影响 PyInstaller/Flet 桌面打包,并增加 macOS 版本、PyInstaller 版本和图标文件诊断输出。 +- 将 GitHub Actions macOS 桌面打包切换为 `flet pack`,通过 Flet 官方 PyInstaller 包装层生成 `.app`,减少手写 PyInstaller 参数与 Flet 运行时资源不匹配导致的构建失败。 +- 升级 GitHub Actions 官方 action 版本,使用 Node.js 24 兼容的 `checkout@v7`、`setup-python@v6`、`upload-artifact@v7`、`download-artifact@v8` 和 `cache@v6`,消除构建日志中的 Node.js 20 弃用警告。 +- 在 GitHub Actions 构建中增加产物冒烟验证:Windows 检查入口程序可执行,macOS 校验 `.app` 结构、`Info.plist`、图标和 Mach-O 依赖,Linux Web 包改为真实启动并通过 HTTP 探测,避免构建成功但产物不可用。 +- 移除 `richardsimko/update-tag@v1` 第三方 action,改用原生 `git tag -f beta` 和 `git push --force` 更新 beta 标签,消除 prerelease 阶段剩余的 Node.js 20 弃用警告。 diff --git a/locales/en.json b/locales/en.json index 4a2ff99a..f8366b66 100644 --- a/locales/en.json +++ b/locales/en.json @@ -4,6 +4,7 @@ "recordings": "Recordings", "settings": "Settings", "storage": "Storage", + "logs": "Logs", "about": "About", "light_theme": "Light theme", "dark_theme": "Dark theme", @@ -111,6 +112,41 @@ "search_dialog": { "search_keyword": "Enter search keyword" }, + "logs_page": { + "title": "Logs", + "refresh": "Refresh logs", + "summary": "Runtime logs {recording_count} · Started {start_count} · Stopped {stop_count}", + "recording_logs": "Runtime Logs", + "recording_history": "Recording History", + "clear_recording_logs": "Clear Runtime Logs", + "clear_recording_history": "Clear History", + "clear_recording_logs_confirm": "Clear runtime log entries from the current log file?", + "clear_recording_history_confirm": "Clear recording history from SQLite and reset recent recording info on live rooms?", + "clear_recording_logs_success": "Cleared {count} runtime log entries", + "clear_recording_history_success": "Cleared {count} history records", + "history_start_recording": "Start Recording", + "history_stop_recording": "Stop Recording", + "empty_history": "No recording history yet", + "time": "Time", + "raw_log": "Raw log", + "log_level_error": "Error", + "log_level_warn": "Warn", + "log_level_ok": "OK", + "log_level_stream": "Record", + "log_level_info": "Info", + "empty_recording_logs": "No runtime logs yet", + "unknown_room": "Unknown live room", + "started_at": "Started At", + "ended_at": "Ended At", + "duration": "Duration", + "file": "Recording File", + "live_title": "Live Title", + "error": "Error", + "history_status_started": "Started", + "history_status_completed": "Completed", + "history_status_stopped": "Stopped", + "history_status_error": "Recording Error" + }, "recording_manager": { "monitor_stopped": "Stopped monitoring", "is_live": "Live streaming in progress", @@ -149,8 +185,13 @@ "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", + "pin_record_success_tip": "Tip: Live room pinned", + "unpin_record_success_tip": "Tip: Live room unpinned", "edit_record_config": "Edit Recording Configuration", "delete_monitor": "Delete Monitoring", + "pin_record": "Pin live room", + "unpin_record": "Unpin live room", + "pinned": "Pinned", "open_folder": "Open Folder", "recording_info": "Recording Information", "anchor_name": "Anchor Name", @@ -174,10 +215,14 @@ "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", + "recording_card_tip": "Click to select, double-click to view recording info", "use_proxy": "Use Proxy", "no_support_open_dir": "Web version does not support opening folder ⚠️", "message_push": "Message Push", + "joined_at": "Joined At", + "last_recorded_at": "Last Recorded At", + "last_record_file": "Last Recording File", + "never_recorded": "No recording history", "all": "All", "recording": "Recording", "recording_error": "Recording Error", @@ -438,14 +483,52 @@ "dont_show_again": "Don't Show Again" }, "storage_page": { + "storage_manage": "Storage Management", "storage_path": "Storage Path", "current_path": "Current Path", + "file_browser": "Files", + "storage_overview": "Storage Overview", + "clean_tools": "Cleanup Tools", + "refresh_storage": "Refresh", + "open_storage_folder": "Open Folder", + "clean_empty_dirs": "Clean Empty Folders", + "clean_logs": "Clean Logs", + "delete_all_files": "Delete Recordings", + "storage_space": "Storage Usage", + "recording_files": "Recording Files", + "app_cleanup": "App & Cleanup", + "recording_size": "Recordings", + "disk_free": "Disk Free", + "video_files": "Files", + "original_size": "Original Files", + "empty_dirs": "Empty Folders", + "logs_size": "Logs", + "database_size": "Database", + "unknown_extension": "No extension", + "maintenance_tip": "Delete Recordings removes recorded media files under the save folder. Empty folders and logs can be cleaned separately.", + "recent_recording_files": "Recent Recording Files", + "empty_recent_files": "No recent recording files", + "recent_file_item": "{time} · {size}", + "recording_file_missing": "Recording file is missing or deleted. List refreshed ⚠️", "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 ⚠️", + "file_list_update_error": "Failed to update file list ⚠️", + "web_open_folder_tip": "Web mode cannot open local folders directly ⚠️", + "open_folder_failed": "Failed to open folder ⚠️", + "no_empty_dirs": "No empty folders to clean", + "clean_empty_dirs_confirm": "Delete {count} empty folders under the recording folder?", + "clean_empty_dirs_success": "Cleaned {count} empty folders", + "no_logs_to_clean": "No logs to clean", + "clean_logs_confirm": "Delete deletable log files under the log folder and free about {size}? Active logs may be skipped.", + "clean_logs_success": "Cleaned {count} log files and freed {size}", + "no_files_to_delete": "No recordings to delete", + "delete_all_files_confirm": "Delete {count} recorded media files under the save folder and free about {size}. This cannot be undone. Continue?", + "delete_all_files_success": "Deleted {count} recording files and freed {size}", + "no_files_cleaned": "No files were cleaned", "copy_stream_url": "Copy Stream URL", "copy_video_url": "Copy Video URL", "copy_success": "Copy Success", @@ -507,4 +590,4 @@ "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 21c9f0fd..26385d81 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -4,6 +4,7 @@ "recordings": "录制", "settings": "设置", "storage": "存储", + "logs": "日志", "about": "说明", "light_theme": "白天模式", "dark_theme": "黑夜模式", @@ -111,6 +112,41 @@ "search_dialog": { "search_keyword": "输入搜索关键词" }, + "logs_page": { + "title": "日志", + "refresh": "刷新日志", + "summary": "运行日志 {recording_count} 条 · 开始录制 {start_count} 条 · 停止录制 {stop_count} 条", + "recording_logs": "运行日志", + "recording_history": "历史记录", + "clear_recording_logs": "清空运行日志", + "clear_recording_history": "清空历史记录", + "clear_recording_logs_confirm": "将清空当前日志文件中的运行日志记录,是否继续?", + "clear_recording_history_confirm": "将清空 SQLite 中的录制历史记录,并重置直播间最近录制信息,是否继续?", + "clear_recording_logs_success": "已清空 {count} 条运行日志", + "clear_recording_history_success": "已清空 {count} 条历史记录", + "history_start_recording": "开始录制", + "history_stop_recording": "停止录制", + "empty_history": "暂无录制历史", + "time": "时间", + "raw_log": "原始日志", + "log_level_error": "错误", + "log_level_warn": "警告", + "log_level_ok": "成功", + "log_level_stream": "录制", + "log_level_info": "信息", + "empty_recording_logs": "暂无运行日志", + "unknown_room": "未知直播间", + "started_at": "开始时间", + "ended_at": "结束时间", + "duration": "录制时长", + "file": "录制文件", + "live_title": "直播标题", + "error": "错误信息", + "history_status_started": "开始录制", + "history_status_completed": "已完成", + "history_status_stopped": "已停止", + "history_status_error": "录制错误" + }, "recording_manager": { "monitor_stopped": "停止监控中", "is_live": "直播中", @@ -149,8 +185,13 @@ "pre_record_tip": "提示:准备开始录制", "stop_record_tip": "提示:已停止直播间录制️", "no_folder_tip": "提示:该直播间文件夹不存在", + "pin_record_success_tip": "提示:直播间已置顶", + "unpin_record_success_tip": "提示:直播间已取消置顶", "edit_record_config": "编辑录制配置", "delete_monitor": "删除监控", + "pin_record": "置顶直播间", + "unpin_record": "取消置顶", + "pinned": "置顶", "open_folder": "打开文件夹", "recording_info": "录制信息", "anchor_name": "主播名称", @@ -174,10 +215,14 @@ "delete_confirm_tip": "确定要删除该直播间吗?", "no_recording_dir_tip": "该直播间暂无保存的录制文件", "none": "无", - "recording_card_tip": "点击选中", + "recording_card_tip": "点击选中,双击查看录制信息", "use_proxy": "使用代理", "no_support_open_dir": "Web端不支持打开文件夹 ⚠️", "message_push": "消息推送", + "joined_at": "加入时间", + "last_recorded_at": "最近录制时间", + "last_record_file": "最近录制文件", + "never_recorded": "暂无录制历史", "all": "全部", "recording": "录制中", "recording_error": "录制错误", @@ -438,14 +483,52 @@ "dont_show_again": "下次不再提示" }, "storage_page": { + "storage_manage": "存储管理", "storage_path": "存储路径", "current_path": "当前路径", + "file_browser": "目录文件", + "storage_overview": "存储概览", + "clean_tools": "清理工具", + "refresh_storage": "刷新", + "open_storage_folder": "打开目录", + "clean_empty_dirs": "清理空目录", + "clean_logs": "清理日志", + "delete_all_files": "删除录制文件", + "storage_space": "空间使用", + "recording_files": "录制文件", + "app_cleanup": "应用与清理", + "recording_size": "录制占用", + "disk_free": "磁盘可用", + "video_files": "文件数量", + "original_size": "原始文件", + "empty_dirs": "空目录", + "logs_size": "日志占用", + "database_size": "数据库", + "unknown_extension": "无扩展名", + "maintenance_tip": "删除录制文件会清理保存目录下的所有媒体录制文件;空目录和日志可单独清理。", + "recent_recording_files": "最近录制文件", + "empty_recent_files": "暂无最近录制文件", + "recent_file_item": "{time} · {size}", + "recording_file_missing": "录制文件不存在或已删除,已刷新列表 ⚠️", "empty_recording_folder": "录制文件夹为空,暂无录制的直播视频", "go_back": "返回上一级", "previewing": "正在预览", "unsupported_file_type":"未找到视频文件 ⚠️", "no_video_file":"未找到视频文件 ⚠️", "no_recording_folder":"录制目录不存在 ⚠️", + "file_list_update_error": "文件列表更新失败 ⚠️", + "web_open_folder_tip": "Web端不支持直接打开本地目录 ⚠️", + "open_folder_failed": "打开目录失败 ⚠️", + "no_empty_dirs": "没有可清理的空目录", + "clean_empty_dirs_confirm": "将删除录制目录下 {count} 个空目录,是否继续?", + "clean_empty_dirs_success": "已清理 {count} 个空目录", + "no_logs_to_clean": "没有可清理的日志文件", + "clean_logs_confirm": "将删除 log 目录下可删除的日志文件,预计释放 {size}。正在写入的日志可能会被跳过,是否继续?", + "clean_logs_success": "已清理 {count} 个日志文件,释放 {size}", + "no_files_to_delete": "没有可删除的录制文件", + "delete_all_files_confirm": "将删除保存目录下 {count} 个媒体录制文件,预计释放 {size}。删除后不可恢复,是否继续?", + "delete_all_files_success": "已删除 {count} 个录制文件,释放 {size}", + "no_files_cleaned": "没有文件被清理", "copy_stream_url": "复制直播源地址", "copy_video_url": "复制视频地址", "copy_success": "复制成功", @@ -507,4 +590,4 @@ "login_failed": "用户名或密码错误", "login_success": "登录成功" } -} \ No newline at end of file +} diff --git a/main.py b/main.py index 259bc2c6..7b4ce1ea 100644 --- a/main.py +++ b/main.py @@ -1,250 +1,289 @@ -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 setup_bundled_flet_view -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" - - -async def setup_window(page: ft.Page, app: App) -> None: - page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") - page.window.skip_task_bar = False - page.window.always_on_top = False - page.focused = True - - if not page.web: - try: - 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" - page.window.min_width = MIN_WIDTH - page.window.min_height = MIN_WIDTH * WINDOW_SCALE - - _services = BackendServices.get() - app = App(page, services=_services) - page.data = app - app.is_web_mode = page.web - app.is_mobile = False - await setup_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__": - 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="store_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() - - 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() - ft.run(main=main, view=ft.AppView.FLET_APP_HIDDEN, assets_dir=ASSETS_DIR) +import argparse +import logging +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 setup_bundled_flet_view +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" + + +class ClientDisconnectLogFilter(logging.Filter): + """Hide noisy ASGI tracebacks produced by normal browser disconnects.""" + + DISCONNECT_EXCEPTIONS = {"ClientDisconnected", "InvalidState", "WebSocketDisconnect"} + + def filter(self, record: logging.LogRecord) -> bool: + if not record.exc_info: + return True + + exc = record.exc_info[1] + while exc is not None: + if type(exc).__name__ in self.DISCONNECT_EXCEPTIONS: + return False + exc = exc.__context__ or exc.__cause__ + return True + + +def suppress_client_disconnect_logs() -> None: + uvicorn_error_logger = logging.getLogger("uvicorn.error") + if not any(isinstance(item, ClientDisconnectLogFilter) for item in uvicorn_error_logger.filters): + uvicorn_error_logger.addFilter(ClientDisconnectLogFilter()) + + +async def setup_window(page: ft.Page, app: App) -> None: + page.window.icon = os.path.join(execute_dir, ASSETS_DIR, "icon.ico") + page.window.skip_task_bar = False + page.window.always_on_top = False + page.focused = True + + if not page.web: + try: + 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", + "/logs": "logs", + "/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" + page.window.min_width = MIN_WIDTH + page.window.min_height = MIN_WIDTH * WINDOW_SCALE + + _services = BackendServices.get() + app = App(page, services=_services) + page.data = app + app.is_web_mode = page.web + app.is_mobile = False + await setup_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() + + +def run_flet_app(**kwargs) -> None: + runner = getattr(ft, "run", None) + if runner is not None: + runner(**kwargs) + return + + app_runner = getattr(ft, "app", None) + if app_runner is None: + raise RuntimeError("The installed flet package exposes neither run() nor app().") + + kwargs["target"] = kwargs.pop("main") + app_runner(**kwargs) + + +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="store_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() + + services = BackendServices.bootstrap(execute_dir) + + is_web = args.web or platform == "web" + if is_web: + suppress_client_disconnect_logs() + services.start_background_loop() + logger.debug("Running in web mode on http://" + args.host + ":" + str(args.port)) + run_flet_app( + main=main, + view=ft.AppView.WEB_BROWSER, + host=args.host, + port=args.port, + assets_dir=ASSETS_DIR, + web_renderer=ft.WebRenderer.CANVAS_KIT, + ) + else: + setup_bundled_flet_view() + run_flet_app(main=main, view=ft.AppView.FLET_APP_HIDDEN, assets_dir=ASSETS_DIR) diff --git a/pyproject.toml b/pyproject.toml index b909355a..68c57323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "httpx[http2]>=0.28.1", "screeninfo>=0.8.1", "aiofiles>=25.1.0", - "streamget>=4.0.10", + "streamget @ git+https://github.com/ihmily/streamget.git", "python-dotenv>=1.2.2", "cachetools>=7.1.4", "pystray>=0.19.5", @@ -55,7 +55,7 @@ flet-video = "^0.85.3" httpx = "^0.28.1" screeninfo = "~0.8.1" aiofiles = "~25.1.0" -streamget = ">=4.0.10" +streamget = { git = "https://github.com/ihmily/streamget.git", branch = "main" } python-dotenv = "~1.2.2" cachetools = ">=7.1.4" pystray = "~0.19.5" diff --git a/requirements-web.txt b/requirements-web.txt index c8bc5911..5a865c17 100644 --- a/requirements-web.txt +++ b/requirements-web.txt @@ -3,7 +3,7 @@ flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 -streamget>=4.0.10 +streamget @ git+https://github.com/ihmily/streamget.git python-dotenv>=1.2.1 cachetools>=7.1.4 -deprecated>=1.3.1 \ No newline at end of file +deprecated>=1.3.1 diff --git a/requirements.txt b/requirements.txt index f862ee52..12905edb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,8 @@ flet-video==0.85.3 httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=25.1.0 -streamget>=4.0.10 +streamget @ git+https://github.com/ihmily/streamget.git python-dotenv>=1.2.1 pystray>=0.19.5 plyer>=2.1.0 -deprecated>=1.3.1 \ No newline at end of file +deprecated>=1.3.1