feat: Optimize app closing process and recording saving mechanism

This commit is contained in:
ihmily
2025-05-28 19:34:04 +08:00
parent e7e59b306f
commit 583cbda90d
9 changed files with 411 additions and 85 deletions

View File

@@ -98,7 +98,12 @@ class App:
self.content_area.update()
async def cleanup(self):
await self.process_manager.cleanup()
try:
await self.process_manager.cleanup()
except ConnectionError:
logger.warning("Connection lost, process may have terminated")
except Exception as e:
logger.error(f"Error during cleanup: {e}")
def add_ffmpeg_process(self, process):
self.process_manager.add_process(process)

View File

@@ -8,6 +8,7 @@ from typing import Any
from ..models.recording_status_model import RecordingStatus
from ..models.video_quality_model import VideoQuality
from ..process_manager import BackgroundService
from ..utils import utils
from ..utils.logger import logger
from . import ffmpeg_builders, platform_handlers
@@ -316,7 +317,6 @@ class LiveStreamRecorder:
self.user_config.get("convert_to_mp4")
)
except Exception as e:
logger.error(f"An error occurred during the subprocess execution: {e}")
return False
@@ -326,6 +326,28 @@ class LiveStreamRecorder:
return True
async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None:
"""Asynchronous transcoding method, can be added to the background service to continue execution"""
if not self.app.recording_enabled:
logger.info(f"Application is closing, adding transcoding task to background service: {converts_file_path}")
BackgroundService.get_instance().add_task(
self.converts_mp4_sync, converts_file_path, is_original_delete
)
return
# Otherwise, execute transcoding normally
await self._do_converts_mp4(converts_file_path, is_original_delete)
def converts_mp4_sync(self, converts_file_path: str, is_original_delete: bool = True) -> None:
"""Synchronous version of the transcoding method, used for background service"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._do_converts_mp4(converts_file_path, is_original_delete))
finally:
loop.close()
async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None:
"""Actual execution method for transcoding"""
converts_success = False
save_path = None
try:
@@ -378,6 +400,8 @@ class LiveStreamRecorder:
split_video_by_time: bool,
converts_to_mp4: bool
):
from ..process_manager import BackgroundService
if "python" in script_command:
params = [
f'--record_name "{record_name}"',
@@ -394,8 +418,23 @@ class LiveStreamRecorder:
f"converts_to_mp4: {converts_to_mp4}"
]
script_command = script_command.strip() + " " + " ".join(params)
self.app.page.run_task(self.run_script_async, script_command)
logger.success("Script command execution completed!")
if not self.app.recording_enabled:
logger.info("Application is closing, adding script execution task to background service")
BackgroundService.get_instance().add_task(self.run_script_sync, script_command)
else:
self.app.page.run_task(self.run_script_async, script_command)
logger.success("Script command execution initiated!")
def run_script_sync(self, command: str) -> None:
"""Synchronous version of the script execution method, used for background service"""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self.run_script_async(command))
finally:
loop.close()
async def run_script_async(self, command: str) -> None:
try:
@@ -440,4 +479,4 @@ class LiveStreamRecorder:
"lang": "referer:https://www.lang.live",
"shopee": "origin:" + live_domain
}
return record_headers.get(platform_key)
return record_headers.get(platform_key)

View File

View File

@@ -0,0 +1,87 @@
import asyncio
import threading
import time
import flet as ft
from ..utils.logger import logger
def _safe_destroy_window(page):
try:
page.update()
to_cancel = asyncio.all_tasks(page.loop)
if not to_cancel:
return
for task in to_cancel:
task.cancel()
except Exception as ex:
logger.error(f"close window error: {ex}")
finally:
page.window.destroy()
async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None:
_ = {}
language = app.language_manager.language
for key in ("app_close_handler", "base"):
_.update(language.get(key, {}))
async def close_dialog_dismissed(e):
app.recording_enabled = False
# check if there are active recordings
active_recordings = [p for p in app.process_manager.ffmpeg_processes if p.returncode is None]
active_recordings_count = len(active_recordings)
if active_recordings_count > 0:
save_progress_overlay.show(_["saving_recordings"].format(active_recordings_count=active_recordings_count),
cancellable=True)
page.update()
def close_app():
try:
# adjust wait time based on the number of recordings, at least 2 seconds
base_wait_time = max(2, min(active_recordings_count, 10))
logger.info(
f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds")
time.sleep(base_wait_time)
# check again if there are active processes
remaining = len([p for p in app.process_manager.ffmpeg_processes if p.returncode is None])
if remaining > 0:
logger.info(f"still {remaining} recordings are not finished, waiting for extra time")
time.sleep(min(remaining, 5))
time.sleep(0.5)
except Exception as ex:
logger.error(f"close window error: {ex}")
finally:
page.window.destroy()
threading.Thread(target=close_app, daemon=True).start()
else:
_safe_destroy_window(page)
await close_dialog(e)
async def close_dialog(_):
confirm_dialog.open = False
page.update()
confirm_dialog = ft.AlertDialog(
modal=True,
title=ft.Text(_["confirm_exit"]),
content=ft.Text(_["confirm_exit_content"]),
actions=[
ft.TextButton(_["cancel"], on_click=close_dialog),
ft.TextButton(_["confirm"], on_click=close_dialog_dismissed),
],
actions_alignment=ft.MainAxisAlignment.END,
)
confirm_dialog.open = True
app.dialog_area.content = confirm_dialog
page.update()

View File

@@ -1,47 +1,85 @@
import asyncio
import os
import threading
from .utils.logger import logger
class BackgroundService:
_instance = None
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = BackgroundService()
return cls._instance
def __init__(self):
self.tasks = []
self.is_running = False
self.worker_thread = None
def add_task(self, task_func, *args, **kwargs):
self.tasks.append((task_func, args, kwargs))
logger.info(f"Added background task: {task_func.__name__}")
if not self.is_running:
self.start()
def start(self):
if self.is_running:
return
self.is_running = True
self.worker_thread = threading.Thread(target=self._process_tasks, daemon=False)
self.worker_thread.start()
logger.info("Background service started")
def _process_tasks(self):
while self.tasks:
task_func, args, kwargs = self.tasks.pop(0)
try:
logger.info(f"Executing background task: {task_func.__name__}")
task_func(*args, **kwargs)
logger.info(f"Background task completed: {task_func.__name__}")
except Exception as e:
logger.error(f"Background task execution failed: {e}")
logger.info("All background tasks completed, service stopped")
self.is_running = False
class AsyncProcessManager:
def __init__(self):
self.ffmpeg_processes: list[asyncio.subprocess.Process] = []
self.ffmpeg_processes = []
def add_process(self, process: asyncio.subprocess.Process):
"""Add an asynchronous process to the management list"""
def add_process(self, process):
self.ffmpeg_processes.append(process)
async def cleanup(self):
"""Asynchronously clean up all processes"""
cleanup_tasks = []
for process in self.ffmpeg_processes:
if process.returncode is None:
task = self._terminate_process(process)
cleanup_tasks.append(task)
if cleanup_tasks:
await asyncio.gather(*cleanup_tasks)
self.ffmpeg_processes = []
@staticmethod
async def _terminate_process(process: asyncio.subprocess.Process):
"""Asynchronously terminate a single process"""
try:
# On Windows, send 'q' to stdin to gracefully terminate FFmpeg
if os.name == 'nt' and process.stdin:
process.stdin.write(b'q')
await process.stdin.drain()
process.stdin.close()
else:
process.terminate()
for process in self.ffmpeg_processes[:]:
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
process.kill()
await process.wait()
if process.returncode is None:
logger.debug(f"Terminating process {process.pid}")
if os.name == "nt":
if process.stdin:
process.stdin.write(b"q")
await process.stdin.drain()
else:
process.terminate()
except Exception as e:
logger.error(f"Error occurred while terminating process: {str(e)}")
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
logger.warning(f"Process {process.pid} did not terminate, killing it")
process.kill()
await process.wait()
self.ffmpeg_processes.remove(process)
except Exception as e:
logger.error(f"Error cleaning up process: {e}")
if process in self.ffmpeg_processes:
self.ffmpeg_processes.remove(process)
logger.debug("All processes cleaned up")

View File

@@ -5,51 +5,195 @@ class SaveProgressOverlay:
def __init__(self, app):
self.app = app
self._ = {}
self.app.language_manager.add_observer(self)
self.load()
self.overlay = ft.Stack(
[
ft.Container(
content=ft.Column(
[
ft.ProgressRing(
width=50,
height=50,
stroke_width=5,
color=ft.colors.BLUE_400,
value=0.7
),
ft.Text(self._["saving_recording"], size=16, weight=ft.FontWeight.W_500)
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=20
),
alignment=ft.alignment.center,
expand=True,
bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK),
shadow=ft.BoxShadow(
spread_radius=1,
color=ft.colors.with_opacity(0.2, ft.colors.BLACK),
offset=ft.Offset(0, 4)
),
animate_opacity=300,
animate_scale=ft.animation.Animation(300, ft.AnimationCurve.EASE_OUT)
)
],
self.message_text = None
self.cancel_button = None
self.warning_text = None
self.progress_ring = None
self.simple_progress_ring = None
self.content_container = None
self.simple_container = None
self.overlay = ft.Stack([], visible=False)
self.is_cancellable = False
self.is_simple_mode = False
self._initialized = False
def _initialize_components(self):
if self._initialized:
return
self.message_text = ft.Text(
self._["saving_recording"],
size=18,
weight=ft.FontWeight.W_500,
color=ft.colors.WHITE,
text_align=ft.TextAlign.CENTER
)
self.cancel_button = ft.ElevatedButton(
text=f"😾 {self._['force_close']}",
on_click=self._on_force_close,
style=ft.ButtonStyle(
color=ft.colors.WHITE,
bgcolor="#FF5252",
shape=ft.RoundedRectangleBorder(radius=8),
elevation=0,
padding=ft.padding.symmetric(horizontal=20, vertical=10),
),
tooltip=self._["force_close_tooltip"],
visible=False
)
self.warning_text = ft.Text(
self._["force_close_warning"],
size=12,
color=ft.colors.with_opacity(0.7, ft.colors.WHITE),
text_align=ft.TextAlign.CENTER,
visible=False
)
self.progress_ring = ft.ProgressRing(
width=60,
height=60,
stroke_width=4,
color="#2196F3",
value=0.7
)
self.simple_progress_ring = ft.ProgressRing(
width=50,
height=50,
stroke_width=3,
color="#2196F3",
value=0.8
)
self.content_container = ft.Container(
content=ft.Column(
[
ft.Container(
content=self.progress_ring,
margin=ft.margin.only(bottom=20)
),
self.message_text,
ft.Container(height=25),
self.cancel_button,
ft.Container(height=8),
self.warning_text
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=0
),
width=400,
height=280,
padding=ft.padding.all(30),
alignment=ft.alignment.center,
bgcolor=ft.colors.with_opacity(0.95, "#212121"),
border_radius=16,
shadow=ft.BoxShadow(
spread_radius=0,
blur_radius=24,
color=ft.colors.with_opacity(0.5, ft.colors.BLACK),
offset=ft.Offset(0, 4)
),
)
self.simple_container = ft.Container(
content=ft.Column(
[
ft.Container(
content=self.simple_progress_ring,
margin=ft.margin.only(bottom=15)
),
self.message_text,
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=0
),
width=300,
height=180,
padding=ft.padding.all(25),
alignment=ft.alignment.center,
bgcolor=ft.colors.with_opacity(0.95, "#212121"),
border_radius=16,
shadow=ft.BoxShadow(
spread_radius=0,
blur_radius=24,
color=ft.colors.with_opacity(0.5, ft.colors.BLACK),
offset=ft.Offset(0, 4)
),
)
self.overlay.controls = [
ft.Container(
content=self.content_container,
alignment=ft.alignment.center,
expand=True,
bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK),
animate_opacity=300,
)
]
self._initialized = True
def _on_force_close(self, e):
self.message_text.value = self._["force_closing"]
self.cancel_button.visible = False
self.warning_text.visible = False
self.overlay.update()
def show(self):
self.app.page.window.destroy()
def show(self, message=None, cancellable=False):
self._initialize_components()
if message:
self.message_text.value = message
else:
self.message_text.value = self._["saving_recording"]
self.is_cancellable = cancellable
if cancellable:
self.is_simple_mode = False
self.overlay.controls[0].content = self.content_container
self.cancel_button.visible = True
self.warning_text.visible = True
else:
self.is_simple_mode = True
self.overlay.controls[0].content = self.simple_container
self.cancel_button.visible = False
self.warning_text.visible = False
self.overlay.visible = True
self.overlay.update()
def update_message(self, message):
if self._initialized:
self.message_text.value = message
self.message_text.update()
def show_cancel_button(self):
if not self._initialized:
return
if not self.cancel_button.visible and not self.is_simple_mode:
self.cancel_button.visible = True
self.warning_text.visible = True
self.cancel_button.update()
self.warning_text.update()
def hide(self):
self.overlay.visible = False
self.overlay.update()
def load(self):
"""Load language resources."""
self._ = self.app.language_manager.language.get("progress_overlay", {})
self._ = self.app.language_manager.language.get("save_progress_overlay", {})
@property
def visible(self):

View File

@@ -333,9 +333,6 @@
"install_tip": "This program requires the following components to function properly.",
"lack_components": "Missing Required Components"
},
"progress_overlay":{
"saving_recording": "Saving recording"
},
"storage_page": {
"storage_path": "Storage Path",
"current_path": "Current Path",
@@ -371,5 +368,17 @@
"update_check_failed": "Update check failed",
"no_update_available": "You are using the latest version",
"unknown": "Unknown"
},
"save_progress_overlay": {
"force_close": "Force Close",
"force_close_tooltip": "Force close the program (may result in video corruption)",
"force_close_warning": "This action will force close the program immediately, potentially resulting in video corruption.",
"force_closing": "Force closing...",
"saving_recording": "Saving recording..."
},
"app_close_handler": {
"saving_recordings": "Saving {active_recordings_count} recordings, please wait...",
"confirm_exit": "Confirm Exit",
"confirm_exit_content": "Are you sure you want to exit the program?"
}
}

View File

@@ -335,9 +335,6 @@
"install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。",
"lack_components": "缺少必要组件"
},
"progress_overlay":{
"saving_recording": "正在保存录制文件..."
},
"storage_page": {
"storage_path": "存储路径",
"current_path": "当前路径",
@@ -373,5 +370,17 @@
"update_check_failed": "检查更新失败",
"no_update_available": "当前已是最新版本",
"unknown": "未知"
},
"save_progress_overlay": {
"force_close": "强制关闭",
"force_close_tooltip": "立即关闭程序(可能导致视频损坏)",
"force_close_warning": "此操作将立即强制关闭程序,可能导致视频文件损坏。",
"force_closing": "正在强制关闭...",
"saving_recording": "正在保存录制文件..."
},
"app_close_handler": {
"saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...",
"confirm_exit": "确认退出",
"confirm_exit_content": "确定要退出程序吗?"
}
}

11
main.py
View File

@@ -7,6 +7,7 @@ from dotenv import load_dotenv
from screeninfo import get_monitors
from app.app_manager import App, execute_dir
from app.lifecycle.app_close_handler import handle_app_close
from app.ui.components.save_progress_overlay import SaveProgressOverlay
from app.utils.logger import logger
@@ -63,14 +64,8 @@ def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SavePro
async def on_window_event(e: ft.ControlEvent) -> None:
if e.data == "close":
save_progress_overlay.show()
page.update()
try:
await app.cleanup()
except Exception as ex:
logger.error(f"Cleanup failed: {ex}")
finally:
page.window.destroy()
await handle_app_close(page, app, save_progress_overlay)
return on_window_event