feat: 增加 Windows 定时关机功能 (#482)

This commit is contained in:
lsr-05
2026-08-05 14:48:13 +08:00
committed by GitHub
parent 6841442c08
commit be8c281ddf
6 changed files with 580 additions and 3 deletions

View File

@@ -6,6 +6,7 @@ import flet as ft
from . import execute_dir, resource_dir
from .core.runtime.backend_services import BackendServices
from .core.runtime.scheduled_shutdown import ScheduledShutdownManager
from .core.update.update_checker import UpdateChecker
from .initialization.installation_manager import InstallationManager
from .ui.components.business.recording_card import RecordingCardManager
@@ -76,6 +77,7 @@ class App:
)
self.snack_bar = ShowSnackBar(self)
self.subprocess_start_up_info = services.subprocess_start_up_info
self.shutdown_manager = ScheduledShutdownManager(self)
self.record_card_manager = RecordingCardManager(self)
self.current_page = None
self._loading_page = False
@@ -87,6 +89,7 @@ class App:
self.page.run_task(self._check_for_updates)
services.register_ui_bridge(self)
self.page.run_task(self.shutdown_manager.reschedule)
@property
def recording_enabled(self):
@@ -183,6 +186,7 @@ class App:
async def cleanup(self):
self.services.unregister_ui_bridge(self)
await self.shutdown_manager.stop()
if not self.is_web_mode:
try:

View File

