diff --git a/app/app_manager.py b/app/app_manager.py index d763d30e..8c44c728 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -6,6 +6,7 @@ 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 @@ def __init__(self, page: ft.Page, services: BackendServices | None = None): ) 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 @@ def __init__(self, page: ft.Page, services: BackendServices | None = None): 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 @@ async def clear_content_area(self): async def cleanup(self): self.services.unregister_ui_bridge(self) + await self.shutdown_manager.stop() if not self.is_web_mode: try: diff --git a/app/core/runtime/scheduled_shutdown.py b/app/core/runtime/scheduled_shutdown.py new file mode 100644 index 00000000..ee7b42ed --- /dev/null +++ b/app/core/runtime/scheduled_shutdown.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import asyncio +import subprocess +import sys +from datetime import datetime, time, timedelta + +import flet as ft + +from ...utils.logger import logger + +DEFAULT_SHUTDOWN_DELAY_SECONDS = 60 +MAX_QUICK_SHUTDOWN_HOURS = 168 + + +def parse_shutdown_time(value: str) -> time: + """Parse the persisted 24-hour shutdown time.""" + return datetime.strptime(value.strip(), "%H:%M").time() + + +def get_next_shutdown_at(value: str, now: datetime | None = None) -> datetime: + """Return the next occurrence of ``value`` after ``now``.""" + current = now or datetime.now() + scheduled_at = datetime.combine(current.date(), parse_shutdown_time(value)) + if scheduled_at <= current: + scheduled_at += timedelta(days=1) + return scheduled_at + + +def get_quick_shutdown_at(hours: int, now: datetime | None = None) -> datetime: + """Return the target time for a positive hour-based shutdown delay.""" + if not 1 <= hours <= MAX_QUICK_SHUTDOWN_HOURS: + raise ValueError(f"Shutdown delay must be between 1 and {MAX_QUICK_SHUTDOWN_HOURS} hours") + return (now or datetime.now()) + timedelta(hours=hours) + + +class ScheduledShutdownManager: + """Manage the desktop-only daily Windows shutdown schedule.""" + + def __init__(self, app) -> None: + self.app = app + self._task: asyncio.Task | None = None + self._countdown_task: asyncio.Task | None = None + self._dialog: ft.AlertDialog | None = None + self._shutdown_requested = False + self._quick_shutdown_at: datetime | None = None + + @property + def is_supported(self) -> bool: + return sys.platform == "win32" and not self.app.page.web + + async def reschedule(self) -> None: + await self.stop_schedule() + if not self.is_supported: + return + if self.quick_shutdown_at is not None: + logger.info(f"Daily shutdown paused until quick shutdown at {self.quick_shutdown_at:%Y-%m-%d %H:%M}") + return + + enabled = self.app.settings.user_config.get("scheduled_shutdown_enabled", False) + if not enabled: + return + + shutdown_time = str(self.app.settings.user_config.get("scheduled_shutdown_time", "23:00")) + try: + next_shutdown = get_next_shutdown_at(shutdown_time) + except ValueError: + logger.error(f"Invalid scheduled shutdown time: {shutdown_time}") + return + + self._task = asyncio.create_task(self._wait_until(next_shutdown, shutdown_time)) + logger.info(f"Scheduled system shutdown for {next_shutdown:%Y-%m-%d %H:%M}") + + @property + def quick_shutdown_at(self) -> datetime | None: + if self._quick_shutdown_at is not None and self._quick_shutdown_at <= datetime.now(): + self._quick_shutdown_at = None + return self._quick_shutdown_at + + async def start_quick_shutdown(self, hours: int) -> datetime: + if not self.is_supported: + raise RuntimeError("Quick shutdown is only supported on Windows desktop") + + target = get_quick_shutdown_at(hours) + await self.stop_schedule() + _ = self._translations() + result = await self._run_shutdown_command( + delay_seconds=hours * 60 * 60, + message=_["quick_shutdown_system_message"].format(hours=hours), + ) + if result.returncode != 0: + await self.reschedule() + raise RuntimeError(result.stderr.strip() or str(result.returncode)) + + self._quick_shutdown_at = target + logger.warning(f"Quick system shutdown scheduled for {target:%Y-%m-%d %H:%M}") + return target + + async def cancel_quick_shutdown(self) -> None: + if self.quick_shutdown_at is None: + return + + result = await asyncio.to_thread( + subprocess.run, + ["shutdown", "/a"], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or str(result.returncode)) + + self._quick_shutdown_at = None + logger.info("Quick shutdown cancelled by user") + await self.reschedule() + + async def stop_schedule(self) -> None: + task = self._task + self._task = None + if task is None or task.done() or task is asyncio.current_task(): + return + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + async def stop(self) -> None: + await self.stop_schedule() + if self._countdown_task is not None and not self._countdown_task.done(): + self._countdown_task.cancel() + self._countdown_task = None + + async def _wait_until(self, scheduled_at: datetime, configured_time: str) -> None: + try: + delay = max(0.0, (scheduled_at - datetime.now()).total_seconds()) + await asyncio.sleep(delay) + + settings = self.app.settings.user_config + if not settings.get("scheduled_shutdown_enabled", False): + return + if str(settings.get("scheduled_shutdown_time", "23:00")) != configured_time: + return + + await self._request_shutdown() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error(f"Scheduled shutdown failed: {exc}") + + def _translations(self) -> dict: + language = self.app.language_manager.language + return language.get("base", {}) | language.get("settings_page", {}) + + async def _run_shutdown_command(self, delay_seconds: int, message: str): + return await asyncio.to_thread( + subprocess.run, + ["shutdown", "/s", "/t", str(delay_seconds), "/c", message], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + + async def _request_shutdown(self) -> None: + if self._shutdown_requested: + return + + _ = self._translations() + result = await self._run_shutdown_command( + delay_seconds=DEFAULT_SHUTDOWN_DELAY_SECONDS, + message=_["scheduled_shutdown_system_message"], + ) + if result.returncode != 0: + error = result.stderr.strip() or str(result.returncode) + logger.error(f"Unable to start scheduled shutdown: {error}") + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_failed"].format(error=error), + bgcolor=ft.Colors.RED, + duration=5000, + ) + return + + self._shutdown_requested = True + logger.warning(f"System shutdown requested in {DEFAULT_SHUTDOWN_DELAY_SECONDS} seconds") + self._show_countdown_dialog(DEFAULT_SHUTDOWN_DELAY_SECONDS) + + def _show_countdown_dialog(self, seconds: int) -> None: + _ = self._translations() + countdown_text = ft.Text( + _["scheduled_shutdown_countdown"].format(seconds=seconds), + size=14, + text_align=ft.TextAlign.CENTER, + ) + + async def cancel_shutdown(_event) -> None: + result = await asyncio.to_thread( + subprocess.run, + ["shutdown", "/a"], + capture_output=True, + text=True, + startupinfo=self.app.subprocess_start_up_info, + check=False, + ) + if result.returncode == 0: + self._shutdown_requested = False + logger.info("Scheduled shutdown cancelled by user") + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_cancelled"], + bgcolor=ft.Colors.PRIMARY, + ) + await self._close_dialog() + await self.reschedule() + else: + error = result.stderr.strip() or str(result.returncode) + await self.app.snack_bar.show_snack_bar( + _["scheduled_shutdown_cancel_failed"].format(error=error), + bgcolor=ft.Colors.RED, + duration=5000, + ) + + self._dialog = ft.AlertDialog( + modal=True, + title=ft.Text(_["scheduled_shutdown"], weight=ft.FontWeight.BOLD), + content=ft.Column( + [ + ft.Icon(ft.Icons.POWER_SETTINGS_NEW, size=36, color=ft.Colors.ERROR), + countdown_text, + ], + tight=True, + spacing=12, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + actions=[ + ft.OutlinedButton( + content=_["scheduled_shutdown_cancel"], + icon=ft.Icons.CANCEL_SCHEDULE_SEND, + on_click=cancel_shutdown, + ) + ], + actions_alignment=ft.MainAxisAlignment.CENTER, + ) + self._dialog.open = True + self.app.dialog_area.content = self._dialog + self.app.dialog_area.update() + self._countdown_task = asyncio.create_task(self._update_countdown(countdown_text, seconds)) + + async def _update_countdown(self, countdown_text: ft.Text, seconds: int) -> None: + _ = self._translations() + try: + for remaining in range(seconds - 1, -1, -1): + await asyncio.sleep(1) + countdown_text.value = _["scheduled_shutdown_countdown"].format(seconds=remaining) + countdown_text.update() + except (asyncio.CancelledError, ft.FletPageDisconnectedException): + pass + + async def _close_dialog(self) -> None: + if self._countdown_task is not None and not self._countdown_task.done(): + self._countdown_task.cancel() + self._countdown_task = None + if self._dialog is not None: + self._dialog.open = False + self.app.dialog_area.update() + self._dialog = None diff --git a/app/ui/views/settings_view.py b/app/ui/views/settings_view.py index 9c3943f7..401e7179 100644 --- a/app/ui/views/settings_view.py +++ b/app/ui/views/settings_view.py @@ -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 @@ async def confirm_dlg(_): ui_language = self.user_config["language"] self.user_config = self.default_config.copy() self.user_config["language"] = ui_language + self.app.services.settings_config.adopt_user_config(self.user_config) self.app.language_manager.notify_observers() self.page.run_task(self.load) await self.config_manager.save_user_config(self.user_config) + await self.app.shutdown_manager.reschedule() logger.success("Default configuration restored.") await self.app.snack_bar.show_snack_bar(self._["success_restore_tip"], bgcolor=ft.Colors.PRIMARY) await close_dialog(None) @@ -185,6 +190,8 @@ async def on_change(self, e): 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 @@ def create_recording_settings_tab(self): 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 @@ def create_folder_setting_row(self, label): 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( diff --git a/config/default_settings.json b/config/default_settings.json index 4a599ca1..053dab97 100644 --- a/config/default_settings.json +++ b/config/default_settings.json @@ -7,6 +7,9 @@ "folder_name_author": true, "folder_name_time": false, "folder_name_title": false, + "scheduled_shutdown_enabled": false, + "scheduled_shutdown_time": "23:00", + "quick_shutdown_hours": "3", "enable_proxy": true, "proxy_address": "", "video_format": "TS", @@ -68,4 +71,4 @@ "platform_max_concurrent_requests": "3", "last_route": "/home", "check_live_on_browser_refresh": false -} \ No newline at end of file +} diff --git a/locales/en.json b/locales/en.json index 38d38f3a..ed0a8544 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 91610afe..e86efeb9 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -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": "代理地址",