@@ -0,0 +1,266 @@
from __future__ import annotations
import asyncio
import subprocess
import sys
from datetime import datetime, time, timedelta
import flet as ft
from ...utils.logger import logger
DEFAULT_SHUTDOWN_DELAY_SECONDS = 60
MAX_QUICK_SHUTDOWN_HOURS = 168
def parse_shutdown_time(value: str) -> time:
"""Parse the persisted 24-hour shutdown time."""
return datetime.strptime(value.strip(), "%H:%M").time()
def get_next_shutdown_at(value: str, now: datetime | None = None) -> datetime:
"""Return the next occurrence of ``value`` after ``now``."""
current = now or datetime.now()
scheduled_at = datetime.combine(current.date(), parse_shutdown_time(value))
if scheduled_at <= current:
scheduled_at += timedelta(days=1)
return scheduled_at
def get_quick_shutdown_at(hours: int, now: datetime | None = None) -> datetime:
"""Return the target time for a positive hour-based shutdown delay."""
if not 1 <= hours <= MAX_QUICK_SHUTDOWN_HOURS:
raise ValueError(f"Shutdown delay must be between 1 and {MAX_QUICK_SHUTDOWN_HOURS} hours")
return (now or datetime.now()) + timedelta(hours=hours)
class ScheduledShutdownManager:
"""Manage the desktop-only daily Windows shutdown schedule."""
def __init__(self, app) -> None:
self.app = app
self._task: asyncio.Task | None = None
self._countdown_task: asyncio.Task | None = None
self._dialog: ft.AlertDialog | None = None
self._shutdown_requested = False
self._quick_shutdown_at: datetime | None = None
@property
def is_supported(self) -> bool:
return sys.platform == "win32" and not self.app.page.web
async def reschedule(self) -> None:
await self.stop_schedule()
if not self.is_supported:
return
if self.quick_shutdown_at is not None:
logger.info(f"Daily shutdown paused until quick shutdown at {self.quick_shutdown_at:%Y-%m-%d %H:%M}")
return
enabled = self.app.settings.user_config.get("scheduled_shutdown_enabled", False)
if not enabled:
return
shutdown_time = str(self.app.settings.user_config.get("scheduled_shutdown_time", "23:00"))
try:
next_shutdown = get_next_shutdown_at(shutdown_time)
except ValueError:
logger.error(f"Invalid scheduled shutdown time: {shutdown_time}")
return
self._task = asyncio.create_task(self._wait_until(next_shutdown, shutdown_time))
logger.info(f"Scheduled system shutdown for {next_shutdown:%Y-%m-%d %H:%M}")
@property
def quick_shutdown_at(self) -> datetime | None:
if self._quick_shutdown_at is not None and self._quick_shutdown_at <= datetime.now():
self._quick_shutdown_at = None
return self._quick_shutdown_at
async def start_quick_shutdown(self, hours: int) -> datetime:
if not self.is_supported:
raise RuntimeError("Quick shutdown is only supported on Windows desktop")
target = get_quick_shutdown_at(hours)
await self.stop_schedule()
_ = self._translations()
result = await self._run_shutdown_command(
delay_seconds=hours * 60 * 60,
message=_["quick_shutdown_system_message"].format(hours=hours),
)
if result.returncode != 0:
await self.reschedule()
raise RuntimeError(result.stderr.strip() or str(result.returncode))
self._quick_shutdown_at = target
logger.warning(f"Quick system shutdown scheduled for {target:%Y-%m-%d %H:%M}")
return target
async def cancel_quick_shutdown(self) -> None:
if self.quick_shutdown_at is None:
return
result = await asyncio.to_thread(
subprocess.run,
["shutdown", "/a"],
capture_output=True,
text=True,
startupinfo=self.app.subprocess_start_up_info,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or str(result.returncode))
self._quick_shutdown_at = None
logger.info("Quick shutdown cancelled by user")
await self.reschedule()
async def stop_schedule(self) -> None:
task = self._task
self._task = None
if task is None or task.done() or task is asyncio.current_task():
return
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def stop(self) -> None:
await self.stop_schedule()
if self._countdown_task is not None and not self._countdown_task.done():
self._countdown_task.cancel()
self._countdown_task = None
async def _wait_until(self, scheduled_at: datetime, configured_time: str) -> None:
try:
delay = max(0.0, (scheduled_at - datetime.now()).total_seconds())
await asyncio.sleep(delay)
settings = self.app.settings.user_config
if not settings.get("scheduled_shutdown_enabled", False):
return
if str(settings.get("scheduled_shutdown_time", "23:00")) != configured_time:
return
await self._request_shutdown()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error(f"Scheduled shutdown failed: {exc}")
def _translations(self) -> dict:
language = self.app.language_manager.language
return language.get("base", {}) | language.get("settings_page", {})
async def _run_shutdown_command(self, delay_seconds: int, message: str):
return await asyncio.to_thread(
subprocess.run,
["shutdown", "/s", "/t", str(delay_seconds), "/c", message],
capture_output=True,
text=True,
startupinfo=self.app.subprocess_start_up_info,
check=False,
)
async def _request_shutdown(self) -> None:
if self._shutdown_requested:
return
_ = self._translations()
result = await self._run_shutdown_command(
delay_seconds=DEFAULT_SHUTDOWN_DELAY_SECONDS,
message=_["scheduled_shutdown_system_message"],
)
if result.returncode != 0:
error = result.stderr.strip() or str(result.returncode)
logger.error(f"Unable to start scheduled shutdown: {error}")
await self.app.snack_bar.show_snack_bar(
_["scheduled_shutdown_failed"].format(error=error),
bgcolor=ft.Colors.RED,
duration=5000,
)
return
self._shutdown_requested = True
logger.warning(f"System shutdown requested in {DEFAULT_SHUTDOWN_DELAY_SECONDS} seconds")
self._show_countdown_dialog(DEFAULT_SHUTDOWN_DELAY_SECONDS)
def _show_countdown_dialog(self, seconds: int) -> None:
_ = self._translations()
countdown_text = ft.Text(
_["scheduled_shutdown_countdown"].format(seconds=seconds),
size=14,
text_align=ft.TextAlign.CENTER,
)
async def cancel_shutdown(_event) -> None:
result = await asyncio.to_thread(
subprocess.run,
["shutdown", "/a"],
capture_output=True,
text=True,
startupinfo=self.app.subprocess_start_up_info,
check=False,
)
if result.returncode == 0:
self._shutdown_requested = False
logger.info("Scheduled shutdown cancelled by user")
await self.app.snack_bar.show_snack_bar(
_["scheduled_shutdown_cancelled"],
bgcolor=ft.Colors.PRIMARY,
)
await self._close_dialog()
await self.reschedule()
else:
error = result.stderr.strip() or str(result.returncode)
await self.app.snack_bar.show_snack_bar(
_["scheduled_shutdown_cancel_failed"].format(error=error),
bgcolor=ft.Colors.RED,
duration=5000,
)
self._dialog = ft.AlertDialog(
modal=True,
title=ft.Text(_["scheduled_shutdown"], weight=ft.FontWeight.BOLD),
content=ft.Column(
[
ft.Icon(ft.Icons.POWER_SETTINGS_NEW, size=36, color=ft.Colors.ERROR),
countdown_text,
],
tight=True,
spacing=12,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
),
actions=[
ft.OutlinedButton(
content=_["scheduled_shutdown_cancel"],
icon=ft.Icons.CANCEL_SCHEDULE_SEND,
on_click=cancel_shutdown,
)
],
actions_alignment=ft.MainAxisAlignment.CENTER,
)
self._dialog.open = True
self.app.dialog_area.content = self._dialog
self.app.dialog_area.update()
self._countdown_task = asyncio.create_task(self._update_countdown(countdown_text, seconds))
async def _update_countdown(self, countdown_text: ft.Text, seconds: int) -> None:
_ = self._translations()
try:
for remaining in range(seconds - 1, -1, -1):
await asyncio.sleep(1)
countdown_text.value = _["scheduled_shutdown_countdown"].format(seconds=remaining)
countdown_text.update()
except (asyncio.CancelledError, ft.FletPageDisconnectedException):
pass
async def _close_dialog(self) -> None:
if self._countdown_task is not None and not self._countdown_task.done():
self._countdown_task.cancel()
self._countdown_task = None
if self._dialog is not None:
self._dialog.open = False
self.app.dialog_area.update()
self._dialog = None

View File

@@ -1,8 +1,11 @@
import asyncio
import sys
from datetime import datetime, timedelta
import flet as ft
from ...core.runtime.paths import default_recordings_dir
from ...core.runtime.scheduled_shutdown import MAX_QUICK_SHUTDOWN_HOURS
from ...models.media.audio_format_model import AudioFormat
from ...models.media.video_format_model import VideoFormat
from ...models.media.video_quality_model import VideoQuality
@@ -136,9 +139,11 @@ class SettingsPage(PageBase):
ui_language = self.user_config["language"]
self.user_config = self.default_config.copy()
self.user_config["language"] = ui_language
self.app.services.settings_config.adopt_user_config(self.user_config)
self.app.language_manager.notify_observers()
self.page.run_task(self.load)
await self.config_manager.save_user_config(self.user_config)
await self.app.shutdown_manager.reschedule()
logger.success("Default configuration restored.")
await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY)
await close_dialog(None)
@@ -185,6 +190,8 @@ class SettingsPage(PageBase):
if key == "loop_time_seconds":
self.app.record_manager.initialize_dynamic_state()
if key in {"scheduled_shutdown_enabled", "scheduled_shutdown_time"}:
await self.app.shutdown_manager.reschedule()
self.page.run_task(self.delay_handler.start_task_timer, self.save_user_config_after_delay, None)
self.has_unsaved_changes["user_config"] = True
@@ -336,6 +343,11 @@ class SettingsPage(PageBase):
data="remember_window_size",
),
),
*(
[self.create_scheduled_shutdown_setting_row()]
if sys.platform == "win32" and not self.app.page.web
else []
),
],
is_mobile,
),
@@ -1156,6 +1168,246 @@ class SettingsPage(PageBase):
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
def create_scheduled_shutdown_setting_row(self):
enabled = bool(self.get_config_value("scheduled_shutdown_enabled", False))
shutdown_time = str(self.get_config_value("scheduled_shutdown_time", "23:00"))
selected_hours = str(self.get_config_value("quick_shutdown_hours", "3"))
active_target = self.app.shutdown_manager.quick_shutdown_at
time_field = ft.TextField(
value=shutdown_time,
width=110,
read_only=True,
disabled=not enabled,
text_align=ft.TextAlign.CENTER,
tooltip=self._["scheduled_shutdown_time"],
)
async def pick_shutdown_time(_event):
async def handle_change(event):
picked = event.control.value
if picked is None:
return
value = picked.strftime("%H:%M")
time_field.value = value
time_field.update()
class _TimeChangeEvent:
def __init__(self):
self.control = time_field
self.control.data = "scheduled_shutdown_time"
self.data = value
await self.on_change(_TimeChangeEvent())
time_picker = ft.TimePicker(
confirm_text=self._["confirm"],
cancel_text=self._["cancel"],
help_text=self._["scheduled_shutdown_pick_time"],
on_change=handle_change,
)
self.page.show_dialog(time_picker)
time_button = ft.IconButton(
icon=ft.Icons.SCHEDULE,
tooltip=self._["scheduled_shutdown_pick_time"],
disabled=not enabled,
on_click=pick_shutdown_time,
)
async def toggle_shutdown(event):
is_enabled = bool(event.control.value)
time_field.disabled = not is_enabled
time_button.disabled = not is_enabled
time_field.update()
time_button.update()
await self.on_change(event)
shutdown_switch = ft.Switch(
value=enabled,
data="scheduled_shutdown_enabled",
tooltip=self._["scheduled_shutdown_tip"],
on_change=toggle_shutdown,
)
status_text = ft.Text(
(
self._["quick_shutdown_active"].format(time=active_target.strftime("%m-%d %H:%M"))
if active_target is not None
else self._["quick_shutdown_ready"].format(max_hours=MAX_QUICK_SHUTDOWN_HOURS)
),
size=12,
color=ft.Colors.PRIMARY if active_target is not None else ft.Colors.GREY_600,
)
hours_field = ft.TextField(
value=selected_hours,
width=80,
hint_text=f"1-{MAX_QUICK_SHUTDOWN_HOURS}",
keyboard_type=ft.KeyboardType.NUMBER,
input_filter=ft.InputFilter(allow=True, regex_string=r"[0-9]*"),
data="quick_shutdown_hours",
on_change=self.on_change,
disabled=active_target is not None,
)
async def update_active_state(target):
is_active = target is not None
hours_field.disabled = is_active
hours_field.error_text = None
start_button.disabled = is_active
cancel_button.disabled = not is_active
status_text.value = (
self._["quick_shutdown_active"].format(time=target.strftime("%m-%d %H:%M"))
if is_active
else self._["quick_shutdown_ready"].format(max_hours=MAX_QUICK_SHUTDOWN_HOURS)
)
status_text.color = ft.Colors.PRIMARY if is_active else ft.Colors.GREY_600
hours_field.update()
start_button.update()
cancel_button.update()
status_text.update()
async def start_quick_shutdown(_event):
try:
hours = int((hours_field.value or "").strip())
except ValueError:
hours = 0
if not 1 <= hours <= MAX_QUICK_SHUTDOWN_HOURS:
hours_field.error_text = self._["quick_shutdown_invalid_hours"].format(
max_hours=MAX_QUICK_SHUTDOWN_HOURS
)
hours_field.update()
return
hours_field.error_text = None
hours_field.update()
expected_time = datetime.now() + timedelta(hours=hours)
async def close_dialog(_):
confirm_dialog.open = False
self.app.dialog_area.update()
async def confirm_start(_):
await close_dialog(None)
try:
target = await self.app.shutdown_manager.start_quick_shutdown(hours)
except RuntimeError as exc:
await self.app.snack_bar.show_snack_bar(
self._["quick_shutdown_failed"].format(error=exc),
bgcolor=ft.Colors.RED,
duration=5000,
)
return
await update_active_state(target)
await self.app.snack_bar.show_snack_bar(
self._["quick_shutdown_started"].format(time=target.strftime("%m-%d %H:%M")),
bgcolor=ft.Colors.PRIMARY,
)
confirm_dialog = ft.AlertDialog(
modal=True,
title=ft.Text(self._["quick_shutdown_confirm_title"], weight=ft.FontWeight.BOLD),
content=ft.Text(
self._["quick_shutdown_confirm_content"].format(
hours=hours,
time=expected_time.strftime("%m-%d %H:%M"),
)
),
actions=[
ft.TextButton(content=self._["cancel"], on_click=close_dialog),
ft.Button(
content=self._["quick_shutdown_start"],
icon=ft.Icons.POWER_SETTINGS_NEW,
on_click=confirm_start,
),
],
actions_alignment=ft.MainAxisAlignment.END,
)
confirm_dialog.open = True
self.app.dialog_area.content = confirm_dialog
self.app.dialog_area.update()
async def cancel_quick_shutdown(_event):
try:
await self.app.shutdown_manager.cancel_quick_shutdown()
except RuntimeError as exc:
await self.app.snack_bar.show_snack_bar(
self._["quick_shutdown_cancel_failed"].format(error=exc),
bgcolor=ft.Colors.RED,
duration=5000,
)
return
await update_active_state(None)
await self.app.snack_bar.show_snack_bar(
self._["quick_shutdown_cancelled"],
bgcolor=ft.Colors.PRIMARY,
)
start_button = ft.Button(
content=self._["quick_shutdown_start"],
icon=ft.Icons.POWER_SETTINGS_NEW,
tooltip=self._["quick_shutdown_start_tip"],
disabled=active_target is not None,
on_click=start_quick_shutdown,
)
cancel_button = ft.IconButton(
icon=ft.Icons.CANCEL_SCHEDULE_SEND,
tooltip=self._["quick_shutdown_cancel"],
disabled=active_target is None,
on_click=cancel_quick_shutdown,
)
quick_shutdown_controls = ft.Column(
[
ft.Text(self._["quick_shutdown"], weight=ft.FontWeight.BOLD),
ft.Row(
[
hours_field,
ft.Text(self._["quick_shutdown_hours_suffix"]),
start_button,
cancel_button,
],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
),
status_text,
],
spacing=6,
tight=True,
)
async def close_shutdown_settings(_event):
shutdown_settings_dialog.open = False
self.app.dialog_area.update()
shutdown_settings_dialog = ft.AlertDialog(
modal=True,
title=ft.Text(self._["scheduled_shutdown"], weight=ft.FontWeight.BOLD),
content=ft.Container(content=quick_shutdown_controls, width=420),
actions=[ft.TextButton(content=self._["close"], on_click=close_shutdown_settings)],
actions_alignment=ft.MainAxisAlignment.END,
)
async def open_shutdown_settings(_event):
shutdown_settings_dialog.open = True
self.app.dialog_area.content = shutdown_settings_dialog
self.app.dialog_area.update()
details_button = ft.IconButton(
icon=ft.Icons.CHEVRON_RIGHT,
tooltip=self._["scheduled_shutdown_more"],
on_click=open_shutdown_settings,
)
controls = ft.Row(
[shutdown_switch, time_field, time_button, details_button],
spacing=8,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
)
return self.create_setting_row(self._["scheduled_shutdown"], controls)
def create_channel_switch_container(self, channel_name, icon, key):
"""Helper method to create a container with a switch and an icon for each channel."""
return ft.Container(

View File

@@ -7,6 +7,9 @@
"folder_name_author": true,
"folder_name_time": false,
"folder_name_title": false,
"scheduled_shutdown_enabled": false,
"scheduled_shutdown_time": "23:00",
"quick_shutdown_hours": "3",
"enable_proxy": true,
"proxy_address": "",
"video_format": "TS",
@@ -68,4 +71,4 @@
"platform_max_concurrent_requests": "3",
"last_route": "/home",
"check_live_on_browser_refresh": false
}
}

View File

@@ -215,7 +215,33 @@
"title": "title",
"proxy_settings": "Proxy Settings",
"remember_window_size": "Remember Window Size",
"is_proxy_enabled": "Configuration for using proxy and related settings",
"scheduled_shutdown": "Scheduled Shutdown",
"scheduled_shutdown_time": "Daily shutdown time",
"scheduled_shutdown_pick_time": "Select daily shutdown time",
"scheduled_shutdown_more": "Open shutdown settings",
"scheduled_shutdown_tip": "Start the shutdown countdown at the selected time every day",
"scheduled_shutdown_system_message": "StreamCap scheduled shutdown started; it can be cancelled within 60 seconds",
"scheduled_shutdown_countdown": "The system will shut down in {seconds} seconds.",
"scheduled_shutdown_cancel": "Cancel this shutdown",
"scheduled_shutdown_cancelled": "This shutdown has been cancelled",
"scheduled_shutdown_failed": "Unable to start scheduled shutdown: {error}",
"scheduled_shutdown_cancel_failed": "Unable to cancel this shutdown: {error}",
"quick_shutdown": "Quick Shutdown",
"quick_shutdown_hours_suffix": "hours",
"quick_shutdown_ready": "Enter 1-{max_hours} hours, then start",
"quick_shutdown_invalid_hours": "Enter a number of hours from 1 to {max_hours}",
"quick_shutdown_start": "Start",
"quick_shutdown_start_tip": "Schedule shutdown after a number of hours",
"quick_shutdown_cancel": "Cancel quick shutdown",
"quick_shutdown_confirm_title": "Confirm Quick Shutdown",
"quick_shutdown_confirm_content": "The computer will shut down in {hours} hour(s), at {time}. Start the timer?",
"quick_shutdown_active": "Scheduled shutdown: {time}",
"quick_shutdown_started": "Shutdown scheduled for {time}",
"quick_shutdown_cancelled": "Quick shutdown cancelled",
"quick_shutdown_failed": "Unable to schedule quick shutdown: {error}",
"quick_shutdown_cancel_failed": "Unable to cancel quick shutdown: {error}",
"quick_shutdown_system_message": "StreamCap quick shutdown: {hours}-hour countdown",
"is_proxy_enabled": "Configuration for using proxy and related settings",
"enable_proxy": "Enable Proxy",
"proxy_address": "Proxy Address",
"skip_proxy_detection": "Skip Proxy Detection",

View File

@@ -214,7 +214,33 @@
"time": "时间",
"title": "标题",
"remember_window_size": "退出时记住窗口大小",
"proxy_settings": "代理设置",
"scheduled_shutdown": "定时关机",
"scheduled_shutdown_time": "每日关机时间",
"scheduled_shutdown_pick_time": "选择每日关机时间",
"scheduled_shutdown_more": "打开关机设置",
"scheduled_shutdown_tip": "启用后,程序将在每天指定时间启动关机倒计时",
"scheduled_shutdown_system_message": "StreamCap 定时关机任务已触发,可在 60 秒内取消",
"scheduled_shutdown_countdown": "系统将在 {seconds} 秒后关机。",
"scheduled_shutdown_cancel": "取消本次关机",
"scheduled_shutdown_cancelled": "已取消本次关机",
"scheduled_shutdown_failed": "无法启动定时关机:{error}",
"scheduled_shutdown_cancel_failed": "无法取消本次关机:{error}",
"quick_shutdown": "快捷关机",
"quick_shutdown_hours_suffix": "小时后",
"quick_shutdown_ready": "输入 1{max_hours} 小时后开始",
"quick_shutdown_invalid_hours": "请输入 1 到 {max_hours} 之间的小时数",
"quick_shutdown_start": "开始",
"quick_shutdown_start_tip": "设置小时倒计时关机",
"quick_shutdown_cancel": "取消快捷关机",
"quick_shutdown_confirm_title": "确认快捷关机",
"quick_shutdown_confirm_content": "电脑将在 {hours} 小时后({time})关机,是否开始?",
"quick_shutdown_active": "计划关机时间:{time}",
"quick_shutdown_started": "已设置在 {time} 关机",
"quick_shutdown_cancelled": "已取消快捷关机",
"quick_shutdown_failed": "无法设置快捷关机:{error}",
"quick_shutdown_cancel_failed": "无法取消快捷关机:{error}",
"quick_shutdown_system_message": "StreamCap 快捷关机:{hours} 小时倒计时",
"proxy_settings": "代理设置",
"is_proxy_enabled": "设置是否使用代理及相关配置",
"enable_proxy": "开启代理",
"proxy_address": "代理地址",